@polymorphism-tech/morph-spec 3.1.0 → 3.2.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 (51) hide show
  1. package/CLAUDE.md +534 -0
  2. package/README.md +78 -4
  3. package/bin/morph-spec.js +50 -1
  4. package/bin/render-template.js +56 -10
  5. package/bin/task-manager.cjs +101 -7
  6. package/docs/cli-auto-detection.md +219 -0
  7. package/docs/llm-interaction-config.md +735 -0
  8. package/docs/troubleshooting.md +269 -0
  9. package/package.json +5 -1
  10. package/src/commands/advance-phase.js +93 -2
  11. package/src/commands/approve.js +221 -0
  12. package/src/commands/capture-pattern.js +121 -0
  13. package/src/commands/generate.js +128 -1
  14. package/src/commands/init.js +37 -0
  15. package/src/commands/migrate-state.js +158 -0
  16. package/src/commands/search-patterns.js +126 -0
  17. package/src/commands/spawn-team.js +172 -0
  18. package/src/commands/task.js +2 -2
  19. package/src/commands/update.js +36 -0
  20. package/src/commands/upgrade.js +346 -0
  21. package/src/generator/.gitkeep +0 -0
  22. package/src/generator/config-generator.js +206 -0
  23. package/src/generator/templates/config.json.template +40 -0
  24. package/src/generator/templates/project.md.template +67 -0
  25. package/src/lib/checkpoint-hooks.js +258 -0
  26. package/src/lib/metadata-extractor.js +380 -0
  27. package/src/lib/phase-state-machine.js +214 -0
  28. package/src/lib/state-manager.js +120 -0
  29. package/src/lib/template-data-sources.js +325 -0
  30. package/src/lib/validators/content-validator.js +351 -0
  31. package/src/llm/.gitkeep +0 -0
  32. package/src/llm/analyzer.js +215 -0
  33. package/src/llm/environment-detector.js +43 -0
  34. package/src/llm/few-shot-examples.js +216 -0
  35. package/src/llm/project-config-schema.json +188 -0
  36. package/src/llm/prompt-builder.js +96 -0
  37. package/src/llm/schema-validator.js +121 -0
  38. package/src/orchestrator.js +206 -0
  39. package/src/sanitizer/.gitkeep +0 -0
  40. package/src/sanitizer/context-sanitizer.js +221 -0
  41. package/src/sanitizer/patterns.js +163 -0
  42. package/src/scanner/.gitkeep +0 -0
  43. package/src/scanner/project-scanner.js +242 -0
  44. package/src/types/index.js +477 -0
  45. package/src/ui/.gitkeep +0 -0
  46. package/src/ui/diff-display.js +91 -0
  47. package/src/ui/interactive-wizard.js +96 -0
  48. package/src/ui/user-review.js +211 -0
  49. package/src/ui/wizard-questions.js +190 -0
  50. package/src/writer/.gitkeep +0 -0
  51. package/src/writer/file-writer.js +86 -0
@@ -0,0 +1,380 @@
1
+ import { readFileSync, existsSync } from 'fs';
2
+
3
+ /**
4
+ * Metadata Extractor - Parse markdown documents into structured JSON
5
+ *
6
+ * Generates quick summaries for LLM to parse, reducing token usage
7
+ * by 60-80% compared to reading full markdown files.
8
+ */
9
+
10
+ /**
11
+ * Extract a field value from markdown content (table format)
12
+ * @param {string} content - Markdown content
13
+ * @param {string} fieldName - Field name to extract
14
+ * @returns {string|null} Field value or null
15
+ */
16
+ function extractField(content, fieldName) {
17
+ // Look for table row: | FieldName | Value |
18
+ const regex = new RegExp(`\\|\\s*${fieldName}\\s*\\|\\s*(.+?)\\s*\\|`, 'i');
19
+ const match = content.match(regex);
20
+ return match ? match[1].trim() : null;
21
+ }
22
+
23
+ /**
24
+ * Extract first paragraph from a section
25
+ * @param {string} content - Markdown content
26
+ * @param {string} sectionHeader - Section header (e.g., "## Overview")
27
+ * @returns {string|null} First paragraph or null
28
+ */
29
+ function extractFirstParagraph(content, sectionHeader) {
30
+ const regex = new RegExp(`${sectionHeader}\\s+([^\\n]+(?:\\n(?!##)[^\\n]+)*)`, 'i');
31
+ const match = content.match(regex);
32
+
33
+ if (!match) return null;
34
+
35
+ // Get first paragraph (up to first blank line or next section)
36
+ const text = match[1].trim();
37
+ const firstPara = text.split('\n\n')[0];
38
+ return firstPara.substring(0, 300); // Limit to 300 chars
39
+ }
40
+
41
+ /**
42
+ * Extract bullet points from a section
43
+ * @param {string} content - Markdown content
44
+ * @param {string} sectionHeader - Section header
45
+ * @returns {string[]} Array of bullet points
46
+ */
47
+ function extractBulletPoints(content, sectionHeader) {
48
+ const regex = new RegExp(`${sectionHeader}([\\s\\S]*?)(?=\\n## |$)`, 'i');
49
+ const match = content.match(regex);
50
+
51
+ if (!match) return [];
52
+
53
+ const sectionContent = match[1];
54
+ const bullets = [];
55
+
56
+ // Match lines starting with -, *, or numbered list
57
+ const bulletRegex = /^[\s]*[-*][\s]+(.+)$/gm;
58
+ let bulletMatch;
59
+
60
+ while ((bulletMatch = bulletRegex.exec(sectionContent)) !== null) {
61
+ bullets.push(bulletMatch[1].trim());
62
+ }
63
+
64
+ return bullets.slice(0, 10); // Limit to 10 items
65
+ }
66
+
67
+ /**
68
+ * Extract table or list from a section
69
+ * @param {string} content - Markdown content
70
+ * @param {string} sectionHeader - Section header
71
+ * @returns {string[]} Array of items
72
+ */
73
+ function extractTableOrList(content, sectionHeader) {
74
+ const items = extractBulletPoints(content, sectionHeader);
75
+
76
+ if (items.length > 0) return items;
77
+
78
+ // Try extracting table rows
79
+ const regex = new RegExp(`${sectionHeader}([\\s\\S]*?)(?=\\n## |$)`, 'i');
80
+ const match = content.match(regex);
81
+
82
+ if (!match) return [];
83
+
84
+ const sectionContent = match[1];
85
+ const tableRows = [];
86
+
87
+ // Match table rows (| col1 | col2 |)
88
+ const rowRegex = /^\|(.+)\|$/gm;
89
+ let rowMatch;
90
+
91
+ while ((rowMatch = rowRegex.exec(sectionContent)) !== null) {
92
+ const row = rowMatch[1].trim();
93
+ // Skip header separator rows
94
+ if (!row.match(/^[-:|\s]+$/)) {
95
+ tableRows.push(row);
96
+ }
97
+ }
98
+
99
+ return tableRows.slice(0, 10); // Limit to 10 rows
100
+ }
101
+
102
+ /**
103
+ * Auto-generate tags from content
104
+ * @param {string} content - Markdown content
105
+ * @returns {string[]} Array of tags
106
+ */
107
+ function autoGenerateTags(content) {
108
+ const tags = new Set();
109
+ const lowerContent = content.toLowerCase();
110
+
111
+ // Technology tags
112
+ const techKeywords = {
113
+ 'blazor': 'blazor',
114
+ 'react': 'react',
115
+ 'nextjs': 'nextjs',
116
+ 'next.js': 'nextjs',
117
+ 'azure': 'azure',
118
+ 'sql': 'database',
119
+ 'entity framework': 'ef-core',
120
+ 'hangfire': 'background-jobs',
121
+ 'signalr': 'realtime',
122
+ 'docker': 'container',
123
+ 'bicep': 'infrastructure-as-code',
124
+ 'api': 'api',
125
+ 'rest': 'rest-api',
126
+ 'authentication': 'auth',
127
+ 'authorization': 'auth'
128
+ };
129
+
130
+ Object.entries(techKeywords).forEach(([keyword, tag]) => {
131
+ if (lowerContent.includes(keyword)) {
132
+ tags.add(tag);
133
+ }
134
+ });
135
+
136
+ return Array.from(tags).slice(0, 8); // Limit to 8 tags
137
+ }
138
+
139
+ /**
140
+ * Extract spec.md metadata
141
+ * @param {string} specPath - Path to spec.md file
142
+ * @returns {Object} Spec metadata
143
+ */
144
+ export function extractSpecMetadata(specPath) {
145
+ if (!existsSync(specPath)) {
146
+ return {
147
+ error: 'Spec file not found',
148
+ path: specPath
149
+ };
150
+ }
151
+
152
+ try {
153
+ const content = readFileSync(specPath, 'utf8');
154
+
155
+ return {
156
+ id: extractField(content, 'ID') || extractField(content, 'Feature'),
157
+ status: extractField(content, 'Status'),
158
+ complexity: extractField(content, 'Complexity'),
159
+ estimatedCost: extractField(content, 'Estimated Cost') || extractField(content, 'Cost'),
160
+
161
+ summary: {
162
+ problem: extractFirstParagraph(content, '## Overview') ||
163
+ extractFirstParagraph(content, '## Problem Statement'),
164
+ solution: extractFirstParagraph(content, '## Proposed Solution') ||
165
+ extractFirstParagraph(content, '## Solution'),
166
+ tags: autoGenerateTags(content)
167
+ },
168
+
169
+ requirements: extractBulletPoints(content, '## Requirements').slice(0, 5),
170
+ dataModel: extractTableOrList(content, '## Data Model').slice(0, 5),
171
+ apiContracts: extractTableOrList(content, '## API Contracts').slice(0, 5),
172
+
173
+ stats: {
174
+ length: content.length,
175
+ sections: (content.match(/^## /gm) || []).length,
176
+ codeBlocks: (content.match(/```/g) || []).length / 2
177
+ },
178
+
179
+ generatedAt: new Date().toISOString(),
180
+ source: specPath
181
+ };
182
+ } catch (error) {
183
+ return {
184
+ error: error.message,
185
+ path: specPath
186
+ };
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Extract proposal.md metadata
192
+ * @param {string} proposalPath - Path to proposal.md file
193
+ * @returns {Object} Proposal metadata
194
+ */
195
+ export function extractProposalMetadata(proposalPath) {
196
+ if (!existsSync(proposalPath)) {
197
+ return {
198
+ error: 'Proposal file not found',
199
+ path: proposalPath
200
+ };
201
+ }
202
+
203
+ try {
204
+ const content = readFileSync(proposalPath, 'utf8');
205
+
206
+ return {
207
+ id: extractField(content, 'ID') || extractField(content, 'Feature'),
208
+ status: extractField(content, 'Status'),
209
+
210
+ summary: {
211
+ problem: extractFirstParagraph(content, '## Problem Statement'),
212
+ solution: extractFirstParagraph(content, '## Proposed Solution'),
213
+ tags: autoGenerateTags(content)
214
+ },
215
+
216
+ successMetrics: extractBulletPoints(content, '## Success Metrics').slice(0, 5),
217
+
218
+ generatedAt: new Date().toISOString(),
219
+ source: proposalPath
220
+ };
221
+ } catch (error) {
222
+ return {
223
+ error: error.message,
224
+ path: proposalPath
225
+ };
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Extract decisions.md metadata (ADRs)
231
+ * @param {string} decisionsPath - Path to decisions.md file
232
+ * @returns {Object} Decisions metadata
233
+ */
234
+ export function extractDecisionsMetadata(decisionsPath) {
235
+ if (!existsSync(decisionsPath)) {
236
+ return {
237
+ error: 'Decisions file not found',
238
+ path: decisionsPath
239
+ };
240
+ }
241
+
242
+ try {
243
+ const content = readFileSync(decisionsPath, 'utf8');
244
+ const decisions = [];
245
+
246
+ // Match ADR pattern: ### ADR-001: Title
247
+ const adrRegex = /### (ADR-\d+): (.+?)\n([\s\S]*?)(?=### ADR-|\n## |$)/g;
248
+ let match;
249
+
250
+ while ((match = adrRegex.exec(content)) !== null) {
251
+ const [, id, title, body] = match;
252
+
253
+ decisions.push({
254
+ id,
255
+ title: title.trim(),
256
+ summary: body.trim().substring(0, 200), // First 200 chars
257
+ fullPath: `${decisionsPath}#${id.toLowerCase()}`
258
+ });
259
+ }
260
+
261
+ return {
262
+ decisions,
263
+ total: decisions.length,
264
+ generatedAt: new Date().toISOString(),
265
+ source: decisionsPath
266
+ };
267
+ } catch (error) {
268
+ return {
269
+ error: error.message,
270
+ path: decisionsPath
271
+ };
272
+ }
273
+ }
274
+
275
+ /**
276
+ * Extract tasks.json metadata (summary only)
277
+ * @param {string} tasksPath - Path to tasks.json file
278
+ * @returns {Object} Tasks metadata
279
+ */
280
+ export function extractTasksMetadata(tasksPath) {
281
+ if (!existsSync(tasksPath)) {
282
+ return {
283
+ error: 'Tasks file not found',
284
+ path: tasksPath
285
+ };
286
+ }
287
+
288
+ try {
289
+ const content = readFileSync(tasksPath, 'utf8');
290
+ const tasks = JSON.parse(content);
291
+
292
+ const regularTasks = tasks.tasks?.filter(t => t.id?.startsWith('T')) || [];
293
+ const checkpoints = tasks.tasks?.filter(t => t.id?.startsWith('CHECKPOINT')) || [];
294
+
295
+ return {
296
+ total: tasks.tasks?.length || 0,
297
+ regularTasks: regularTasks.length,
298
+ checkpoints: checkpoints.length,
299
+
300
+ categories: tasks.categories || {},
301
+
302
+ byStatus: {
303
+ pending: regularTasks.filter(t => t.status === 'pending').length,
304
+ inProgress: regularTasks.filter(t => t.status === 'in_progress').length,
305
+ done: regularTasks.filter(t => t.status === 'done').length,
306
+ blocked: regularTasks.filter(t => t.status === 'blocked').length
307
+ },
308
+
309
+ estimatedTime: regularTasks.reduce((sum, t) => sum + (t.estimatedMinutes || 0), 0),
310
+
311
+ nextTask: regularTasks.find(t => t.status === 'pending' &&
312
+ (!t.dependencies || t.dependencies.length === 0)),
313
+
314
+ generatedAt: new Date().toISOString(),
315
+ source: tasksPath
316
+ };
317
+ } catch (error) {
318
+ return {
319
+ error: error.message,
320
+ path: tasksPath
321
+ };
322
+ }
323
+ }
324
+
325
+ /**
326
+ * Extract comprehensive feature metadata
327
+ * @param {Object} featureState - Feature state from state.json
328
+ * @returns {Object} Complete feature metadata
329
+ */
330
+ export function extractFeatureMetadata(featureState) {
331
+ const metadata = {
332
+ version: "1.0.0",
333
+ feature: featureState.name || 'unknown',
334
+ status: featureState.status,
335
+ phase: featureState.phase,
336
+ workflow: featureState.workflow,
337
+
338
+ agents: featureState.activeAgents || [],
339
+ checkpoints: featureState.checkpoints || [],
340
+
341
+ outputs: {},
342
+ quickLinks: {}
343
+ };
344
+
345
+ // Extract metadata from each output
346
+ if (featureState.outputs?.proposal?.created) {
347
+ metadata.outputs.proposal = extractProposalMetadata(featureState.outputs.proposal.path);
348
+ metadata.quickLinks.proposal = featureState.outputs.proposal.path;
349
+ }
350
+
351
+ if (featureState.outputs?.spec?.created) {
352
+ metadata.outputs.spec = extractSpecMetadata(featureState.outputs.spec.path);
353
+ metadata.quickLinks.spec = featureState.outputs.spec.path;
354
+ }
355
+
356
+ if (featureState.outputs?.decisions?.created) {
357
+ metadata.outputs.decisions = extractDecisionsMetadata(featureState.outputs.decisions.path);
358
+ metadata.quickLinks.decisions = featureState.outputs.decisions.path;
359
+ }
360
+
361
+ if (featureState.outputs?.tasks?.created) {
362
+ metadata.outputs.tasks = extractTasksMetadata(featureState.outputs.tasks.path);
363
+ metadata.quickLinks.tasks = featureState.outputs.tasks.path;
364
+ }
365
+
366
+ // Calculate progress
367
+ if (featureState.tasks) {
368
+ metadata.progress = {
369
+ total: featureState.tasks.total || 0,
370
+ completed: featureState.tasks.completed || 0,
371
+ percentage: featureState.tasks.total > 0
372
+ ? Math.round((featureState.tasks.completed / featureState.tasks.total) * 100)
373
+ : 0
374
+ };
375
+ }
376
+
377
+ metadata.generatedAt = new Date().toISOString();
378
+
379
+ return metadata;
380
+ }
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Phase State Machine - Enforces valid phase transitions
3
+ *
4
+ * Prevents invalid workflow jumps and ensures sequential phase progression.
5
+ * Based on the MORPH 5-phase workflow.
6
+ */
7
+
8
+ /**
9
+ * Valid phase transitions map
10
+ * Each phase can only transition to specific next phases
11
+ */
12
+ const VALID_TRANSITIONS = {
13
+ 'proposal': ['setup'],
14
+ 'setup': ['uiux', 'design'], // Can skip UI/UX if no frontend
15
+ 'uiux': ['design'],
16
+ 'design': ['clarify'],
17
+ 'clarify': ['tasks'],
18
+ 'tasks': ['implement'],
19
+ 'implement': ['sync', 'archived'], // Can skip sync
20
+ 'sync': ['archived']
21
+ };
22
+
23
+ /**
24
+ * Phase display names for error messages
25
+ */
26
+ const PHASE_NAMES = {
27
+ 'proposal': 'Proposal (Phase 0)',
28
+ 'setup': 'Setup (Phase 1)',
29
+ 'uiux': 'UI/UX Design (Phase 1.5)',
30
+ 'design': 'Design (Phase 2)',
31
+ 'clarify': 'Clarify (Phase 3)',
32
+ 'tasks': 'Tasks (Phase 4)',
33
+ 'implement': 'Implement (Phase 5)',
34
+ 'sync': 'Sync (Phase 6)',
35
+ 'archived': 'Archived'
36
+ };
37
+
38
+ /**
39
+ * Optional phases that can be skipped
40
+ */
41
+ const OPTIONAL_PHASES = ['uiux', 'sync'];
42
+
43
+ /**
44
+ * Validate if a phase transition is allowed
45
+ * @param {string} fromPhase - Current phase
46
+ * @param {string} toPhase - Target phase
47
+ * @returns {boolean} True if transition is valid
48
+ */
49
+ export function isValidTransition(fromPhase, toPhase) {
50
+ if (!fromPhase || !toPhase) {
51
+ return false;
52
+ }
53
+
54
+ const validNextPhases = VALID_TRANSITIONS[fromPhase];
55
+ return validNextPhases ? validNextPhases.includes(toPhase) : false;
56
+ }
57
+
58
+ /**
59
+ * Get list of valid next phases for a given phase
60
+ * @param {string} currentPhase - Current phase
61
+ * @returns {string[]} Array of valid next phase names
62
+ */
63
+ export function getValidNextPhases(currentPhase) {
64
+ return VALID_TRANSITIONS[currentPhase] || [];
65
+ }
66
+
67
+ /**
68
+ * Get display name for a phase
69
+ * @param {string} phase - Phase identifier
70
+ * @returns {string} Human-readable phase name
71
+ */
72
+ export function getPhaseDisplayName(phase) {
73
+ return PHASE_NAMES[phase] || phase;
74
+ }
75
+
76
+ /**
77
+ * Check if a phase is optional
78
+ * @param {string} phase - Phase identifier
79
+ * @returns {boolean} True if phase is optional
80
+ */
81
+ export function isOptionalPhase(phase) {
82
+ return OPTIONAL_PHASES.includes(phase);
83
+ }
84
+
85
+ /**
86
+ * Validate transition and throw error if invalid
87
+ * @param {string} fromPhase - Current phase
88
+ * @param {string} toPhase - Target phase
89
+ * @throws {Error} If transition is invalid
90
+ */
91
+ export function validateTransition(fromPhase, toPhase) {
92
+ if (!fromPhase) {
93
+ throw new Error('Current phase is undefined. Cannot validate transition.');
94
+ }
95
+
96
+ if (!toPhase) {
97
+ throw new Error('Target phase is undefined. Cannot validate transition.');
98
+ }
99
+
100
+ if (!isValidTransition(fromPhase, toPhase)) {
101
+ const validPhases = getValidNextPhases(fromPhase);
102
+ const fromDisplay = getPhaseDisplayName(fromPhase);
103
+ const toDisplay = getPhaseDisplayName(toPhase);
104
+
105
+ throw new Error(
106
+ `Invalid phase transition: ${fromDisplay} → ${toDisplay}\n\n` +
107
+ `Valid next phases from ${fromDisplay}:\n` +
108
+ validPhases.map(p => ` • ${getPhaseDisplayName(p)}`).join('\n') +
109
+ '\n\n' +
110
+ 'You cannot skip phases in the MORPH workflow.\n' +
111
+ 'To override this check, use the --force flag (not recommended).'
112
+ );
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Get the sequential order of phases
118
+ * @returns {string[]} Ordered list of phases
119
+ */
120
+ export function getPhaseSequence() {
121
+ return [
122
+ 'proposal',
123
+ 'setup',
124
+ 'uiux', // optional
125
+ 'design',
126
+ 'clarify',
127
+ 'tasks',
128
+ 'implement',
129
+ 'sync', // optional
130
+ 'archived'
131
+ ];
132
+ }
133
+
134
+ /**
135
+ * Get phase order index (for comparison)
136
+ * @param {string} phase - Phase identifier
137
+ * @returns {number} Index in sequence (-1 if not found)
138
+ */
139
+ export function getPhaseOrder(phase) {
140
+ return getPhaseSequence().indexOf(phase);
141
+ }
142
+
143
+ /**
144
+ * Check if target phase is ahead of current phase
145
+ * @param {string} currentPhase - Current phase
146
+ * @param {string} targetPhase - Target phase
147
+ * @returns {boolean} True if target is ahead
148
+ */
149
+ export function isPhaseAhead(currentPhase, targetPhase) {
150
+ return getPhaseOrder(targetPhase) > getPhaseOrder(currentPhase);
151
+ }
152
+
153
+ /**
154
+ * Check if target phase is behind current phase (rollback scenario)
155
+ * @param {string} currentPhase - Current phase
156
+ * @param {string} targetPhase - Target phase
157
+ * @returns {boolean} True if target is behind
158
+ */
159
+ export function isPhaseRollback(currentPhase, targetPhase) {
160
+ return getPhaseOrder(targetPhase) < getPhaseOrder(currentPhase);
161
+ }
162
+
163
+ /**
164
+ * Get all skipped phases between two phases
165
+ * @param {string} fromPhase - Starting phase
166
+ * @param {string} toPhase - Ending phase
167
+ * @returns {string[]} Array of skipped phases
168
+ */
169
+ export function getSkippedPhases(fromPhase, toPhase) {
170
+ const sequence = getPhaseSequence();
171
+ const fromIndex = sequence.indexOf(fromPhase);
172
+ const toIndex = sequence.indexOf(toPhase);
173
+
174
+ if (fromIndex === -1 || toIndex === -1 || toIndex <= fromIndex) {
175
+ return [];
176
+ }
177
+
178
+ return sequence.slice(fromIndex + 1, toIndex);
179
+ }
180
+
181
+ /**
182
+ * Validate that no required phases were skipped
183
+ * @param {string} fromPhase - Starting phase
184
+ * @param {string} toPhase - Ending phase
185
+ * @throws {Error} If required phases were skipped
186
+ */
187
+ export function validateNoRequiredPhasesSkipped(fromPhase, toPhase) {
188
+ const skipped = getSkippedPhases(fromPhase, toPhase);
189
+ const requiredSkipped = skipped.filter(p => !isOptionalPhase(p));
190
+
191
+ if (requiredSkipped.length > 0) {
192
+ throw new Error(
193
+ `Cannot skip required phases:\n` +
194
+ requiredSkipped.map(p => ` • ${getPhaseDisplayName(p)}`).join('\n') +
195
+ '\n\nYou must complete each required phase in sequence.'
196
+ );
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Get comprehensive phase information
202
+ * @param {string} phase - Phase identifier
203
+ * @returns {Object} Phase metadata
204
+ */
205
+ export function getPhaseInfo(phase) {
206
+ return {
207
+ id: phase,
208
+ displayName: getPhaseDisplayName(phase),
209
+ order: getPhaseOrder(phase),
210
+ isOptional: isOptionalPhase(phase),
211
+ validNextPhases: getValidNextPhases(phase),
212
+ canTransitionTo: (targetPhase) => isValidTransition(phase, targetPhase)
213
+ };
214
+ }