@equilateral_ai/mindmeld 3.3.1 → 3.5.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 (72) hide show
  1. package/README.md +1 -10
  2. package/hooks/pre-compact.js +213 -25
  3. package/hooks/session-end.js +112 -3
  4. package/hooks/session-start.js +635 -41
  5. package/hooks/subagent-start.js +150 -0
  6. package/hooks/subagent-stop.js +184 -0
  7. package/package.json +8 -7
  8. package/scripts/init-project.js +74 -33
  9. package/scripts/mcp-bridge.js +220 -0
  10. package/src/core/CorrelationAnalyzer.js +157 -0
  11. package/src/core/LLMPatternDetector.js +198 -0
  12. package/src/core/RelevanceDetector.js +123 -36
  13. package/src/core/StandardsIngestion.js +119 -18
  14. package/src/handlers/activity/activityGetMe.js +1 -1
  15. package/src/handlers/activity/activityGetTeam.js +100 -55
  16. package/src/handlers/admin/adminSetup.js +216 -0
  17. package/src/handlers/alerts/alertsAcknowledge.js +6 -6
  18. package/src/handlers/alerts/alertsGet.js +11 -11
  19. package/src/handlers/analytics/activitySummaryGet.js +34 -35
  20. package/src/handlers/analytics/coachingGet.js +11 -11
  21. package/src/handlers/analytics/convergenceGet.js +236 -0
  22. package/src/handlers/analytics/developerScoreGet.js +41 -111
  23. package/src/handlers/collaborators/collaboratorInvite.js +1 -1
  24. package/src/handlers/company/companyUsersDelete.js +141 -0
  25. package/src/handlers/company/companyUsersGet.js +90 -0
  26. package/src/handlers/company/companyUsersPost.js +267 -0
  27. package/src/handlers/company/companyUsersPut.js +76 -0
  28. package/src/handlers/correlations/correlationsDeveloperGet.js +12 -12
  29. package/src/handlers/correlations/correlationsGet.js +8 -8
  30. package/src/handlers/correlations/correlationsProjectGet.js +5 -5
  31. package/src/handlers/enterprise/controlTowerGet.js +224 -0
  32. package/src/handlers/enterprise/enterpriseOnboardingSetup.js +48 -9
  33. package/src/handlers/enterprise/enterpriseOnboardingStatus.js +1 -3
  34. package/src/handlers/github/githubConnectionStatus.js +1 -1
  35. package/src/handlers/github/githubDiscoverPatterns.js +4 -2
  36. package/src/handlers/github/githubPatternsReview.js +7 -36
  37. package/src/handlers/health/healthGet.js +55 -0
  38. package/src/handlers/helpers/checkSuperAdmin.js +13 -14
  39. package/src/handlers/helpers/mindmeldMcpCore.js +594 -0
  40. package/src/handlers/helpers/subscriptionTiers.js +27 -27
  41. package/src/handlers/mcp/mcpHandler.js +569 -0
  42. package/src/handlers/mcp/mindmeldMcpHandler.js +124 -0
  43. package/src/handlers/mcp/mindmeldMcpStreamHandler.js +243 -0
  44. package/src/handlers/notifications/sendNotification.js +18 -18
  45. package/src/handlers/patterns/patternEvaluatePromotionPost.js +173 -0
  46. package/src/handlers/projects/projectCreate.js +124 -10
  47. package/src/handlers/projects/projectDelete.js +4 -4
  48. package/src/handlers/projects/projectGet.js +8 -8
  49. package/src/handlers/projects/projectUpdate.js +4 -4
  50. package/src/handlers/reports/aiLeverage.js +34 -30
  51. package/src/handlers/reports/engineeringInvestment.js +16 -16
  52. package/src/handlers/reports/riskForecast.js +41 -21
  53. package/src/handlers/reports/standardsRoi.js +101 -9
  54. package/src/handlers/scheduled/maturityUpdateJob.js +166 -0
  55. package/src/handlers/sessions/sessionStandardsPost.js +43 -7
  56. package/src/handlers/standards/discoveriesGet.js +93 -0
  57. package/src/handlers/standards/projectStandardsGet.js +2 -2
  58. package/src/handlers/standards/projectStandardsPut.js +2 -2
  59. package/src/handlers/standards/standardsRelevantPost.js +107 -12
  60. package/src/handlers/standards/standardsTransition.js +112 -15
  61. package/src/handlers/stripe/billingPortalPost.js +1 -1
  62. package/src/handlers/stripe/enterpriseCheckoutPost.js +2 -2
  63. package/src/handlers/stripe/subscriptionCreatePost.js +2 -2
  64. package/src/handlers/stripe/webhookPost.js +42 -14
  65. package/src/handlers/user/apiTokenCreate.js +71 -0
  66. package/src/handlers/user/apiTokenList.js +64 -0
  67. package/src/handlers/user/userSplashGet.js +90 -73
  68. package/src/handlers/users/cognitoPostConfirmation.js +37 -1
  69. package/src/handlers/users/cognitoPreSignUp.js +114 -0
  70. package/src/handlers/users/userGet.js +15 -11
  71. package/src/handlers/webhooks/githubWebhook.js +117 -125
  72. package/src/index.js +8 -5
@@ -215,20 +215,27 @@ class RelevanceDetector {
215
215
  * Detect React usage
216
216
  */
217
217
  async detectReact(keyFiles) {
218
- if (!keyFiles.includes('package.json')) {
219
- return false;
220
- }
221
-
218
+ // Check root package.json first
222
219
  try {
223
220
  const pkgPath = path.join(this.workingDirectory, 'package.json');
224
221
  const content = await fs.readFile(pkgPath, 'utf-8');
225
222
  const pkg = JSON.parse(content);
223
+ if (pkg.dependencies?.react || pkg.devDependencies?.react) return true;
224
+ } catch (error) { /* continue */ }
226
225
 
227
- return !!(pkg.dependencies?.react || pkg.devDependencies?.react);
228
- } catch (error) {
229
- // Check for .jsx or .tsx files
230
- return keyFiles.some(f => f.endsWith('.jsx') || f.endsWith('.tsx'));
226
+ // Check subdirectory package.json files (monorepo support)
227
+ const subDirs = ['frontend', 'frontend/admin', 'client', 'web', 'app'];
228
+ for (const dir of subDirs) {
229
+ try {
230
+ const pkgPath = path.join(this.workingDirectory, dir, 'package.json');
231
+ const content = await fs.readFile(pkgPath, 'utf-8');
232
+ const pkg = JSON.parse(content);
233
+ if (pkg.dependencies?.react || pkg.devDependencies?.react) return true;
234
+ } catch (error) { /* continue */ }
231
235
  }
236
+
237
+ // Fallback: check for .jsx or .tsx files in keyFiles
238
+ return keyFiles.some(f => f.endsWith('.jsx') || f.endsWith('.tsx'));
232
239
  }
233
240
 
234
241
  /**
@@ -462,15 +469,18 @@ class RelevanceDetector {
462
469
  } catch (error) {
463
470
  // Database unavailable — fall back to reading YAML files directly
464
471
  console.warn('Standards patterns table not found. Falling back to file-based standards.');
465
- return this.loadStandardsFromFiles(categories);
472
+ return this.loadStandardsFromFiles(categories, characteristics);
466
473
  }
467
474
  }
468
475
 
469
476
  /**
470
477
  * File-system fallback: load and filter standards from YAML files
471
478
  * Used when database is unavailable (local dev, first run, DB down)
479
+ * @param {Array<string>} categories - Categories to filter by
480
+ * @param {Object} characteristics - Project characteristics for relevance scoring
481
+ * @returns {Promise<Array>} Ranked standards
472
482
  */
473
- async loadStandardsFromFiles(categories) {
483
+ async loadStandardsFromFiles(categories, characteristics = null) {
474
484
  try {
475
485
  const standardsPath = path.isAbsolute(this.standardsPath)
476
486
  ? this.standardsPath
@@ -483,12 +493,20 @@ class RelevanceDetector {
483
493
  const validMaturity = ['enforced', 'validated', 'recommended'];
484
494
  const categorySet = new Set(categories);
485
495
 
486
- return allPatterns
487
- .filter(p => categorySet.has(p.category) && validMaturity.includes(p.maturity))
488
- .sort((a, b) => {
489
- const maturityOrder = { enforced: 1, validated: 2, recommended: 3 };
490
- return (maturityOrder[a.maturity] || 3) - (maturityOrder[b.maturity] || 3);
491
- });
496
+ const filtered = allPatterns.filter(p =>
497
+ categorySet.has(p.category) && validMaturity.includes(p.maturity)
498
+ );
499
+
500
+ // Apply full relevance scoring if characteristics provided
501
+ if (characteristics) {
502
+ return this.rankStandards(filtered, characteristics, categories);
503
+ }
504
+
505
+ // Fallback: sort by maturity only (legacy behavior)
506
+ return filtered.sort((a, b) => {
507
+ const maturityOrder = { enforced: 1, validated: 2, recommended: 3 };
508
+ return (maturityOrder[a.maturity] || 3) - (maturityOrder[b.maturity] || 3);
509
+ });
492
510
  } catch (fallbackError) {
493
511
  console.error('File-based standards fallback failed:', fallbackError.message);
494
512
  return [];
@@ -533,6 +551,11 @@ class RelevanceDetector {
533
551
  score += 5;
534
552
  }
535
553
 
554
+ // Workflow bonus (actionable procedures are high-value)
555
+ if (standard.type === 'workflow' || (standard.tags && standard.tags.includes('workflow'))) {
556
+ score += 10;
557
+ }
558
+
536
559
  return {
537
560
  ...standard,
538
561
  relevance_score: Math.round(score * 10) / 10 // Round to 1 decimal
@@ -542,39 +565,103 @@ class RelevanceDetector {
542
565
 
543
566
  /**
544
567
  * Format standards for injection into Claude Code context
568
+ * Handles both standard rules and workflow procedures
545
569
  */
546
570
  formatForInjection(relevantStandards) {
547
571
  if (!relevantStandards || relevantStandards.length === 0) {
548
572
  return '';
549
573
  }
550
574
 
551
- let output = '## Relevant Standards (Auto-Detected)\n\n';
575
+ const standards = [];
576
+ const workflows = [];
577
+
578
+ for (const item of relevantStandards) {
579
+ if (item.type === 'workflow' || (item.tags && item.tags.includes('workflow') && item.examples && item.examples[0]?.type === 'workflow')) {
580
+ workflows.push(item);
581
+ } else {
582
+ standards.push(item);
583
+ }
584
+ }
585
+
586
+ let output = '';
552
587
 
553
- for (const standard of relevantStandards) {
554
- output += `### ${standard.element}\n`;
555
- output += `**Category**: ${standard.category}\n`;
556
- output += `**Maturity**: ${standard.maturity}\n`;
557
- output += `**Relevance**: ${standard.relevance_score}/100\n\n`;
558
- output += `${standard.rule}\n\n`;
588
+ if (standards.length > 0) {
589
+ output += '## Relevant Standards (Auto-Detected)\n\n';
559
590
 
560
- // Include examples if available
561
- if (standard.examples && standard.examples.length > 0) {
562
- output += '**Examples**:\n';
563
- for (const example of standard.examples.slice(0, 2)) { // Max 2 examples
564
- output += `\`\`\`javascript\n${example.code || example}\n\`\`\`\n\n`;
591
+ for (const standard of standards) {
592
+ output += `### ${standard.element}\n`;
593
+ output += `**Category**: ${standard.category}\n`;
594
+ output += `**Maturity**: ${standard.maturity}\n`;
595
+ output += `**Relevance**: ${standard.relevance_score}/100\n\n`;
596
+ output += `${standard.rule}\n\n`;
597
+
598
+ if (standard.examples && standard.examples.length > 0) {
599
+ output += '**Examples**:\n';
600
+ for (const example of standard.examples.slice(0, 2)) {
601
+ output += `\`\`\`javascript\n${example.code || example}\n\`\`\`\n\n`;
602
+ }
565
603
  }
566
- }
567
604
 
568
- // Include anti-patterns if available
569
- if (standard.anti_patterns && Object.keys(standard.anti_patterns).length > 0) {
570
- output += '**Anti-Patterns** (Avoid):\n';
571
- for (const [key, value] of Object.entries(standard.anti_patterns)) {
572
- output += `- ❌ ${key}: ${value}\n`;
605
+ if (standard.anti_patterns && Object.keys(standard.anti_patterns).length > 0) {
606
+ output += '**Anti-Patterns** (Avoid):\n';
607
+ for (const [key, value] of Object.entries(standard.anti_patterns)) {
608
+ output += `- ${key}: ${value}\n`;
609
+ }
610
+ output += '\n';
573
611
  }
574
- output += '\n';
612
+
613
+ output += '---\n\n';
575
614
  }
615
+ }
616
+
617
+ if (workflows.length > 0) {
618
+ output += '## Workflows (Auto-Detected)\n\n';
619
+
620
+ for (const workflow of workflows) {
621
+ const workflowData = workflow.examples && workflow.examples[0]?.type === 'workflow'
622
+ ? workflow.examples[0]
623
+ : null;
624
+
625
+ output += `### ${workflow.element}\n`;
626
+ output += `**Category**: ${workflow.category}\n`;
576
627
 
577
- output += '---\n\n';
628
+ if (workflowData) {
629
+ output += `**When**: ${workflowData.trigger}\n\n`;
630
+
631
+ if (workflowData.preconditions && workflowData.preconditions.length > 0) {
632
+ output += '**Preconditions**:\n';
633
+ for (const pre of workflowData.preconditions) {
634
+ output += `- ${pre}\n`;
635
+ }
636
+ output += '\n';
637
+ }
638
+
639
+ if (workflowData.steps && workflowData.steps.length > 0) {
640
+ output += '**Steps**:\n';
641
+ for (const step of workflowData.steps) {
642
+ const gateMarker = step.gate ? ' (GATE)' : '';
643
+ output += `${step.index}. **${step.name}**${gateMarker} — ${step.description}\n`;
644
+ if (step.command) {
645
+ output += ` \`${step.command.trim().split('\n')[0]}\`\n`;
646
+ }
647
+ if (step.validation) {
648
+ output += ` Verify: ${step.validation}\n`;
649
+ }
650
+ }
651
+ output += '\n';
652
+ }
653
+ }
654
+
655
+ if (workflow.anti_patterns && Object.keys(workflow.anti_patterns).length > 0) {
656
+ output += '**Anti-Patterns** (Avoid):\n';
657
+ for (const [key, value] of Object.entries(workflow.anti_patterns)) {
658
+ output += `- ❌ ${key}: ${value}\n`;
659
+ }
660
+ output += '\n';
661
+ }
662
+
663
+ output += '---\n\n';
664
+ }
578
665
  }
579
666
 
580
667
  return output;
@@ -227,16 +227,13 @@ class StandardsIngestion {
227
227
 
228
228
  /**
229
229
  * Parse a YAML standards file into Rapport pattern format
230
- * YAML format:
231
- * id: string
232
- * category: string
233
- * priority: 10 | 20 | 30
234
- * rules:
235
- * - action: ALWAYS | NEVER | USE | PREFER | AVOID
236
- * rule: string
237
- * applies_to: [glob patterns]
238
- * anti_patterns:
239
- * - string
230
+ * Supports both type: standard (v2.0) and type: workflow (v3.0)
231
+ *
232
+ * YAML format (standard):
233
+ * id, category, priority, rules[], anti_patterns[]
234
+ *
235
+ * YAML format (workflow):
236
+ * id, category, priority, type: workflow, trigger, steps[], preconditions[], anti_patterns[]
240
237
  */
241
238
  async parseYamlStandard(filepath, category) {
242
239
  const content = await fs.readFile(filepath, 'utf-8');
@@ -254,6 +251,11 @@ class StandardsIngestion {
254
251
  throw new Error('Invalid YAML structure: expected an object');
255
252
  }
256
253
 
254
+ // Route to workflow parser if type: workflow
255
+ if (doc.type === 'workflow') {
256
+ return this.parseYamlWorkflow(doc, filepath, filename, category);
257
+ }
258
+
257
259
  const patterns = [];
258
260
  const standardId = doc.id || filename;
259
261
  const standardCategory = doc.category || category;
@@ -268,15 +270,36 @@ class StandardsIngestion {
268
270
  }))
269
271
  : [];
270
272
 
271
- // Extract examples - now a named object in YAML format
273
+ // Extract examples - handles both array of strings and named object formats
272
274
  const examples = [];
273
- if (doc.examples && typeof doc.examples === 'object') {
274
- for (const [name, code] of Object.entries(doc.examples)) {
275
- examples.push({
276
- description: name.replace(/_/g, ' '),
277
- code: typeof code === 'string' ? code.trim() : String(code),
278
- language: this.inferLanguageFromCode(code)
279
- });
275
+ if (doc.examples) {
276
+ if (Array.isArray(doc.examples)) {
277
+ // Array format: ["code example 1", "code example 2"]
278
+ for (const example of doc.examples) {
279
+ if (typeof example === 'string') {
280
+ examples.push({
281
+ description: 'Example',
282
+ code: example.trim(),
283
+ language: this.inferLanguageFromCode(example)
284
+ });
285
+ } else if (typeof example === 'object' && example !== null) {
286
+ // Object in array: { description: "...", code: "..." }
287
+ examples.push({
288
+ description: example.description || 'Example',
289
+ code: (example.code || '').trim(),
290
+ language: example.language || this.inferLanguageFromCode(example.code || '')
291
+ });
292
+ }
293
+ }
294
+ } else if (typeof doc.examples === 'object') {
295
+ // Named object format: { example_name: "code here" }
296
+ for (const [name, code] of Object.entries(doc.examples)) {
297
+ examples.push({
298
+ description: name.replace(/_/g, ' '),
299
+ code: typeof code === 'string' ? code.trim() : String(code),
300
+ language: this.inferLanguageFromCode(code)
301
+ });
302
+ }
280
303
  }
281
304
  }
282
305
 
@@ -366,6 +389,84 @@ class StandardsIngestion {
366
389
  return { patterns };
367
390
  }
368
391
 
392
+ /**
393
+ * Parse a workflow YAML file into a single Rapport pattern with workflow metadata
394
+ * Workflows are stored as one pattern (not exploded into per-rule patterns)
395
+ * so the injection system can render them as ordered procedures.
396
+ */
397
+ parseYamlWorkflow(doc, filepath, filename, category) {
398
+ const standardId = doc.id || filename;
399
+ const standardCategory = doc.category || category;
400
+ const standardPriority = doc.priority || 10; // Workflows default to high priority
401
+
402
+ const antiPatterns = Array.isArray(doc.anti_patterns)
403
+ ? doc.anti_patterns.map(ap => ({
404
+ description: typeof ap === 'string' ? ap : ap.description || 'Anti-pattern to avoid',
405
+ code: typeof ap === 'object' ? ap.code : null,
406
+ language: typeof ap === 'object' ? ap.language : null
407
+ }))
408
+ : [];
409
+
410
+ const tags = Array.isArray(doc.tags) ? doc.tags : [];
411
+ const costImpact = doc.cost_impact && typeof doc.cost_impact === 'object'
412
+ ? doc.cost_impact
413
+ : (typeof doc.cost_impact === 'string' ? { description: doc.cost_impact } : null);
414
+
415
+ const steps = Array.isArray(doc.steps) ? doc.steps : [];
416
+ const preconditions = Array.isArray(doc.preconditions) ? doc.preconditions : [];
417
+ const trigger = doc.trigger || '';
418
+ const related = Array.isArray(doc.related) ? doc.related : [];
419
+
420
+ const patternId = this.generatePatternId(standardCategory, standardId, 'workflow');
421
+
422
+ // Build a readable element name from the id
423
+ const element = standardId.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
424
+
425
+ // The rule field contains the trigger for search/matching
426
+ const rule = `WORKFLOW: ${trigger}`;
427
+
428
+ // Store workflow structure in examples field (JSON-serializable)
429
+ // This preserves the full workflow for the injection formatter
430
+ const workflowData = {
431
+ type: 'workflow',
432
+ trigger: trigger,
433
+ preconditions: preconditions,
434
+ steps: steps.map((step, i) => ({
435
+ index: i + 1,
436
+ name: step.name || `Step ${i + 1}`,
437
+ description: step.description || '',
438
+ command: step.command || null,
439
+ validation: step.validation || null,
440
+ standards: Array.isArray(step.standards) ? step.standards : [],
441
+ gate: step.gate === true
442
+ })),
443
+ related: related
444
+ };
445
+
446
+ return {
447
+ patterns: [{
448
+ pattern_id: patternId,
449
+ file_name: filename,
450
+ element: element,
451
+ rule: rule,
452
+ priority: standardPriority,
453
+ correlation: 1.0,
454
+ source: 'equilateral-standards',
455
+ maturity: 'enforced',
456
+ scope: 'organization',
457
+ category: standardCategory,
458
+ applicable_files: this.extractApplicableFiles('', standardCategory),
459
+ anti_patterns: antiPatterns,
460
+ examples: [workflowData], // Workflow data stored as first example
461
+ cost_impact: costImpact,
462
+ tags: [...tags, 'workflow'],
463
+ source_file: filepath,
464
+ title: standardId,
465
+ type: 'workflow' // Flag for injection formatter
466
+ }]
467
+ };
468
+ }
469
+
369
470
  /**
370
471
  * Parse a markdown standards file into Rapport pattern format
371
472
  */
@@ -77,7 +77,7 @@ exports.handler = wrapHandler(async ({ requestContext }) => {
77
77
  last_30_days: parseInt(activity.commits_30d) || 0,
78
78
  last_7_days: parseInt(activity.commits_7d) || 0,
79
79
  last_commit: activity.last_commit,
80
- days_since_commit: parseInt(activity.days_since_commit) || null
80
+ days_since_commit: activity.days_since_commit !== null ? parseInt(activity.days_since_commit) : null
81
81
  },
82
82
  pull_requests: {
83
83
  opened_30d: parseInt(activity.prs_opened_30d) || 0,
@@ -18,10 +18,10 @@ exports.handler = wrapHandler(async ({ requestContext, queryStringParameters })
18
18
 
19
19
  // Check if user is a manager or admin
20
20
  const managerCheck = await executeQuery(`
21
- SELECT ue."Company_ID", ue."Manager", ue."Admin", u."Super_Admin"
22
- FROM "UserEntitlements" ue
23
- JOIN "Users" u ON ue."Email_Address" = u."Email_Address"
24
- WHERE ue."Email_Address" = $1
21
+ SELECT ue.company_id, ue.manager, ue.admin, u.super_admin
22
+ FROM rapport.user_entitlements ue
23
+ JOIN rapport.users u ON ue.email_address = u.email_address
24
+ WHERE ue.email_address = $1
25
25
  `, [email]);
26
26
 
27
27
  if (managerCheck.rowCount === 0) {
@@ -29,72 +29,117 @@ exports.handler = wrapHandler(async ({ requestContext, queryStringParameters })
29
29
  }
30
30
 
31
31
  const userRole = managerCheck.rows[0];
32
- const isAuthorized = userRole.Manager || userRole.Admin || userRole.Super_Admin;
32
+ const isAuthorized = userRole.manager || userRole.admin || userRole.super_admin;
33
33
 
34
34
  if (!isAuthorized) {
35
35
  return createErrorResponse(403, 'Manager or Admin access required');
36
36
  }
37
37
 
38
- const companyId = queryStringParameters?.company_id || userRole.Company_ID;
38
+ const companyId = queryStringParameters?.company_id || userRole.company_id;
39
39
 
40
- // Get team summary
40
+ // Get team summary directly from base tables
41
41
  const summaryResult = await executeQuery(`
42
- SELECT * FROM rapport.v_team_activity_summary
43
- WHERE company_id = $1
42
+ SELECT
43
+ COUNT(DISTINCT ue.email_address) as total_developers,
44
+ COUNT(DISTINCT ue.email_address) FILTER (
45
+ WHERE EXISTS (
46
+ SELECT 1 FROM rapport.sessions s
47
+ WHERE s.email_address = ue.email_address
48
+ AND s.started_at > NOW() - INTERVAL '30 days'
49
+ )
50
+ ) as active_developers
51
+ FROM rapport.user_entitlements ue
52
+ JOIN rapport.users u ON ue.email_address = u.email_address
53
+ WHERE ue.company_id = $1
54
+ AND u.active = true
44
55
  `, [companyId]);
45
56
 
46
57
  // Get individual developer activity
47
58
  const developersResult = await executeQuery(`
48
59
  SELECT
49
- email_address,
50
- display_name,
51
- sessions_30d,
52
- sessions_7d,
53
- last_session,
54
- commits_30d,
55
- commits_7d,
56
- last_commit,
57
- days_since_commit,
58
- prs_merged_30d,
59
- session_to_commit_conversion_pct
60
- FROM rapport.mv_developer_activity
61
- WHERE company_id = $1
60
+ ue.email_address,
61
+ u.first_name || ' ' || u.last_name as display_name,
62
+ COALESCE(sess.sessions_30d, 0) as sessions_30d,
63
+ COALESCE(sess.sessions_7d, 0) as sessions_7d,
64
+ sess.last_session,
65
+ COALESCE(comm.commits_30d, 0) as commits_30d,
66
+ COALESCE(comm.commits_7d, 0) as commits_7d,
67
+ comm.last_commit,
68
+ CASE WHEN comm.last_commit IS NOT NULL
69
+ THEN EXTRACT(DAY FROM NOW() - comm.last_commit)::INTEGER
70
+ ELSE NULL
71
+ END as days_since_commit,
72
+ COALESCE(pr_data.prs_merged_30d, 0) as prs_merged_30d
73
+ FROM rapport.user_entitlements ue
74
+ JOIN rapport.users u ON ue.email_address = u.email_address
75
+ LEFT JOIN (
76
+ SELECT email_address,
77
+ COUNT(*) FILTER (WHERE started_at > NOW() - INTERVAL '30 days') as sessions_30d,
78
+ COUNT(*) FILTER (WHERE started_at > NOW() - INTERVAL '7 days') as sessions_7d,
79
+ MAX(started_at) as last_session
80
+ FROM rapport.sessions
81
+ GROUP BY email_address
82
+ ) sess ON sess.email_address = ue.email_address
83
+ LEFT JOIN (
84
+ SELECT author_email,
85
+ COUNT(*) FILTER (WHERE commit_timestamp > NOW() - INTERVAL '30 days') as commits_30d,
86
+ COUNT(*) FILTER (WHERE commit_timestamp > NOW() - INTERVAL '7 days') as commits_7d,
87
+ MAX(commit_timestamp) as last_commit
88
+ FROM rapport.commits
89
+ GROUP BY author_email
90
+ ) comm ON comm.author_email = ue.email_address
91
+ LEFT JOIN (
92
+ SELECT pr.author_email,
93
+ COUNT(*) as prs_merged_30d
94
+ FROM rapport.pull_requests pr
95
+ WHERE pr.merged_at > NOW() - INTERVAL '30 days'
96
+ GROUP BY pr.author_email
97
+ ) pr_data ON pr_data.author_email = ue.email_address
98
+ WHERE ue.company_id = $1
99
+ AND u.active = true
62
100
  ORDER BY
63
- CASE
64
- WHEN days_since_commit IS NULL THEN 999
65
- ELSE days_since_commit
101
+ CASE WHEN comm.last_commit IS NULL THEN 999
102
+ ELSE EXTRACT(DAY FROM NOW() - comm.last_commit)
66
103
  END DESC,
67
- sessions_30d DESC
104
+ COALESCE(sess.sessions_30d, 0) DESC
68
105
  `, [companyId]);
69
106
 
70
107
  const summary = summaryResult.rows[0] || {
71
108
  total_developers: 0,
72
- active_developers: 0,
73
- stale_developers: 0,
74
- very_stale_developers: 0,
75
- avg_sessions_30d: 0,
76
- avg_commits_30d: 0,
77
- avg_conversion_pct: 0
109
+ active_developers: 0
78
110
  };
79
111
 
80
- const developers = developersResult.rows.map(dev => ({
81
- email_address: dev.email_address,
82
- display_name: dev.display_name,
83
- sessions: {
84
- last_30_days: parseInt(dev.sessions_30d) || 0,
85
- last_7_days: parseInt(dev.sessions_7d) || 0,
86
- last_session: dev.last_session
87
- },
88
- commits: {
89
- last_30_days: parseInt(dev.commits_30d) || 0,
90
- last_7_days: parseInt(dev.commits_7d) || 0,
91
- last_commit: dev.last_commit,
92
- days_since_commit: parseInt(dev.days_since_commit) || null
93
- },
94
- prs_merged_30d: parseInt(dev.prs_merged_30d) || 0,
95
- session_to_commit_conversion_pct: parseFloat(dev.session_to_commit_conversion_pct) || 0,
96
- status: getDevStatus(dev)
97
- }));
112
+ const developers = developersResult.rows.map(dev => {
113
+ const sessions30d = parseInt(dev.sessions_30d) || 0;
114
+ const commits30d = parseInt(dev.commits_30d) || 0;
115
+ const conversionPct = sessions30d > 0 ? Math.round((commits30d / sessions30d) * 100) : 0;
116
+
117
+ return {
118
+ email_address: dev.email_address,
119
+ display_name: dev.display_name,
120
+ sessions: {
121
+ last_30_days: sessions30d,
122
+ last_7_days: parseInt(dev.sessions_7d) || 0,
123
+ last_session: dev.last_session
124
+ },
125
+ commits: {
126
+ last_30_days: commits30d,
127
+ last_7_days: parseInt(dev.commits_7d) || 0,
128
+ last_commit: dev.last_commit,
129
+ days_since_commit: parseInt(dev.days_since_commit) || null
130
+ },
131
+ prs_merged_30d: parseInt(dev.prs_merged_30d) || 0,
132
+ session_to_commit_conversion_pct: conversionPct,
133
+ status: getDevStatus(dev)
134
+ };
135
+ });
136
+
137
+ const totalSessions = developers.reduce((sum, d) => sum + d.sessions.last_30_days, 0);
138
+ const totalCommits = developers.reduce((sum, d) => sum + d.commits.last_30_days, 0);
139
+ const devCount = developers.length || 1;
140
+ const avgConversion = developers.length > 0
141
+ ? developers.reduce((sum, d) => sum + d.session_to_commit_conversion_pct, 0) / developers.length
142
+ : 0;
98
143
 
99
144
  return createSuccessResponse(
100
145
  {
@@ -103,11 +148,11 @@ exports.handler = wrapHandler(async ({ requestContext, queryStringParameters })
103
148
  summary: {
104
149
  total_developers: parseInt(summary.total_developers) || 0,
105
150
  active_developers: parseInt(summary.active_developers) || 0,
106
- stale_developers: parseInt(summary.stale_developers) || 0,
107
- very_stale_developers: parseInt(summary.very_stale_developers) || 0,
108
- avg_sessions_30d: parseFloat(summary.avg_sessions_30d)?.toFixed(1) || 0,
109
- avg_commits_30d: parseFloat(summary.avg_commits_30d)?.toFixed(1) || 0,
110
- avg_conversion_pct: parseFloat(summary.avg_conversion_pct)?.toFixed(1) || 0
151
+ stale_developers: developers.filter(d => d.status === 'stale').length,
152
+ very_stale_developers: developers.filter(d => d.status === 'very_stale').length,
153
+ avg_sessions_30d: parseFloat((totalSessions / devCount).toFixed(1)),
154
+ avg_commits_30d: parseFloat((totalCommits / devCount).toFixed(1)),
155
+ avg_conversion_pct: parseFloat(avgConversion.toFixed(1))
111
156
  },
112
157
  developers
113
158
  }]