@polymorphism-tech/morph-spec 4.8.7 → 4.8.8

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 (33) hide show
  1. package/README.md +2 -2
  2. package/bin/morph-spec.js +22 -1
  3. package/bin/task-manager.cjs +120 -16
  4. package/claude-plugin.json +1 -1
  5. package/docs/CHEATSHEET.md +1 -1
  6. package/docs/QUICKSTART.md +1 -1
  7. package/framework/agents.json +1854 -1815
  8. package/framework/hooks/claude-code/pre-compact/save-morph-context.js +141 -23
  9. package/framework/hooks/claude-code/statusline.py +0 -12
  10. package/framework/hooks/claude-code/statusline.sh +6 -2
  11. package/framework/hooks/claude-code/stop/validate-completion.js +70 -23
  12. package/framework/hooks/dev/guard-version-numbers.js +1 -1
  13. package/framework/skills/level-0-meta/morph-init/SKILL.md +44 -6
  14. package/framework/skills/level-0-meta/tool-usage-guide/SKILL.md +67 -16
  15. package/framework/skills/level-1-workflows/phase-clarify/SKILL.md +1 -1
  16. package/framework/skills/level-1-workflows/phase-codebase-analysis/SKILL.md +77 -7
  17. package/framework/skills/level-1-workflows/phase-design/SKILL.md +114 -50
  18. package/framework/skills/level-1-workflows/phase-implement/SKILL.md +139 -1
  19. package/framework/skills/level-1-workflows/phase-setup/SKILL.md +29 -6
  20. package/framework/skills/level-1-workflows/phase-tasks/SKILL.md +4 -3
  21. package/framework/skills/level-1-workflows/phase-uiux/SKILL.md +1 -1
  22. package/framework/standards/STANDARDS.json +944 -933
  23. package/framework/standards/architecture/vertical-slice/vertical-slice.md +429 -0
  24. package/framework/templates/REGISTRY.json +1909 -1888
  25. package/framework/templates/code/dotnet/contracts/contracts-vsa.cs +282 -0
  26. package/package.json +1 -1
  27. package/src/commands/agents/dispatch-agents.js +430 -0
  28. package/src/commands/agents/index.js +2 -1
  29. package/src/commands/project/doctor.js +137 -2
  30. package/src/commands/state/state.js +20 -4
  31. package/src/commands/templates/generate-contracts.js +445 -0
  32. package/src/commands/templates/index.js +1 -0
  33. 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,3 +1,4 @@
1
1
  /**
2
- * Agent Commands — no active commands after CLI simplification
2
+ * Agent Commands
3
3
  */
4
+ export { dispatchAgentsCommand, buildDispatchConfig } from './dispatch-agents.js';
@@ -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)
@@ -278,6 +280,134 @@ async function doctorFullCommand(frameworkRoot) {
278
280
  }
279
281
  }
280
282
 
283
+ /**
284
+ * MCP health check: reads settings.json / settings.local.json and validates
285
+ * each configured MCP server's command binary and env var requirements.
286
+ *
287
+ * Strategy:
288
+ * - "ok" : command binary exists (or is a known runtime like npx/node) AND
289
+ * all env vars referenced in args/env are set in process.env
290
+ * - "warn" : binary found but env vars missing (MCP won't auth)
291
+ * - "missing": command binary not found in PATH
292
+ *
293
+ * NOTE: This does NOT test actual connectivity — that requires spawning the MCP.
294
+ * It's a static configuration health check that catches 80% of "MCP not working" cases.
295
+ */
296
+ async function doctorMcpCommand(targetPath) {
297
+ logger.header('MCP Server Health Check');
298
+ logger.blank();
299
+
300
+ const settingsPaths = [
301
+ join(targetPath, '.claude', 'settings.json'),
302
+ join(targetPath, '.claude', 'settings.local.json'),
303
+ ];
304
+
305
+ const allMcpServers = {};
306
+ const sourcesFound = [];
307
+
308
+ for (const sp of settingsPaths) {
309
+ if (await pathExists(sp)) {
310
+ try {
311
+ const settings = JSON.parse(await fs.readFile(sp, 'utf8'));
312
+ const mcps = settings.mcpServers || {};
313
+ const count = Object.keys(mcps).length;
314
+ if (count > 0) {
315
+ Object.assign(allMcpServers, mcps);
316
+ sourcesFound.push(`${relative(targetPath, sp)} (${count})`);
317
+ }
318
+ } catch { /* invalid JSON — ignore */ }
319
+ }
320
+ }
321
+
322
+ if (Object.keys(allMcpServers).length === 0) {
323
+ logger.warn('No MCP servers configured');
324
+ logger.blank();
325
+ logger.dim('Configure MCPs in: .claude/settings.json → mcpServers: { ... }');
326
+ logger.dim('Or use: morph-spec mcp setup');
327
+ return;
328
+ }
329
+
330
+ logger.dim(`Sources: ${sourcesFound.join(', ')}`);
331
+ logger.blank();
332
+
333
+ const KNOWN_RUNTIMES = new Set(['npx', 'node', 'python', 'python3', 'uvx', 'bun', 'deno']);
334
+ const checks = [];
335
+
336
+ for (const [name, config] of Object.entries(allMcpServers)) {
337
+ const issues = [];
338
+ let status = 'ok';
339
+
340
+ // ── 1. Binary check ────────────────────────────────────────────────────
341
+ const cmd = config.command || '';
342
+ if (cmd && !KNOWN_RUNTIMES.has(cmd)) {
343
+ try {
344
+ execSync(isWindows ? `where "${cmd}"` : `which "${cmd}"`, { stdio: 'ignore' });
345
+ } catch {
346
+ status = 'missing';
347
+ issues.push(`command "${cmd}" not found in PATH`);
348
+ }
349
+ }
350
+
351
+ // ── 2. Env vars from config.env ────────────────────────────────────────
352
+ for (const [key, val] of Object.entries(config.env || {})) {
353
+ if (typeof val === 'string' && (val.startsWith('${') || (val.startsWith('$') && !val.includes(' ')))) {
354
+ const varName = val.replace(/^\$\{?/, '').replace(/\}$/, '');
355
+ if (!process.env[varName]) {
356
+ if (status === 'ok') status = 'warn';
357
+ issues.push(`env "${key}" → $${varName} not set`);
358
+ }
359
+ }
360
+ }
361
+
362
+ // ── 3. Env refs in args array ──────────────────────────────────────────
363
+ const argsStr = (config.args || []).join(' ');
364
+ const envRefsInArgs = [...argsStr.matchAll(/\$\{?([A-Z_][A-Z0-9_]*)\}?/g)].map(m => m[1]);
365
+ for (const varName of envRefsInArgs) {
366
+ if (!process.env[varName] && !(config.env || {})[varName]) {
367
+ if (status === 'ok') status = 'warn';
368
+ issues.push(`args reference $${varName} which is not set`);
369
+ }
370
+ }
371
+
372
+ checks.push({ name, status, issues, cmd });
373
+ }
374
+
375
+ // ── Display ────────────────────────────────────────────────────────────────
376
+ for (const c of checks) {
377
+ const typeLabel = c.cmd ? chalk.gray(` [${c.cmd}]`) : '';
378
+
379
+ if (c.status === 'ok') {
380
+ console.log(chalk.green(` ✓ ${c.name}`) + typeLabel);
381
+ } else if (c.status === 'warn') {
382
+ console.log(chalk.yellow(` ⚠ ${c.name}`) + typeLabel);
383
+ c.issues.forEach(issue => console.log(chalk.gray(` → ${issue}`)));
384
+ } else {
385
+ console.log(chalk.red(` ✗ ${c.name}`) + typeLabel);
386
+ c.issues.forEach(issue => console.log(chalk.gray(` → ${issue}`)));
387
+ }
388
+ }
389
+
390
+ logger.blank();
391
+
392
+ const ok = checks.filter(c => c.status === 'ok').length;
393
+ const warn = checks.filter(c => c.status === 'warn').length;
394
+ const missing = checks.filter(c => c.status === 'missing').length;
395
+
396
+ if (missing > 0) {
397
+ console.log(chalk.red(` ❌ ${ok}/${checks.length} OK — ${missing} MCP(s) have missing binaries`));
398
+ process.exit(1);
399
+ } else if (warn > 0) {
400
+ console.log(chalk.yellow(` ⚠️ ${ok}/${checks.length} OK — ${warn} MCP(s) have missing env vars (won't authenticate)`));
401
+ } else {
402
+ console.log(chalk.green(` ✅ ${ok}/${checks.length} — All MCPs configured correctly`));
403
+ }
404
+
405
+ logger.blank();
406
+ logger.dim('Note: "configured correctly" = binary exists + env vars set.');
407
+ logger.dim(' Does NOT test actual connectivity or authentication.');
408
+ logger.blank();
409
+ }
410
+
281
411
  export async function doctorCommand(options = {}) {
282
412
  // Reset mode: remove morph-managed settings entries
283
413
  if (options.reset) {
@@ -310,6 +440,11 @@ export async function doctorCommand(options = {}) {
310
440
  return doctorFullCommand(frameworkRoot);
311
441
  }
312
442
 
443
+ // MCP health check mode
444
+ if (options.mcp) {
445
+ return doctorMcpCommand(process.cwd());
446
+ }
447
+
313
448
  const targetPath = process.cwd();
314
449
 
315
450
  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
- logger.info(`Status: ${chalk.cyan(feature.status)}`);
143
- logger.info(`Phase: ${chalk.cyan(phase)}`);
144
- logger.info(`Tasks: ${chalk.cyan(`${tasks.completed}/${tasks.total}`)}`);
145
- logger.info(`Agents: ${chalk.cyan((feature.activeAgents || []).join(', ') || 'None')}`);
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)