@girardmedia/bootspring 1.1.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 (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +255 -0
  3. package/agents/README.md +93 -0
  4. package/agents/api-expert/context.md +416 -0
  5. package/agents/architecture-expert/context.md +454 -0
  6. package/agents/backend-expert/context.md +483 -0
  7. package/agents/code-review-expert/context.md +365 -0
  8. package/agents/database-expert/context.md +250 -0
  9. package/agents/devops-expert/context.md +446 -0
  10. package/agents/frontend-expert/context.md +364 -0
  11. package/agents/index.js +140 -0
  12. package/agents/performance-expert/context.md +377 -0
  13. package/agents/security-expert/context.md +343 -0
  14. package/agents/testing-expert/context.md +414 -0
  15. package/agents/ui-ux-expert/context.md +448 -0
  16. package/agents/vercel-expert/context.md +426 -0
  17. package/bin/bootspring.js +310 -0
  18. package/cli/agent.js +337 -0
  19. package/cli/context.js +194 -0
  20. package/cli/dashboard.js +150 -0
  21. package/cli/generate.js +294 -0
  22. package/cli/init.js +410 -0
  23. package/cli/loop.js +421 -0
  24. package/cli/mcp.js +241 -0
  25. package/cli/memory.js +303 -0
  26. package/cli/orchestrator.js +400 -0
  27. package/cli/plugin.js +451 -0
  28. package/cli/quality.js +332 -0
  29. package/cli/skill.js +369 -0
  30. package/cli/task.js +628 -0
  31. package/cli/telemetry.js +114 -0
  32. package/cli/todo.js +614 -0
  33. package/cli/update.js +312 -0
  34. package/core/config.js +245 -0
  35. package/core/context.js +329 -0
  36. package/core/entitlements.js +209 -0
  37. package/core/index.js +43 -0
  38. package/core/policies.js +68 -0
  39. package/core/telemetry.js +247 -0
  40. package/core/utils.js +380 -0
  41. package/dashboard/server.js +818 -0
  42. package/docs/integrations/claude-code.md +42 -0
  43. package/docs/integrations/codex.md +42 -0
  44. package/docs/mcp-api-platform.md +102 -0
  45. package/generators/generate.js +598 -0
  46. package/generators/index.js +18 -0
  47. package/hooks/context-detector.js +177 -0
  48. package/hooks/index.js +35 -0
  49. package/hooks/prompt-enhancer.js +289 -0
  50. package/intelligence/git-memory.js +551 -0
  51. package/intelligence/index.js +59 -0
  52. package/intelligence/orchestrator.js +964 -0
  53. package/intelligence/prd.js +447 -0
  54. package/intelligence/recommendation-weights.json +18 -0
  55. package/intelligence/recommendations.js +234 -0
  56. package/mcp/capabilities.js +71 -0
  57. package/mcp/contracts/mcp-contract.v1.json +497 -0
  58. package/mcp/registry.js +213 -0
  59. package/mcp/response-formatter.js +462 -0
  60. package/mcp/server.js +99 -0
  61. package/mcp/tools/agent-tool.js +137 -0
  62. package/mcp/tools/capabilities-tool.js +54 -0
  63. package/mcp/tools/context-tool.js +49 -0
  64. package/mcp/tools/dashboard-tool.js +58 -0
  65. package/mcp/tools/generate-tool.js +46 -0
  66. package/mcp/tools/loop-tool.js +134 -0
  67. package/mcp/tools/memory-tool.js +180 -0
  68. package/mcp/tools/orchestrator-tool.js +232 -0
  69. package/mcp/tools/plugin-tool.js +76 -0
  70. package/mcp/tools/quality-tool.js +47 -0
  71. package/mcp/tools/skill-tool.js +233 -0
  72. package/mcp/tools/telemetry-tool.js +95 -0
  73. package/mcp/tools/todo-tool.js +133 -0
  74. package/package.json +98 -0
  75. package/plugins/index.js +141 -0
  76. package/quality/index.js +380 -0
  77. package/quality/lint-budgets.json +19 -0
  78. package/skills/index.js +787 -0
  79. package/skills/patterns/README.md +163 -0
  80. package/skills/patterns/api/route-handler.md +217 -0
  81. package/skills/patterns/api/server-action.md +249 -0
  82. package/skills/patterns/auth/clerk.md +132 -0
  83. package/skills/patterns/database/prisma.md +180 -0
  84. package/skills/patterns/payments/stripe.md +272 -0
  85. package/skills/patterns/security/validation.md +268 -0
  86. package/skills/patterns/testing/vitest.md +307 -0
  87. package/templates/bootspring.config.js +83 -0
  88. package/templates/mcp.json +9 -0
@@ -0,0 +1,447 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Bootspring PRD (Product Requirements Document) Manager
5
+ *
6
+ * Manages structured task files for autonomous loop execution.
7
+ * Compatible with Ralph's prd.json format.
8
+ *
9
+ * @package bootspring
10
+ * @module intelligence/prd
11
+ */
12
+
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+
16
+ /**
17
+ * PRD Schema
18
+ *
19
+ * {
20
+ * "name": "feature-name",
21
+ * "description": "Feature description",
22
+ * "created": "ISO timestamp",
23
+ * "stories": [
24
+ * {
25
+ * "id": "story-1",
26
+ * "title": "Short title",
27
+ * "description": "Detailed description",
28
+ * "acceptance": ["Criteria 1", "Criteria 2"],
29
+ * "priority": 1,
30
+ * "status": "pending|in_progress|complete|blocked",
31
+ * "completedAt": null | "ISO timestamp"
32
+ * }
33
+ * ]
34
+ * }
35
+ */
36
+
37
+ const DEFAULT_PRD_DIR = 'tasks';
38
+ const DEFAULT_PRD_FILE = 'prd.json';
39
+
40
+ /**
41
+ * Create a new PRD
42
+ */
43
+ function createPRD(name, description, stories = []) {
44
+ return {
45
+ name,
46
+ description,
47
+ created: new Date().toISOString(),
48
+ updated: new Date().toISOString(),
49
+ stories: stories.map((story, index) => ({
50
+ id: story.id || `story-${index + 1}`,
51
+ title: story.title,
52
+ description: story.description || '',
53
+ acceptance: story.acceptance || [],
54
+ priority: story.priority || index + 1,
55
+ status: 'pending',
56
+ completedAt: null
57
+ }))
58
+ };
59
+ }
60
+
61
+ /**
62
+ * Load PRD from file
63
+ */
64
+ function loadPRD(filePath = path.join(DEFAULT_PRD_DIR, DEFAULT_PRD_FILE)) {
65
+ if (!fs.existsSync(filePath)) {
66
+ return null;
67
+ }
68
+
69
+ try {
70
+ const content = fs.readFileSync(filePath, 'utf-8');
71
+ return JSON.parse(content);
72
+ } catch (e) {
73
+ console.error(`Error loading PRD: ${e.message}`);
74
+ return null;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Save PRD to file
80
+ */
81
+ function savePRD(prd, filePath = path.join(DEFAULT_PRD_DIR, DEFAULT_PRD_FILE)) {
82
+ const dir = path.dirname(filePath);
83
+ if (!fs.existsSync(dir)) {
84
+ fs.mkdirSync(dir, { recursive: true });
85
+ }
86
+
87
+ prd.updated = new Date().toISOString();
88
+ fs.writeFileSync(filePath, JSON.stringify(prd, null, 2));
89
+ return filePath;
90
+ }
91
+
92
+ /**
93
+ * Get next incomplete story (highest priority)
94
+ */
95
+ function getNextStory(prd) {
96
+ if (!prd || !prd.stories) return null;
97
+
98
+ return prd.stories
99
+ .filter(s => s.status === 'pending' || s.status === 'in_progress')
100
+ .sort((a, b) => a.priority - b.priority)[0] || null;
101
+ }
102
+
103
+ /**
104
+ * Update story status
105
+ */
106
+ function updateStoryStatus(prd, storyId, status) {
107
+ const story = prd.stories.find(s => s.id === storyId);
108
+ if (!story) {
109
+ throw new Error(`Story not found: ${storyId}`);
110
+ }
111
+
112
+ story.status = status;
113
+ if (status === 'complete') {
114
+ story.completedAt = new Date().toISOString();
115
+ }
116
+
117
+ return prd;
118
+ }
119
+
120
+ /**
121
+ * Get PRD progress summary
122
+ */
123
+ function getProgress(prd) {
124
+ if (!prd || !prd.stories) {
125
+ return { total: 0, complete: 0, pending: 0, blocked: 0, percent: 0 };
126
+ }
127
+
128
+ const total = prd.stories.length;
129
+ const complete = prd.stories.filter(s => s.status === 'complete').length;
130
+ const pending = prd.stories.filter(s => s.status === 'pending' || s.status === 'in_progress').length;
131
+ const blocked = prd.stories.filter(s => s.status === 'blocked').length;
132
+
133
+ return {
134
+ total,
135
+ complete,
136
+ pending,
137
+ blocked,
138
+ percent: total > 0 ? Math.round((complete / total) * 100) : 0
139
+ };
140
+ }
141
+
142
+ /**
143
+ * Check if all stories are complete
144
+ */
145
+ function isComplete(prd) {
146
+ if (!prd || !prd.stories || prd.stories.length === 0) return false;
147
+ return prd.stories.every(s => s.status === 'complete');
148
+ }
149
+
150
+ /**
151
+ * Parse markdown PRD into structured format
152
+ * Converts human-readable PRD to prd.json
153
+ */
154
+ function parseMarkdownPRD(markdown) {
155
+ const lines = markdown.split('\n');
156
+ const stories = [];
157
+
158
+ let currentStory = null;
159
+ let inAcceptance = false;
160
+ let name = '';
161
+ let description = '';
162
+ let sawTitle = false;
163
+
164
+ for (const line of lines) {
165
+ // Extract name from H1
166
+ if (line.startsWith('# ')) {
167
+ name = line.replace('# ', '').trim();
168
+ sawTitle = true;
169
+ continue;
170
+ }
171
+
172
+ // Extract top-level description (content after title and before first story heading)
173
+ if (sawTitle && !currentStory && line.trim() && !line.startsWith('#')) {
174
+ description += line.trim() + ' ';
175
+ continue;
176
+ }
177
+
178
+ // Acceptance criteria header (check BEFORE story header to avoid false matches)
179
+ if (line.match(/^#{3,4}\s+acceptance/i) || line.match(/^\*\*acceptance/i)) {
180
+ inAcceptance = true;
181
+ continue;
182
+ }
183
+
184
+ // Story header supports both manual PRDs (## Story X) and exported markdown (### [ ] Story)
185
+ const storyMatch = line.match(/^#{2,3}\s+(?:\[[ x!]\]\s*)?(?:Story\s+)?(\d+)?[:.]?\s*(.+)/i);
186
+ if (storyMatch && !line.match(/acceptance|criteria|progress/i)) {
187
+ if (currentStory) {
188
+ stories.push(currentStory);
189
+ }
190
+ currentStory = {
191
+ id: `story-${stories.length + 1}`,
192
+ title: storyMatch[2].trim(),
193
+ description: '',
194
+ acceptance: [],
195
+ priority: stories.length + 1
196
+ };
197
+ inAcceptance = false;
198
+ continue;
199
+ }
200
+
201
+ // Acceptance criteria items
202
+ if (inAcceptance && line.match(/^[-*]\s+/)) {
203
+ const criteria = line.replace(/^[-*]\s+/, '').trim();
204
+ if (criteria && currentStory) {
205
+ currentStory.acceptance.push(criteria);
206
+ }
207
+ continue;
208
+ }
209
+
210
+ // Story description (non-list content under story header)
211
+ if (currentStory && !inAcceptance && line.trim() && !line.startsWith('#')) {
212
+ currentStory.description += line.trim() + ' ';
213
+ }
214
+ }
215
+
216
+ // Don't forget last story
217
+ if (currentStory) {
218
+ stories.push(currentStory);
219
+ }
220
+
221
+ // Clean up descriptions
222
+ stories.forEach(s => {
223
+ s.description = s.description.trim();
224
+ });
225
+
226
+ return createPRD(name, description.trim(), stories);
227
+ }
228
+
229
+ /**
230
+ * Generate markdown from PRD
231
+ */
232
+ function toMarkdown(prd) {
233
+ let md = `# ${prd.name}\n\n`;
234
+
235
+ if (prd.description) {
236
+ md += `${prd.description}\n\n`;
237
+ }
238
+
239
+ const progress = getProgress(prd);
240
+ md += `## Progress: ${progress.percent}% (${progress.complete}/${progress.total})\n\n`;
241
+
242
+ for (const story of prd.stories) {
243
+ const statusIcon = story.status === 'complete' ? '[x]' :
244
+ story.status === 'blocked' ? '[!]' : '[ ]';
245
+
246
+ md += `### ${statusIcon} ${story.title}\n\n`;
247
+
248
+ if (story.description) {
249
+ md += `${story.description}\n\n`;
250
+ }
251
+
252
+ if (story.acceptance.length > 0) {
253
+ md += `**Acceptance Criteria:**\n`;
254
+ for (const criteria of story.acceptance) {
255
+ md += `- ${criteria}\n`;
256
+ }
257
+ md += '\n';
258
+ }
259
+ }
260
+
261
+ return md;
262
+ }
263
+
264
+ /**
265
+ * Validate story is right-sized for autonomous execution
266
+ * Stories should be completable in a single AI context window
267
+ */
268
+ function validateStorySize(story) {
269
+ const warnings = [];
270
+
271
+ // Check title length (should be concise)
272
+ if (story.title.length > 100) {
273
+ warnings.push('Title too long - consider breaking down');
274
+ }
275
+
276
+ // Check for scope indicators (use word boundaries to avoid false positives)
277
+ const scopeIndicators = [
278
+ /\bentire\b/i, /\ball\s+(the|of)\b/i, /\bcomplete\s+system\b/i,
279
+ /\bfull\s+(system|implementation)\b/i, /\bwhole\b/i,
280
+ /\bauthentication system\b/i, /\bpayment system\b/i, /\buser management\b/i,
281
+ /\bbuild\s+(the|entire|full)\b/i
282
+ ];
283
+
284
+ const titleLower = story.title.toLowerCase();
285
+ const descLower = (story.description || '').toLowerCase();
286
+ const combined = `${titleLower} ${descLower}`;
287
+
288
+ for (const pattern of scopeIndicators) {
289
+ if (pattern.test(combined)) {
290
+ warnings.push(`Scope might be too large (matches pattern)`);
291
+ break; // Only add one scope warning
292
+ }
293
+ }
294
+
295
+ // Check acceptance criteria count
296
+ if (story.acceptance.length > 5) {
297
+ warnings.push('Many acceptance criteria - consider splitting');
298
+ }
299
+
300
+ return {
301
+ valid: warnings.length === 0,
302
+ warnings
303
+ };
304
+ }
305
+
306
+ // CLI
307
+ if (require.main === module) {
308
+ const args = process.argv.slice(2);
309
+ const command = args[0];
310
+
311
+ switch (command) {
312
+ case 'create': {
313
+ const name = args[1] || 'feature';
314
+ const prd = createPRD(name, 'Created by bootspring', [
315
+ { title: 'First task', description: 'Implement the first piece' },
316
+ { title: 'Second task', description: 'Implement the second piece' }
317
+ ]);
318
+ const filePath = savePRD(prd);
319
+ console.log(`Created PRD: ${filePath}`);
320
+ break;
321
+ }
322
+
323
+ case 'status': {
324
+ const prd = loadPRD(args[1]);
325
+ if (!prd) {
326
+ console.error('PRD not found');
327
+ process.exit(1);
328
+ }
329
+ const progress = getProgress(prd);
330
+ console.log(`PRD: ${prd.name}`);
331
+ console.log(`Progress: ${progress.percent}% (${progress.complete}/${progress.total})`);
332
+ console.log(`Pending: ${progress.pending} | Blocked: ${progress.blocked}`);
333
+
334
+ const next = getNextStory(prd);
335
+ if (next) {
336
+ console.log(`\nNext story: ${next.title}`);
337
+ }
338
+ break;
339
+ }
340
+
341
+ case 'next': {
342
+ const prd = loadPRD(args[1]);
343
+ if (!prd) {
344
+ console.error('PRD not found');
345
+ process.exit(1);
346
+ }
347
+ const next = getNextStory(prd);
348
+ if (next) {
349
+ console.log(JSON.stringify(next, null, 2));
350
+ } else {
351
+ console.log('No pending stories');
352
+ }
353
+ break;
354
+ }
355
+
356
+ case 'complete': {
357
+ const storyId = args[1];
358
+ const prdPath = args[2] || path.join(DEFAULT_PRD_DIR, DEFAULT_PRD_FILE);
359
+
360
+ if (!storyId) {
361
+ console.error('Usage: prd.js complete <story-id> [prd-path]');
362
+ process.exit(1);
363
+ }
364
+
365
+ const prd = loadPRD(prdPath);
366
+ if (!prd) {
367
+ console.error('PRD not found');
368
+ process.exit(1);
369
+ }
370
+
371
+ updateStoryStatus(prd, storyId, 'complete');
372
+ savePRD(prd, prdPath);
373
+ console.log(`Marked ${storyId} as complete`);
374
+
375
+ if (isComplete(prd)) {
376
+ console.log('\nAll stories complete!');
377
+ }
378
+ break;
379
+ }
380
+
381
+ case 'parse': {
382
+ const mdPath = args[1];
383
+ if (!mdPath || !fs.existsSync(mdPath)) {
384
+ console.error('Usage: prd.js parse <markdown-file>');
385
+ process.exit(1);
386
+ }
387
+
388
+ const markdown = fs.readFileSync(mdPath, 'utf-8');
389
+ const prd = parseMarkdownPRD(markdown);
390
+
391
+ // Validate stories
392
+ console.log(`Parsed PRD: ${prd.name}`);
393
+ console.log(`Stories: ${prd.stories.length}\n`);
394
+
395
+ for (const story of prd.stories) {
396
+ const validation = validateStorySize(story);
397
+ console.log(`- ${story.title}`);
398
+ if (!validation.valid) {
399
+ validation.warnings.forEach(w => console.log(` ⚠️ ${w}`));
400
+ }
401
+ }
402
+
403
+ // Save
404
+ const outPath = savePRD(prd);
405
+ console.log(`\nSaved to: ${outPath}`);
406
+ break;
407
+ }
408
+
409
+ case 'markdown': {
410
+ const prd = loadPRD(args[1]);
411
+ if (!prd) {
412
+ console.error('PRD not found');
413
+ process.exit(1);
414
+ }
415
+ console.log(toMarkdown(prd));
416
+ break;
417
+ }
418
+
419
+ default:
420
+ console.log(`
421
+ Bootspring PRD Manager
422
+
423
+ Usage:
424
+ prd.js create [name] Create new PRD
425
+ prd.js status [path] Show PRD progress
426
+ prd.js next [path] Get next story as JSON
427
+ prd.js complete <id> [path] Mark story complete
428
+ prd.js parse <markdown> Convert markdown to prd.json
429
+ prd.js markdown [path] Convert prd.json to markdown
430
+ `);
431
+ }
432
+ }
433
+
434
+ module.exports = {
435
+ createPRD,
436
+ loadPRD,
437
+ savePRD,
438
+ getNextStory,
439
+ updateStoryStatus,
440
+ getProgress,
441
+ isComplete,
442
+ parseMarkdownPRD,
443
+ toMarkdown,
444
+ validateStorySize,
445
+ DEFAULT_PRD_DIR,
446
+ DEFAULT_PRD_FILE
447
+ };
@@ -0,0 +1,18 @@
1
+ {
2
+ "workflow": {
3
+ "base": 10,
4
+ "completed": 6,
5
+ "started": 2,
6
+ "checkpointCap": 10,
7
+ "completionRateMultiplier": 20,
8
+ "contextMatch": 12,
9
+ "packBonus": 3,
10
+ "premiumUnlock": 4
11
+ },
12
+ "skill": {
13
+ "base": 25,
14
+ "usage": 4,
15
+ "premiumUnlock": 3
16
+ },
17
+ "eventWindowLimit": 1000
18
+ }
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Recommendation engine v1
3
+ * Ranks workflows and skills from context + telemetry outcomes.
4
+ */
5
+
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+
9
+ const DEFAULT_WEIGHTS_PATH = path.join(__dirname, 'recommendation-weights.json');
10
+
11
+ function tokenize(text) {
12
+ return String(text || '')
13
+ .toLowerCase()
14
+ .split(/[^a-z0-9]+/g)
15
+ .filter(Boolean);
16
+ }
17
+
18
+ function buildWorkflowStats(events) {
19
+ const stats = new Map();
20
+
21
+ for (const event of events) {
22
+ const workflow = event?.payload?.workflow;
23
+ if (!workflow) continue;
24
+ if (!stats.has(workflow)) {
25
+ stats.set(workflow, {
26
+ started: 0,
27
+ completed: 0,
28
+ checkpoints: 0,
29
+ premiumUnlocks: 0,
30
+ lastEventAt: null
31
+ });
32
+ }
33
+ const current = stats.get(workflow);
34
+ if (event.event === 'workflow_started') current.started += 1;
35
+ if (event.event === 'workflow_completed') current.completed += 1;
36
+ if (event.event === 'workflow_checkpoint_completed' || event.event === 'pack_signal_checkpoint') {
37
+ current.checkpoints += 1;
38
+ }
39
+ if (event.event === 'premium_unlocked') {
40
+ current.premiumUnlocks += 1;
41
+ }
42
+ current.lastEventAt = event.timestamp || current.lastEventAt;
43
+ }
44
+
45
+ return stats;
46
+ }
47
+
48
+ function buildSkillUsage(events) {
49
+ const usage = new Map();
50
+ const premiumUnlocks = new Map();
51
+ for (const event of events) {
52
+ const skillId = event?.payload?.skillId;
53
+ if (!skillId) continue;
54
+ usage.set(skillId, (usage.get(skillId) || 0) + 1);
55
+ if (event.event === 'premium_unlocked') {
56
+ premiumUnlocks.set(skillId, (premiumUnlocks.get(skillId) || 0) + 1);
57
+ }
58
+ }
59
+ return { usage, premiumUnlocks };
60
+ }
61
+
62
+ function workflowMatchesContext(workflow, tokens) {
63
+ if (tokens.length === 0) return false;
64
+ const haystack = [
65
+ workflow.name,
66
+ workflow.description,
67
+ ...(workflow.outcomes || []),
68
+ ...(workflow.completionSignals || [])
69
+ ]
70
+ .join(' ')
71
+ .toLowerCase();
72
+
73
+ return tokens.some(token => haystack.includes(token));
74
+ }
75
+
76
+ function mergeWeights(overrides = {}) {
77
+ let base = {};
78
+ try {
79
+ if (fs.existsSync(DEFAULT_WEIGHTS_PATH)) {
80
+ base = JSON.parse(fs.readFileSync(DEFAULT_WEIGHTS_PATH, 'utf-8'));
81
+ }
82
+ } catch {
83
+ base = {};
84
+ }
85
+ return {
86
+ workflow: {
87
+ ...(base.workflow || {}),
88
+ ...((overrides.workflow) || {})
89
+ },
90
+ skill: {
91
+ ...(base.skill || {}),
92
+ ...((overrides.skill) || {})
93
+ },
94
+ eventWindowLimit: Number(overrides.eventWindowLimit || base.eventWindowLimit || 1000)
95
+ };
96
+ }
97
+
98
+ function resolveWeights(options = {}) {
99
+ if (options.weights && typeof options.weights === 'object') {
100
+ return mergeWeights(options.weights);
101
+ }
102
+
103
+ const customPath = options.weightsPath || process.env.BOOTSPRING_RECOMMENDATION_WEIGHTS_PATH;
104
+ if (customPath) {
105
+ try {
106
+ const raw = JSON.parse(fs.readFileSync(customPath, 'utf-8'));
107
+ return mergeWeights(raw);
108
+ } catch {
109
+ return mergeWeights({});
110
+ }
111
+ }
112
+
113
+ return mergeWeights({});
114
+ }
115
+
116
+ function createRecommendationsEngine({ intelligence, telemetry, skills, entitlements, weights }) {
117
+ const resolvedWeights = resolveWeights({ weights });
118
+
119
+ return {
120
+ recommend({ contextText = '', limit = 5, accessOptions = {} } = {}) {
121
+ const parsedLimit = Number(limit);
122
+ const maxItems = Number.isFinite(parsedLimit) && parsedLimit > 0 ? parsedLimit : 5;
123
+ const events = telemetry.listEvents({ limit: resolvedWeights.eventWindowLimit });
124
+ const analysis = intelligence.analyzeContext(contextText || '');
125
+ const tokens = tokenize(contextText);
126
+ const workflowStats = buildWorkflowStats(events);
127
+ const skillStats = buildSkillUsage(events);
128
+
129
+ const available = intelligence.listWorkflows();
130
+ const accessible = entitlements.filterAccessibleWorkflows(available, accessOptions).allowed;
131
+ const workflows = accessible
132
+ .map(workflow => {
133
+ const stats = workflowStats.get(workflow.key) || {
134
+ started: 0,
135
+ completed: 0,
136
+ checkpoints: 0,
137
+ premiumUnlocks: 0,
138
+ lastEventAt: null
139
+ };
140
+
141
+ const completionRate = stats.started > 0 ? (stats.completed / stats.started) : 0;
142
+ let score = Number(resolvedWeights.workflow.base || 10)
143
+ + (stats.completed * Number(resolvedWeights.workflow.completed || 6))
144
+ + (stats.started * Number(resolvedWeights.workflow.started || 2))
145
+ + Math.min(Number(resolvedWeights.workflow.checkpointCap || 10), stats.checkpoints);
146
+ const reasons = [];
147
+
148
+ if (stats.completed > 0) reasons.push(`completed ${stats.completed} time(s)`);
149
+ if (completionRate > 0) {
150
+ score += Math.round(completionRate * Number(resolvedWeights.workflow.completionRateMultiplier || 20));
151
+ reasons.push(`completion rate ${(completionRate * 100).toFixed(0)}%`);
152
+ }
153
+ if (workflowMatchesContext(workflow, tokens)) {
154
+ score += Number(resolvedWeights.workflow.contextMatch || 12);
155
+ reasons.push('matches current context');
156
+ }
157
+ if (workflow.pack) {
158
+ score += Number(resolvedWeights.workflow.packBonus || 3);
159
+ }
160
+ if (stats.premiumUnlocks > 0) {
161
+ score += stats.premiumUnlocks * Number(resolvedWeights.workflow.premiumUnlock || 4);
162
+ reasons.push(`premium unlocks ${stats.premiumUnlocks}`);
163
+ }
164
+
165
+ return {
166
+ key: workflow.key,
167
+ name: workflow.name,
168
+ tier: workflow.tier || 'free',
169
+ pack: workflow.pack || null,
170
+ score,
171
+ reasons,
172
+ metrics: {
173
+ started: stats.started,
174
+ completed: stats.completed,
175
+ checkpoints: stats.checkpoints,
176
+ premiumUnlocks: stats.premiumUnlocks,
177
+ completionRate
178
+ }
179
+ };
180
+ })
181
+ .sort((a, b) => b.score - a.score)
182
+ .slice(0, maxItems);
183
+
184
+ const contextSkillIds = Array.from(new Set(analysis.skills || []));
185
+ let availableSkillIds = skills.listSkills({ includeExternal: true });
186
+ availableSkillIds = entitlements.filterAccessibleSkills(availableSkillIds, accessOptions).allowed;
187
+ const availableSkillSet = new Set(availableSkillIds);
188
+
189
+ const skillsRanked = contextSkillIds
190
+ .filter(skillId => availableSkillSet.has(skillId))
191
+ .map(skillId => {
192
+ const metadata = skills.getSkillMetadata(skillId) || {};
193
+ const usage = skillStats.usage.get(skillId) || 0;
194
+ const premiumUnlocks = skillStats.premiumUnlocks.get(skillId) || 0;
195
+ return {
196
+ id: skillId,
197
+ name: metadata.name || skillId,
198
+ description: metadata.description || '',
199
+ source: metadata.source || 'built-in',
200
+ score: Number(resolvedWeights.skill.base || 25)
201
+ + (usage * Number(resolvedWeights.skill.usage || 4))
202
+ + (premiumUnlocks * Number(resolvedWeights.skill.premiumUnlock || 3)),
203
+ reasons: [
204
+ 'suggested by context analysis',
205
+ ...(usage > 0 ? [`used ${usage} time(s) in prior sessions`] : []),
206
+ ...(premiumUnlocks > 0 ? [`premium unlocks ${premiumUnlocks}`] : [])
207
+ ]
208
+ };
209
+ })
210
+ .sort((a, b) => b.score - a.score)
211
+ .slice(0, maxItems);
212
+
213
+ return {
214
+ context: {
215
+ phase: analysis.phase,
216
+ phaseName: analysis.phaseConfig?.name,
217
+ suggestions: analysis.suggestions
218
+ },
219
+ workflows,
220
+ skills: skillsRanked,
221
+ telemetryWindow: {
222
+ eventsAnalyzed: events.length
223
+ },
224
+ weights: resolvedWeights
225
+ };
226
+ }
227
+ };
228
+ }
229
+
230
+ module.exports = {
231
+ createRecommendationsEngine,
232
+ resolveWeights,
233
+ mergeWeights
234
+ };