@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
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Maturity Update Scheduled Job
3
+ * Auto-progresses/demotes standards maturity based on 30-day compliance evidence
4
+ *
5
+ * Schedule: Daily
6
+ * Auth: None (Lambda scheduled event)
7
+ *
8
+ * Maturity progression rules (based on session_standards compliance data):
9
+ * - >90% followed + >50 sessions → enforced
10
+ * - >70% followed + >20 sessions → validated
11
+ * - <30% followed + >20 sessions → demote to provisional
12
+ *
13
+ * Records all transitions in standards_audit_trail for traceability
14
+ */
15
+
16
+ const { wrapHandler, executeQuery, createSuccessResponse } = require('./helpers');
17
+
18
+ /**
19
+ * Main handler
20
+ */
21
+ exports.handler = wrapHandler(async (event, context) => {
22
+ console.log('[MaturityUpdateJob] Starting maturity auto-progression...');
23
+
24
+ const lookbackDays = (event && event.lookbackDays) || 30;
25
+ const dryRun = (event && event.dryRun) || false;
26
+
27
+ const summary = {
28
+ startedAt: new Date().toISOString(),
29
+ standardsEvaluated: 0,
30
+ promotions: 0,
31
+ demotions: 0,
32
+ unchanged: 0,
33
+ transitions: [],
34
+ errors: []
35
+ };
36
+
37
+ try {
38
+ // Query 30-day compliance rates per standard from session_standards
39
+ const complianceQuery = `
40
+ SELECT
41
+ ss.standard_id,
42
+ ss.standard_name,
43
+ COUNT(*) as total_sessions,
44
+ COUNT(*) FILTER (WHERE ss.followed = true) as followed_count,
45
+ COUNT(*) FILTER (WHERE ss.violated = true) as violated_count,
46
+ ROUND(
47
+ COUNT(*) FILTER (WHERE ss.followed = true)::decimal /
48
+ NULLIF(COUNT(*), 0),
49
+ 3
50
+ ) as follow_rate,
51
+ sp.maturity as current_maturity
52
+ FROM rapport.session_standards ss
53
+ LEFT JOIN rapport.standards_patterns sp ON sp.pattern_id = ss.standard_id
54
+ WHERE ss.shown_at > NOW() - INTERVAL '${lookbackDays} days'
55
+ GROUP BY ss.standard_id, ss.standard_name, sp.maturity
56
+ HAVING COUNT(*) >= 10
57
+ ORDER BY total_sessions DESC
58
+ `;
59
+
60
+ const complianceResult = await executeQuery(complianceQuery);
61
+ const standards = complianceResult.rows;
62
+
63
+ summary.standardsEvaluated = standards.length;
64
+ console.log(`[MaturityUpdateJob] Evaluating ${standards.length} standards with sufficient data`);
65
+
66
+ for (const standard of standards) {
67
+ const totalSessions = parseInt(standard.total_sessions);
68
+ const followRate = parseFloat(standard.follow_rate) || 0;
69
+ const currentMaturity = standard.current_maturity || 'provisional';
70
+
71
+ // Determine target maturity
72
+ let targetMaturity = currentMaturity;
73
+
74
+ if (followRate > 0.90 && totalSessions >= 50) {
75
+ targetMaturity = 'enforced';
76
+ } else if (followRate > 0.70 && totalSessions >= 20) {
77
+ targetMaturity = 'validated';
78
+ } else if (followRate < 0.30 && totalSessions >= 20) {
79
+ targetMaturity = 'provisional';
80
+ }
81
+
82
+ if (targetMaturity === currentMaturity) {
83
+ summary.unchanged++;
84
+ continue;
85
+ }
86
+
87
+ // Determine if this is a promotion or demotion
88
+ const maturityOrder = { provisional: 0, validated: 1, reinforced: 2, enforced: 3 };
89
+ const isPromotion = (maturityOrder[targetMaturity] || 0) > (maturityOrder[currentMaturity] || 0);
90
+
91
+ const transition = {
92
+ standard_id: standard.standard_id,
93
+ standard_name: standard.standard_name,
94
+ from: currentMaturity,
95
+ to: targetMaturity,
96
+ direction: isPromotion ? 'promotion' : 'demotion',
97
+ follow_rate: followRate,
98
+ total_sessions: totalSessions
99
+ };
100
+
101
+ summary.transitions.push(transition);
102
+
103
+ if (dryRun) {
104
+ console.log(`[MaturityUpdateJob] DRY RUN: Would ${transition.direction} ${standard.standard_id}: ${currentMaturity} → ${targetMaturity} (${(followRate * 100).toFixed(1)}% followed, ${totalSessions} sessions)`);
105
+ if (isPromotion) summary.promotions++;
106
+ else summary.demotions++;
107
+ continue;
108
+ }
109
+
110
+ try {
111
+ // Update standards_patterns maturity
112
+ await executeQuery(`
113
+ UPDATE rapport.standards_patterns
114
+ SET maturity = $1, last_updated = NOW()
115
+ WHERE pattern_id = $2
116
+ `, [targetMaturity, standard.standard_id]);
117
+
118
+ // Record in audit trail
119
+ await executeQuery(`
120
+ INSERT INTO rapport.standards_audit_trail (
121
+ standard_id, action, old_state, new_state,
122
+ user_email, reason, metadata, created_at
123
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
124
+ `, [
125
+ standard.standard_id,
126
+ isPromotion ? 'auto_promote' : 'auto_demote',
127
+ currentMaturity,
128
+ targetMaturity,
129
+ 'system@mindmeld.dev',
130
+ `Auto-${transition.direction}: ${(followRate * 100).toFixed(1)}% follow rate across ${totalSessions} sessions (30-day window)`,
131
+ JSON.stringify({
132
+ follow_rate: followRate,
133
+ total_sessions: totalSessions,
134
+ followed_count: parseInt(standard.followed_count),
135
+ violated_count: parseInt(standard.violated_count),
136
+ lookback_days: lookbackDays
137
+ })
138
+ ]);
139
+
140
+ if (isPromotion) summary.promotions++;
141
+ else summary.demotions++;
142
+
143
+ console.log(`[MaturityUpdateJob] ${transition.direction}: ${standard.standard_id} ${currentMaturity} → ${targetMaturity}`);
144
+
145
+ } catch (error) {
146
+ console.error(`[MaturityUpdateJob] Error updating ${standard.standard_id}:`, error.message);
147
+ summary.errors.push({
148
+ standard_id: standard.standard_id,
149
+ error: error.message
150
+ });
151
+ }
152
+ }
153
+
154
+ } catch (error) {
155
+ console.error('[MaturityUpdateJob] Job failed:', error);
156
+ summary.errors.push({ step: 'main', error: error.message });
157
+ }
158
+
159
+ summary.completedAt = new Date().toISOString();
160
+ console.log(`[MaturityUpdateJob] Complete: ${summary.promotions} promotions, ${summary.demotions} demotions, ${summary.unchanged} unchanged`);
161
+
162
+ return createSuccessResponse(
163
+ summary,
164
+ `Maturity update complete: ${summary.promotions} promotions, ${summary.demotions} demotions`
165
+ );
166
+ });
@@ -41,7 +41,7 @@ async function recordSessionStandards({ body: requestBody = {}, requestContext }
41
41
  }
42
42
 
43
43
  // Try to ensure session exists (upsert with minimal data)
44
- // This may fail if project_id is not provided or invalid - that's OK
44
+ // If project doesn't exist, auto-create it to prevent silent session loss
45
45
  if (project_id && user_id) {
46
46
  const sessionUpsertQuery = `
47
47
  INSERT INTO rapport.sessions (
@@ -59,9 +59,42 @@ async function recordSessionStandards({ body: requestBody = {}, requestContext }
59
59
  try {
60
60
  await executeQuery(sessionUpsertQuery, [session_id, project_id, user_id]);
61
61
  } catch (sessionError) {
62
- // Session insert failed (FK constraint probably)
63
- // Continue anyway - we'll try to record standards
64
- console.error('[sessionStandardsPost] Session upsert failed:', sessionError.message);
62
+ // FK constraint on project_id auto-create the project and retry
63
+ if (sessionError.code === '23503' && sessionError.constraint && sessionError.constraint.includes('project_id')) {
64
+ console.log(`[sessionStandardsPost] Project ${project_id} not found, auto-creating`);
65
+ try {
66
+ // Look up user's company_id
67
+ const entResult = await executeQuery(
68
+ `SELECT company_id FROM rapport.user_entitlements WHERE email_address = $1 LIMIT 1`,
69
+ [user_id]
70
+ );
71
+ if (entResult.rowCount > 0) {
72
+ const company_id = entResult.rows[0].company_id;
73
+ // Derive a readable project name from the project_id
74
+ // e.g. prj_pareidolia_main_1770727596802 → pareidolia main
75
+ const projectName = project_id
76
+ .replace(/^prj_/, '')
77
+ .replace(/_\d+$/, '')
78
+ .replace(/_/g, ' ');
79
+
80
+ await executeQuery(`
81
+ INSERT INTO rapport.projects (project_id, company_id, project_name, private)
82
+ VALUES ($1, $2, $3, false)
83
+ ON CONFLICT (project_id) DO NOTHING
84
+ `, [project_id, company_id, projectName]);
85
+
86
+ // Retry session upsert
87
+ await executeQuery(sessionUpsertQuery, [session_id, project_id, user_id]);
88
+ console.log(`[sessionStandardsPost] Auto-created project ${project_id} under ${company_id}`);
89
+ } else {
90
+ console.error(`[sessionStandardsPost] No entitlement found for ${user_id}, cannot auto-create project`);
91
+ }
92
+ } catch (autoCreateError) {
93
+ console.error('[sessionStandardsPost] Auto-create project failed:', autoCreateError.message);
94
+ }
95
+ } else {
96
+ console.error('[sessionStandardsPost] Session upsert failed:', sessionError.message);
97
+ }
65
98
  }
66
99
  }
67
100
 
@@ -71,6 +104,7 @@ async function recordSessionStandards({ body: requestBody = {}, requestContext }
71
104
 
72
105
  for (const standard of standards) {
73
106
  const standardId = standard.pattern_id || standard.element;
107
+ const standardName = standard.title || standard.element || standardId;
74
108
  const relevanceScore = standard.relevance_score || standard.score || 0;
75
109
 
76
110
  if (!standardId) {
@@ -82,14 +116,15 @@ async function recordSessionStandards({ body: requestBody = {}, requestContext }
82
116
  INSERT INTO rapport.session_standards (
83
117
  session_id,
84
118
  standard_id,
119
+ standard_name,
85
120
  relevance_score,
86
- shown_at
121
+ created_at
87
122
  ) VALUES (
88
- $1, $2, $3, NOW()
123
+ $1, $2, $3, $4, NOW()
89
124
  )
90
125
  ON CONFLICT (session_id, standard_id) DO UPDATE SET
91
126
  relevance_score = EXCLUDED.relevance_score,
92
- shown_at = NOW()
127
+ created_at = NOW()
93
128
  RETURNING id
94
129
  `;
95
130
 
@@ -97,6 +132,7 @@ async function recordSessionStandards({ body: requestBody = {}, requestContext }
97
132
  const result = await executeQuery(insertQuery, [
98
133
  session_id,
99
134
  standardId,
135
+ standardName,
100
136
  relevanceScore
101
137
  ]);
102
138
 
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Discoveries Get Handler
3
+ * Returns discovery review queue for a project
4
+ *
5
+ * GET /api/standards/discoveries?project_id=xxx&status=proposed
6
+ * Auth: Cognito JWT required
7
+ */
8
+
9
+ const { wrapHandler, executeQuery, createSuccessResponse, createErrorResponse } = require('./helpers');
10
+
11
+ async function getDiscoveries({ queryStringParameters, requestContext }) {
12
+ try {
13
+ const email = requestContext.authorizer?.claims?.email || requestContext.authorizer?.jwt?.claims?.email;
14
+
15
+ if (!email) {
16
+ return createErrorResponse(401, 'Authentication required');
17
+ }
18
+
19
+ const projectId = (queryStringParameters || {}).project_id;
20
+ const status = (queryStringParameters || {}).status;
21
+
22
+ if (!projectId) {
23
+ return createErrorResponse(400, 'project_id is required');
24
+ }
25
+
26
+ // Verify user has access to project
27
+ const accessResult = await executeQuery(`
28
+ SELECT role FROM rapport.project_collaborators
29
+ WHERE project_id = $1 AND email_address = $2
30
+ `, [projectId, email]);
31
+
32
+ if (accessResult.rowCount === 0) {
33
+ return createErrorResponse(403, 'Access denied to project');
34
+ }
35
+
36
+ // Build query with optional status filter
37
+ let query = `
38
+ SELECT
39
+ discovery_id,
40
+ project_id,
41
+ pattern_name,
42
+ pattern_description,
43
+ confidence,
44
+ discovery_type,
45
+ status,
46
+ evidence,
47
+ reason,
48
+ reason_code,
49
+ created_at,
50
+ updated_at
51
+ FROM rapport.onboarding_discoveries
52
+ WHERE project_id = $1
53
+ `;
54
+ const params = [projectId];
55
+
56
+ if (status) {
57
+ query += ` AND status = $2`;
58
+ params.push(status);
59
+ }
60
+
61
+ query += ` ORDER BY confidence DESC, created_at DESC`;
62
+
63
+ const result = await executeQuery(query, params);
64
+
65
+ // Map DB columns to frontend Discovery interface
66
+ const records = result.rows.map(row => ({
67
+ id: row.discovery_id,
68
+ project_id: row.project_id,
69
+ name: row.pattern_name,
70
+ description: row.pattern_description || '',
71
+ confidence: parseFloat(row.confidence) || 0,
72
+ source: 'github',
73
+ category: row.discovery_type || 'Architecture',
74
+ status: row.status || 'proposed',
75
+ rule_text: null,
76
+ action_type: null,
77
+ evidence: row.evidence || null,
78
+ created_at: row.created_at,
79
+ updated_at: row.updated_at
80
+ }));
81
+
82
+ return createSuccessResponse({
83
+ records,
84
+ total: result.rowCount
85
+ }, `Found ${result.rowCount} discoveries`);
86
+
87
+ } catch (error) {
88
+ console.error('Discoveries Get Error:', error);
89
+ return createErrorResponse(500, 'Failed to load discoveries');
90
+ }
91
+ }
92
+
93
+ exports.handler = wrapHandler(getDiscoveries);
@@ -8,7 +8,7 @@
8
8
 
9
9
  const { wrapHandler, executeQuery, createSuccessResponse, createErrorResponse } = require('./helpers');
10
10
 
11
- async function getProjectStandards({ queryStringParameters, requestContext }) {
11
+ async function getProjectStandards({ queryStringParameters, pathParameters, requestContext }) {
12
12
  try {
13
13
  const email = requestContext.authorizer?.claims?.email || requestContext.authorizer?.jwt?.claims?.email;
14
14
 
@@ -16,7 +16,7 @@ async function getProjectStandards({ queryStringParameters, requestContext }) {
16
16
  return createErrorResponse(401, 'Authentication required');
17
17
  }
18
18
 
19
- const projectId = (queryStringParameters || {}).project_id;
19
+ const projectId = pathParameters?.projectId || (queryStringParameters || {}).project_id;
20
20
 
21
21
  if (!projectId) {
22
22
  return createErrorResponse(400, 'projectId is required');
@@ -9,7 +9,7 @@
9
9
 
10
10
  const { wrapHandler, executeQuery, createSuccessResponse, createErrorResponse } = require('./helpers');
11
11
 
12
- async function updateProjectStandards({ body, requestContext }) {
12
+ async function updateProjectStandards({ body, pathParameters, requestContext }) {
13
13
  try {
14
14
  const email = requestContext.authorizer?.claims?.email || requestContext.authorizer?.jwt?.claims?.email;
15
15
 
@@ -17,7 +17,7 @@ async function updateProjectStandards({ body, requestContext }) {
17
17
  return createErrorResponse(401, 'Authentication required');
18
18
  }
19
19
 
20
- const projectId = (body || {}).project_id;
20
+ const projectId = pathParameters?.projectId || (body || {}).project_id;
21
21
 
22
22
  if (!projectId) {
23
23
  return createErrorResponse(400, 'projectId is required');
@@ -18,15 +18,16 @@ const { wrapHandler, executeQuery, createSuccessResponse, createErrorResponse }
18
18
  */
19
19
  const CATEGORY_WEIGHTS = {
20
20
  'serverless-saas-aws': 1.0,
21
- 'multi-agent-orchestration': 1.0,
22
21
  'frontend-development': 1.0,
23
22
  'database': 0.9,
24
- 'compliance-security': 0.8,
25
- 'cost-optimization': 0.7,
26
- 'real-time-systems': 0.8,
27
- 'testing': 0.6,
28
23
  'backend': 0.9,
29
- 'well-architected': 0.7
24
+ 'compliance-security': 0.9,
25
+ 'deployment': 0.8,
26
+ 'testing': 0.7,
27
+ 'real-time-systems': 0.7,
28
+ 'well-architected': 0.7,
29
+ 'cost-optimization': 0.7,
30
+ 'multi-agent-orchestration': 0.1, // Infrastructure config, rarely needed in coding sessions
30
31
  };
31
32
 
32
33
  /**
@@ -54,6 +55,9 @@ function mapCharacteristicsToCategories(characteristics) {
54
55
  if (characteristics.hasTests) {
55
56
  categories.add('testing');
56
57
  }
58
+ if (characteristics.hasSAM || characteristics.hasLambda || characteristics.hasAPI) {
59
+ categories.add('deployment');
60
+ }
57
61
 
58
62
  // Always relevant
59
63
  categories.add('compliance-security');
@@ -65,7 +69,7 @@ function mapCharacteristicsToCategories(characteristics) {
65
69
  /**
66
70
  * Rank standards by relevance score
67
71
  */
68
- function rankStandards(standards) {
72
+ function rankStandards(standards, recentCategories) {
69
73
  return standards.map(standard => {
70
74
  let score = 0;
71
75
 
@@ -98,6 +102,23 @@ function rankStandards(standards) {
98
102
  if (apCount > 0) score += 5;
99
103
  }
100
104
 
105
+ // Workflow bonus — workflows are high-value procedural knowledge
106
+ const isWorkflow = (standard.rule && standard.rule.startsWith('WORKFLOW:'))
107
+ || (Array.isArray(standard.keywords) && standard.keywords.includes('workflow'));
108
+ if (isWorkflow) score += 10;
109
+
110
+ // Recency bonus — boost categories the user has been working in recently
111
+ // Scaled by category weight to prevent feedback loops (low-weight categories
112
+ // that got injected shouldn't bootstrap themselves back into the top 10)
113
+ if (recentCategories && recentCategories[standard.category]) {
114
+ const usageCount = recentCategories[standard.category];
115
+ let rawBonus;
116
+ if (usageCount >= 8) rawBonus = 25;
117
+ else if (usageCount >= 4) rawBonus = 18;
118
+ else rawBonus = 10;
119
+ score += rawBonus * categoryWeight;
120
+ }
121
+
101
122
  return {
102
123
  ...standard,
103
124
  relevance_score: Math.round(score * 10) / 10
@@ -141,6 +162,25 @@ async function getRelevantStandards({ body, requestContext }) {
141
162
  return createErrorResponse(401, 'Authentication required');
142
163
  }
143
164
 
165
+ // Verify active subscription — no free tier access
166
+ const subResult = await executeQuery(`
167
+ SELECT c.subscription_tier, c.subscription_status
168
+ FROM rapport.users u
169
+ JOIN rapport.clients c ON u.client_id = c.client_id
170
+ WHERE u.email_address = $1
171
+ LIMIT 1
172
+ `, [email]);
173
+
174
+ if (subResult.rows.length > 0) {
175
+ const { subscription_tier, subscription_status } = subResult.rows[0];
176
+ if (!subscription_tier || subscription_tier === 'free') {
177
+ return createErrorResponse(403, 'Active MindMeld subscription required. Subscribe at app.mindmeld.dev');
178
+ }
179
+ if (subscription_status === 'canceled') {
180
+ return createErrorResponse(403, 'Subscription canceled. Resubscribe at app.mindmeld.dev');
181
+ }
182
+ }
183
+
144
184
  // Parse request body
145
185
  let requestBody;
146
186
  try {
@@ -166,13 +206,43 @@ async function getRelevantStandards({ body, requestContext }) {
166
206
  }, 'No relevant categories detected');
167
207
  }
168
208
 
169
- // Query standards matching categories
209
+ // Query recent session categories first (fast), then merge into categories for main query
210
+ const recentCategories = {};
211
+ try {
212
+ const recencyResult = await executeQuery(`
213
+ SELECT sp.category, COUNT(*) as usage_count
214
+ FROM rapport.session_standards ss
215
+ JOIN rapport.sessions s ON s.session_id = ss.session_id
216
+ JOIN rapport.standards_patterns sp ON sp.pattern_id = ss.standard_id
217
+ WHERE s.email_address = $1
218
+ AND s.started_at >= NOW() - INTERVAL '7 days'
219
+ GROUP BY sp.category
220
+ ORDER BY usage_count DESC
221
+ LIMIT 5
222
+ `, [email]);
223
+ for (const row of recencyResult.rows) {
224
+ recentCategories[row.category] = parseInt(row.usage_count, 10);
225
+ }
226
+ } catch (err) {
227
+ console.error('[standardsRelevant] Recency query failed:', err.message);
228
+ }
229
+
230
+ // Merge recency categories into query — recent activity should always be represented
231
+ for (const category of Object.keys(recentCategories)) {
232
+ if (!categories.includes(category)) {
233
+ categories.push(category);
234
+ }
235
+ }
236
+
237
+ // Query standards from all relevant categories (static + recency)
170
238
  const result = await executeQuery(`
171
239
  SELECT
172
240
  pattern_id,
173
241
  element,
242
+ title,
174
243
  rule,
175
244
  category,
245
+ keywords,
176
246
  correlation,
177
247
  maturity,
178
248
  applicable_files,
@@ -192,16 +262,41 @@ async function getRelevantStandards({ body, requestContext }) {
192
262
  correlation DESC
193
263
  `, [categories]);
194
264
 
195
- // Rank by relevance
196
- let ranked = rankStandards(result.rows);
265
+ // Rank by relevance with recency boost
266
+ let ranked = rankStandards(result.rows, recentCategories);
267
+
268
+ // Deduplicate by element name (same rule can be ingested with different path prefixes)
269
+ const seenElements = new Set();
270
+ ranked = ranked.filter(standard => {
271
+ const key = standard.element;
272
+ if (seenElements.has(key)) return false;
273
+ seenElements.add(key);
274
+ return true;
275
+ });
197
276
 
198
277
  // Apply preferences if provided
199
278
  if (preferences) {
200
279
  ranked = applyPreferences(ranked, preferences);
201
280
  }
202
281
 
203
- // Return top 10
204
- const top = ranked.slice(0, 10);
282
+ // Return top 10 with diversity caps:
283
+ // - Max 2 per category (prevents single-category saturation)
284
+ // - Max 1 per standard title (prevents same-file rule pairs wasting slots)
285
+ const MAX_PER_CATEGORY = 2;
286
+ const MAX_PER_TITLE = 1;
287
+ const top = [];
288
+ const categoryCounts = {};
289
+ const titleCounts = {};
290
+ for (const standard of ranked) {
291
+ const cat = standard.category;
292
+ const title = standard.title || standard.element;
293
+ categoryCounts[cat] = (categoryCounts[cat] || 0) + 1;
294
+ titleCounts[title] = (titleCounts[title] || 0) + 1;
295
+ if (categoryCounts[cat] <= MAX_PER_CATEGORY && titleCounts[title] <= MAX_PER_TITLE) {
296
+ top.push(standard);
297
+ if (top.length >= 10) break;
298
+ }
299
+ }
205
300
 
206
301
  return createSuccessResponse({
207
302
  standards: top,