fraim-framework 2.0.26 → 2.0.30

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 (104) hide show
  1. package/.github/workflows/deploy-fraim.yml +1 -1
  2. package/dist/registry/scripts/build-scripts-generator.js +205 -0
  3. package/dist/registry/scripts/cleanup-branch.js +258 -0
  4. package/dist/registry/scripts/evaluate-code-quality.js +66 -0
  5. package/dist/registry/scripts/exec-with-timeout.js +142 -0
  6. package/dist/registry/scripts/fraim-config.js +61 -0
  7. package/dist/registry/scripts/generate-engagement-emails.js +630 -0
  8. package/dist/registry/scripts/generic-issues-api.js +100 -0
  9. package/dist/registry/scripts/newsletter-helpers.js +731 -0
  10. package/dist/registry/scripts/openapi-generator.js +664 -0
  11. package/dist/registry/scripts/performance/profile-server.js +390 -0
  12. package/dist/registry/scripts/run-thank-you-workflow.js +92 -0
  13. package/dist/registry/scripts/send-newsletter-simple.js +85 -0
  14. package/dist/registry/scripts/send-thank-you-emails.js +54 -0
  15. package/dist/registry/scripts/validate-openapi-limits.js +311 -0
  16. package/dist/registry/scripts/validate-test-coverage.js +262 -0
  17. package/dist/registry/scripts/verify-test-coverage.js +66 -0
  18. package/dist/src/cli/commands/init.js +14 -12
  19. package/dist/src/cli/commands/sync.js +19 -2
  20. package/dist/src/cli/fraim.js +24 -22
  21. package/dist/src/cli/setup/first-run.js +13 -6
  22. package/dist/src/fraim/config-loader.js +0 -8
  23. package/dist/src/fraim/db-service.js +26 -15
  24. package/dist/src/fraim/issues.js +67 -0
  25. package/dist/src/fraim/setup-wizard.js +1 -69
  26. package/dist/src/fraim/types.js +0 -11
  27. package/dist/src/fraim-mcp-server.js +272 -18
  28. package/dist/src/utils/git-utils.js +1 -1
  29. package/dist/src/utils/version-utils.js +32 -0
  30. package/dist/tests/debug-tools.js +79 -0
  31. package/dist/tests/esm-compat.js +11 -0
  32. package/dist/tests/test-chalk-esm-issue.js +159 -0
  33. package/dist/tests/test-chalk-real-world.js +265 -0
  34. package/dist/tests/test-chalk-regression.js +327 -0
  35. package/dist/tests/test-chalk-resolution-issue.js +304 -0
  36. package/dist/tests/test-cli.js +0 -2
  37. package/dist/tests/test-fraim-install-chalk-issue.js +254 -0
  38. package/dist/tests/test-fraim-issues.js +59 -0
  39. package/dist/tests/test-genericization.js +1 -3
  40. package/dist/tests/test-mcp-connection.js +166 -0
  41. package/dist/tests/test-mcp-issue-integration.js +144 -0
  42. package/dist/tests/test-mcp-lifecycle-methods.js +312 -0
  43. package/dist/tests/test-node-compatibility.js +71 -0
  44. package/dist/tests/test-npm-install.js +66 -0
  45. package/dist/tests/test-npm-resolution-diagnostic.js +140 -0
  46. package/dist/tests/test-session-rehydration.js +145 -0
  47. package/dist/tests/test-standalone.js +2 -8
  48. package/dist/tests/test-sync-version-update.js +93 -0
  49. package/dist/tests/test-telemetry.js +190 -0
  50. package/package.json +10 -8
  51. package/registry/agent-guardrails.md +62 -54
  52. package/registry/rules/agent-success-criteria.md +52 -0
  53. package/registry/rules/agent-testing-guidelines.md +502 -502
  54. package/registry/rules/communication.md +121 -121
  55. package/registry/rules/continuous-learning.md +54 -54
  56. package/registry/rules/ephemeral-execution.md +10 -5
  57. package/registry/rules/hitl-ppe-record-analysis.md +302 -302
  58. package/registry/rules/local-development.md +251 -251
  59. package/registry/rules/software-development-lifecycle.md +104 -104
  60. package/registry/rules/successful-debugging-patterns.md +482 -478
  61. package/registry/rules/telemetry.md +67 -0
  62. package/registry/scripts/build-scripts-generator.ts +216 -215
  63. package/registry/scripts/cleanup-branch.ts +303 -284
  64. package/registry/scripts/code-quality-check.sh +559 -559
  65. package/registry/scripts/detect-tautological-tests.sh +38 -38
  66. package/registry/scripts/evaluate-code-quality.ts +1 -1
  67. package/registry/scripts/generate-engagement-emails.ts +744 -744
  68. package/registry/scripts/generic-issues-api.ts +110 -150
  69. package/registry/scripts/newsletter-helpers.ts +874 -874
  70. package/registry/scripts/openapi-generator.ts +695 -693
  71. package/registry/scripts/performance/profile-server.ts +5 -3
  72. package/registry/scripts/prep-issue.sh +468 -455
  73. package/registry/scripts/validate-openapi-limits.ts +366 -365
  74. package/registry/scripts/validate-test-coverage.ts +280 -280
  75. package/registry/scripts/verify-pr-comments.sh +70 -70
  76. package/registry/scripts/verify-test-coverage.ts +1 -1
  77. package/registry/templates/bootstrap/ARCHITECTURE-TEMPLATE.md +53 -53
  78. package/registry/templates/evidence/Implementation-BugEvidence.md +85 -85
  79. package/registry/templates/evidence/Implementation-FeatureEvidence.md +120 -120
  80. package/registry/templates/marketing/HBR-ARTICLE-TEMPLATE.md +66 -0
  81. package/registry/workflows/bootstrap/create-architecture.md +2 -2
  82. package/registry/workflows/bootstrap/evaluate-code-quality.md +3 -3
  83. package/registry/workflows/bootstrap/verify-test-coverage.md +2 -2
  84. package/registry/workflows/customer-development/insight-analysis.md +156 -156
  85. package/registry/workflows/customer-development/interview-preparation.md +421 -421
  86. package/registry/workflows/customer-development/strategic-brainstorming.md +146 -146
  87. package/registry/workflows/customer-development/thank-customers.md +193 -191
  88. package/registry/workflows/customer-development/weekly-newsletter.md +362 -352
  89. package/registry/workflows/improve-fraim/contribute.md +32 -0
  90. package/registry/workflows/improve-fraim/file-issue.md +32 -0
  91. package/registry/workflows/marketing/hbr-article.md +73 -0
  92. package/registry/workflows/performance/analyze-performance.md +63 -59
  93. package/registry/workflows/product-building/design.md +3 -2
  94. package/registry/workflows/product-building/implement.md +4 -3
  95. package/registry/workflows/product-building/prep-issue.md +28 -17
  96. package/registry/workflows/product-building/resolve.md +3 -2
  97. package/registry/workflows/product-building/retrospect.md +3 -2
  98. package/registry/workflows/product-building/spec.md +5 -4
  99. package/registry/workflows/product-building/test.md +3 -2
  100. package/registry/workflows/quality-assurance/iterative-improvement-cycle.md +562 -562
  101. package/registry/workflows/replicate/website-discovery-analysis.md +3 -3
  102. package/registry/workflows/reviewer/review-implementation-vs-design-spec.md +632 -632
  103. package/registry/workflows/reviewer/review-implementation-vs-feature-spec.md +669 -669
  104. package/tsconfig.json +2 -1
@@ -0,0 +1,731 @@
1
+ #!/usr/bin/env tsx
2
+ "use strict";
3
+ /**
4
+ * Newsletter Helper Functions
5
+ *
6
+ * DETERMINISTIC functions for AI agents to use when creating newsletters.
7
+ * AI agents do the CREATIVE work (categorization, content writing).
8
+ * These functions provide TOOLS (data fetching, email sending).
9
+ */
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.getLatestNewsletterDate = getLatestNewsletterDate;
12
+ exports.getResolvedIssuesForNewsletter = getResolvedIssuesForNewsletter;
13
+ exports.getAllActiveExecutives = getAllActiveExecutives;
14
+ exports.getPotentialCustomers = getPotentialCustomers;
15
+ exports.generateNewsletterHTML = generateNewsletterHTML;
16
+ exports.saveNewsletter = saveNewsletter;
17
+ exports.sendNewsletterToExecutives = sendNewsletterToExecutives;
18
+ const child_process_1 = require("child_process");
19
+ const fs_1 = require("fs");
20
+ const mongodb_1 = require("mongodb");
21
+ const path_1 = require("path");
22
+ const git_utils_1 = require("../../src/utils/git-utils");
23
+ const fraim_config_1 = require("./fraim-config");
24
+ // Reuse existing email infrastructure
25
+ const generate_engagement_emails_1 = require("./generate-engagement-emails");
26
+ /**
27
+ * Get the latest date from existing newsletter files
28
+ * Returns the most recent date when newsletter was sent, or null if no files exist
29
+ */
30
+ function getLatestNewsletterDate() {
31
+ const newslettersDir = 'docs/customer-development/newsletters';
32
+ if (!(0, fs_1.existsSync)(newslettersDir)) {
33
+ return null;
34
+ }
35
+ const files = (0, fs_1.readdirSync)(newslettersDir).filter(f => f.startsWith('newsletter-') && f.endsWith('.json'));
36
+ if (files.length === 0) {
37
+ return null;
38
+ }
39
+ // Extract dates from filenames (e.g., newsletter-2025-11-01.json)
40
+ const dates = [];
41
+ for (const file of files) {
42
+ const dateMatch = file.match(/newsletter-(\d{4}-\d{2}-\d{2})\.json/);
43
+ if (dateMatch) {
44
+ const filePath = (0, path_1.join)(newslettersDir, file);
45
+ // Verify content is valid if needed, but for date extraction filename is enough
46
+ dates.push(dateMatch[1]);
47
+ }
48
+ }
49
+ if (dates.length === 0) {
50
+ return null;
51
+ }
52
+ // Return the most recent date
53
+ dates.sort();
54
+ const latestDate = dates[dates.length - 1];
55
+ console.log(`📅 Latest newsletter date found: ${latestDate}`);
56
+ return latestDate;
57
+ }
58
+ /**
59
+ * Get resolved issues for newsletter
60
+ * DETERMINISTIC: Just fetches issues, doesn't categorize or filter
61
+ * AI agent decides what to include and how to present it
62
+ *
63
+ * @param date Optional date string (YYYY-MM-DD). If not provided, uses latest newsletter date or last 7 days
64
+ */
65
+ async function getResolvedIssuesForNewsletter(date) {
66
+ if (!date) {
67
+ // Try to get the latest newsletter date
68
+ const latestNewsletterDate = getLatestNewsletterDate();
69
+ if (latestNewsletterDate) {
70
+ date = latestNewsletterDate;
71
+ console.log(`📋 Using latest newsletter date as starting point: ${date}`);
72
+ }
73
+ else {
74
+ // Fallback to last 7 days if no previous newsletters exist
75
+ const fallbackDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
76
+ date = fallbackDate;
77
+ console.log(`📋 No previous newsletters found, using last 7 days: ${date}`);
78
+ }
79
+ }
80
+ console.log(`📋 Fetching issues resolved since ${date}...`);
81
+ // Get all closed issues from the specified date (not just user-reported)
82
+ const command = `gh search issues --repo=${fraim_config_1.fraimConfig.repoOwner}/${fraim_config_1.fraimConfig.repoName} --state=closed --closed=">${date}" --json number,title,body,labels,closedAt,author --limit 100`;
83
+ const output = (0, child_process_1.execSync)(command, { encoding: 'utf-8' });
84
+ const issues = JSON.parse(output);
85
+ console.log(`✅ Found ${issues.length} resolved issues`);
86
+ return issues;
87
+ }
88
+ /**
89
+ * Get database name, defaulting to production
90
+ */
91
+ function getDatabaseName() {
92
+ const env = process.env.NODE_ENV || process.env.ENVIRONMENT;
93
+ // If explicitly set to ppe/staging, use that; otherwise default to prod
94
+ if (env === 'ppe' || env === 'staging') {
95
+ return (0, git_utils_1.determineDatabaseName)();
96
+ }
97
+ // Default to production
98
+ return process.env.MONGO_DB_NAME || 'fraim_prod';
99
+ }
100
+ /**
101
+ * Get collection name with schema prefix (defaults to prod schema)
102
+ */
103
+ function getCollectionName(baseName) {
104
+ const env = process.env.NODE_ENV || process.env.ENVIRONMENT;
105
+ // If explicitly set to ppe/staging, use that schema; otherwise default to prod
106
+ if (env === 'ppe' || env === 'staging') {
107
+ const schema = (0, git_utils_1.determineSchema)((0, git_utils_1.getCurrentGitBranch)());
108
+ return `${schema}_${baseName}`;
109
+ }
110
+ // Default to prod schema
111
+ return `prod_${baseName}`;
112
+ }
113
+ /**
114
+ * Get all active executives (defaults to production database)
115
+ * DETERMINISTIC: Just fetches executive list
116
+ */
117
+ async function getAllActiveExecutives() {
118
+ const mongoUrl = process.env.PROD_MONGO_DATABASE_URL || process.env.MONGO_DATABASE_URL;
119
+ if (!mongoUrl) {
120
+ throw new Error('PROD_MONGO_DATABASE_URL or MONGO_DATABASE_URL environment variable is required');
121
+ }
122
+ const client = new mongodb_1.MongoClient(mongoUrl);
123
+ try {
124
+ await client.connect();
125
+ const dbName = getDatabaseName();
126
+ const collectionName = getCollectionName('Executive');
127
+ console.log(`[getAllActiveExecutives] Querying database: ${dbName}, collection: ${collectionName}`);
128
+ const db = client.db(dbName);
129
+ const executives = await db.collection(collectionName)
130
+ .find({})
131
+ .toArray();
132
+ console.log(`[getAllActiveExecutives] Found ${executives.length} executives in ${dbName}.${collectionName}`);
133
+ // If no executives found in prod, check PPE as fallback
134
+ if (executives.length === 0 && dbName === 'fraim_prod') {
135
+ console.log(`[getAllActiveExecutives] No executives in prod, checking PPE as fallback...`);
136
+ const ppeDb = client.db('fraim_ppe');
137
+ const ppeExecutives = await ppeDb.collection('ppe_Executive')
138
+ .find({})
139
+ .toArray();
140
+ console.log(`[getAllActiveExecutives] Found ${ppeExecutives.length} executives in fraim_ppe.ppe_Executive`);
141
+ return ppeExecutives;
142
+ }
143
+ return executives;
144
+ }
145
+ finally {
146
+ await client.close();
147
+ }
148
+ }
149
+ /**
150
+ * Get all potential customers (leads who have expressed interest)
151
+ * DETERMINISTIC: Just fetches potential customer list from JSON file
152
+ */
153
+ function getPotentialCustomers() {
154
+ const potentialCustomersPath = (0, path_1.join)(process.cwd(), 'docs', 'customer-development', 'potential-customers.json');
155
+ if (!(0, fs_1.existsSync)(potentialCustomersPath)) {
156
+ return [];
157
+ }
158
+ try {
159
+ const content = (0, fs_1.readFileSync)(potentialCustomersPath, 'utf-8');
160
+ return JSON.parse(content);
161
+ }
162
+ catch (error) {
163
+ console.error('❌ Error reading potential customers file:', error);
164
+ return [];
165
+ }
166
+ }
167
+ /**
168
+ * Get default Persona email from database (defaults to production)
169
+ * Extracts the base email (part before the +) from existing executive Persona emails
170
+ * DETERMINISTIC: Reads from database, extracts pattern
171
+ */
172
+ async function getDefaultPersonaEmailFromDatabase() {
173
+ const mongoUrl = process.env.PROD_MONGO_DATABASE_URL || process.env.MONGO_DATABASE_URL;
174
+ if (!mongoUrl) {
175
+ return null;
176
+ }
177
+ const client = new mongodb_1.MongoClient(mongoUrl);
178
+ try {
179
+ await client.connect();
180
+ const dbName = getDatabaseName();
181
+ const db = client.db(dbName);
182
+ const collectionName = getCollectionName(fraim_config_1.fraimConfig.identityCollection);
183
+ // Get any active Persona identity from the database (defaults to prod)
184
+ let identity = await db.collection(collectionName)
185
+ .findOne({ status: 'active' });
186
+ // Try any identity if no active one found or no email
187
+ if (!identity || !identity.email) {
188
+ identity = await db.collection(collectionName)
189
+ .findOne({ email: { $exists: true } });
190
+ }
191
+ if (!identity || !identity.email) {
192
+ return null;
193
+ }
194
+ const email = identity.email;
195
+ // Extract base email: if email is "persona+username@domain.com", extract "persona@domain.com"
196
+ if (email.includes('+')) {
197
+ const [localPart, rest] = email.split('+');
198
+ const domain = rest.split('@')[1];
199
+ return `${localPart}@${domain}`;
200
+ }
201
+ // If no plus sign, return as-is (it's already the base email)
202
+ return email;
203
+ }
204
+ catch (error) {
205
+ console.warn(`⚠️ Could not get default ${fraim_config_1.fraimConfig.personaName} email from database:`, error);
206
+ return null;
207
+ }
208
+ finally {
209
+ await client.close();
210
+ }
211
+ }
212
+ /**
213
+ * Generate HTML from newsletter JSON
214
+ * DETERMINISTIC: Just applies template, no creative decisions
215
+ */
216
+ function generateNewsletterHTML(newsletter) {
217
+ const templatePath = (0, path_1.join)(process.cwd(), 'registry', 'templates', 'customer-development', 'weekly-newsletter-template.html');
218
+ let html = (0, fs_1.readFileSync)(templatePath, 'utf-8');
219
+ const { content } = newsletter;
220
+ // Replace simple variables
221
+ html = html.replace(/\{\{weekTitle\}\}/g, escapeHtml(content.weekTitle));
222
+ html = html.replace(/\{\{weekSubtitle\}\}/g, escapeHtml(content.weekSubtitle));
223
+ html = html.replace(/\{\{weekDate\}\}/g, escapeHtml(content.weekDate));
224
+ html = html.replace(/\{\{personaName\}\}/g, escapeHtml(fraim_config_1.fraimConfig.personaName));
225
+ html = html.replace(/\{\{chatUrl\}\}/g, escapeHtml(fraim_config_1.fraimConfig.chatUrl || '#'));
226
+ html = html.replace(/\{\{webAppUrl\}\}/g, escapeHtml(fraim_config_1.fraimConfig.webAppUrl || '#'));
227
+ html = html.replace(/\{\{openingMessage\}\}/g, escapeHtml(content.openingMessage));
228
+ html = html.replace(/\{\{ctaLink\}\}/g, content.ctaLink || (fraim_config_1.fraimConfig.webAppUrl ? `${fraim_config_1.fraimConfig.webAppUrl}/wellness/${fraim_config_1.fraimConfig.personaName.toLowerCase()}` : '#'));
229
+ html = html.replace(/\{\{ctaText\}\}/g, escapeHtml(content.ctaText || `Get ${fraim_config_1.fraimConfig.personaName} Now`));
230
+ html = html.replace(/\{\{unsubscribeLink\}\}/g, '#');
231
+ // Custom header text - check if this is the first newsletter
232
+ if (content.weekDate && (content.weekDate.toLowerCase().includes('month of november') || content.weekDate.toLowerCase().includes('month of october'))) {
233
+ html = html.replace(/🚀 Weekly Update/g, `🎉 ${fraim_config_1.fraimConfig.personaName}'s First Update`);
234
+ html = html.replace(/📅 Week of/g, "📅");
235
+ html = html.replace(/Coming Next Week/g, "Coming Next Month");
236
+ }
237
+ // Stats
238
+ html = html.replace(/\{\{statsFeatures\}\}/g, (newsletter.metadata.featuresCount || 0).toString());
239
+ html = html.replace(/\{\{statsImprovements\}\}/g, (newsletter.metadata.improvementsCount || 0).toString());
240
+ html = html.replace(/\{\{statsBugFixes\}\}/g, (newsletter.metadata.bugFixesCount || 0).toString());
241
+ // Hero feature(s) - support both single heroFeature and array of heroFeatures
242
+ const heroFeatures = content.heroFeatures || (content.heroFeature ? [content.heroFeature] : []);
243
+ if (heroFeatures.length > 0) {
244
+ // Generate HTML for all hero features, wrapped in proper table structure
245
+ const heroFeaturesHTML = heroFeatures.map((hero, index) => {
246
+ const badge = heroFeatures.length > 1 ? `HERO FEATURE ${index + 1}` : 'HERO FEATURE';
247
+ const marginBottom = index < heroFeatures.length - 1 ? '40px' : '0';
248
+ return `
249
+ <tr>
250
+ <td style="padding: 0 40px ${marginBottom} 40px;">
251
+ <div style="background: linear-gradient(135deg, #667eea15 0%, #764ba215 100%); border: 2px solid #667eea; border-radius: 12px; padding: 35px; position: relative; overflow: hidden;">
252
+ <div style="position: absolute; top: -10px; right: -10px; background: #ff6b6b; color: white; padding: 8px 20px; border-radius: 20px; font-size: 12px; font-weight: 700; letter-spacing: 1px; transform: rotate(12deg); box-shadow: 0 4px 12px rgba(255, 107, 107, 0.4);">
253
+ ⭐ ${badge}
254
+ </div>
255
+ <div style="font-size: 14px; color: #667eea; font-weight: 700; text-transform: uppercase; letter-spacing: 1.5px; margin-bottom: 12px;">🎯 ${heroFeatures.length > 1 ? `Feature ${index + 1} of the Week` : 'Feature of the Week'}</div>
256
+ <div style="font-size: 28px; font-weight: 800; color: #212529; margin-bottom: 16px; line-height: 1.3;">${escapeHtml(hero.title)}</div>
257
+ <div style="font-size: 16px; color: #495057; line-height: 1.8; margin-bottom: ${hero.impact ? '20px' : '0'};">${escapeHtml(hero.description)}</div>
258
+ ${hero.impact ? `
259
+ <div style="background: white; border-left: 4px solid #28a745; padding: 16px 20px; border-radius: 8px; margin-top: 20px;">
260
+ <div style="font-size: 14px; font-weight: 700; color: #28a745; margin-bottom: 8px;">💚 Real Impact:</div>
261
+ <div style="font-size: 15px; color: #495057; line-height: 1.7;">${escapeHtml(hero.impact)}</div>
262
+ </div>
263
+ ` : ''}
264
+ </div>
265
+ </td>
266
+ </tr>
267
+ `;
268
+ }).join('');
269
+ html = html.replace(/\{\{heroFeatureTitle\}\}/g, escapeHtml(heroFeatures[0].title));
270
+ html = html.replace(/\{\{heroFeatureDescription\}\}/g, escapeHtml(heroFeatures[0].description));
271
+ html = html.replace(/\{\{heroFeatureImpact\}\}/g, escapeHtml(heroFeatures[0].impact || ''));
272
+ html = html.replace(/\{\{#if hasHeroFeature\}\}([\s\S]*?)\{\{\/if\}\}/, heroFeaturesHTML);
273
+ html = html.replace(/\{\{#if heroFeatureImpact\}\}([\s\S]*?)\{\{\/if\}\}/, heroFeatures[0].impact ? '$1' : '');
274
+ }
275
+ else {
276
+ html = html.replace(/\{\{#if hasHeroFeature\}\}[\s\S]*?\{\{\/if\}\}/, '');
277
+ }
278
+ // New features list
279
+ if (content.newFeatures && content.newFeatures.length > 0) {
280
+ const featuresHTML = content.newFeatures.map((feature, index) => `
281
+ <div style="margin-bottom: ${index < content.newFeatures.length - 1 ? '24px' : '0'}; padding: 24px; background: white; border-radius: 10px; border: 2px solid #e9ecef; box-shadow: 0 2px 8px rgba(0,0,0,0.04);">
282
+ <div style="font-size: 19px; font-weight: 700; color: #212529; margin-bottom: 10px;">✨ ${escapeHtml(feature.title)}</div>
283
+ <div style="font-size: 15px; color: #495057; line-height: 1.7;">${escapeHtml(feature.description)}</div>
284
+ ${feature.impact ? `<div style="margin-top: 12px; padding-top: 12px; border-top: 1px solid #e9ecef; font-size: 14px; color: #667eea; font-weight: 600;">💡 ${escapeHtml(feature.impact)}</div>` : ''}
285
+ </div>
286
+ `).join('');
287
+ html = html.replace(/\{\{newFeaturesList\}\}/g, featuresHTML);
288
+ html = html.replace(/\{\{#if hasNewFeatures\}\}([\s\S]*?)\{\{\/if\}\}/, '$1');
289
+ }
290
+ else {
291
+ html = html.replace(/\{\{#if hasNewFeatures\}\}[\s\S]*?\{\{\/if\}\}/, '');
292
+ }
293
+ // Improvements list
294
+ if (content.improvements && content.improvements.length > 0) {
295
+ const improvementsHTML = content.improvements.map((improvement, index) => `
296
+ <div style="margin-bottom: ${index < content.improvements.length - 1 ? '20px' : '0'}; padding: 20px; background: #f8f9fa; border-radius: 8px; border-left: 4px solid #28a745;">
297
+ <div style="font-size: 17px; font-weight: 700; color: #212529; margin-bottom: 8px;">🚀 ${escapeHtml(improvement.title)}</div>
298
+ <div style="font-size: 15px; color: #495057; line-height: 1.7;">${escapeHtml(improvement.description)}</div>
299
+ </div>
300
+ `).join('');
301
+ html = html.replace(/\{\{improvementsList\}\}/g, improvementsHTML);
302
+ html = html.replace(/\{\{#if hasImprovements\}\}([\s\S]*?)\{\{\/if\}\}/, '$1');
303
+ }
304
+ else {
305
+ html = html.replace(/\{\{#if hasImprovements\}\}[\s\S]*?\{\{\/if\}\}/, '');
306
+ }
307
+ // Bug fixes list
308
+ if (content.bugFixes && content.bugFixes.length > 0) {
309
+ const bugFixesHTML = content.bugFixes.map((fix, index) => `
310
+ <div style="margin-bottom: ${index < content.bugFixes.length - 1 ? '16px' : '0'}; padding: 16px; background: #fff8f0; border-radius: 8px; border-left: 4px solid #fd7e14;">
311
+ <div style="font-size: 16px; font-weight: 600; color: #212529; margin-bottom: 6px;">🔧 ${escapeHtml(fix.title)}</div>
312
+ <div style="font-size: 14px; color: #495057; line-height: 1.6;">${escapeHtml(fix.description)}</div>
313
+ </div>
314
+ `).join('');
315
+ html = html.replace(/\{\{bugFixesList\}\}/g, bugFixesHTML);
316
+ html = html.replace(/\{\{#if hasBugFixes\}\}([\s\S]*?)\{\{\/if\}\}/, '$1');
317
+ }
318
+ else {
319
+ html = html.replace(/\{\{#if hasBugFixes\}\}[\s\S]*?\{\{\/if\}\}/, '');
320
+ }
321
+ // Testimonial
322
+ if (content.testimonial) {
323
+ html = html.replace(/\{\{testimonialText\}\}/g, escapeHtml(content.testimonial.text));
324
+ html = html.replace(/\{\{testimonialAuthor\}\}/g, escapeHtml(content.testimonial.author));
325
+ html = html.replace(/\{\{testimonialRole\}\}/g, escapeHtml(content.testimonial.role));
326
+ html = html.replace(/\{\{#if hasTestimonial\}\}([\s\S]*?)\{\{\/if\}\}/, '$1');
327
+ }
328
+ else {
329
+ html = html.replace(/\{\{#if hasTestimonial\}\}[\s\S]*?\{\{\/if\}\}/, '');
330
+ }
331
+ // Coming next
332
+ if (content.comingNext) {
333
+ html = html.replace(/\{\{comingNextText\}\}/g, escapeHtml(content.comingNext));
334
+ html = html.replace(/\{\{#if hasComingNext\}\}([\s\S]*?)\{\{\/if\}\}/, '$1');
335
+ }
336
+ else {
337
+ html = html.replace(/\{\{#if hasComingNext\}\}[\s\S]*?\{\{\/if\}\}/, '');
338
+ }
339
+ // Conditional sections
340
+ html = html.replace(/\{\{#if showStats\}\}([\s\S]*?)\{\{\/if\}\}/, '$1');
341
+ html = html.replace(/\{\{#if showFOMO\}\}([\s\S]*?)\{\{\/if\}\}/, '$1');
342
+ return html;
343
+ }
344
+ /**
345
+ * Escape HTML special characters
346
+ */
347
+ function escapeHtml(text) {
348
+ if (!text)
349
+ return '';
350
+ return text
351
+ .replace(/&/g, '&amp;')
352
+ .replace(/</g, '&lt;')
353
+ .replace(/>/g, '&gt;')
354
+ .replace(/"/g, '&quot;')
355
+ .replace(/'/g, '&#039;');
356
+ }
357
+ /**
358
+ * Save newsletter JSON and HTML
359
+ * DETERMINISTIC: Just writes files
360
+ */
361
+ function saveNewsletter(newsletter, outputDir) {
362
+ const dir = outputDir || (0, path_1.join)(process.cwd(), 'docs', 'customer-development', 'newsletters');
363
+ if (!(0, fs_1.existsSync)(dir)) {
364
+ (0, fs_1.mkdirSync)(dir, { recursive: true });
365
+ }
366
+ const dateStr = newsletter.metadata.weekEnd;
367
+ const jsonPath = (0, path_1.join)(dir, `newsletter-${dateStr}.json`);
368
+ const htmlPath = (0, path_1.join)(dir, `newsletter-${dateStr}.html`);
369
+ // Generate HTML
370
+ const html = generateNewsletterHTML(newsletter);
371
+ // Save JSON
372
+ (0, fs_1.writeFileSync)(jsonPath, JSON.stringify(newsletter, null, 2));
373
+ console.log(`✅ Saved newsletter JSON to: ${jsonPath}`);
374
+ // Save HTML
375
+ (0, fs_1.writeFileSync)(htmlPath, html);
376
+ console.log(`✅ Saved newsletter HTML to: ${htmlPath}`);
377
+ return jsonPath;
378
+ }
379
+ /**
380
+ * Send newsletter to all executives and potential customers
381
+ * DETERMINISTIC: Just sends emails, no decisions
382
+ *
383
+ * AI agents call this AFTER user approves the newsletter JSON
384
+ * @param newsletterPath Path to newsletter JSON file
385
+ * @param filterEmails Optional array of email addresses to send to (for testing)
386
+ * @param filterExecIds Optional array of executive IDs to send to (for testing)
387
+ * @param includePotentialCustomers Whether to include potential customers in the send (default: true)
388
+ * @param showOnly If true, only show recipient list without sending
389
+ */
390
+ async function sendNewsletterToExecutives(newsletterPath, filterEmails, filterExecIds, includePotentialCustomers = true, showOnly = false, includeExecutives = true) {
391
+ console.log(`📧 Sending newsletter from: ${newsletterPath}`);
392
+ if (!(0, fs_1.existsSync)(newsletterPath)) {
393
+ throw new Error(`Newsletter file not found: ${newsletterPath}`);
394
+ }
395
+ const newsletter = JSON.parse((0, fs_1.readFileSync)(newsletterPath, 'utf-8'));
396
+ const htmlPath = newsletterPath.replace('.json', '.html');
397
+ if (!(0, fs_1.existsSync)(htmlPath)) {
398
+ throw new Error(`Newsletter HTML not found: ${htmlPath}. Generate it first.`);
399
+ }
400
+ const html = (0, fs_1.readFileSync)(htmlPath, 'utf-8');
401
+ let executives = [];
402
+ // Get executives only if includeExecutives is true
403
+ if (includeExecutives) {
404
+ executives = await getAllActiveExecutives();
405
+ // Filter executives if filters are provided
406
+ if (filterEmails && filterEmails.length > 0) {
407
+ console.log(`🔍 Filtering to ${filterEmails.length} email(s): ${filterEmails.join(', ')}`);
408
+ executives = executives.filter(exec => filterEmails.includes(exec.email));
409
+ }
410
+ if (filterExecIds && filterExecIds.length > 0) {
411
+ console.log(`🔍 Filtering to ${filterExecIds.length} executive ID(s): ${filterExecIds.join(', ')}`);
412
+ executives = executives.filter(exec => exec.id && filterExecIds.includes(exec.id));
413
+ }
414
+ }
415
+ // Get potential customers if requested
416
+ let potentialCustomers = [];
417
+ if (includePotentialCustomers) {
418
+ potentialCustomers = getPotentialCustomers();
419
+ // Filter potential customers if filterEmails is provided
420
+ if (filterEmails && filterEmails.length > 0) {
421
+ potentialCustomers = potentialCustomers.filter(customer => filterEmails.includes(customer.email));
422
+ }
423
+ }
424
+ if (includeExecutives) {
425
+ console.log(`📊 Found ${executives.length} executive(s) to send to`);
426
+ }
427
+ if (includePotentialCustomers) {
428
+ console.log(`📊 Found ${potentialCustomers.length} potential customer(s) to send to`);
429
+ }
430
+ if (showOnly) {
431
+ console.log(`\n📋 Recipient List (--showonly mode - no emails will be sent):\n`);
432
+ if (includeExecutives && executives.length > 0) {
433
+ console.log(`\n👥 ACTIVE EXECUTIVES (${executives.length}):\n`);
434
+ for (const exec of executives) {
435
+ const fromEmail = exec.id
436
+ ? await (0, generate_engagement_emails_1.getPersonaEmailForExecutive)(exec.id)
437
+ : fraim_config_1.fraimConfig.defaultEmail;
438
+ const fromDisplayName = `${fraim_config_1.fraimConfig.personaName} - ${exec.name}'s AI Executive Assistant`;
439
+ console.log(` ${exec.name || 'Unknown Name'}`);
440
+ console.log(` To: ${exec.email || 'No email'}`);
441
+ console.log(` From Email: ${fromEmail}`);
442
+ console.log(` From Display Name: ${fromDisplayName}`);
443
+ console.log(` Executive ID: ${exec.id || 'No ID'}`);
444
+ console.log('');
445
+ }
446
+ }
447
+ if (potentialCustomers.length > 0) {
448
+ const defaultEmail = fraim_config_1.fraimConfig.prodDefaultEmail || 'agent@example.com';
449
+ const defaultDisplayName = `${fraim_config_1.fraimConfig.personaName} - Your AI Executive Assistant`;
450
+ console.log(`\n🎯 POTENTIAL CUSTOMERS (${potentialCustomers.length}):\n`);
451
+ potentialCustomers.forEach((customer, index) => {
452
+ console.log(` ${customer.name || 'Unknown Name'}`);
453
+ console.log(` To: ${customer.email || 'No email'}`);
454
+ console.log(` From Email: ${defaultEmail}`);
455
+ console.log(` From Display Name: ${defaultDisplayName}`);
456
+ console.log(` Source: ${customer.source || 'Unknown'}`);
457
+ console.log('');
458
+ });
459
+ }
460
+ const totalRecipients = (includeExecutives ? executives.length : 0) + (includePotentialCustomers ? potentialCustomers.length : 0);
461
+ console.log(`✅ Total: ${totalRecipients} recipient(s) would receive the newsletter`);
462
+ return;
463
+ }
464
+ console.log(`\n📧 Sending newsletter...\n`);
465
+ // Send to active executives (from their personal Persona email) - only if includeExecutives is true
466
+ if (includeExecutives) {
467
+ for (const executive of executives) {
468
+ try {
469
+ console.log(`📧 Sending to executive ${executive.name} (${executive.email})...`);
470
+ const fromEmail = executive.id
471
+ ? await (0, generate_engagement_emails_1.getPersonaEmailForExecutive)(executive.id)
472
+ : fraim_config_1.fraimConfig.defaultEmail;
473
+ const fromDisplayName = `${fraim_config_1.fraimConfig.personaName} - ${executive.name}'s AI Executive Assistant`;
474
+ // Remove emojis from subject line for ASCII compatibility
475
+ const subject = newsletter.content.weekTitle.replace(/[^\x00-\x7F]/g, '').trim();
476
+ const plainText = generatePlainText(newsletter);
477
+ // Get tokens and send (reuses existing infrastructure)
478
+ const personaTokens = await getPersonaTokens(executive.id);
479
+ const executiveData = await (0, generate_engagement_emails_1.findExecutiveByEmail)(executive.email);
480
+ await sendEmailViaGmail({
481
+ to: executive.email,
482
+ subject,
483
+ plainTextBody: plainText,
484
+ htmlBody: html,
485
+ fromEmail,
486
+ fromDisplayName
487
+ }, executiveData, personaTokens);
488
+ console.log(`✅ Sent to ${executive.email}`);
489
+ }
490
+ catch (error) {
491
+ console.error(`❌ Failed to send to ${executive.email}:`, error);
492
+ }
493
+ }
494
+ }
495
+ // Send to potential customers (from PROD_FRAIM_DEFAULT_EMAIL)
496
+ const defaultEmail = fraim_config_1.fraimConfig.prodDefaultEmail || 'agent@example.com';
497
+ const defaultDisplayName = `${fraim_config_1.fraimConfig.personaName} - Your AI Executive Assistant`;
498
+ const defaultAccessToken = fraim_config_1.fraimConfig.prodAccessToken || '';
499
+ const defaultRefreshToken = fraim_config_1.fraimConfig.prodRefreshToken || '';
500
+ if (!defaultAccessToken || !defaultRefreshToken) {
501
+ console.warn('⚠️ PROD_FRAIM_DEFAULT_ACCESS_TOKEN or PROD_FRAIM_DEFAULT_REFRESH_TOKEN not set. Skipping potential customers.');
502
+ }
503
+ else {
504
+ for (const customer of potentialCustomers) {
505
+ try {
506
+ console.log(`📧 Sending to potential customer ${customer.name} (${customer.email})...`);
507
+ // Remove emojis from subject line for ASCII compatibility
508
+ const subject = newsletter.content.weekTitle.replace(/[^\x00-\x7F]/g, '').trim();
509
+ const plainText = generatePlainText(newsletter);
510
+ // Send from default Persona email with default tokens
511
+ await sendEmailViaGmail({
512
+ to: customer.email,
513
+ subject,
514
+ plainTextBody: plainText,
515
+ htmlBody: html,
516
+ fromEmail: defaultEmail,
517
+ fromDisplayName: defaultDisplayName
518
+ }, undefined, { access_token: defaultAccessToken, refresh_token: defaultRefreshToken });
519
+ console.log(`✅ Sent to ${customer.email}`);
520
+ }
521
+ catch (error) {
522
+ console.error(`❌ Failed to send to ${customer.email}:`, error);
523
+ }
524
+ }
525
+ }
526
+ console.log(`\n✅ Newsletter sending complete!`);
527
+ if (includeExecutives) {
528
+ console.log(` Sent to ${executives.length} executive(s)`);
529
+ }
530
+ if (includePotentialCustomers) {
531
+ console.log(` Sent to ${potentialCustomers.length} potential customer(s)`);
532
+ }
533
+ process.exit(0);
534
+ }
535
+ /**
536
+ * Get Persona tokens for executive
537
+ */
538
+ async function getPersonaTokens(executiveId) {
539
+ if (!executiveId)
540
+ return null;
541
+ const mongoUrl = process.env.PROD_MONGO_DATABASE_URL || process.env.MONGO_DATABASE_URL;
542
+ if (!mongoUrl)
543
+ return null;
544
+ const client = new mongodb_1.MongoClient(mongoUrl);
545
+ try {
546
+ await client.connect();
547
+ const dbName = getDatabaseName();
548
+ const db = client.db(dbName);
549
+ const collectionName = getCollectionName(fraim_config_1.fraimConfig.identityCollection);
550
+ // Check for identity by executive ID
551
+ const identity = await db.collection(collectionName).findOne({
552
+ executive_id: executiveId,
553
+ status: 'active'
554
+ }) || await db.collection(collectionName).findOne({
555
+ executive_id: executiveId
556
+ });
557
+ if (identity && identity.access_token && identity.refresh_token) {
558
+ return {
559
+ access_token: identity.access_token,
560
+ refresh_token: identity.refresh_token
561
+ };
562
+ }
563
+ return null;
564
+ }
565
+ finally {
566
+ await client.close();
567
+ }
568
+ }
569
+ /**
570
+ * Generate plain text version
571
+ */
572
+ function generatePlainText(newsletter) {
573
+ const { content } = newsletter;
574
+ let text = `${content.weekTitle}\n`;
575
+ text += `${content.weekSubtitle}\n`;
576
+ text += `Week of ${content.weekDate}\n\n`;
577
+ text += `${content.openingMessage}\n\n`;
578
+ // Hero feature(s) - support both single heroFeature and array of heroFeatures
579
+ const heroFeatures = content.heroFeatures || (content.heroFeature ? [content.heroFeature] : []);
580
+ if (heroFeatures.length > 0) {
581
+ const heroLabel = heroFeatures.length > 1 ? '✨ HERO FEATURES OF THE WEEK' : '✨ FEATURE OF THE WEEK';
582
+ text += `${heroLabel}\n\n`;
583
+ heroFeatures.forEach((hero, index) => {
584
+ if (heroFeatures.length > 1) {
585
+ text += `HERO FEATURE ${index + 1}:\n`;
586
+ }
587
+ text += `${hero.title}\n`;
588
+ text += `${hero.description}\n`;
589
+ if (hero.impact) {
590
+ text += `Impact: ${hero.impact}\n`;
591
+ }
592
+ text += `\n`;
593
+ });
594
+ }
595
+ if (content.newFeatures && content.newFeatures.length > 0) {
596
+ text += `NEW FEATURES:\n`;
597
+ content.newFeatures.forEach((f) => {
598
+ text += `• ${f.title}: ${f.description}\n`;
599
+ });
600
+ text += `\n`;
601
+ }
602
+ if (content.improvements && content.improvements.length > 0) {
603
+ text += `IMPROVEMENTS:\n`;
604
+ content.improvements.forEach((i) => {
605
+ text += `• ${i.title}: ${i.description}\n`;
606
+ });
607
+ text += `\n`;
608
+ }
609
+ if (content.bugFixes && content.bugFixes.length > 0) {
610
+ text += `BUG FIXES:\n`;
611
+ content.bugFixes.forEach((b) => {
612
+ text += `• ${b.title}: ${b.description}\n`;
613
+ });
614
+ text += `\n`;
615
+ }
616
+ if (content.testimonial) {
617
+ text += `\n"${content.testimonial.text}"\n`;
618
+ text += `— ${content.testimonial.author}, ${content.testimonial.role}\n\n`;
619
+ }
620
+ if (content.comingNext) {
621
+ text += `COMING NEXT:\n${content.comingNext}\n\n`;
622
+ }
623
+ text += `\nWith gratitude,\n${fraim_config_1.fraimConfig.personaName}\nYour AI Executive Assistant\n\n`;
624
+ text += `Visit: ${fraim_config_1.fraimConfig.webAppUrl ? `${fraim_config_1.fraimConfig.webAppUrl}/wellness/${fraim_config_1.fraimConfig.personaName.toLowerCase()}` : '#'}\n`;
625
+ return text;
626
+ }
627
+ /**
628
+ * Send email via Gmail API (reuses existing pattern)
629
+ */
630
+ async function sendEmailViaGmail(params, executive, personaTokens) {
631
+ const { to, subject, plainTextBody, htmlBody, fromEmail, fromDisplayName } = params;
632
+ let accessToken;
633
+ let refreshToken;
634
+ let isProdToken = false; // Track if we're using PROD tokens
635
+ if (personaTokens?.access_token && personaTokens?.refresh_token) {
636
+ accessToken = personaTokens.access_token;
637
+ refreshToken = personaTokens.refresh_token;
638
+ // Check if these are PROD tokens by comparing with PROD_FRAIM_DEFAULT_REFRESH_TOKEN
639
+ const prodRefreshToken = fraim_config_1.fraimConfig.prodRefreshToken || '';
640
+ if (prodRefreshToken && refreshToken === prodRefreshToken) {
641
+ isProdToken = true;
642
+ }
643
+ }
644
+ else if (executive?.personaAccessToken && executive?.personaRefreshToken) {
645
+ accessToken = executive.personaAccessToken;
646
+ refreshToken = executive.personaRefreshToken;
647
+ }
648
+ else {
649
+ accessToken = fraim_config_1.fraimConfig.defaultAccessToken || '';
650
+ refreshToken = fraim_config_1.fraimConfig.defaultRefreshToken || '';
651
+ if (!accessToken || !refreshToken) {
652
+ throw new Error(`${fraim_config_1.fraimConfig.personaName} Gmail tokens not found`);
653
+ }
654
+ }
655
+ const boundary = `boundary_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
656
+ const emailMessage = [
657
+ `To: ${to}`,
658
+ `From: ${fromDisplayName} <${fromEmail}>`,
659
+ `Subject: ${subject}`,
660
+ `Content-Type: multipart/alternative; boundary="${boundary}"`,
661
+ `MIME-Version: 1.0`,
662
+ ``,
663
+ `--${boundary}`,
664
+ `Content-Type: text/plain; charset=utf-8`,
665
+ ``,
666
+ `--${boundary}`,
667
+ `Content-Type: text/html; charset=utf-8`,
668
+ ``,
669
+ htmlBody,
670
+ ``,
671
+ `--${boundary}--`
672
+ ].join('\r\n');
673
+ // Note: base64 encoding without newlines is important
674
+ const url = 'https://gmail.googleapis.com/gmail/v1/users/me/messages/send';
675
+ const requestBody = {
676
+ raw: Buffer.from(emailMessage).toString('base64')
677
+ .replace(/\+/g, '-')
678
+ .replace(/\//g, '_')
679
+ .replace(/=+$/, '')
680
+ };
681
+ const response = await fetch(url, {
682
+ method: 'POST',
683
+ headers: {
684
+ 'Authorization': `Bearer ${accessToken}`,
685
+ 'Content-Type': 'application/json',
686
+ },
687
+ body: JSON.stringify(requestBody)
688
+ });
689
+ if (!response.ok) {
690
+ if (response.status === 401) {
691
+ // Token refresh logic - use PROD OAuth credentials if using PROD tokens
692
+ const clientId = isProdToken
693
+ ? fraim_config_1.fraimConfig.prodOAuthClientId
694
+ : fraim_config_1.fraimConfig.defaultOAuthClientId;
695
+ const clientSecret = isProdToken
696
+ ? fraim_config_1.fraimConfig.prodOAuthClientSecret
697
+ : fraim_config_1.fraimConfig.defaultOAuthClientSecret;
698
+ if (!clientId || !clientSecret) {
699
+ throw new Error(`OAuth credentials not found for token refresh${isProdToken ? ' (PROD)' : ''}`);
700
+ }
701
+ const refreshResponse = await fetch('https://oauth2.googleapis.com/token', {
702
+ method: 'POST',
703
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
704
+ body: new URLSearchParams({
705
+ client_id: clientId,
706
+ client_secret: clientSecret,
707
+ refresh_token: refreshToken,
708
+ grant_type: 'refresh_token'
709
+ })
710
+ });
711
+ if (!refreshResponse.ok) {
712
+ throw new Error('Failed to refresh access token');
713
+ }
714
+ const tokenData = await refreshResponse.json();
715
+ // Retry with new token
716
+ const retryResponse = await fetch(url, {
717
+ method: 'POST',
718
+ headers: {
719
+ 'Authorization': `Bearer ${tokenData.access_token}`,
720
+ 'Content-Type': 'application/json',
721
+ },
722
+ body: JSON.stringify(requestBody)
723
+ });
724
+ if (!retryResponse.ok) {
725
+ throw new Error(`Gmail API error after refresh: ${retryResponse.status}`);
726
+ }
727
+ return;
728
+ }
729
+ throw new Error(`Gmail API error: ${response.status}`);
730
+ }
731
+ }