@polymorphism-tech/morph-spec 6.0.0 → 7.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 (114) hide show
  1. package/README.md +367 -366
  2. package/docs/CHEATSHEET.md +3 -3
  3. package/docs/QUICKSTART.md +4 -4
  4. package/framework/CLAUDE.md +13 -0
  5. package/framework/MORPH.md +57 -36
  6. package/framework/agents.json +132 -127
  7. package/framework/commands/morph-apply.md +4 -21
  8. package/framework/commands/morph-archive.md +1 -1
  9. package/framework/commands/morph-proposal.md +2 -0
  10. package/framework/hooks/claude-code/core/enforce-phase-writes.js +1 -1
  11. package/framework/hooks/claude-code/core/protect-spec-files.js +1 -1
  12. package/framework/hooks/claude-code/core/state-sync.js +212 -0
  13. package/framework/hooks/shared/phase-utils.js +5 -4
  14. package/framework/hooks/shared/state-reader.js +1 -1
  15. package/framework/phases.json +1 -1
  16. package/framework/rubrics/backend.json +41 -36
  17. package/framework/rules/ai-agents-standards.md +45 -15
  18. package/framework/rules/morph-workflow.md +15 -34
  19. package/framework/rules/neon-standards.md +25 -26
  20. package/framework/schemas/tasks.schema.json +35 -0
  21. package/framework/skills/core/morph-apply/SKILL.md +154 -0
  22. package/framework/skills/core/morph-brainstorming/SKILL.md +53 -30
  23. package/framework/skills/core/morph-implement/SKILL.md +25 -14
  24. package/framework/skills/core/morph-plan/SKILL.md +91 -23
  25. package/framework/skills/core/morph-proposal/SKILL.md +137 -0
  26. package/framework/skills/core/morph-review/SKILL.md +43 -7
  27. package/framework/skills/core/morph-stack-scan/SKILL.md +40 -1
  28. package/framework/standards/STANDARDS.json +1729 -1134
  29. package/framework/standards/ai-agents/agent-session.md +160 -0
  30. package/framework/standards/ai-agents/batching.md +193 -0
  31. package/framework/standards/ai-agents/context-providers.md +188 -0
  32. package/framework/standards/ai-agents/mcp-server.md +250 -0
  33. package/framework/standards/ai-agents/mcp-tools.md +197 -0
  34. package/framework/standards/ai-agents/middleware-patterns.md +207 -0
  35. package/framework/standards/ai-agents/modalities-image-gen.md +173 -0
  36. package/framework/standards/ai-agents/modalities-speech-to-text.md +171 -0
  37. package/framework/standards/ai-agents/modalities-text-to-speech.md +181 -0
  38. package/framework/standards/ai-agents/modalities-vision.md +201 -0
  39. package/framework/standards/ai-agents/multi-agent-patterns.md +155 -0
  40. package/framework/standards/ai-agents/production.md +114 -258
  41. package/framework/standards/ai-agents/prompts-in-db.md +214 -0
  42. package/framework/standards/ai-agents/providers/model-registry.md +158 -0
  43. package/framework/standards/ai-agents/rag-custom-pgvector.md +243 -0
  44. package/framework/standards/ai-agents/service-tier-flex.md +135 -0
  45. package/framework/standards/ai-agents/setup.md +142 -316
  46. package/framework/standards/ai-agents/structured-output.md +249 -0
  47. package/framework/standards/ai-agents/sweet-spot.md +109 -0
  48. package/framework/standards/ai-agents/workflows.md +114 -284
  49. package/framework/standards/architecture/vertical-slice/vertical-slice.md +433 -429
  50. package/framework/standards/backend/api/validation.md +92 -88
  51. package/framework/standards/backend/database/postgresql/database.md +336 -332
  52. package/framework/standards/backend/database/vector-search-rag.md +599 -541
  53. package/framework/standards/backend/dotnet/fluent-validation-vsa.md +4 -0
  54. package/framework/standards/backend/dotnet/result-pattern.md +4 -0
  55. package/framework/standards/backend/dotnet/scalar-ui.md +176 -172
  56. package/framework/standards/backend/dotnet/scrutor.md +4 -0
  57. package/framework/standards/backend/dotnet/vsa-handler-patterns.md +278 -274
  58. package/framework/standards/backend/integrations/asaas/asaas-api.md +260 -216
  59. package/framework/standards/backend/integrations/hangfire/hangfire-jobs.md +354 -350
  60. package/framework/standards/backend/integrations/neon-auth/neon-auth.md +30 -19
  61. package/framework/standards/backend/integrations/resend/resend-email.md +400 -385
  62. package/framework/standards/data/nosql/cache/redis.md +106 -97
  63. package/framework/standards/data/nosql/cosmos-db.md +127 -118
  64. package/framework/standards/data/vector-search/rag-chunking.md +113 -104
  65. package/framework/standards/frontend/design-system/ai-image-generation.md +4 -0
  66. package/framework/standards/frontend/design-system/animations.md +164 -160
  67. package/framework/standards/frontend/design-system/naming.md +68 -64
  68. package/framework/standards/frontend/gsap/gsap-core.md +14 -8
  69. package/framework/standards/frontend/gsap/gsap-plugins.md +26 -21
  70. package/framework/standards/frontend/gsap/gsap-scrolltrigger.md +9 -7
  71. package/framework/standards/frontend/nextjs/app-router.md +127 -123
  72. package/framework/standards/frontend/nextjs/components.md +136 -132
  73. package/framework/standards/frontend/nextjs/data-fetching.md +130 -126
  74. package/framework/standards/frontend/nextjs/forms.md +134 -128
  75. package/framework/standards/frontend/nextjs/motion-patterns.md +328 -322
  76. package/framework/standards/frontend/nextjs/naming-conventions.md +71 -67
  77. package/framework/standards/frontend/nextjs/nextjs-patterns.md +229 -225
  78. package/framework/standards/frontend/nextjs/project-structure.md +106 -102
  79. package/framework/standards/frontend/nextjs/state-management.md +76 -72
  80. package/framework/standards/frontend/nextjs/testing.md +115 -111
  81. package/framework/standards/infrastructure/docker/easypanel-deploy.md +196 -192
  82. package/framework/standards/infrastructure/neon/neon-pgrag.md +6 -2
  83. package/framework/standards/infrastructure/neon/neon-pgvector.md +4 -0
  84. package/framework/standards/infrastructure/neon/neon-setup.md +5 -1
  85. package/framework/standards/integration/mcp/mcp-tools.md +471 -462
  86. package/framework/templates/REGISTRY.json +1745 -1239
  87. package/framework/templates/code/dotnet/ai-agents/AgentBootstrap.cs.template +39 -0
  88. package/framework/templates/code/dotnet/ai-agents/EmbeddingService.cs.template +68 -0
  89. package/framework/templates/code/dotnet/ai-agents/ModelRegistry.cs.template +117 -0
  90. package/framework/templates/code/dotnet/ai-agents/PromptRepository.cs.template +95 -0
  91. package/framework/templates/code/dotnet/ai-agents/ResilientToolMiddleware.cs.template +61 -0
  92. package/framework/templates/code/dotnet/ai-agents/VectorSearchTool.cs.template +107 -0
  93. package/framework/templates/code/dotnet/ai-agents/model-registry.json.template +58 -0
  94. package/framework/templates/code/sql/ai-agents/agent-prompts-migration.sql.template +58 -0
  95. package/framework/templates/code/sql/ai-agents/pgvector-embeddings-migration.sql.template +91 -0
  96. package/framework/templates/docs/onboarding.md +4 -4
  97. package/framework/templates/tasks.template.json +17 -0
  98. package/package.json +89 -83
  99. package/src/commands/project/init.js +16 -0
  100. package/src/commands/validation/validate-feature.js +1 -1
  101. package/src/core/paths/output-schema.js +6 -6
  102. package/src/core/state/state-manager.js +1 -1
  103. package/src/core/templates/template-registry.js +289 -256
  104. package/src/lib/detectors/design-system-detector.js +1 -1
  105. package/src/lib/generators/context-generator.js +516 -518
  106. package/src/lib/generators/scaffold-generator.js +23 -26
  107. package/src/lib/installers/memory-installer.js +138 -0
  108. package/src/lib/phase-chain/phase-validator.js +4 -4
  109. package/src/lib/risk-detector.js +80 -0
  110. package/src/lib/tasks/tasks-json-parser.js +185 -0
  111. package/src/scripts/setup-infra.js +25 -0
  112. package/src/utils/hooks-installer.js +59 -10
  113. package/framework/standards/infrastructure/neon/neon-semantic-kernel.md +0 -310
  114. package/framework/templates/integrations/semantic-kernel-neon.cs +0 -125
@@ -1,518 +1,516 @@
1
- /**
2
- * @fileoverview Context Generator - Auto-generates CONTEXT.md files
3
- *
4
- * Populates CONTEXT.md templates with real project data from:
5
- * - state.json (features, phases, tasks, checkpoints)
6
- * - config.json (project settings, Azure resources, agent config)
7
- * - agents.json (hierarchical agent structure)
8
- * - project.md (project description)
9
- * - standards/*.md (project-specific standards)
10
- *
11
- * @module context-generator
12
- */
13
-
14
- import fs from 'fs/promises';
15
- import path from 'path';
16
- import { existsSync } from 'fs';
17
- import { getOutputPath } from '../../core/paths/output-schema.js';
18
-
19
- /**
20
- * Resolve a template path: check .morph/framework/templates/ first, then framework/templates/.
21
- * @param {string} projectPath
22
- * @param {string} templatePath - relative path e.g. 'context/CONTEXT.md'
23
- * @returns {string|null}
24
- */
25
- function resolveTemplatePath(projectPath, templatePath) {
26
- const projectLocal = path.join(projectPath, '.morph/framework/templates', templatePath);
27
- if (existsSync(projectLocal)) return projectLocal;
28
- const framework = path.join(projectPath, 'framework/templates', templatePath);
29
- if (existsSync(framework)) return framework;
30
- return null;
31
- }
32
-
33
- /**
34
- * Load project state from state.json
35
- * @param {string} projectPath - Root path of the project
36
- * @returns {Promise<Object>} Parsed state
37
- */
38
- async function loadState(projectPath) {
39
- const statePath = path.join(projectPath, '.morph/state.json');
40
- try {
41
- const content = await fs.readFile(statePath, 'utf8');
42
- return JSON.parse(content);
43
- } catch (err) {
44
- return { features: [], checkpoints: [] };
45
- }
46
- }
47
-
48
- /**
49
- * Load project config from config.json
50
- * @param {string} projectPath - Root path of the project
51
- * @returns {Promise<Object>} Parsed config
52
- */
53
- async function loadConfig(projectPath) {
54
- const configPath = path.join(projectPath, '.morph/config/config.json');
55
- try {
56
- const content = await fs.readFile(configPath, 'utf8');
57
- return JSON.parse(content);
58
- } catch (err) {
59
- return null;
60
- }
61
- }
62
-
63
- /**
64
- * Load agents hierarchy from agents.json
65
- * @param {string} projectPath - Root path of the project
66
- * @returns {Promise<Object>} Parsed agents config
67
- */
68
- async function loadAgents(projectPath) {
69
- const agentsPath = path.join(projectPath, '.morph/framework/agents.json');
70
- try {
71
- const content = await fs.readFile(agentsPath, 'utf8');
72
- return JSON.parse(content);
73
- } catch (err) {
74
- return null;
75
- }
76
- }
77
-
78
- /**
79
- * Load project description from project.md
80
- * @param {string} projectPath - Root path of the project
81
- * @returns {Promise<string>} Project description
82
- */
83
- async function loadProjectDescription(projectPath) {
84
- const projectMdPath = path.join(projectPath, '.morph/project.md');
85
- try {
86
- return await fs.readFile(projectMdPath, 'utf8');
87
- } catch (err) {
88
- return '';
89
- }
90
- }
91
-
92
- /**
93
- * Load project-specific standards
94
- * @param {string} projectPath - Root path of the project
95
- * @returns {Promise<Array>} Array of {name, path, description}
96
- */
97
- async function loadProjectStandards(projectPath) {
98
- const standardsDir = path.join(projectPath, '.morph/context');
99
- try {
100
- const files = await fs.readdir(standardsDir);
101
- return files
102
- .filter(f => f.endsWith('.md'))
103
- .map(f => ({
104
- name: f.replace('.md', ''),
105
- path: `standards/${f}`,
106
- description: null
107
- }));
108
- } catch (err) {
109
- return [];
110
- }
111
- }
112
-
113
- /**
114
- * Get nested property value from object using dot notation
115
- * @param {Object} obj - Object to get value from
116
- * @param {string} path - Dot-notation path (e.g., 'tasks.completed')
117
- * @returns {*} Value at path or empty string
118
- */
119
- function getNestedValue(obj, path) {
120
- const keys = path.split('.');
121
- let value = obj;
122
-
123
- for (const key of keys) {
124
- if (value && typeof value === 'object' && key in value) {
125
- value = value[key];
126
- } else {
127
- return '';
128
- }
129
- }
130
-
131
- return value !== null && value !== undefined ? value : '';
132
- }
133
-
134
- /**
135
- * Simple template renderer (Handlebars-like syntax)
136
- * @param {string} template - Template string with {{VAR}} placeholders
137
- * @param {Object} data - Data object with values
138
- * @returns {string} Rendered template
139
- */
140
- function renderTemplate(template, data) {
141
- let result = template;
142
-
143
- // Handle {{#each VAR}}...{{/each}} blocks first (before variable replacement)
144
- result = result.replace(/{{#each\s+(\w+)}}([\s\S]*?){{\/each}}/g, (_, varName, itemTemplate) => {
145
- const array = data[varName];
146
- if (!Array.isArray(array) || array.length === 0) return '';
147
-
148
- return array.map(item => {
149
- let itemResult = itemTemplate;
150
-
151
- // Replace all {{prop}} and {{nested.prop}} in the item template
152
- itemResult = itemResult.replace(/{{([\w.]+)}}/g, (__, propPath) => {
153
- return String(getNestedValue(item, propPath));
154
- });
155
-
156
- return itemResult;
157
- }).join('');
158
- });
159
-
160
- // Handle {{#if VAR}}...{{/if}} blocks
161
- result = result.replace(/{{#if\s+(\w+)}}([\s\S]*?){{\/if}}/g, (_, varName, content) => {
162
- return data[varName] ? content : '';
163
- });
164
-
165
- // Handle {{#unless VAR}}...{{/unless}} blocks
166
- result = result.replace(/{{#unless\s+(\w+)}}([\s\S]*?){{\/unless}}/g, (_, varName, content) => {
167
- const value = data[varName];
168
- // Check if value is falsy, empty array, or empty object
169
- const isFalsy = !value ||
170
- (Array.isArray(value) && value.length === 0) ||
171
- (typeof value === 'object' && Object.keys(value).length === 0);
172
- return isFalsy ? content : '';
173
- });
174
-
175
- // Replace simple {{VAR}} and {{nested.prop}} placeholders
176
- result = result.replace(/{{([\w.]+)}}/g, (_, path) => {
177
- return String(getNestedValue(data, path));
178
- });
179
-
180
- return result;
181
- }
182
-
183
- /**
184
- * Generate project-level CONTEXT.md
185
- * @param {string} projectPath - Root path of the project
186
- * @returns {Promise<string>} Generated CONTEXT.md content
187
- */
188
- export async function generateProjectContext(projectPath) {
189
- const [state, config, agents, projectDesc, standards] = await Promise.all([
190
- loadState(projectPath),
191
- loadConfig(projectPath),
192
- loadAgents(projectPath),
193
- loadProjectDescription(projectPath),
194
- loadProjectStandards(projectPath)
195
- ]);
196
-
197
- // Load template with fallback (.morph/framework/templates/ → framework/templates/)
198
- const templatePath = resolveTemplatePath(projectPath, 'context/CONTEXT.md');
199
-
200
- if (!templatePath) {
201
- throw new Error('CONTEXT.md template not found in stack templates or framework/templates/');
202
- }
203
-
204
- const template = await fs.readFile(templatePath, 'utf8');
205
-
206
- // Build data object
207
- const data = {
208
- PROJECT_NAME: config?.project?.name || 'Unknown Project',
209
- PROJECT_TYPE: config?.project?.type || 'dotnet-nextjs',
210
- PROJECT_DESCRIPTION: projectDesc || config?.project?.description || '',
211
- REPOSITORY_URL: config?.project?.repository || '',
212
- TIMESTAMP: new Date().toISOString(),
213
-
214
- // Stack detection
215
- DOTNET_ENABLED: config?.project?.type?.includes('dotnet') ? true : false,
216
- NEXTJS_ENABLED: config?.project?.type?.includes('nextjs') ? true : false,
217
- DOTNET_VERSION: '10.0',
218
- EF_VERSION: '10.0',
219
-
220
- // Azure config
221
- AZURE_LOCATION: config?.azure?.location || 'eastus2',
222
- CONTAINER_APPS_ENABLED: config?.azure?.resources?.containerApps?.enabled || false,
223
- CONTAINER_ENV: config?.azure?.resources?.containerApps?.environment || '',
224
- SQL_ENABLED: config?.azure?.resources?.sql?.enabled || false,
225
- SQL_SERVER: config?.azure?.resources?.sql?.server || '',
226
- SQL_DATABASE: config?.azure?.resources?.sql?.database || '',
227
- OPENAI_ENABLED: config?.azure?.resources?.openai?.enabled || false,
228
- OPENAI_MODEL: config?.azure?.resources?.openai?.model || '',
229
- MONITORING_ENABLED: config?.azure?.resources?.monitoring?.enabled || false,
230
- APP_INSIGHTS: config?.azure?.resources?.monitoring?.appInsights || '',
231
-
232
- // Active features (convert object to array if needed)
233
- ACTIVE_FEATURES: (Array.isArray(state?.features)
234
- ? state.features
235
- : Object.entries(state?.features || {}).map(([name, data]) => ({ name, ...data }))
236
- ).map(f => ({
237
- name: f.name,
238
- phase: f.phase,
239
- status: f.status,
240
- complexity: f.complexity || 'medium',
241
- activeAgents: (f.activeAgents || []).join(', '),
242
- description: f.description || '',
243
- tasks: {
244
- completed: f.tasks?.completed || 0,
245
- total: f.tasks?.total || 0
246
- },
247
- outputs: Array.isArray(f.outputs)
248
- ? f.outputs.join(', ')
249
- : (f.outputs ? Object.keys(f.outputs).join(', ') : '')
250
- })),
251
-
252
- // Project standards
253
- PROJECT_STANDARDS: standards,
254
-
255
- // Agents (v5: flat personas, no tiers)
256
- AGENT_COUNT: agents?.total_agents || Object.keys(agents?.agents || {}).length,
257
- PERSONAS: Object.entries(agents?.agents || {})
258
- .filter(([id]) => !id.startsWith('_'))
259
- .map(([id, agent]) => ({
260
- id,
261
- title: agent.title,
262
- domains: (agent.domains || []).join(', '),
263
- stack: (agent.stack || ['*']).join(', ')
264
- })),
265
- // v4 compat (kept empty so legacy templates still render)
266
- TIER_1_AGENTS: [],
267
- TIER_2_AGENTS: [],
268
- TIER_3_AGENTS: [],
269
-
270
- // Agent Teams config
271
- AGENT_TEAMS_ENABLED: config?.agentTeams?.enabled || false,
272
- AGENT_TEAMS_DISPLAY_MODE: config?.agentTeams?.displayMode || 'auto',
273
- AGENT_TEAMS_THRESHOLDS: config?.agentTeams?.spawnThresholds || {},
274
-
275
- // Recent checkpoints
276
- RECENT_CHECKPOINTS: (state?.checkpoints || []).slice(-5).map(cp => ({
277
- timestamp: cp.timestamp,
278
- feature: cp.feature,
279
- phase: cp.phase,
280
- note: cp.note,
281
- validation: cp.validation
282
- })),
283
-
284
- // DevOps config
285
- DEVOPS_ENABLED: config?.devops ? true : false,
286
- DEVOPS_ORG: config?.devops?.organization || '',
287
- DEVOPS_PROJECT: config?.devops?.project || '',
288
- DEVOPS_BOARDS_ENABLED: config?.devops?.boards?.enabled || false,
289
- DEVOPS_AREA_PATH: config?.devops?.boards?.areaPath || '',
290
- DEVOPS_WIKI_ENABLED: config?.devops?.wiki?.enabled || false,
291
- DEVOPS_WIKI_NAME: config?.devops?.wiki?.wikiName || '',
292
- DEVOPS_BUILD_PIPELINE: config?.devops?.pipelines?.buildPipeline || '',
293
- DEVOPS_RELEASE_PIPELINE: config?.devops?.pipelines?.releasePipeline || '',
294
-
295
- // Version
296
- MORPH_VERSION: '3.0.0-hierarchical'
297
- };
298
-
299
- return renderTemplate(template, data);
300
- }
301
-
302
- /**
303
- * Generate feature-specific CONTEXT-{feature}.md
304
- * @param {string} projectPath - Root path of the project
305
- * @param {string} featureName - Feature name
306
- * @returns {Promise<string>} Generated CONTEXT-{feature}.md content
307
- */
308
- export async function generateFeatureContext(projectPath, featureName) {
309
- const [state, agents] = await Promise.all([
310
- loadState(projectPath),
311
- loadAgents(projectPath)
312
- ]);
313
-
314
- // Find feature in state (handle both array and object format)
315
- let feature;
316
- if (Array.isArray(state?.features)) {
317
- feature = state.features.find(f => f.name === featureName);
318
- } else if (state?.features && typeof state.features === 'object') {
319
- feature = state.features[featureName];
320
- if (feature) {
321
- feature = { name: featureName, ...feature };
322
- }
323
- }
324
-
325
- if (!feature) {
326
- throw new Error(`Feature "${featureName}" not found in state.json`);
327
- }
328
-
329
- // Load feature outputs
330
- const outputsDir = path.join(projectPath, `.morph/features/${featureName}`);
331
- let decisions = [];
332
- let tasks = [];
333
-
334
- try {
335
- const decisionsPath = path.join(outputsDir, '2-plan', 'decisions.md');
336
- const decisionsContent = await fs.readFile(decisionsPath, 'utf8');
337
- // Parse decisions.md (simplified - just extract ADR headers)
338
- const adrMatches = decisionsContent.matchAll(/##\s+ADR-(\d+):\s+(.+)/g);
339
- decisions = Array.from(adrMatches).map(match => ({
340
- id: match[1],
341
- title: match[2],
342
- status: 'Accepted',
343
- date: new Date().toISOString().split('T')[0],
344
- context: '',
345
- decision: ''
346
- }));
347
- } catch (err) {
348
- // No decisions.md yet
349
- }
350
-
351
- try {
352
- const tasksPath = path.join(outputsDir, '2-plan', 'tasks.md');
353
- const tasksContent = await fs.readFile(tasksPath, 'utf8');
354
- tasks = JSON.parse(tasksContent).tasks || [];
355
- } catch (err) {
356
- // No tasks.json yet
357
- }
358
-
359
- // Load template with fallback (.morph/framework/templates/ → framework/templates/)
360
- const featureTemplatePath = resolveTemplatePath(projectPath, 'context/CONTEXT-FEATURE.md');
361
-
362
- if (!featureTemplatePath) {
363
- throw new Error('CONTEXT-FEATURE.md template not found in stack templates or framework/templates/');
364
- }
365
-
366
- const template = await fs.readFile(featureTemplatePath, 'utf8');
367
-
368
- // Build active agents list with hierarchy
369
- const activeAgentIds = feature.activeAgents || [];
370
- const activeAgents = activeAgentIds
371
- .filter(id => agents?.agents[id])
372
- .map(id => ({ id, ...agents.agents[id] }));
373
-
374
- // v5: flat persona model — no tiers, no squads. Just list active personas.
375
- const teamLead = null;
376
- const domainLeaders = [];
377
- const specialists = activeAgents;
378
- const validators = activeAgents.filter(a => a.id === 'evaluator');
379
- const squads = [];
380
-
381
- // Build data object
382
- const data = {
383
- FEATURE_NAME: feature.name,
384
- PHASE: feature.phase,
385
- WORKFLOW_TYPE: feature.workflow || 'standard',
386
- STATUS: feature.status,
387
- COMPLEXITY: feature.complexity || 'medium',
388
- DESCRIPTION: feature.description || '',
389
- TIMESTAMP: new Date().toISOString(),
390
-
391
- // Phase tracking
392
- PHASE_DESCRIPTION: `Current phase: ${feature.phase}`,
393
- COMPLETED_PHASES: [], // TODO: track phase history
394
- UPCOMING_PHASES: [], // TODO: calculate remaining phases
395
-
396
- // Tasks
397
- TASKS_TOTAL: feature.tasks?.total || tasks.length,
398
- TASKS_COMPLETED: feature.tasks?.completed || tasks.filter(t => t.status === 'completed').length,
399
- TASKS_IN_PROGRESS: tasks.filter(t => t.status === 'in_progress').length,
400
- TASKS_BLOCKED: tasks.filter(t => t.status === 'blocked').length,
401
- TASKS_PROGRESS: feature.tasks?.total > 0
402
- ? Math.round((feature.tasks.completed / feature.tasks.total) * 100)
403
- : 0,
404
- CURRENT_TASK: tasks.find(t => t.status === 'in_progress') || null,
405
-
406
- // Active agents
407
- ACTIVE_AGENTS_COUNT: activeAgents.length,
408
- TEAM_LEAD: teamLead ? {
409
- id: teamLead.id,
410
- title: teamLead.title
411
- } : null,
412
- SQUADS: squads,
413
- VALIDATORS: validators.map(v => ({
414
- id: v.id,
415
- title: v.title
416
- })),
417
-
418
- // Outputs (handle both object and array format)
419
- OUTPUTS: (Array.isArray(feature.outputs)
420
- ? feature.outputs.map(output => ({
421
- type: output,
422
- generated: true,
423
- path: `.morph/features/${featureName}/${output}`,
424
- generatedAt: null
425
- }))
426
- : Object.entries(feature.outputs || {}).map(([type, data]) => ({
427
- type,
428
- generated: data.created || false,
429
- path: data.path || `.morph/features/${featureName}/${type}`,
430
- generatedAt: data.createdAt || null
431
- }))
432
- ),
433
-
434
- // Decisions
435
- DECISIONS: decisions,
436
-
437
- // Validation
438
- VALIDATION_RESULTS: feature.lastValidation?.results || null,
439
- VALIDATION_PASSED: feature.lastValidation?.passed || false,
440
- VALIDATION_TIMESTAMP: feature.lastValidation?.timestamp || null,
441
-
442
- // Tasks list
443
- TASKS: tasks,
444
-
445
- // Checkpoints
446
- CHECKPOINTS: (state?.checkpoints || [])
447
- .filter(cp => cp.feature === featureName)
448
- .map(cp => ({
449
- timestamp: cp.timestamp,
450
- phase: cp.phase,
451
- tasksCompleted: cp.tasksCompleted,
452
- tasksTotal: cp.tasksTotal,
453
- note: cp.note,
454
- validation: cp.validation
455
- })),
456
-
457
- // Related standards
458
- RELATED_STANDARDS: [], // TODO: link standards based on activeAgents
459
-
460
- // File paths
461
- CURRENT_TASK_ID: tasks.find(t => t.status === 'in_progress')?.id || '',
462
- PROPOSAL_PATH: getOutputPath(featureName, 'proposal'),
463
- SPEC_PATH: getOutputPath(featureName, 'spec'),
464
- CONTRACTS_PATH: getOutputPath(featureName, 'contracts'),
465
- TASKS_PATH: `.morph/features/${featureName}/2-plan/tasks.md`,
466
- DECISIONS_PATH: getOutputPath(featureName, 'decisions'),
467
- RECAP_PATH: getOutputPath(featureName, 'recap'),
468
-
469
- // Version
470
- MORPH_VERSION: '5.0.0',
471
- FEATURE_ID: feature.id || featureName
472
- };
473
-
474
- return renderTemplate(template, data);
475
- }
476
-
477
- /**
478
- * Write generated context to file
479
- * @param {string} projectPath - Root path of the project
480
- * @param {string} content - Generated content
481
- * @param {string} filename - Output filename
482
- * @returns {Promise<string>} Path to written file
483
- */
484
- export async function writeContext(projectPath, content, filename = 'CONTEXT.md') {
485
- const contextDir = path.join(projectPath, '.morph/context');
486
- await fs.mkdir(contextDir, { recursive: true });
487
-
488
- const outputPath = path.join(contextDir, filename);
489
- await fs.writeFile(outputPath, content, 'utf8');
490
-
491
- return outputPath;
492
- }
493
-
494
- /**
495
- * Generate and write both project and feature context files
496
- * @param {string} projectPath - Root path of the project
497
- * @param {string} featureName - Optional feature name (if null, only generates project context)
498
- * @returns {Promise<Object>} Paths to generated files
499
- */
500
- export async function generateContext(projectPath, featureName = null) {
501
- const results = {};
502
-
503
- // Always generate project context
504
- const projectContext = await generateProjectContext(projectPath);
505
- results.projectContext = await writeContext(projectPath, projectContext, 'CONTEXT.md');
506
-
507
- // Generate feature context if requested
508
- if (featureName) {
509
- const featureContext = await generateFeatureContext(projectPath, featureName);
510
- results.featureContext = await writeContext(
511
- projectPath,
512
- featureContext,
513
- `CONTEXT-${featureName}.md`
514
- );
515
- }
516
-
517
- return results;
518
- }
1
+ /**
2
+ * @fileoverview Context Generator - Auto-generates CONTEXT.md files
3
+ *
4
+ * Populates CONTEXT.md templates with real project data from:
5
+ * - state.json (features, phases, tasks, checkpoints)
6
+ * - config.json (project settings, Azure resources, agent config)
7
+ * - agents.json (hierarchical agent structure)
8
+ * - project.md (project description)
9
+ * - standards/*.md (project-specific standards)
10
+ *
11
+ * @module context-generator
12
+ */
13
+
14
+ import fs from 'fs/promises';
15
+ import path from 'path';
16
+ import { getOutputPath } from '../../core/paths/output-schema.js';
17
+ import { resolveTemplatePathById } from '../../core/templates/template-registry.js';
18
+
19
+ /**
20
+ * Resolve a context template by its REGISTRY.json id, honoring a project-local
21
+ * override. REGISTRY owns the relative path; resolveTemplatePathById checks
22
+ * `.morph/framework/templates/` first, then `framework/templates/`.
23
+ * @param {string} projectPath
24
+ * @param {string} templateId - REGISTRY id, e.g. 'context' or 'context-feature'
25
+ * @returns {string|null} Absolute path, or null if neither location has the file
26
+ */
27
+ function resolveTemplatePath(projectPath, templateId) {
28
+ return resolveTemplatePathById(templateId, projectPath);
29
+ }
30
+
31
+ /**
32
+ * Load project state from state.json
33
+ * @param {string} projectPath - Root path of the project
34
+ * @returns {Promise<Object>} Parsed state
35
+ */
36
+ async function loadState(projectPath) {
37
+ const statePath = path.join(projectPath, '.morph/state.json');
38
+ try {
39
+ const content = await fs.readFile(statePath, 'utf8');
40
+ return JSON.parse(content);
41
+ } catch (err) {
42
+ return { features: [], checkpoints: [] };
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Load project config from config.json
48
+ * @param {string} projectPath - Root path of the project
49
+ * @returns {Promise<Object>} Parsed config
50
+ */
51
+ async function loadConfig(projectPath) {
52
+ const configPath = path.join(projectPath, '.morph/config/config.json');
53
+ try {
54
+ const content = await fs.readFile(configPath, 'utf8');
55
+ return JSON.parse(content);
56
+ } catch (err) {
57
+ return null;
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Load agents hierarchy from agents.json
63
+ * @param {string} projectPath - Root path of the project
64
+ * @returns {Promise<Object>} Parsed agents config
65
+ */
66
+ async function loadAgents(projectPath) {
67
+ const agentsPath = path.join(projectPath, '.morph/framework/agents.json');
68
+ try {
69
+ const content = await fs.readFile(agentsPath, 'utf8');
70
+ return JSON.parse(content);
71
+ } catch (err) {
72
+ return null;
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Load project description from project.md
78
+ * @param {string} projectPath - Root path of the project
79
+ * @returns {Promise<string>} Project description
80
+ */
81
+ async function loadProjectDescription(projectPath) {
82
+ const projectMdPath = path.join(projectPath, '.morph/project.md');
83
+ try {
84
+ return await fs.readFile(projectMdPath, 'utf8');
85
+ } catch (err) {
86
+ return '';
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Load project-specific standards
92
+ * @param {string} projectPath - Root path of the project
93
+ * @returns {Promise<Array>} Array of {name, path, description}
94
+ */
95
+ async function loadProjectStandards(projectPath) {
96
+ const standardsDir = path.join(projectPath, '.morph/context');
97
+ try {
98
+ const files = await fs.readdir(standardsDir);
99
+ return files
100
+ .filter(f => f.endsWith('.md'))
101
+ .map(f => ({
102
+ name: f.replace('.md', ''),
103
+ path: `standards/${f}`,
104
+ description: null
105
+ }));
106
+ } catch (err) {
107
+ return [];
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Get nested property value from object using dot notation
113
+ * @param {Object} obj - Object to get value from
114
+ * @param {string} path - Dot-notation path (e.g., 'tasks.completed')
115
+ * @returns {*} Value at path or empty string
116
+ */
117
+ function getNestedValue(obj, path) {
118
+ const keys = path.split('.');
119
+ let value = obj;
120
+
121
+ for (const key of keys) {
122
+ if (value && typeof value === 'object' && key in value) {
123
+ value = value[key];
124
+ } else {
125
+ return '';
126
+ }
127
+ }
128
+
129
+ return value !== null && value !== undefined ? value : '';
130
+ }
131
+
132
+ /**
133
+ * Simple template renderer (Handlebars-like syntax)
134
+ * @param {string} template - Template string with {{VAR}} placeholders
135
+ * @param {Object} data - Data object with values
136
+ * @returns {string} Rendered template
137
+ */
138
+ function renderTemplate(template, data) {
139
+ let result = template;
140
+
141
+ // Handle {{#each VAR}}...{{/each}} blocks first (before variable replacement)
142
+ result = result.replace(/{{#each\s+(\w+)}}([\s\S]*?){{\/each}}/g, (_, varName, itemTemplate) => {
143
+ const array = data[varName];
144
+ if (!Array.isArray(array) || array.length === 0) return '';
145
+
146
+ return array.map(item => {
147
+ let itemResult = itemTemplate;
148
+
149
+ // Replace all {{prop}} and {{nested.prop}} in the item template
150
+ itemResult = itemResult.replace(/{{([\w.]+)}}/g, (__, propPath) => {
151
+ return String(getNestedValue(item, propPath));
152
+ });
153
+
154
+ return itemResult;
155
+ }).join('');
156
+ });
157
+
158
+ // Handle {{#if VAR}}...{{/if}} blocks
159
+ result = result.replace(/{{#if\s+(\w+)}}([\s\S]*?){{\/if}}/g, (_, varName, content) => {
160
+ return data[varName] ? content : '';
161
+ });
162
+
163
+ // Handle {{#unless VAR}}...{{/unless}} blocks
164
+ result = result.replace(/{{#unless\s+(\w+)}}([\s\S]*?){{\/unless}}/g, (_, varName, content) => {
165
+ const value = data[varName];
166
+ // Check if value is falsy, empty array, or empty object
167
+ const isFalsy = !value ||
168
+ (Array.isArray(value) && value.length === 0) ||
169
+ (typeof value === 'object' && Object.keys(value).length === 0);
170
+ return isFalsy ? content : '';
171
+ });
172
+
173
+ // Replace simple {{VAR}} and {{nested.prop}} placeholders
174
+ result = result.replace(/{{([\w.]+)}}/g, (_, path) => {
175
+ return String(getNestedValue(data, path));
176
+ });
177
+
178
+ return result;
179
+ }
180
+
181
+ /**
182
+ * Generate project-level CONTEXT.md
183
+ * @param {string} projectPath - Root path of the project
184
+ * @returns {Promise<string>} Generated CONTEXT.md content
185
+ */
186
+ export async function generateProjectContext(projectPath) {
187
+ const [state, config, agents, projectDesc, standards] = await Promise.all([
188
+ loadState(projectPath),
189
+ loadConfig(projectPath),
190
+ loadAgents(projectPath),
191
+ loadProjectDescription(projectPath),
192
+ loadProjectStandards(projectPath)
193
+ ]);
194
+
195
+ // Resolve via REGISTRY id, with project-local override fallback.
196
+ const templatePath = resolveTemplatePath(projectPath, 'context');
197
+
198
+ if (!templatePath) {
199
+ throw new Error('CONTEXT.md template not found in stack templates or framework/templates/');
200
+ }
201
+
202
+ const template = await fs.readFile(templatePath, 'utf8');
203
+
204
+ // Build data object
205
+ const data = {
206
+ PROJECT_NAME: config?.project?.name || 'Unknown Project',
207
+ PROJECT_TYPE: config?.project?.type || 'dotnet-nextjs',
208
+ PROJECT_DESCRIPTION: projectDesc || config?.project?.description || '',
209
+ REPOSITORY_URL: config?.project?.repository || '',
210
+ TIMESTAMP: new Date().toISOString(),
211
+
212
+ // Stack detection
213
+ DOTNET_ENABLED: config?.project?.type?.includes('dotnet') ? true : false,
214
+ NEXTJS_ENABLED: config?.project?.type?.includes('nextjs') ? true : false,
215
+ DOTNET_VERSION: '10.0',
216
+ EF_VERSION: '10.0',
217
+
218
+ // Azure config
219
+ AZURE_LOCATION: config?.azure?.location || 'eastus2',
220
+ CONTAINER_APPS_ENABLED: config?.azure?.resources?.containerApps?.enabled || false,
221
+ CONTAINER_ENV: config?.azure?.resources?.containerApps?.environment || '',
222
+ SQL_ENABLED: config?.azure?.resources?.sql?.enabled || false,
223
+ SQL_SERVER: config?.azure?.resources?.sql?.server || '',
224
+ SQL_DATABASE: config?.azure?.resources?.sql?.database || '',
225
+ OPENAI_ENABLED: config?.azure?.resources?.openai?.enabled || false,
226
+ OPENAI_MODEL: config?.azure?.resources?.openai?.model || '',
227
+ MONITORING_ENABLED: config?.azure?.resources?.monitoring?.enabled || false,
228
+ APP_INSIGHTS: config?.azure?.resources?.monitoring?.appInsights || '',
229
+
230
+ // Active features (convert object to array if needed)
231
+ ACTIVE_FEATURES: (Array.isArray(state?.features)
232
+ ? state.features
233
+ : Object.entries(state?.features || {}).map(([name, data]) => ({ name, ...data }))
234
+ ).map(f => ({
235
+ name: f.name,
236
+ phase: f.phase,
237
+ status: f.status,
238
+ complexity: f.complexity || 'medium',
239
+ activeAgents: (f.activeAgents || []).join(', '),
240
+ description: f.description || '',
241
+ tasks: {
242
+ completed: f.tasks?.completed || 0,
243
+ total: f.tasks?.total || 0
244
+ },
245
+ outputs: Array.isArray(f.outputs)
246
+ ? f.outputs.join(', ')
247
+ : (f.outputs ? Object.keys(f.outputs).join(', ') : '')
248
+ })),
249
+
250
+ // Project standards
251
+ PROJECT_STANDARDS: standards,
252
+
253
+ // Agents (v5: flat personas, no tiers)
254
+ AGENT_COUNT: agents?.total_agents || Object.keys(agents?.agents || {}).length,
255
+ PERSONAS: Object.entries(agents?.agents || {})
256
+ .filter(([id]) => !id.startsWith('_'))
257
+ .map(([id, agent]) => ({
258
+ id,
259
+ title: agent.title,
260
+ domains: (agent.domains || []).join(', '),
261
+ stack: (agent.stack || ['*']).join(', ')
262
+ })),
263
+ // v4 compat (kept empty so legacy templates still render)
264
+ TIER_1_AGENTS: [],
265
+ TIER_2_AGENTS: [],
266
+ TIER_3_AGENTS: [],
267
+
268
+ // Agent Teams config
269
+ AGENT_TEAMS_ENABLED: config?.agentTeams?.enabled || false,
270
+ AGENT_TEAMS_DISPLAY_MODE: config?.agentTeams?.displayMode || 'auto',
271
+ AGENT_TEAMS_THRESHOLDS: config?.agentTeams?.spawnThresholds || {},
272
+
273
+ // Recent checkpoints
274
+ RECENT_CHECKPOINTS: (state?.checkpoints || []).slice(-5).map(cp => ({
275
+ timestamp: cp.timestamp,
276
+ feature: cp.feature,
277
+ phase: cp.phase,
278
+ note: cp.note,
279
+ validation: cp.validation
280
+ })),
281
+
282
+ // DevOps config
283
+ DEVOPS_ENABLED: config?.devops ? true : false,
284
+ DEVOPS_ORG: config?.devops?.organization || '',
285
+ DEVOPS_PROJECT: config?.devops?.project || '',
286
+ DEVOPS_BOARDS_ENABLED: config?.devops?.boards?.enabled || false,
287
+ DEVOPS_AREA_PATH: config?.devops?.boards?.areaPath || '',
288
+ DEVOPS_WIKI_ENABLED: config?.devops?.wiki?.enabled || false,
289
+ DEVOPS_WIKI_NAME: config?.devops?.wiki?.wikiName || '',
290
+ DEVOPS_BUILD_PIPELINE: config?.devops?.pipelines?.buildPipeline || '',
291
+ DEVOPS_RELEASE_PIPELINE: config?.devops?.pipelines?.releasePipeline || '',
292
+
293
+ // Version
294
+ MORPH_VERSION: '3.0.0-hierarchical'
295
+ };
296
+
297
+ return renderTemplate(template, data);
298
+ }
299
+
300
+ /**
301
+ * Generate feature-specific CONTEXT-{feature}.md
302
+ * @param {string} projectPath - Root path of the project
303
+ * @param {string} featureName - Feature name
304
+ * @returns {Promise<string>} Generated CONTEXT-{feature}.md content
305
+ */
306
+ export async function generateFeatureContext(projectPath, featureName) {
307
+ const [state, agents] = await Promise.all([
308
+ loadState(projectPath),
309
+ loadAgents(projectPath)
310
+ ]);
311
+
312
+ // Find feature in state (handle both array and object format)
313
+ let feature;
314
+ if (Array.isArray(state?.features)) {
315
+ feature = state.features.find(f => f.name === featureName);
316
+ } else if (state?.features && typeof state.features === 'object') {
317
+ feature = state.features[featureName];
318
+ if (feature) {
319
+ feature = { name: featureName, ...feature };
320
+ }
321
+ }
322
+
323
+ if (!feature) {
324
+ throw new Error(`Feature "${featureName}" not found in state.json`);
325
+ }
326
+
327
+ // Load feature outputs
328
+ const outputsDir = path.join(projectPath, `.morph/features/${featureName}`);
329
+ let decisions = [];
330
+ let tasks = [];
331
+
332
+ try {
333
+ const decisionsPath = path.join(outputsDir, '2-plan', 'decisions.md');
334
+ const decisionsContent = await fs.readFile(decisionsPath, 'utf8');
335
+ // Parse decisions.md (simplified - just extract ADR headers)
336
+ const adrMatches = decisionsContent.matchAll(/##\s+ADR-(\d+):\s+(.+)/g);
337
+ decisions = Array.from(adrMatches).map(match => ({
338
+ id: match[1],
339
+ title: match[2],
340
+ status: 'Accepted',
341
+ date: new Date().toISOString().split('T')[0],
342
+ context: '',
343
+ decision: ''
344
+ }));
345
+ } catch (err) {
346
+ // No decisions.md yet
347
+ }
348
+
349
+ try {
350
+ const tasksPath = path.join(outputsDir, '2-plan', 'tasks.md');
351
+ const tasksContent = await fs.readFile(tasksPath, 'utf8');
352
+ tasks = JSON.parse(tasksContent).tasks || [];
353
+ } catch (err) {
354
+ // No tasks.json yet
355
+ }
356
+
357
+ // Resolve via REGISTRY id, with project-local override fallback.
358
+ const featureTemplatePath = resolveTemplatePath(projectPath, 'context-feature');
359
+
360
+ if (!featureTemplatePath) {
361
+ throw new Error('CONTEXT-FEATURE.md template not found in stack templates or framework/templates/');
362
+ }
363
+
364
+ const template = await fs.readFile(featureTemplatePath, 'utf8');
365
+
366
+ // Build active agents list with hierarchy
367
+ const activeAgentIds = feature.activeAgents || [];
368
+ const activeAgents = activeAgentIds
369
+ .filter(id => agents?.agents[id])
370
+ .map(id => ({ id, ...agents.agents[id] }));
371
+
372
+ // v5: flat persona model — no tiers, no squads. Just list active personas.
373
+ const teamLead = null;
374
+ const domainLeaders = [];
375
+ const specialists = activeAgents;
376
+ const validators = activeAgents.filter(a => a.id === 'evaluator');
377
+ const squads = [];
378
+
379
+ // Build data object
380
+ const data = {
381
+ FEATURE_NAME: feature.name,
382
+ PHASE: feature.phase,
383
+ WORKFLOW_TYPE: feature.workflow || 'standard',
384
+ STATUS: feature.status,
385
+ COMPLEXITY: feature.complexity || 'medium',
386
+ DESCRIPTION: feature.description || '',
387
+ TIMESTAMP: new Date().toISOString(),
388
+
389
+ // Phase tracking
390
+ PHASE_DESCRIPTION: `Current phase: ${feature.phase}`,
391
+ COMPLETED_PHASES: [], // TODO: track phase history
392
+ UPCOMING_PHASES: [], // TODO: calculate remaining phases
393
+
394
+ // Tasks
395
+ TASKS_TOTAL: feature.tasks?.total || tasks.length,
396
+ TASKS_COMPLETED: feature.tasks?.completed || tasks.filter(t => t.status === 'completed').length,
397
+ TASKS_IN_PROGRESS: tasks.filter(t => t.status === 'in_progress').length,
398
+ TASKS_BLOCKED: tasks.filter(t => t.status === 'blocked').length,
399
+ TASKS_PROGRESS: feature.tasks?.total > 0
400
+ ? Math.round((feature.tasks.completed / feature.tasks.total) * 100)
401
+ : 0,
402
+ CURRENT_TASK: tasks.find(t => t.status === 'in_progress') || null,
403
+
404
+ // Active agents
405
+ ACTIVE_AGENTS_COUNT: activeAgents.length,
406
+ TEAM_LEAD: teamLead ? {
407
+ id: teamLead.id,
408
+ title: teamLead.title
409
+ } : null,
410
+ SQUADS: squads,
411
+ VALIDATORS: validators.map(v => ({
412
+ id: v.id,
413
+ title: v.title
414
+ })),
415
+
416
+ // Outputs (handle both object and array format)
417
+ OUTPUTS: (Array.isArray(feature.outputs)
418
+ ? feature.outputs.map(output => ({
419
+ type: output,
420
+ generated: true,
421
+ path: `.morph/features/${featureName}/${output}`,
422
+ generatedAt: null
423
+ }))
424
+ : Object.entries(feature.outputs || {}).map(([type, data]) => ({
425
+ type,
426
+ generated: data.created || false,
427
+ path: data.path || `.morph/features/${featureName}/${type}`,
428
+ generatedAt: data.createdAt || null
429
+ }))
430
+ ),
431
+
432
+ // Decisions
433
+ DECISIONS: decisions,
434
+
435
+ // Validation
436
+ VALIDATION_RESULTS: feature.lastValidation?.results || null,
437
+ VALIDATION_PASSED: feature.lastValidation?.passed || false,
438
+ VALIDATION_TIMESTAMP: feature.lastValidation?.timestamp || null,
439
+
440
+ // Tasks list
441
+ TASKS: tasks,
442
+
443
+ // Checkpoints
444
+ CHECKPOINTS: (state?.checkpoints || [])
445
+ .filter(cp => cp.feature === featureName)
446
+ .map(cp => ({
447
+ timestamp: cp.timestamp,
448
+ phase: cp.phase,
449
+ tasksCompleted: cp.tasksCompleted,
450
+ tasksTotal: cp.tasksTotal,
451
+ note: cp.note,
452
+ validation: cp.validation
453
+ })),
454
+
455
+ // Related standards
456
+ RELATED_STANDARDS: [], // TODO: link standards based on activeAgents
457
+
458
+ // File paths
459
+ CURRENT_TASK_ID: tasks.find(t => t.status === 'in_progress')?.id || '',
460
+ PROPOSAL_PATH: getOutputPath(featureName, 'proposal'),
461
+ SPEC_PATH: getOutputPath(featureName, 'spec'),
462
+ CONTRACTS_PATH: getOutputPath(featureName, 'contracts'),
463
+ TASKS_PATH: `.morph/features/${featureName}/2-plan/tasks.md`,
464
+ DECISIONS_PATH: getOutputPath(featureName, 'decisions'),
465
+ RECAP_PATH: getOutputPath(featureName, 'recap'),
466
+
467
+ // Version
468
+ MORPH_VERSION: '5.0.0',
469
+ FEATURE_ID: feature.id || featureName
470
+ };
471
+
472
+ return renderTemplate(template, data);
473
+ }
474
+
475
+ /**
476
+ * Write generated context to file
477
+ * @param {string} projectPath - Root path of the project
478
+ * @param {string} content - Generated content
479
+ * @param {string} filename - Output filename
480
+ * @returns {Promise<string>} Path to written file
481
+ */
482
+ export async function writeContext(projectPath, content, filename = 'CONTEXT.md') {
483
+ const contextDir = path.join(projectPath, '.morph/context');
484
+ await fs.mkdir(contextDir, { recursive: true });
485
+
486
+ const outputPath = path.join(contextDir, filename);
487
+ await fs.writeFile(outputPath, content, 'utf8');
488
+
489
+ return outputPath;
490
+ }
491
+
492
+ /**
493
+ * Generate and write both project and feature context files
494
+ * @param {string} projectPath - Root path of the project
495
+ * @param {string} featureName - Optional feature name (if null, only generates project context)
496
+ * @returns {Promise<Object>} Paths to generated files
497
+ */
498
+ export async function generateContext(projectPath, featureName = null) {
499
+ const results = {};
500
+
501
+ // Always generate project context
502
+ const projectContext = await generateProjectContext(projectPath);
503
+ results.projectContext = await writeContext(projectPath, projectContext, 'CONTEXT.md');
504
+
505
+ // Generate feature context if requested
506
+ if (featureName) {
507
+ const featureContext = await generateFeatureContext(projectPath, featureName);
508
+ results.featureContext = await writeContext(
509
+ projectPath,
510
+ featureContext,
511
+ `CONTEXT-${featureName}.md`
512
+ );
513
+ }
514
+
515
+ return results;
516
+ }