@polymorphism-tech/morph-spec 4.8.7 → 4.8.9
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.
- package/README.md +2 -2
- package/bin/morph-spec.js +22 -1
- package/bin/task-manager.cjs +120 -16
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/agents.json +1855 -1815
- package/framework/hooks/claude-code/pre-compact/save-morph-context.js +141 -23
- package/framework/hooks/claude-code/statusline.py +0 -12
- package/framework/hooks/claude-code/statusline.sh +6 -2
- package/framework/hooks/claude-code/stop/validate-completion.js +70 -23
- package/framework/hooks/dev/guard-version-numbers.js +1 -1
- package/framework/skills/level-0-meta/morph-init/SKILL.md +44 -6
- package/framework/skills/level-0-meta/tool-usage-guide/SKILL.md +67 -16
- package/framework/skills/level-1-workflows/phase-clarify/SKILL.md +1 -1
- package/framework/skills/level-1-workflows/phase-codebase-analysis/SKILL.md +77 -7
- package/framework/skills/level-1-workflows/phase-design/SKILL.md +115 -51
- package/framework/skills/level-1-workflows/phase-implement/SKILL.md +139 -1
- package/framework/skills/level-1-workflows/phase-setup/SKILL.md +29 -6
- package/framework/skills/level-1-workflows/phase-tasks/SKILL.md +115 -5
- package/framework/skills/level-1-workflows/phase-tasks/references/tasks-example.md +173 -0
- package/framework/skills/level-1-workflows/phase-uiux/SKILL.md +1 -1
- package/framework/standards/STANDARDS.json +944 -933
- package/framework/standards/architecture/vertical-slice/vertical-slice.md +429 -0
- package/framework/templates/REGISTRY.json +1909 -1888
- package/framework/templates/code/dotnet/contracts/contracts-vsa.cs +282 -0
- package/framework/templates/docs/spec.md +33 -1
- package/package.json +1 -1
- package/src/commands/agents/dispatch-agents.js +430 -0
- package/src/commands/agents/index.js +2 -1
- package/src/commands/project/doctor.js +138 -2
- package/src/commands/state/state.js +20 -4
- package/src/commands/templates/generate-contracts.js +445 -0
- package/src/commands/templates/index.js +1 -0
- package/src/lib/validators/validation-runner.js +19 -7
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dispatch-agents — Build dispatch config for parallel agent orchestration
|
|
3
|
+
*
|
|
4
|
+
* Reads activeAgents from state.json + spawn_prompts from agents.json and
|
|
5
|
+
* outputs a JSON dispatch config that phase skills use to spawn parallel subagents.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* morph-spec dispatch-agents <feature> <phase>
|
|
9
|
+
* morph-spec dispatch-agents <feature> <phase> --table
|
|
10
|
+
*
|
|
11
|
+
* Output (JSON):
|
|
12
|
+
* {
|
|
13
|
+
* feature, phase, shouldDispatch, reason,
|
|
14
|
+
* agents: [{ id, title, tier, icon, role, parallelGroup, canRunParallel, taskPrompt }],
|
|
15
|
+
* taskGroups: { [group]: { agentId, description, tasks, canRunParallel, taskPrompt } }
|
|
16
|
+
* }
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { readFileSync, existsSync } from 'fs';
|
|
20
|
+
import { join, dirname } from 'path';
|
|
21
|
+
import { fileURLToPath } from 'url';
|
|
22
|
+
import { loadState, stateExists } from '../../core/state/state-manager.js';
|
|
23
|
+
import chalk from 'chalk';
|
|
24
|
+
|
|
25
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
26
|
+
|
|
27
|
+
/** Path to agents.json in the installed package */
|
|
28
|
+
const AGENTS_JSON_PATH = join(__dirname, '../../../framework/agents.json');
|
|
29
|
+
|
|
30
|
+
/** Phases where agent dispatch is meaningful */
|
|
31
|
+
const DISPATCHABLE_PHASES = ['design', 'tasks', 'implement'];
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Domain → parallel group mapping.
|
|
35
|
+
* Agents inherit group from their first matching domain.
|
|
36
|
+
*/
|
|
37
|
+
const DOMAIN_TO_GROUP = {
|
|
38
|
+
'backend': 'backend',
|
|
39
|
+
'backend-data': 'backend',
|
|
40
|
+
'backend-jobs': 'backend',
|
|
41
|
+
'backend-events': 'backend',
|
|
42
|
+
'frontend': 'frontend',
|
|
43
|
+
'frontend-blazor': 'frontend',
|
|
44
|
+
'frontend-nextjs': 'frontend',
|
|
45
|
+
'frontend-css': 'frontend',
|
|
46
|
+
'frontend-design': 'frontend',
|
|
47
|
+
'infrastructure': 'infra',
|
|
48
|
+
'cloud-azure': 'infra',
|
|
49
|
+
'devops': 'infra',
|
|
50
|
+
'domain': 'domain-analysis',
|
|
51
|
+
'domain-modeling': 'domain-analysis',
|
|
52
|
+
'architecture': 'domain-analysis',
|
|
53
|
+
'ai-orchestration': 'domain-analysis',
|
|
54
|
+
'testing': 'tests',
|
|
55
|
+
'qa': 'tests',
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** Agent-level group overrides (higher priority than domain mapping) */
|
|
59
|
+
const AGENT_GROUP_OVERRIDES = {
|
|
60
|
+
'domain-architect': 'domain-analysis',
|
|
61
|
+
'standards-architect': 'domain-analysis',
|
|
62
|
+
'ai-system-architect': 'domain-analysis',
|
|
63
|
+
'popm-advisor': 'domain-analysis',
|
|
64
|
+
'dotnet-senior': 'backend',
|
|
65
|
+
'infra-architect': 'infra',
|
|
66
|
+
'ui-designer': 'frontend',
|
|
67
|
+
'testing-specialist': 'tests',
|
|
68
|
+
'code-analyzer': 'tests',
|
|
69
|
+
'load-testing-expert': 'tests',
|
|
70
|
+
'documentation-specialist': 'docs',
|
|
71
|
+
'troubleshooting-expert': 'support',
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Task category → parallel group mapping for implement-phase task splitting.
|
|
76
|
+
* Matches task categories defined in phase-tasks SKILL.md.
|
|
77
|
+
*/
|
|
78
|
+
const TASK_CATEGORY_TO_GROUP = {
|
|
79
|
+
'domain': 'backend',
|
|
80
|
+
'domain-bc': 'backend',
|
|
81
|
+
'infrastructure': 'backend',
|
|
82
|
+
'application': 'backend',
|
|
83
|
+
'presentation': 'frontend',
|
|
84
|
+
'tests': 'tests',
|
|
85
|
+
'infra': 'infra',
|
|
86
|
+
'docs': 'docs',
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
90
|
+
// Helpers
|
|
91
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Determine the parallel execution group for an agent.
|
|
95
|
+
* Priority: explicit override → domain mapping → tier fallback.
|
|
96
|
+
*
|
|
97
|
+
* @param {string} agentId
|
|
98
|
+
* @param {Object} agentData
|
|
99
|
+
* @returns {string} group name
|
|
100
|
+
*/
|
|
101
|
+
function getAgentGroup(agentId, agentData) {
|
|
102
|
+
if (AGENT_GROUP_OVERRIDES[agentId]) return AGENT_GROUP_OVERRIDES[agentId];
|
|
103
|
+
|
|
104
|
+
const domains = agentData.domains || [];
|
|
105
|
+
for (const domain of domains) {
|
|
106
|
+
if (DOMAIN_TO_GROUP[domain]) return DOMAIN_TO_GROUP[domain];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Tier-2 domain leaders default to backend if no domain matched
|
|
110
|
+
if (agentData.tier === 2) return 'backend';
|
|
111
|
+
return 'backend';
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Check whether an agent should be dispatched for the given phase.
|
|
116
|
+
*
|
|
117
|
+
* Rules (in priority order):
|
|
118
|
+
* 1. Tier-4 validators → never dispatch (no task work)
|
|
119
|
+
* 2. Agent-level `active_phases` array → use that list
|
|
120
|
+
* 3. Relationships-level `active_phases` array → use that list
|
|
121
|
+
* 4. `always_active: true` → dispatchable for all DISPATCHABLE_PHASES
|
|
122
|
+
* 5. Default: specialists without active_phases → implement only
|
|
123
|
+
*
|
|
124
|
+
* @param {string} agentId
|
|
125
|
+
* @param {Object} agentData
|
|
126
|
+
* @param {string} phase
|
|
127
|
+
* @returns {boolean}
|
|
128
|
+
*/
|
|
129
|
+
function isRelevantForPhase(agentId, agentData, phase) {
|
|
130
|
+
// Tier-4 validators have no implementation tasks
|
|
131
|
+
if (agentData.tier === 4) return false;
|
|
132
|
+
|
|
133
|
+
// Explicit active_phases on agent root
|
|
134
|
+
if (Array.isArray(agentData.active_phases)) {
|
|
135
|
+
return agentData.active_phases.includes(phase);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// active_phases nested in relationships
|
|
139
|
+
const relPhases = agentData.relationships?.active_phases;
|
|
140
|
+
if (Array.isArray(relPhases)) {
|
|
141
|
+
return relPhases.includes(phase);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// always_active agents: dispatch for all recognised phases
|
|
145
|
+
if (agentData.always_active && DISPATCHABLE_PHASES.includes(phase)) return true;
|
|
146
|
+
|
|
147
|
+
// Specialists without active_phases: implement phase only
|
|
148
|
+
return phase === 'implement';
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Parse tasks.md and return a map of { parallelGroup → taskId[] }.
|
|
153
|
+
* Gracefully returns {} if the file doesn't exist or is unparseable.
|
|
154
|
+
*
|
|
155
|
+
* @param {string} tasksPath - Absolute path to tasks.md
|
|
156
|
+
* @returns {Object}
|
|
157
|
+
*/
|
|
158
|
+
export function parseAndGroupTasks(tasksPath) {
|
|
159
|
+
if (!existsSync(tasksPath)) return {};
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
const content = readFileSync(tasksPath, 'utf8');
|
|
163
|
+
const groups = {};
|
|
164
|
+
|
|
165
|
+
// Match task headings: ### T001 — Title or ### T001: Title
|
|
166
|
+
const taskHeadingRe = /^###\s+(T\d{3,})\s*[—:\-]\s*(.+)$/gm;
|
|
167
|
+
// Match category line within a task block: **category**: `domain` or - category: application
|
|
168
|
+
const categoryRe = /(?:\*\*category\*\*\s*:\s*`?([a-z-]+)`?|category:\s*([a-z-]+))/i;
|
|
169
|
+
|
|
170
|
+
let headingMatch;
|
|
171
|
+
const taskPositions = [];
|
|
172
|
+
|
|
173
|
+
while ((headingMatch = taskHeadingRe.exec(content)) !== null) {
|
|
174
|
+
taskPositions.push({ id: headingMatch[1], pos: headingMatch.index });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
for (let i = 0; i < taskPositions.length; i++) {
|
|
178
|
+
const { id, pos } = taskPositions[i];
|
|
179
|
+
const end = i + 1 < taskPositions.length ? taskPositions[i + 1].pos : content.length;
|
|
180
|
+
const block = content.slice(pos, end);
|
|
181
|
+
|
|
182
|
+
const catMatch = categoryRe.exec(block);
|
|
183
|
+
const category = (catMatch?.[1] || catMatch?.[2] || 'application').toLowerCase();
|
|
184
|
+
const group = TASK_CATEGORY_TO_GROUP[category] || 'backend';
|
|
185
|
+
|
|
186
|
+
if (!groups[group]) groups[group] = [];
|
|
187
|
+
groups[group].push(id);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return groups;
|
|
191
|
+
} catch {
|
|
192
|
+
return {};
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
197
|
+
// Core Logic
|
|
198
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Build dispatch config for a feature and phase.
|
|
202
|
+
*
|
|
203
|
+
* @param {string} projectPath - Absolute path to project root (cwd)
|
|
204
|
+
* @param {string} featureName - Feature name
|
|
205
|
+
* @param {string} phase - Phase: 'design' | 'tasks' | 'implement'
|
|
206
|
+
* @param {Object} [opts] - Optional overrides (for testing)
|
|
207
|
+
* @param {string} [opts.agentsJsonPath] - Override path to agents.json
|
|
208
|
+
* @returns {Object} Dispatch config
|
|
209
|
+
*/
|
|
210
|
+
export async function buildDispatchConfig(projectPath, featureName, phase, opts = {}) {
|
|
211
|
+
// Load state
|
|
212
|
+
const state = loadState(false);
|
|
213
|
+
if (!state) {
|
|
214
|
+
return {
|
|
215
|
+
shouldDispatch: false,
|
|
216
|
+
reason: 'state.json not found — run morph-spec state init',
|
|
217
|
+
agents: [],
|
|
218
|
+
taskGroups: {},
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const feature = state.features?.[featureName];
|
|
223
|
+
if (!feature) {
|
|
224
|
+
return {
|
|
225
|
+
feature: featureName,
|
|
226
|
+
phase,
|
|
227
|
+
shouldDispatch: false,
|
|
228
|
+
reason: `Feature '${featureName}' not found in state.json`,
|
|
229
|
+
agents: [],
|
|
230
|
+
taskGroups: {},
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Load agents.json (opts.agentsJsonPath allows override for testing)
|
|
235
|
+
const agentsPath = opts.agentsJsonPath || AGENTS_JSON_PATH;
|
|
236
|
+
if (!existsSync(agentsPath)) {
|
|
237
|
+
return {
|
|
238
|
+
feature: featureName,
|
|
239
|
+
phase,
|
|
240
|
+
shouldDispatch: false,
|
|
241
|
+
reason: 'framework/agents.json not found',
|
|
242
|
+
agents: [],
|
|
243
|
+
taskGroups: {},
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const agentsData = JSON.parse(readFileSync(agentsPath, 'utf8'));
|
|
248
|
+
const allAgents = agentsData.agents || {};
|
|
249
|
+
|
|
250
|
+
// Active agents from state (keyword-detected during setup phase)
|
|
251
|
+
const activeAgentIds = new Set(feature.activeAgents || []);
|
|
252
|
+
|
|
253
|
+
// Collect dispatchable agents
|
|
254
|
+
// Process all agents: active ones from state + always_active ones not yet in state
|
|
255
|
+
const processed = new Set();
|
|
256
|
+
const dispatchableAgents = [];
|
|
257
|
+
|
|
258
|
+
const candidateIds = [
|
|
259
|
+
...activeAgentIds,
|
|
260
|
+
// Add always_active agents that might not have been explicitly detected
|
|
261
|
+
...Object.entries(allAgents)
|
|
262
|
+
.filter(([id, data]) => !id.startsWith('_comment') && data.always_active)
|
|
263
|
+
.map(([id]) => id),
|
|
264
|
+
];
|
|
265
|
+
|
|
266
|
+
for (const agentId of candidateIds) {
|
|
267
|
+
if (processed.has(agentId)) continue;
|
|
268
|
+
processed.add(agentId);
|
|
269
|
+
|
|
270
|
+
const agentData = allAgents[agentId];
|
|
271
|
+
if (!agentData || agentId.startsWith('_comment')) continue;
|
|
272
|
+
|
|
273
|
+
// Must have a spawn_prompt to be dispatchable
|
|
274
|
+
if (!agentData.teammate?.spawn_prompt) continue;
|
|
275
|
+
|
|
276
|
+
// Phase relevance check
|
|
277
|
+
if (!isRelevantForPhase(agentId, agentData, phase)) continue;
|
|
278
|
+
|
|
279
|
+
const group = getAgentGroup(agentId, agentData);
|
|
280
|
+
|
|
281
|
+
dispatchableAgents.push({
|
|
282
|
+
id: agentId,
|
|
283
|
+
title: agentData.title,
|
|
284
|
+
tier: agentData.tier,
|
|
285
|
+
icon: agentData.teammate.icon || '🤖',
|
|
286
|
+
role: agentData.teammate.role,
|
|
287
|
+
parallelGroup: group,
|
|
288
|
+
canRunParallel: true,
|
|
289
|
+
taskPrompt: agentData.teammate.spawn_prompt,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Sort: domain-analysis first, then by tier (ascending), then alphabetically
|
|
294
|
+
const groupOrder = ['domain-analysis', 'backend', 'frontend', 'infra', 'tests', 'docs', 'support'];
|
|
295
|
+
dispatchableAgents.sort((a, b) => {
|
|
296
|
+
const gi = groupOrder.indexOf(a.parallelGroup);
|
|
297
|
+
const gj = groupOrder.indexOf(b.parallelGroup);
|
|
298
|
+
if (gi !== gj) return gi - gj;
|
|
299
|
+
if (a.tier !== b.tier) return a.tier - b.tier;
|
|
300
|
+
return a.id.localeCompare(b.id);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
const shouldDispatch = dispatchableAgents.length >= 2;
|
|
304
|
+
|
|
305
|
+
// ── Task groups ────────────────────────────────────────────────────────────
|
|
306
|
+
const taskGroups = {};
|
|
307
|
+
|
|
308
|
+
if (phase === 'implement') {
|
|
309
|
+
// Group tasks from tasks.md by domain category
|
|
310
|
+
const tasksPath = join(projectPath, `.morph/features/${featureName}/3-tasks/tasks.md`);
|
|
311
|
+
const tasksByGroup = parseAndGroupTasks(tasksPath);
|
|
312
|
+
|
|
313
|
+
for (const [group, taskIds] of Object.entries(tasksByGroup)) {
|
|
314
|
+
const groupAgent = dispatchableAgents.find(a => a.parallelGroup === group);
|
|
315
|
+
taskGroups[group] = {
|
|
316
|
+
agentId: groupAgent?.id ?? null,
|
|
317
|
+
description: `${group} implementation tasks for feature '${featureName}'`,
|
|
318
|
+
tasks: taskIds,
|
|
319
|
+
canRunParallel: true,
|
|
320
|
+
taskPrompt: groupAgent?.taskPrompt ?? null,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
} else {
|
|
324
|
+
// For design/tasks: one group per parallel group, no task IDs yet
|
|
325
|
+
const seenGroups = new Set();
|
|
326
|
+
for (const agent of dispatchableAgents) {
|
|
327
|
+
if (seenGroups.has(agent.parallelGroup)) continue;
|
|
328
|
+
seenGroups.add(agent.parallelGroup);
|
|
329
|
+
|
|
330
|
+
taskGroups[agent.parallelGroup] = {
|
|
331
|
+
agentId: agent.id,
|
|
332
|
+
description: agent.role,
|
|
333
|
+
tasks: [],
|
|
334
|
+
canRunParallel: true,
|
|
335
|
+
taskPrompt: agent.taskPrompt,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return {
|
|
341
|
+
feature: featureName,
|
|
342
|
+
phase,
|
|
343
|
+
shouldDispatch,
|
|
344
|
+
reason: shouldDispatch
|
|
345
|
+
? `${dispatchableAgents.length} dispatchable agents for phase '${phase}'`
|
|
346
|
+
: dispatchableAgents.length === 1
|
|
347
|
+
? `Only 1 dispatchable agent — sequential execution preferred`
|
|
348
|
+
: `No dispatchable agents found for phase '${phase}'`,
|
|
349
|
+
agents: dispatchableAgents,
|
|
350
|
+
taskGroups,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
355
|
+
// CLI Handler
|
|
356
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* CLI command: morph-spec dispatch-agents <feature> <phase>
|
|
360
|
+
*/
|
|
361
|
+
export async function dispatchAgentsCommand(featureName, phase, options = {}) {
|
|
362
|
+
if (!featureName || !phase) {
|
|
363
|
+
console.error(chalk.red('Usage: morph-spec dispatch-agents <feature> <phase>'));
|
|
364
|
+
console.error(chalk.gray('Phases: design | tasks | implement'));
|
|
365
|
+
process.exit(1);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const normalizedPhase = phase.toLowerCase();
|
|
369
|
+
if (!DISPATCHABLE_PHASES.includes(normalizedPhase)) {
|
|
370
|
+
console.error(chalk.red(`Invalid phase: '${phase}'. Must be one of: ${DISPATCHABLE_PHASES.join(', ')}`));
|
|
371
|
+
process.exit(1);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (!stateExists()) {
|
|
375
|
+
console.error(chalk.red('No state.json found. Run morph-spec state init first.'));
|
|
376
|
+
process.exit(1);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
try {
|
|
380
|
+
const config = await buildDispatchConfig(process.cwd(), featureName, normalizedPhase);
|
|
381
|
+
|
|
382
|
+
if (options.table) {
|
|
383
|
+
// Human-readable table format
|
|
384
|
+
console.log(chalk.bold(`\n Dispatch Config — ${featureName} (${normalizedPhase})`));
|
|
385
|
+
console.log(' ' + '─'.repeat(62));
|
|
386
|
+
|
|
387
|
+
const dispatchLabel = config.shouldDispatch ? chalk.green('YES') : chalk.yellow('NO');
|
|
388
|
+
console.log(` Should dispatch: ${dispatchLabel}`);
|
|
389
|
+
console.log(` Reason: ${config.reason}`);
|
|
390
|
+
|
|
391
|
+
if (config.agents?.length > 0) {
|
|
392
|
+
console.log(`\n Agents (${config.agents.length}):`);
|
|
393
|
+
console.log(` ${'ID'.padEnd(28)} ${'Group'.padEnd(16)} Tier`);
|
|
394
|
+
console.log(' ' + '─'.repeat(50));
|
|
395
|
+
for (const agent of config.agents) {
|
|
396
|
+
console.log(
|
|
397
|
+
` ${(agent.icon + ' ' + agent.id).padEnd(28)} ` +
|
|
398
|
+
`${chalk.cyan(agent.parallelGroup.padEnd(16))} ` +
|
|
399
|
+
`${agent.tier}`
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
} else {
|
|
403
|
+
console.log(chalk.gray('\n No dispatchable agents.'));
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const groupEntries = Object.entries(config.taskGroups || {});
|
|
407
|
+
if (groupEntries.length > 0) {
|
|
408
|
+
console.log(`\n Task Groups:`);
|
|
409
|
+
console.log(` ${'Group'.padEnd(20)} ${'Tasks'.padEnd(8)} Agent`);
|
|
410
|
+
console.log(' ' + '─'.repeat(50));
|
|
411
|
+
for (const [group, data] of groupEntries) {
|
|
412
|
+
const taskCount = data.tasks?.length || 0;
|
|
413
|
+
console.log(
|
|
414
|
+
` ${group.padEnd(20)} ` +
|
|
415
|
+
`${String(taskCount).padEnd(8)} ` +
|
|
416
|
+
`${data.agentId || chalk.gray('none')}`
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
console.log();
|
|
421
|
+
} else {
|
|
422
|
+
// JSON (default — consumed by phase skills / LLM)
|
|
423
|
+
console.log(JSON.stringify(config, null, 2));
|
|
424
|
+
}
|
|
425
|
+
} catch (err) {
|
|
426
|
+
console.error(chalk.red(`Error: ${err.message}`));
|
|
427
|
+
if (options.verbose) console.error(err.stack);
|
|
428
|
+
process.exit(1);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { join } from 'path';
|
|
1
|
+
import { join, relative } from 'path';
|
|
2
2
|
import { execSync } from 'child_process';
|
|
3
3
|
import { platform, homedir } from 'os';
|
|
4
4
|
import fs from 'fs-extra';
|
|
@@ -97,7 +97,9 @@ const REQUIRED_COMMAND_FILES = [
|
|
|
97
97
|
'src/commands/tasks/task.js',
|
|
98
98
|
'src/commands/trust/trust.js',
|
|
99
99
|
'src/commands/validation/validate-feature.js',
|
|
100
|
-
'src/commands/templates/template-render.js'
|
|
100
|
+
'src/commands/templates/template-render.js',
|
|
101
|
+
'src/commands/agents/dispatch-agents.js',
|
|
102
|
+
'src/commands/templates/generate-contracts.js'
|
|
101
103
|
];
|
|
102
104
|
|
|
103
105
|
// HOP templates (meta-prompts)
|
|
@@ -134,6 +136,7 @@ const FRAMEWORK_STANDARDS = [
|
|
|
134
136
|
'framework/standards/architecture/ddd/aggregates.md',
|
|
135
137
|
'framework/standards/architecture/ddd/entities.md',
|
|
136
138
|
'framework/standards/architecture/ddd/value-objects.md',
|
|
139
|
+
'framework/standards/architecture/vertical-slice/vertical-slice.md',
|
|
137
140
|
'framework/standards/data/vector-search/azure-ai-search.md',
|
|
138
141
|
'framework/standards/data/vector-search/rag-chunking.md',
|
|
139
142
|
'framework/standards/context/priming.md',
|
|
@@ -278,6 +281,134 @@ async function doctorFullCommand(frameworkRoot) {
|
|
|
278
281
|
}
|
|
279
282
|
}
|
|
280
283
|
|
|
284
|
+
/**
|
|
285
|
+
* MCP health check: reads settings.json / settings.local.json and validates
|
|
286
|
+
* each configured MCP server's command binary and env var requirements.
|
|
287
|
+
*
|
|
288
|
+
* Strategy:
|
|
289
|
+
* - "ok" : command binary exists (or is a known runtime like npx/node) AND
|
|
290
|
+
* all env vars referenced in args/env are set in process.env
|
|
291
|
+
* - "warn" : binary found but env vars missing (MCP won't auth)
|
|
292
|
+
* - "missing": command binary not found in PATH
|
|
293
|
+
*
|
|
294
|
+
* NOTE: This does NOT test actual connectivity — that requires spawning the MCP.
|
|
295
|
+
* It's a static configuration health check that catches 80% of "MCP not working" cases.
|
|
296
|
+
*/
|
|
297
|
+
async function doctorMcpCommand(targetPath) {
|
|
298
|
+
logger.header('MCP Server Health Check');
|
|
299
|
+
logger.blank();
|
|
300
|
+
|
|
301
|
+
const settingsPaths = [
|
|
302
|
+
join(targetPath, '.claude', 'settings.json'),
|
|
303
|
+
join(targetPath, '.claude', 'settings.local.json'),
|
|
304
|
+
];
|
|
305
|
+
|
|
306
|
+
const allMcpServers = {};
|
|
307
|
+
const sourcesFound = [];
|
|
308
|
+
|
|
309
|
+
for (const sp of settingsPaths) {
|
|
310
|
+
if (await pathExists(sp)) {
|
|
311
|
+
try {
|
|
312
|
+
const settings = JSON.parse(await fs.readFile(sp, 'utf8'));
|
|
313
|
+
const mcps = settings.mcpServers || {};
|
|
314
|
+
const count = Object.keys(mcps).length;
|
|
315
|
+
if (count > 0) {
|
|
316
|
+
Object.assign(allMcpServers, mcps);
|
|
317
|
+
sourcesFound.push(`${relative(targetPath, sp)} (${count})`);
|
|
318
|
+
}
|
|
319
|
+
} catch { /* invalid JSON — ignore */ }
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (Object.keys(allMcpServers).length === 0) {
|
|
324
|
+
logger.warn('No MCP servers configured');
|
|
325
|
+
logger.blank();
|
|
326
|
+
logger.dim('Configure MCPs in: .claude/settings.json → mcpServers: { ... }');
|
|
327
|
+
logger.dim('Or use: morph-spec mcp setup');
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
logger.dim(`Sources: ${sourcesFound.join(', ')}`);
|
|
332
|
+
logger.blank();
|
|
333
|
+
|
|
334
|
+
const KNOWN_RUNTIMES = new Set(['npx', 'node', 'python', 'python3', 'uvx', 'bun', 'deno']);
|
|
335
|
+
const checks = [];
|
|
336
|
+
|
|
337
|
+
for (const [name, config] of Object.entries(allMcpServers)) {
|
|
338
|
+
const issues = [];
|
|
339
|
+
let status = 'ok';
|
|
340
|
+
|
|
341
|
+
// ── 1. Binary check ────────────────────────────────────────────────────
|
|
342
|
+
const cmd = config.command || '';
|
|
343
|
+
if (cmd && !KNOWN_RUNTIMES.has(cmd)) {
|
|
344
|
+
try {
|
|
345
|
+
execSync(isWindows ? `where "${cmd}"` : `which "${cmd}"`, { stdio: 'ignore' });
|
|
346
|
+
} catch {
|
|
347
|
+
status = 'missing';
|
|
348
|
+
issues.push(`command "${cmd}" not found in PATH`);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ── 2. Env vars from config.env ────────────────────────────────────────
|
|
353
|
+
for (const [key, val] of Object.entries(config.env || {})) {
|
|
354
|
+
if (typeof val === 'string' && (val.startsWith('${') || (val.startsWith('$') && !val.includes(' ')))) {
|
|
355
|
+
const varName = val.replace(/^\$\{?/, '').replace(/\}$/, '');
|
|
356
|
+
if (!process.env[varName]) {
|
|
357
|
+
if (status === 'ok') status = 'warn';
|
|
358
|
+
issues.push(`env "${key}" → $${varName} not set`);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// ── 3. Env refs in args array ──────────────────────────────────────────
|
|
364
|
+
const argsStr = (config.args || []).join(' ');
|
|
365
|
+
const envRefsInArgs = [...argsStr.matchAll(/\$\{?([A-Z_][A-Z0-9_]*)\}?/g)].map(m => m[1]);
|
|
366
|
+
for (const varName of envRefsInArgs) {
|
|
367
|
+
if (!process.env[varName] && !(config.env || {})[varName]) {
|
|
368
|
+
if (status === 'ok') status = 'warn';
|
|
369
|
+
issues.push(`args reference $${varName} which is not set`);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
checks.push({ name, status, issues, cmd });
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// ── Display ────────────────────────────────────────────────────────────────
|
|
377
|
+
for (const c of checks) {
|
|
378
|
+
const typeLabel = c.cmd ? chalk.gray(` [${c.cmd}]`) : '';
|
|
379
|
+
|
|
380
|
+
if (c.status === 'ok') {
|
|
381
|
+
console.log(chalk.green(` ✓ ${c.name}`) + typeLabel);
|
|
382
|
+
} else if (c.status === 'warn') {
|
|
383
|
+
console.log(chalk.yellow(` ⚠ ${c.name}`) + typeLabel);
|
|
384
|
+
c.issues.forEach(issue => console.log(chalk.gray(` → ${issue}`)));
|
|
385
|
+
} else {
|
|
386
|
+
console.log(chalk.red(` ✗ ${c.name}`) + typeLabel);
|
|
387
|
+
c.issues.forEach(issue => console.log(chalk.gray(` → ${issue}`)));
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
logger.blank();
|
|
392
|
+
|
|
393
|
+
const ok = checks.filter(c => c.status === 'ok').length;
|
|
394
|
+
const warn = checks.filter(c => c.status === 'warn').length;
|
|
395
|
+
const missing = checks.filter(c => c.status === 'missing').length;
|
|
396
|
+
|
|
397
|
+
if (missing > 0) {
|
|
398
|
+
console.log(chalk.red(` ❌ ${ok}/${checks.length} OK — ${missing} MCP(s) have missing binaries`));
|
|
399
|
+
process.exit(1);
|
|
400
|
+
} else if (warn > 0) {
|
|
401
|
+
console.log(chalk.yellow(` ⚠️ ${ok}/${checks.length} OK — ${warn} MCP(s) have missing env vars (won't authenticate)`));
|
|
402
|
+
} else {
|
|
403
|
+
console.log(chalk.green(` ✅ ${ok}/${checks.length} — All MCPs configured correctly`));
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
logger.blank();
|
|
407
|
+
logger.dim('Note: "configured correctly" = binary exists + env vars set.');
|
|
408
|
+
logger.dim(' Does NOT test actual connectivity or authentication.');
|
|
409
|
+
logger.blank();
|
|
410
|
+
}
|
|
411
|
+
|
|
281
412
|
export async function doctorCommand(options = {}) {
|
|
282
413
|
// Reset mode: remove morph-managed settings entries
|
|
283
414
|
if (options.reset) {
|
|
@@ -310,6 +441,11 @@ export async function doctorCommand(options = {}) {
|
|
|
310
441
|
return doctorFullCommand(frameworkRoot);
|
|
311
442
|
}
|
|
312
443
|
|
|
444
|
+
// MCP health check mode
|
|
445
|
+
if (options.mcp) {
|
|
446
|
+
return doctorMcpCommand(process.cwd());
|
|
447
|
+
}
|
|
448
|
+
|
|
313
449
|
const targetPath = process.cwd();
|
|
314
450
|
|
|
315
451
|
logger.header('MORPH-SPEC Health Check');
|
|
@@ -139,10 +139,19 @@ async function getCommand(featureName, options) {
|
|
|
139
139
|
const featurePath = join(process.cwd(), '.morph/features', featureName);
|
|
140
140
|
const phase = derivePhase(featurePath);
|
|
141
141
|
const tasks = feature.tasks || { completed: 0, total: 0 };
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
142
|
+
// Prefer taskList.length as total when tasks.total is stale/zero (Problem 3 complement)
|
|
143
|
+
const total = tasks.total || (Array.isArray(feature.taskList) ? feature.taskList.length : 0);
|
|
144
|
+
const taskDisplay = total > 0
|
|
145
|
+
? `${tasks.completed}/${total}`
|
|
146
|
+
: tasks.completed > 0 ? `${tasks.completed}/? (run task done to sync)` : '0/? (tasks not yet loaded)';
|
|
147
|
+
logger.info(`Status: ${chalk.cyan(feature.status || 'draft')}`);
|
|
148
|
+
logger.info(`Phase: ${chalk.cyan(phase)}`);
|
|
149
|
+
logger.info(`Tasks: ${chalk.cyan(taskDisplay)}`);
|
|
150
|
+
logger.info(`Agents: ${chalk.cyan((feature.activeAgents || []).join(', ') || 'None')}`);
|
|
151
|
+
const outputs = Object.keys(feature.outputs || {}).filter(k => feature.outputs[k]?.created);
|
|
152
|
+
if (outputs.length > 0) {
|
|
153
|
+
logger.info(`Outputs: ${chalk.cyan(outputs.join(', '))}`);
|
|
154
|
+
}
|
|
146
155
|
logger.blank();
|
|
147
156
|
}
|
|
148
157
|
|
|
@@ -165,6 +174,13 @@ async function setCommand(featureName, key, value, options) {
|
|
|
165
174
|
}
|
|
166
175
|
|
|
167
176
|
try {
|
|
177
|
+
// Warn when bypassing the validated phase advance flow
|
|
178
|
+
if (key === 'phase') {
|
|
179
|
+
logger.warn(`Direct phase override — all validation gates will be bypassed.`);
|
|
180
|
+
logger.dim(` Recommended: morph-spec phase advance ${featureName}`);
|
|
181
|
+
logger.blank();
|
|
182
|
+
}
|
|
183
|
+
|
|
168
184
|
const spinner = ora('Updating state...').start();
|
|
169
185
|
|
|
170
186
|
// Parse value automatically (JSON, number, boolean)
|