@polymorphism-tech/morph-spec 8.1.2 → 8.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 (34) hide show
  1. package/README.md +2 -2
  2. package/bin/morph-spec.js +15 -0
  3. package/claude-plugin.json +1 -1
  4. package/docs/CHEATSHEET.md +6 -4
  5. package/docs/QUICKSTART.md +1 -1
  6. package/framework/CLAUDE.md +6 -3
  7. package/framework/MORPH.md +8 -2
  8. package/framework/hooks/claude-code/core/worktree-context.js +43 -0
  9. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  10. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  11. package/framework/skills/core/morph-apply/SKILL.md +2 -2
  12. package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
  13. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  14. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  15. package/framework/skills/core/morph-implement/SKILL.md +1 -1
  16. package/framework/skills/core/morph-plan/SKILL.md +1 -1
  17. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  18. package/framework/skills/core/morph-review/SKILL.md +1 -1
  19. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  20. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  21. package/package.json +1 -1
  22. package/src/commands/project/fleet.js +54 -0
  23. package/src/commands/project/worktree.js +56 -7
  24. package/src/commands/state/advance.js +8 -0
  25. package/src/commands/state/create.js +6 -0
  26. package/src/commands/state/handoff.js +49 -0
  27. package/src/lib/generators/handoff-generator.js +267 -0
  28. package/src/lib/generators/index.js +1 -3
  29. package/src/lib/worktree/env-allocator.js +149 -0
  30. package/src/lib/worktree/fleet-scanner.js +156 -0
  31. package/src/lib/context/context-builder.js +0 -289
  32. package/src/lib/generators/context-generator.js +0 -519
  33. package/src/lib/generators/mandate-generator.js +0 -468
  34. package/src/lib/generators/recap-generator.js +0 -285
@@ -1,519 +0,0 @@
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 the feature. The authoritative per-feature state lives in
313
- // .morph/features/{name}/feature.json (the root state.json is now a thin
314
- // index); read it directly, falling back to the index for back-compat.
315
- let feature;
316
- try {
317
- const fjPath = path.join(projectPath, '.morph/features', featureName, 'feature.json');
318
- feature = { name: featureName, ...JSON.parse(await fs.readFile(fjPath, 'utf8')) };
319
- } catch {
320
- if (Array.isArray(state?.features)) {
321
- feature = state.features.find(f => f.name === featureName);
322
- } else if (state?.features && typeof state.features === 'object' && state.features[featureName]) {
323
- feature = { name: featureName, ...state.features[featureName] };
324
- }
325
- }
326
-
327
- if (!feature) {
328
- throw new Error(`Feature "${featureName}" not found in .morph/features/ or state.json`);
329
- }
330
-
331
- // Load feature outputs
332
- const outputsDir = path.join(projectPath, `.morph/features/${featureName}`);
333
- let decisions = [];
334
- let tasks = [];
335
-
336
- try {
337
- const decisionsPath = path.join(outputsDir, '2-plan', 'decisions.md');
338
- const decisionsContent = await fs.readFile(decisionsPath, 'utf8');
339
- // Parse decisions.md (simplified - just extract ADR headers)
340
- const adrMatches = decisionsContent.matchAll(/##\s+ADR-(\d+):\s+(.+)/g);
341
- decisions = Array.from(adrMatches).map(match => ({
342
- id: match[1],
343
- title: match[2],
344
- status: 'Accepted',
345
- date: new Date().toISOString().split('T')[0],
346
- context: '',
347
- decision: ''
348
- }));
349
- } catch (err) {
350
- // No decisions.md yet
351
- }
352
-
353
- try {
354
- const tasksPath = path.join(outputsDir, '2-plan', 'tasks.json');
355
- const tasksContent = await fs.readFile(tasksPath, 'utf8');
356
- tasks = JSON.parse(tasksContent).tasks || [];
357
- } catch (err) {
358
- // No tasks.json yet
359
- }
360
-
361
- // Resolve via REGISTRY id, with project-local override fallback.
362
- const featureTemplatePath = resolveTemplatePath(projectPath, 'context-feature');
363
-
364
- if (!featureTemplatePath) {
365
- throw new Error('CONTEXT-FEATURE.md template not found in stack templates or framework/templates/');
366
- }
367
-
368
- const template = await fs.readFile(featureTemplatePath, 'utf8');
369
-
370
- // Build active agents list with hierarchy
371
- const activeAgentIds = feature.activeAgents || [];
372
- const activeAgents = activeAgentIds
373
- .filter(id => agents?.agents[id])
374
- .map(id => ({ id, ...agents.agents[id] }));
375
-
376
- // v5: flat persona model — no tiers, no squads. Just list active personas.
377
- const teamLead = null;
378
- const domainLeaders = [];
379
- const specialists = activeAgents;
380
- const validators = activeAgents.filter(a => a.id === 'evaluator');
381
- const squads = [];
382
-
383
- // Build data object
384
- const data = {
385
- FEATURE_NAME: feature.name,
386
- PHASE: feature.phase,
387
- STATUS: feature.status,
388
- COMPLEXITY: feature.complexity || 'medium',
389
- DESCRIPTION: feature.description || '',
390
- TIMESTAMP: new Date().toISOString(),
391
-
392
- // Phase tracking
393
- PHASE_DESCRIPTION: `Current phase: ${feature.phase}`,
394
- COMPLETED_PHASES: [], // TODO: track phase history
395
- UPCOMING_PHASES: [], // TODO: calculate remaining phases
396
-
397
- // Tasks
398
- TASKS_TOTAL: feature.tasks?.total || tasks.length,
399
- TASKS_COMPLETED: feature.tasks?.completed || tasks.filter(t => t.status === 'completed').length,
400
- TASKS_IN_PROGRESS: tasks.filter(t => t.status === 'in_progress').length,
401
- TASKS_BLOCKED: tasks.filter(t => t.status === 'blocked').length,
402
- TASKS_PROGRESS: feature.tasks?.total > 0
403
- ? Math.round((feature.tasks.completed / feature.tasks.total) * 100)
404
- : 0,
405
- CURRENT_TASK: tasks.find(t => t.status === 'in_progress') || null,
406
-
407
- // Active agents
408
- ACTIVE_AGENTS_COUNT: activeAgents.length,
409
- TEAM_LEAD: teamLead ? {
410
- id: teamLead.id,
411
- title: teamLead.title
412
- } : null,
413
- SQUADS: squads,
414
- VALIDATORS: validators.map(v => ({
415
- id: v.id,
416
- title: v.title
417
- })),
418
-
419
- // Outputs (handle both object and array format)
420
- OUTPUTS: (Array.isArray(feature.outputs)
421
- ? feature.outputs.map(output => ({
422
- type: output,
423
- generated: true,
424
- path: `.morph/features/${featureName}/${output}`,
425
- generatedAt: null
426
- }))
427
- : Object.entries(feature.outputs || {}).map(([type, data]) => ({
428
- type,
429
- generated: data.created || false,
430
- path: data.path || `.morph/features/${featureName}/${type}`,
431
- generatedAt: data.createdAt || null
432
- }))
433
- ),
434
-
435
- // Decisions
436
- DECISIONS: decisions,
437
-
438
- // Validation
439
- VALIDATION_RESULTS: feature.lastValidation?.results || null,
440
- VALIDATION_PASSED: feature.lastValidation?.passed || false,
441
- VALIDATION_TIMESTAMP: feature.lastValidation?.timestamp || null,
442
-
443
- // Tasks list
444
- TASKS: tasks,
445
-
446
- // Checkpoints
447
- CHECKPOINTS: (state?.checkpoints || [])
448
- .filter(cp => cp.feature === featureName)
449
- .map(cp => ({
450
- timestamp: cp.timestamp,
451
- phase: cp.phase,
452
- tasksCompleted: cp.tasksCompleted,
453
- tasksTotal: cp.tasksTotal,
454
- note: cp.note,
455
- validation: cp.validation
456
- })),
457
-
458
- // Related standards
459
- RELATED_STANDARDS: [], // TODO: link standards based on activeAgents
460
-
461
- // File paths
462
- CURRENT_TASK_ID: tasks.find(t => t.status === 'in_progress')?.id || '',
463
- PROPOSAL_PATH: getOutputPath(featureName, 'proposal'),
464
- SPEC_PATH: getOutputPath(featureName, 'spec'),
465
- CONTRACTS_PATH: getOutputPath(featureName, 'contracts'),
466
- TASKS_PATH: `.morph/features/${featureName}/2-plan/tasks.json`,
467
- DECISIONS_PATH: getOutputPath(featureName, 'decisions'),
468
- RECAP_PATH: getOutputPath(featureName, 'recap'),
469
-
470
- // Version
471
- MORPH_VERSION: '5.0.0',
472
- FEATURE_ID: feature.id || featureName
473
- };
474
-
475
- return renderTemplate(template, data);
476
- }
477
-
478
- /**
479
- * Write generated context to file
480
- * @param {string} projectPath - Root path of the project
481
- * @param {string} content - Generated content
482
- * @param {string} filename - Output filename
483
- * @returns {Promise<string>} Path to written file
484
- */
485
- export async function writeContext(projectPath, content, filename = 'CONTEXT.md') {
486
- const contextDir = path.join(projectPath, '.morph/context');
487
- await fs.mkdir(contextDir, { recursive: true });
488
-
489
- const outputPath = path.join(contextDir, filename);
490
- await fs.writeFile(outputPath, content, 'utf8');
491
-
492
- return outputPath;
493
- }
494
-
495
- /**
496
- * Generate and write both project and feature context files
497
- * @param {string} projectPath - Root path of the project
498
- * @param {string} featureName - Optional feature name (if null, only generates project context)
499
- * @returns {Promise<Object>} Paths to generated files
500
- */
501
- export async function generateContext(projectPath, featureName = null) {
502
- const results = {};
503
-
504
- // Always generate project context
505
- const projectContext = await generateProjectContext(projectPath);
506
- results.projectContext = await writeContext(projectPath, projectContext, 'CONTEXT.md');
507
-
508
- // Generate feature context if requested
509
- if (featureName) {
510
- const featureContext = await generateFeatureContext(projectPath, featureName);
511
- results.featureContext = await writeContext(
512
- projectPath,
513
- featureContext,
514
- `CONTEXT-${featureName}.md`
515
- );
516
- }
517
-
518
- return results;
519
- }