@girardmedia/bootspring 2.0.36 → 2.0.37

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.
@@ -0,0 +1,958 @@
1
+ /**
2
+ * Adaptive Planning Engine
3
+ *
4
+ * Living planning system that monitors code changes and keeps plans synchronized.
5
+ * Implements continuous plan awareness with drift detection and auto-refresh.
6
+ *
7
+ * @package bootspring
8
+ * @module core/planning/adaptive-engine
9
+ */
10
+
11
+ const fs = require('fs').promises;
12
+ const path = require('path');
13
+ const EventEmitter = require('events');
14
+
15
+ /**
16
+ * Plan refresh triggers
17
+ */
18
+ const REFRESH_TRIGGERS = {
19
+ codeChange: {
20
+ threshold: 10, // Number of significant changes before refresh
21
+ priority: 'medium',
22
+ documents: ['tasks', 'sprint', 'technical-spec']
23
+ },
24
+ modelChange: {
25
+ threshold: 1, // Any model change triggers
26
+ priority: 'high',
27
+ documents: ['prd', 'technical-spec', 'api']
28
+ },
29
+ newFeature: {
30
+ threshold: 1,
31
+ priority: 'high',
32
+ documents: ['prd', 'roadmap', 'tasks']
33
+ },
34
+ bugAccumulation: {
35
+ threshold: 5, // Number of bug fixes
36
+ priority: 'medium',
37
+ documents: ['health', 'tech-debt']
38
+ },
39
+ dependencyUpdate: {
40
+ threshold: 3, // Major dependency updates
41
+ priority: 'low',
42
+ documents: ['technical-spec', 'security']
43
+ },
44
+ testCoverageChange: {
45
+ threshold: 10, // Percentage change in coverage
46
+ priority: 'medium',
47
+ documents: ['health', 'quality']
48
+ },
49
+ securityIssue: {
50
+ threshold: 1, // Any security issue
51
+ priority: 'critical',
52
+ documents: ['security', 'technical-spec']
53
+ }
54
+ };
55
+
56
+ /**
57
+ * Planning layer types
58
+ */
59
+ const PLANNING_LAYERS = {
60
+ strategic: {
61
+ name: 'Strategic',
62
+ horizon: '6-12 months',
63
+ documents: ['vision', 'roadmap', 'milestones'],
64
+ refreshCadence: 'monthly',
65
+ metrics: ['revenue', 'users', 'marketShare']
66
+ },
67
+ tactical: {
68
+ name: 'Tactical',
69
+ horizon: '2-6 weeks',
70
+ documents: ['sprint', 'tasks', 'priorities'],
71
+ refreshCadence: 'weekly',
72
+ metrics: ['velocity', 'burndown', 'blockers']
73
+ },
74
+ operational: {
75
+ name: 'Operational',
76
+ horizon: '1-7 days',
77
+ documents: ['daily', 'blockers', 'progress'],
78
+ refreshCadence: 'daily',
79
+ metrics: ['tasksCompleted', 'hoursLogged', 'blockers']
80
+ }
81
+ };
82
+
83
+ /**
84
+ * Drift severity levels
85
+ */
86
+ const DRIFT_SEVERITY = {
87
+ none: { threshold: 0, action: 'none', label: 'In Sync' },
88
+ minor: { threshold: 0.15, action: 'suggest', label: 'Minor Drift' },
89
+ moderate: { threshold: 0.35, action: 'warn', label: 'Moderate Drift' },
90
+ significant: { threshold: 0.55, action: 'alert', label: 'Significant Drift' },
91
+ critical: { threshold: 0.75, action: 'require', label: 'Critical Drift' }
92
+ };
93
+
94
+ /**
95
+ * AdaptivePlanningEngine class
96
+ *
97
+ * Monitors codebase changes and maintains living plans
98
+ */
99
+ class AdaptivePlanningEngine extends EventEmitter {
100
+ /**
101
+ * @param {Object} options - Configuration options
102
+ */
103
+ constructor(options = {}) {
104
+ super();
105
+ this.projectRoot = options.projectRoot || process.cwd();
106
+ this.planningDir = options.planningDir || path.join(this.projectRoot, 'planning');
107
+ this.stateFile = path.join(this.planningDir, '.planning-state.json');
108
+ this.state = null;
109
+ this.changeBuffer = [];
110
+ this.isWatching = false;
111
+ }
112
+
113
+ /**
114
+ * Initialize the adaptive planning engine
115
+ */
116
+ async initialize() {
117
+ await this.loadState();
118
+ return this;
119
+ }
120
+
121
+ /**
122
+ * Load planning state from disk
123
+ */
124
+ async loadState() {
125
+ try {
126
+ const content = await fs.readFile(this.stateFile, 'utf-8');
127
+ this.state = JSON.parse(content);
128
+ } catch {
129
+ this.state = this.createInitialState();
130
+ }
131
+ return this.state;
132
+ }
133
+
134
+ /**
135
+ * Create initial planning state
136
+ */
137
+ createInitialState() {
138
+ return {
139
+ version: '1.0',
140
+ createdAt: new Date().toISOString(),
141
+ lastRefresh: null,
142
+ currentStage: 'discovery',
143
+ documents: {},
144
+ changeLog: [],
145
+ driftMetrics: {},
146
+ triggers: {},
147
+ layers: {
148
+ strategic: { lastUpdated: null, health: 'unknown' },
149
+ tactical: { lastUpdated: null, health: 'unknown' },
150
+ operational: { lastUpdated: null, health: 'unknown' }
151
+ }
152
+ };
153
+ }
154
+
155
+ /**
156
+ * Save planning state to disk
157
+ */
158
+ async saveState() {
159
+ try {
160
+ await fs.mkdir(this.planningDir, { recursive: true });
161
+ await fs.writeFile(this.stateFile, JSON.stringify(this.state, null, 2));
162
+ } catch (error) {
163
+ this.emit('error', { type: 'save-state', error });
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Record a code change for drift analysis
169
+ * @param {Object} change - Change details
170
+ */
171
+ async recordChange(change) {
172
+ const normalizedChange = {
173
+ timestamp: new Date().toISOString(),
174
+ type: change.type || 'unknown',
175
+ file: change.file,
176
+ category: this.categorizeChange(change),
177
+ impact: this.assessChangeImpact(change),
178
+ affectedDocuments: this.identifyAffectedDocuments(change)
179
+ };
180
+
181
+ this.changeBuffer.push(normalizedChange);
182
+ this.state.changeLog.push(normalizedChange);
183
+
184
+ // Keep changelog trimmed
185
+ if (this.state.changeLog.length > 1000) {
186
+ this.state.changeLog = this.state.changeLog.slice(-500);
187
+ }
188
+
189
+ // Check triggers
190
+ const triggered = await this.checkTriggers(normalizedChange);
191
+ if (triggered.length > 0) {
192
+ this.emit('refresh-needed', {
193
+ triggers: triggered,
194
+ documents: this.getTriggeredDocuments(triggered)
195
+ });
196
+ }
197
+
198
+ await this.saveState();
199
+ return normalizedChange;
200
+ }
201
+
202
+ /**
203
+ * Categorize a code change
204
+ * @param {Object} change - Change details
205
+ */
206
+ categorizeChange(change) {
207
+ const file = change.file || '';
208
+ const ext = path.extname(file);
209
+
210
+ // Model/schema changes
211
+ if (file.includes('prisma') || file.includes('schema') || file.includes('model')) {
212
+ return 'model';
213
+ }
214
+
215
+ // API changes
216
+ if (file.includes('/api/') || file.includes('routes') || file.includes('endpoints')) {
217
+ return 'api';
218
+ }
219
+
220
+ // UI changes
221
+ if (file.includes('components') || file.includes('pages') || file.includes('views')) {
222
+ return 'ui';
223
+ }
224
+
225
+ // Test changes
226
+ if (file.includes('test') || file.includes('spec') || file.includes('__tests__')) {
227
+ return 'test';
228
+ }
229
+
230
+ // Config changes
231
+ if (file.includes('config') || ext === '.json' || ext === '.yml' || ext === '.yaml') {
232
+ return 'config';
233
+ }
234
+
235
+ // Documentation changes
236
+ if (ext === '.md' || file.includes('docs')) {
237
+ return 'documentation';
238
+ }
239
+
240
+ return 'code';
241
+ }
242
+
243
+ /**
244
+ * Assess the impact of a change
245
+ * @param {Object} change - Change details
246
+ */
247
+ assessChangeImpact(change) {
248
+ const category = this.categorizeChange(change);
249
+ const linesChanged = change.linesChanged || 0;
250
+
251
+ // Base impact by category
252
+ const categoryImpact = {
253
+ model: 0.8,
254
+ api: 0.7,
255
+ config: 0.6,
256
+ ui: 0.4,
257
+ code: 0.3,
258
+ test: 0.2,
259
+ documentation: 0.1
260
+ };
261
+
262
+ let impact = categoryImpact[category] || 0.3;
263
+
264
+ // Adjust by lines changed
265
+ if (linesChanged > 100) impact += 0.2;
266
+ else if (linesChanged > 50) impact += 0.1;
267
+ else if (linesChanged < 10) impact -= 0.1;
268
+
269
+ // Adjust by change type
270
+ if (change.type === 'add') impact += 0.1;
271
+ if (change.type === 'delete') impact += 0.15;
272
+
273
+ return Math.min(1.0, Math.max(0.0, impact));
274
+ }
275
+
276
+ /**
277
+ * Identify documents affected by a change
278
+ * @param {Object} change - Change details
279
+ */
280
+ identifyAffectedDocuments(change) {
281
+ const category = this.categorizeChange(change);
282
+
283
+ const documentMapping = {
284
+ model: ['prd', 'technical-spec', 'api'],
285
+ api: ['api', 'technical-spec', 'prd'],
286
+ ui: ['prd', 'design', 'user-stories'],
287
+ config: ['technical-spec', 'deployment'],
288
+ code: ['technical-spec'],
289
+ test: ['health', 'quality'],
290
+ documentation: []
291
+ };
292
+
293
+ return documentMapping[category] || ['technical-spec'];
294
+ }
295
+
296
+ /**
297
+ * Check if any refresh triggers have been hit
298
+ * @param {Object} change - Recent change
299
+ */
300
+ async checkTriggers(change) {
301
+ const triggeredList = [];
302
+
303
+ // Count changes by category in recent history
304
+ const recentChanges = this.state.changeLog.filter(c => {
305
+ const changeTime = new Date(c.timestamp).getTime();
306
+ const dayAgo = Date.now() - (24 * 60 * 60 * 1000);
307
+ return changeTime > dayAgo;
308
+ });
309
+
310
+ const changeCounts = {};
311
+ for (const c of recentChanges) {
312
+ changeCounts[c.category] = (changeCounts[c.category] || 0) + 1;
313
+ }
314
+
315
+ // Check model changes
316
+ if (change.category === 'model') {
317
+ this.state.triggers.modelChange = (this.state.triggers.modelChange || 0) + 1;
318
+ if (this.state.triggers.modelChange >= REFRESH_TRIGGERS.modelChange.threshold) {
319
+ triggeredList.push({ type: 'modelChange', ...REFRESH_TRIGGERS.modelChange });
320
+ this.state.triggers.modelChange = 0;
321
+ }
322
+ }
323
+
324
+ // Check code changes accumulation
325
+ if (changeCounts.code >= REFRESH_TRIGGERS.codeChange.threshold) {
326
+ triggeredList.push({ type: 'codeChange', ...REFRESH_TRIGGERS.codeChange });
327
+ }
328
+
329
+ // Check for bug accumulation (look for "fix" in commits)
330
+ if (change.message && change.message.toLowerCase().includes('fix')) {
331
+ this.state.triggers.bugAccumulation = (this.state.triggers.bugAccumulation || 0) + 1;
332
+ if (this.state.triggers.bugAccumulation >= REFRESH_TRIGGERS.bugAccumulation.threshold) {
333
+ triggeredList.push({ type: 'bugAccumulation', ...REFRESH_TRIGGERS.bugAccumulation });
334
+ this.state.triggers.bugAccumulation = 0;
335
+ }
336
+ }
337
+
338
+ return triggeredList;
339
+ }
340
+
341
+ /**
342
+ * Get documents that need updating based on triggers
343
+ * @param {Array} triggers - List of triggered refresh types
344
+ */
345
+ getTriggeredDocuments(triggers) {
346
+ const documents = new Set();
347
+ for (const trigger of triggers) {
348
+ for (const doc of trigger.documents) {
349
+ documents.add(doc);
350
+ }
351
+ }
352
+ return Array.from(documents);
353
+ }
354
+
355
+ /**
356
+ * Calculate drift between code and documentation
357
+ * @param {Object} options - Analysis options
358
+ */
359
+ async calculateDrift(options = {}) {
360
+ const analysis = {
361
+ timestamp: new Date().toISOString(),
362
+ overall: 0,
363
+ byDocument: {},
364
+ byLayer: {},
365
+ recommendations: []
366
+ };
367
+
368
+ // Analyze changes since last refresh
369
+ const lastRefresh = this.state.lastRefresh
370
+ ? new Date(this.state.lastRefresh).getTime()
371
+ : 0;
372
+
373
+ const changesSinceRefresh = this.state.changeLog.filter(c => {
374
+ return new Date(c.timestamp).getTime() > lastRefresh;
375
+ });
376
+
377
+ // Calculate impact-weighted drift
378
+ let totalImpact = 0;
379
+ const documentDrift = {};
380
+
381
+ for (const change of changesSinceRefresh) {
382
+ totalImpact += change.impact;
383
+
384
+ for (const doc of change.affectedDocuments) {
385
+ documentDrift[doc] = (documentDrift[doc] || 0) + change.impact;
386
+ }
387
+ }
388
+
389
+ // Normalize drift scores
390
+ const maxDrift = Math.max(1, changesSinceRefresh.length * 0.5);
391
+ analysis.overall = Math.min(1.0, totalImpact / maxDrift);
392
+
393
+ for (const [doc, drift] of Object.entries(documentDrift)) {
394
+ analysis.byDocument[doc] = {
395
+ drift: Math.min(1.0, drift / maxDrift),
396
+ severity: this.getDriftSeverity(drift / maxDrift),
397
+ changesAffecting: changesSinceRefresh.filter(c =>
398
+ c.affectedDocuments.includes(doc)
399
+ ).length
400
+ };
401
+ }
402
+
403
+ // Analyze by layer
404
+ for (const [layerName, layer] of Object.entries(PLANNING_LAYERS)) {
405
+ const layerDocs = layer.documents;
406
+ const layerDrift = layerDocs.reduce((sum, doc) => {
407
+ return sum + (analysis.byDocument[doc]?.drift || 0);
408
+ }, 0) / layerDocs.length;
409
+
410
+ analysis.byLayer[layerName] = {
411
+ drift: layerDrift,
412
+ severity: this.getDriftSeverity(layerDrift),
413
+ documents: layerDocs.map(doc => ({
414
+ name: doc,
415
+ drift: analysis.byDocument[doc]?.drift || 0
416
+ }))
417
+ };
418
+ }
419
+
420
+ // Generate recommendations
421
+ analysis.recommendations = this.generateDriftRecommendations(analysis);
422
+
423
+ // Store drift metrics
424
+ this.state.driftMetrics = analysis;
425
+ await this.saveState();
426
+
427
+ return analysis;
428
+ }
429
+
430
+ /**
431
+ * Get drift severity level
432
+ * @param {number} drift - Drift score (0-1)
433
+ */
434
+ getDriftSeverity(drift) {
435
+ for (const [level, config] of Object.entries(DRIFT_SEVERITY).reverse()) {
436
+ if (drift >= config.threshold) {
437
+ return { level, ...config };
438
+ }
439
+ }
440
+ return { level: 'none', ...DRIFT_SEVERITY.none };
441
+ }
442
+
443
+ /**
444
+ * Generate recommendations based on drift analysis
445
+ * @param {Object} analysis - Drift analysis results
446
+ */
447
+ generateDriftRecommendations(analysis) {
448
+ const recommendations = [];
449
+
450
+ // High drift documents
451
+ for (const [doc, data] of Object.entries(analysis.byDocument)) {
452
+ if (data.severity.level === 'critical') {
453
+ recommendations.push({
454
+ priority: 'critical',
455
+ document: doc,
456
+ action: `Immediately update ${doc.toUpperCase()}.md - critical drift detected`,
457
+ drift: data.drift
458
+ });
459
+ } else if (data.severity.level === 'significant') {
460
+ recommendations.push({
461
+ priority: 'high',
462
+ document: doc,
463
+ action: `Update ${doc.toUpperCase()}.md soon - significant drift detected`,
464
+ drift: data.drift
465
+ });
466
+ }
467
+ }
468
+
469
+ // Layer-level recommendations
470
+ for (const [layerName, data] of Object.entries(analysis.byLayer)) {
471
+ if (data.drift > 0.5) {
472
+ const layer = PLANNING_LAYERS[layerName];
473
+ recommendations.push({
474
+ priority: 'high',
475
+ layer: layerName,
476
+ action: `${layer.name} planning layer needs refresh - ${Math.round(data.drift * 100)}% drift`,
477
+ documents: data.documents.filter(d => d.drift > 0.3).map(d => d.name)
478
+ });
479
+ }
480
+ }
481
+
482
+ // Overall recommendation
483
+ if (analysis.overall > 0.7) {
484
+ recommendations.unshift({
485
+ priority: 'critical',
486
+ action: 'Full planning refresh recommended - overall drift is critical',
487
+ suggestedCommand: 'bootspring plan refresh --all'
488
+ });
489
+ } else if (analysis.overall > 0.4) {
490
+ recommendations.unshift({
491
+ priority: 'medium',
492
+ action: 'Consider partial planning refresh',
493
+ suggestedCommand: 'bootspring plan refresh'
494
+ });
495
+ }
496
+
497
+ return recommendations.sort((a, b) => {
498
+ const priorityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
499
+ return priorityOrder[a.priority] - priorityOrder[b.priority];
500
+ });
501
+ }
502
+
503
+ /**
504
+ * Refresh planning documents
505
+ * @param {Object} options - Refresh options
506
+ */
507
+ async refresh(options = {}) {
508
+ const refreshResult = {
509
+ timestamp: new Date().toISOString(),
510
+ documentsRefreshed: [],
511
+ changes: [],
512
+ errors: []
513
+ };
514
+
515
+ // Determine what to refresh
516
+ const drift = await this.calculateDrift();
517
+ const documentsToRefresh = options.all
518
+ ? Object.keys(drift.byDocument)
519
+ : options.documents || drift.recommendations
520
+ .filter(r => r.priority === 'critical' || r.priority === 'high')
521
+ .flatMap(r => r.document ? [r.document] : (r.documents || []));
522
+
523
+ const uniqueDocs = [...new Set(documentsToRefresh)];
524
+
525
+ for (const doc of uniqueDocs) {
526
+ try {
527
+ const result = await this.refreshDocument(doc, options);
528
+ refreshResult.documentsRefreshed.push(doc);
529
+ refreshResult.changes.push(result);
530
+ } catch (error) {
531
+ refreshResult.errors.push({ document: doc, error: error.message });
532
+ }
533
+ }
534
+
535
+ // Update state
536
+ this.state.lastRefresh = refreshResult.timestamp;
537
+ await this.saveState();
538
+
539
+ // Emit refresh complete event
540
+ this.emit('refresh-complete', refreshResult);
541
+
542
+ return refreshResult;
543
+ }
544
+
545
+ /**
546
+ * Refresh a single document
547
+ * @param {string} docName - Document name
548
+ * @param {Object} options - Refresh options
549
+ */
550
+ async refreshDocument(docName, options = {}) {
551
+ const docPath = path.join(this.planningDir, `${docName.toUpperCase()}.md`);
552
+
553
+ // Read current content
554
+ let currentContent = '';
555
+ try {
556
+ currentContent = await fs.readFile(docPath, 'utf-8');
557
+ } catch {
558
+ // Document doesn't exist yet
559
+ }
560
+
561
+ // Get changes affecting this document
562
+ const lastRefresh = this.state.documents[docName]?.lastRefresh
563
+ ? new Date(this.state.documents[docName].lastRefresh).getTime()
564
+ : 0;
565
+
566
+ const relevantChanges = this.state.changeLog.filter(c => {
567
+ return new Date(c.timestamp).getTime() > lastRefresh &&
568
+ c.affectedDocuments.includes(docName);
569
+ });
570
+
571
+ // Update document state
572
+ this.state.documents[docName] = {
573
+ lastRefresh: new Date().toISOString(),
574
+ version: (this.state.documents[docName]?.version || 0) + 1,
575
+ changeCount: relevantChanges.length
576
+ };
577
+
578
+ return {
579
+ document: docName,
580
+ path: docPath,
581
+ previousVersion: this.state.documents[docName].version - 1,
582
+ newVersion: this.state.documents[docName].version,
583
+ changesIncorporated: relevantChanges.length,
584
+ hadContent: !!currentContent
585
+ };
586
+ }
587
+
588
+ /**
589
+ * Get current planning status
590
+ */
591
+ async getStatus() {
592
+ const drift = await this.calculateDrift();
593
+
594
+ return {
595
+ initialized: true,
596
+ stage: this.state.currentStage,
597
+ lastRefresh: this.state.lastRefresh,
598
+ drift: {
599
+ overall: drift.overall,
600
+ severity: this.getDriftSeverity(drift.overall)
601
+ },
602
+ layers: Object.entries(PLANNING_LAYERS).map(([name, config]) => ({
603
+ name: config.name,
604
+ horizon: config.horizon,
605
+ health: this.state.layers[name]?.health || 'unknown',
606
+ lastUpdated: this.state.layers[name]?.lastUpdated,
607
+ drift: drift.byLayer[name]?.drift || 0
608
+ })),
609
+ pendingChanges: this.state.changeLog.filter(c => {
610
+ const lastRefresh = this.state.lastRefresh
611
+ ? new Date(this.state.lastRefresh).getTime()
612
+ : 0;
613
+ return new Date(c.timestamp).getTime() > lastRefresh;
614
+ }).length,
615
+ recommendations: drift.recommendations.slice(0, 5)
616
+ };
617
+ }
618
+
619
+ /**
620
+ * Simulate plan execution
621
+ * @param {Object} scenario - Scenario to simulate
622
+ */
623
+ async simulate(scenario) {
624
+ const simulation = {
625
+ timestamp: new Date().toISOString(),
626
+ scenario: scenario,
627
+ outcomes: [],
628
+ risks: [],
629
+ recommendations: []
630
+ };
631
+
632
+ // Analyze scenario type
633
+ if (scenario.type === 'feature') {
634
+ simulation.outcomes = this.simulateFeature(scenario);
635
+ } else if (scenario.type === 'refactor') {
636
+ simulation.outcomes = this.simulateRefactor(scenario);
637
+ } else if (scenario.type === 'timeline') {
638
+ simulation.outcomes = this.simulateTimeline(scenario);
639
+ } else if (scenario.type === 'resource') {
640
+ simulation.outcomes = this.simulateResource(scenario);
641
+ }
642
+
643
+ // Identify risks
644
+ simulation.risks = this.identifySimulationRisks(scenario, simulation.outcomes);
645
+
646
+ // Generate recommendations
647
+ simulation.recommendations = this.generateSimulationRecommendations(
648
+ scenario,
649
+ simulation.outcomes,
650
+ simulation.risks
651
+ );
652
+
653
+ return simulation;
654
+ }
655
+
656
+ /**
657
+ * Simulate feature addition
658
+ * @param {Object} scenario - Feature scenario
659
+ */
660
+ simulateFeature(scenario) {
661
+ const outcomes = [];
662
+ const feature = scenario.feature || 'Unknown Feature';
663
+
664
+ // Document impact
665
+ outcomes.push({
666
+ type: 'documents',
667
+ affected: ['prd', 'technical-spec', 'roadmap'],
668
+ impact: 'high',
669
+ description: `Adding "${feature}" will require updates to PRD, technical spec, and roadmap`
670
+ });
671
+
672
+ // Timeline impact
673
+ const estimatedDays = scenario.complexity === 'high' ? 14 :
674
+ scenario.complexity === 'medium' ? 7 : 3;
675
+ outcomes.push({
676
+ type: 'timeline',
677
+ impact: estimatedDays > 7 ? 'high' : 'medium',
678
+ estimatedDays,
679
+ description: `Feature implementation estimated at ${estimatedDays} days`
680
+ });
681
+
682
+ // Dependencies
683
+ outcomes.push({
684
+ type: 'dependencies',
685
+ newDependencies: scenario.dependencies || [],
686
+ impact: scenario.dependencies?.length > 2 ? 'high' : 'low',
687
+ description: `Feature may introduce ${scenario.dependencies?.length || 0} new dependencies`
688
+ });
689
+
690
+ return outcomes;
691
+ }
692
+
693
+ /**
694
+ * Simulate refactoring
695
+ * @param {Object} scenario - Refactor scenario
696
+ */
697
+ simulateRefactor(scenario) {
698
+ const outcomes = [];
699
+
700
+ outcomes.push({
701
+ type: 'risk',
702
+ level: 'medium',
703
+ description: 'Refactoring may introduce temporary instability'
704
+ });
705
+
706
+ outcomes.push({
707
+ type: 'testing',
708
+ impact: 'high',
709
+ description: 'Comprehensive testing required after refactor'
710
+ });
711
+
712
+ outcomes.push({
713
+ type: 'documentation',
714
+ impact: 'medium',
715
+ description: 'Technical documentation will need updating'
716
+ });
717
+
718
+ return outcomes;
719
+ }
720
+
721
+ /**
722
+ * Simulate timeline changes
723
+ * @param {Object} scenario - Timeline scenario
724
+ */
725
+ simulateTimeline(scenario) {
726
+ const outcomes = [];
727
+ const change = scenario.change || 'compression';
728
+
729
+ if (change === 'compression') {
730
+ outcomes.push({
731
+ type: 'scope',
732
+ impact: 'high',
733
+ description: 'Timeline compression may require scope reduction'
734
+ });
735
+
736
+ outcomes.push({
737
+ type: 'quality',
738
+ risk: 'medium',
739
+ description: 'Quality may be impacted by accelerated timeline'
740
+ });
741
+ } else if (change === 'extension') {
742
+ outcomes.push({
743
+ type: 'scope',
744
+ impact: 'positive',
745
+ description: 'Extended timeline allows for additional features'
746
+ });
747
+
748
+ outcomes.push({
749
+ type: 'budget',
750
+ impact: 'high',
751
+ description: 'Extended timeline increases resource costs'
752
+ });
753
+ }
754
+
755
+ return outcomes;
756
+ }
757
+
758
+ /**
759
+ * Simulate resource changes
760
+ * @param {Object} scenario - Resource scenario
761
+ */
762
+ simulateResource(scenario) {
763
+ const outcomes = [];
764
+
765
+ if (scenario.action === 'add') {
766
+ outcomes.push({
767
+ type: 'velocity',
768
+ impact: 'positive',
769
+ description: 'Additional resources should increase velocity after onboarding'
770
+ });
771
+
772
+ outcomes.push({
773
+ type: 'coordination',
774
+ impact: 'negative',
775
+ description: 'More resources increase coordination overhead'
776
+ });
777
+ } else if (scenario.action === 'remove') {
778
+ outcomes.push({
779
+ type: 'velocity',
780
+ impact: 'negative',
781
+ description: 'Reduced resources will decrease velocity'
782
+ });
783
+
784
+ outcomes.push({
785
+ type: 'scope',
786
+ impact: 'high',
787
+ description: 'May need to reduce scope or extend timeline'
788
+ });
789
+ }
790
+
791
+ return outcomes;
792
+ }
793
+
794
+ /**
795
+ * Identify risks from simulation
796
+ * @param {Object} scenario - Simulation scenario
797
+ * @param {Array} outcomes - Simulation outcomes
798
+ */
799
+ identifySimulationRisks(scenario, outcomes) {
800
+ const risks = [];
801
+
802
+ // High impact outcomes become risks
803
+ for (const outcome of outcomes) {
804
+ if (outcome.impact === 'high' || outcome.risk === 'high') {
805
+ risks.push({
806
+ source: outcome.type,
807
+ severity: 'high',
808
+ description: outcome.description,
809
+ mitigation: this.suggestMitigation(outcome)
810
+ });
811
+ }
812
+ }
813
+
814
+ // Scenario-specific risks
815
+ if (scenario.type === 'feature' && scenario.complexity === 'high') {
816
+ risks.push({
817
+ source: 'complexity',
818
+ severity: 'high',
819
+ description: 'High complexity features have higher failure risk',
820
+ mitigation: 'Consider breaking into smaller deliverables'
821
+ });
822
+ }
823
+
824
+ return risks;
825
+ }
826
+
827
+ /**
828
+ * Suggest mitigation for a risk
829
+ * @param {Object} outcome - Outcome that creates risk
830
+ */
831
+ suggestMitigation(outcome) {
832
+ const mitigations = {
833
+ documents: 'Create document update plan before starting implementation',
834
+ timeline: 'Build in buffer time and identify scope reduction options',
835
+ dependencies: 'Evaluate alternatives and lock dependency versions',
836
+ risk: 'Create rollback plan and increase testing',
837
+ scope: 'Prioritize features and identify what can be deferred',
838
+ quality: 'Increase code review coverage and add automated checks',
839
+ coordination: 'Establish clear communication channels and handoff points'
840
+ };
841
+
842
+ return mitigations[outcome.type] || 'Review and plan accordingly';
843
+ }
844
+
845
+ /**
846
+ * Generate recommendations from simulation
847
+ * @param {Object} scenario - Simulation scenario
848
+ * @param {Array} outcomes - Simulation outcomes
849
+ * @param {Array} risks - Identified risks
850
+ */
851
+ generateSimulationRecommendations(scenario, outcomes, risks) {
852
+ const recommendations = [];
853
+
854
+ // Risk-based recommendations
855
+ const highRisks = risks.filter(r => r.severity === 'high');
856
+ if (highRisks.length > 0) {
857
+ recommendations.push({
858
+ priority: 'high',
859
+ action: 'Address high-severity risks before proceeding',
860
+ details: highRisks.map(r => r.mitigation)
861
+ });
862
+ }
863
+
864
+ // Scenario-specific recommendations
865
+ if (scenario.type === 'feature') {
866
+ recommendations.push({
867
+ priority: 'medium',
868
+ action: 'Create feature decomposition',
869
+ command: `bootspring plan decompose "${scenario.feature}"`
870
+ });
871
+ }
872
+
873
+ // Document update recommendations
874
+ const affectedDocs = outcomes
875
+ .filter(o => o.type === 'documents')
876
+ .flatMap(o => o.affected || []);
877
+
878
+ if (affectedDocs.length > 0) {
879
+ recommendations.push({
880
+ priority: 'medium',
881
+ action: 'Update affected planning documents',
882
+ documents: [...new Set(affectedDocs)]
883
+ });
884
+ }
885
+
886
+ return recommendations;
887
+ }
888
+
889
+ /**
890
+ * Transition to a new planning stage
891
+ * @param {string} newStage - Stage to transition to
892
+ * @param {Object} options - Transition options
893
+ */
894
+ async transitionStage(newStage, options = {}) {
895
+ const validStages = ['discovery', 'definition', 'execution', 'iteration', 'scale'];
896
+
897
+ if (!validStages.includes(newStage)) {
898
+ throw new Error(`Invalid stage: ${newStage}. Valid stages: ${validStages.join(', ')}`);
899
+ }
900
+
901
+ const previousStage = this.state.currentStage;
902
+ this.state.currentStage = newStage;
903
+
904
+ const transition = {
905
+ timestamp: new Date().toISOString(),
906
+ from: previousStage,
907
+ to: newStage,
908
+ reason: options.reason || 'Manual transition'
909
+ };
910
+
911
+ // Record transition
912
+ if (!this.state.stageHistory) {
913
+ this.state.stageHistory = [];
914
+ }
915
+ this.state.stageHistory.push(transition);
916
+
917
+ await this.saveState();
918
+
919
+ // Emit stage change event
920
+ this.emit('stage-change', transition);
921
+
922
+ return transition;
923
+ }
924
+
925
+ /**
926
+ * Get planning context for AI prompts
927
+ */
928
+ async getAIContext() {
929
+ const status = await this.getStatus();
930
+ const drift = await this.calculateDrift();
931
+
932
+ return {
933
+ projectStage: this.state.currentStage,
934
+ planningLayers: PLANNING_LAYERS,
935
+ currentDrift: {
936
+ overall: drift.overall,
937
+ severity: drift.recommendations[0]?.priority || 'none'
938
+ },
939
+ recentChanges: this.state.changeLog.slice(-10).map(c => ({
940
+ type: c.type,
941
+ category: c.category,
942
+ impact: c.impact
943
+ })),
944
+ refreshNeeded: drift.overall > 0.4,
945
+ documentsNeedingAttention: Object.entries(drift.byDocument)
946
+ .filter(([, data]) => data.drift > 0.3)
947
+ .map(([doc]) => doc),
948
+ suggestedActions: status.recommendations
949
+ };
950
+ }
951
+ }
952
+
953
+ module.exports = {
954
+ AdaptivePlanningEngine,
955
+ REFRESH_TRIGGERS,
956
+ PLANNING_LAYERS,
957
+ DRIFT_SEVERITY
958
+ };