@polymorphism-tech/morph-spec 7.2.0 → 7.3.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.
- package/README.md +2 -2
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +1 -1
- package/framework/commands/morph-apply.md +1 -1
- package/framework/commands/morph-proposal.md +1 -1
- package/framework/hooks/README.md +1 -1
- package/framework/hooks/claude-code/core/validate-completion.js +136 -136
- package/framework/phases.json +1 -1
- package/framework/skills/README.md +1 -1
- package/framework/skills/core/morph-apply/SKILL.md +1 -1
- package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
- package/framework/skills/core/morph-eval/SKILL.md +1 -1
- package/framework/skills/core/morph-implement/SKILL.md +1 -1
- package/framework/skills/core/morph-plan/SKILL.md +1 -1
- package/framework/skills/core/morph-proposal/SKILL.md +1 -1
- package/framework/skills/core/morph-review/SKILL.md +1 -1
- package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/framework/templates/README.md +708 -709
- package/framework/templates/REGISTRY.json +1 -20
- package/framework/templates/context/CONTEXT-FEATURE.md +276 -276
- package/framework/templates/docs/proposal.md +171 -171
- package/framework/templates/feature/recap.md +145 -145
- package/package.json +1 -1
- package/src/commands/project/doctor.js +941 -946
- package/src/commands/project/status.js +373 -376
- package/src/commands/validation/validate-feature.js +315 -287
- package/src/core/state/state-manager.js +900 -934
- package/src/dashboard/app/App.jsx +29 -31
- package/src/dashboard/app/components/Sidebar.jsx +44 -45
- package/src/dashboard/scanner/index.js +20 -21
- package/src/lib/context/context-builder.js +3 -3
- package/src/lib/generators/context-generator.js +2 -3
- package/src/lib/generators/mandate-generator.js +48 -92
- package/src/lib/generators/metadata-extractor.js +385 -386
- package/src/lib/generators/recap-generator.js +285 -285
- package/src/lib/validators/content/content-validator.js +89 -286
- package/src/lib/validators/content/index.js +0 -1
- package/framework/templates/feature/tasks.md +0 -199
- package/framework/workflows/README.md +0 -1041
- package/framework/workflows/configs/fast-track.json +0 -46
- package/framework/workflows/configs/full.json +0 -64
- package/framework/workflows/configs/quick.json +0 -32
- package/framework/workflows/configs/standard.json +0 -57
- package/framework/workflows/docs/STORY-DRIVEN-DEVELOPMENT.md +0 -392
- package/framework/workflows/docs/design-impl.md +0 -37
- package/framework/workflows/docs/enforcement-pipeline.md +0 -668
- package/framework/workflows/docs/fast-track.md +0 -29
- package/framework/workflows/docs/full-morph.md +0 -69
- package/framework/workflows/docs/standard.md +0 -49
- package/framework/workflows/docs/ui-refresh.md +0 -39
- package/src/commands/state/index.js +0 -7
- package/src/core/workflows/workflow-detector.js +0 -586
- package/src/core/workflows/workflow-router.js +0 -204
- package/src/dashboard/app/pages/Workflows.jsx +0 -346
- package/src/dashboard/scanner/workflows-scanner.js +0 -18
- package/src/lib/phase-chain/phase-validator.js +0 -359
- package/src/lib/validators/content/cross-phase-validator.js +0 -281
- package/src/lib/validators/spec-validator.js +0 -312
|
@@ -1,946 +1,941 @@
|
|
|
1
|
-
import { join, relative } from 'path';
|
|
2
|
-
import { execSync } from 'child_process';
|
|
3
|
-
import { platform, homedir } from 'os';
|
|
4
|
-
import fs from 'fs-extra';
|
|
5
|
-
import chalk from 'chalk';
|
|
6
|
-
import { logger } from '../../utils/logger.js';
|
|
7
|
-
import { pathExists, readJson } from '../../utils/file-copier.js';
|
|
8
|
-
import { checkClaudeMdImport } from '../../utils/claude-md-injector.js';
|
|
9
|
-
import {
|
|
10
|
-
checkCLIOutdated,
|
|
11
|
-
checkProjectOutdated,
|
|
12
|
-
getInstalledCLIVersion
|
|
13
|
-
} from '../../utils/version-checker.js';
|
|
14
|
-
import { resetMorphSettings } from '../../utils/claude-settings-manager.js';
|
|
15
|
-
import { checkMissingAgents } from '../../utils/agents-installer.js';
|
|
16
|
-
import { getProjectTags } from '../../lib/stack-filter.js';
|
|
17
|
-
|
|
18
|
-
const isWindows = platform() === 'win32';
|
|
19
|
-
/**
|
|
20
|
-
* Check which required Claude Code plugins are installed.
|
|
21
|
-
* @param {Object} pluginsData - The `plugins` object from installed_plugins.json
|
|
22
|
-
* @returns {{ superpowers: boolean, context7: boolean }}
|
|
23
|
-
*/
|
|
24
|
-
export function checkInstalledPlugins(pluginsData) {
|
|
25
|
-
const REQUIRED = ['superpowers', 'context7'];
|
|
26
|
-
return Object.fromEntries(
|
|
27
|
-
REQUIRED.map(name => [name, `${name}@claude-plugins-official` in (pluginsData || {})])
|
|
28
|
-
);
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Check if morph-spec command is in PATH
|
|
34
|
-
*/
|
|
35
|
-
function isMorphSpecInPath() {
|
|
36
|
-
try {
|
|
37
|
-
const command = isWindows ? 'where morph-spec' : 'which morph-spec';
|
|
38
|
-
execSync(command, { stdio: 'ignore' });
|
|
39
|
-
return true;
|
|
40
|
-
} catch {
|
|
41
|
-
return false;
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Detect if nvm-windows is installed
|
|
47
|
-
*/
|
|
48
|
-
function isUsingNvmWindows() {
|
|
49
|
-
if (!isWindows) return false;
|
|
50
|
-
|
|
51
|
-
try {
|
|
52
|
-
const path = process.env.PATH || '';
|
|
53
|
-
return path.includes('nvm4w') || path.includes('\\nvm\\');
|
|
54
|
-
} catch {
|
|
55
|
-
return false;
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Get npm global prefix
|
|
61
|
-
*/
|
|
62
|
-
function getNpmGlobalPrefix() {
|
|
63
|
-
try {
|
|
64
|
-
const prefix = execSync('npm config get prefix', { encoding: 'utf8' }).trim();
|
|
65
|
-
return prefix;
|
|
66
|
-
} catch {
|
|
67
|
-
return null;
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// lib files (v5 — minimal surface)
|
|
72
|
-
const REQUIRED_LIB_FILES = [
|
|
73
|
-
'src/lib/stack-filter.js'
|
|
74
|
-
];
|
|
75
|
-
|
|
76
|
-
// command files (v5 CLI surface — installer-only)
|
|
77
|
-
const REQUIRED_COMMAND_FILES = [
|
|
78
|
-
'src/commands/project/init.js',
|
|
79
|
-
'src/commands/project/update.js',
|
|
80
|
-
'src/commands/project/doctor.js',
|
|
81
|
-
'src/commands/project/status.js',
|
|
82
|
-
'src/commands/project/worktree.js',
|
|
83
|
-
'src/commands/state/approve.js',
|
|
84
|
-
'src/commands/validation/validate-feature.js',
|
|
85
|
-
'src/commands/scaffold/scaffold.js',
|
|
86
|
-
'src/commands/mcp/mcp-setup.js',
|
|
87
|
-
'src/commands/context/context-refresh.js',
|
|
88
|
-
];
|
|
89
|
-
|
|
90
|
-
// framework standards (stack-relevant only)
|
|
91
|
-
const FRAMEWORK_STANDARDS = [
|
|
92
|
-
'framework/standards/architecture/vertical-slice/vertical-slice.md',
|
|
93
|
-
'framework/standards/data/nosql/cosmos-db.md',
|
|
94
|
-
'framework/standards/data/nosql/cache/redis.md',
|
|
95
|
-
'framework/standards/data/vector-search/rag-chunking.md',
|
|
96
|
-
'framework/standards/integration/mcp/mcp-tools.md',
|
|
97
|
-
'framework/standards/workflows/thread-management.md',
|
|
98
|
-
'framework/standards/workflows/parallel-execution.md'
|
|
99
|
-
];
|
|
100
|
-
|
|
101
|
-
// required personas expected in agents.json (v5 — at least the universal evaluator)
|
|
102
|
-
const REQUIRED_AGENTS = [
|
|
103
|
-
'evaluator'
|
|
104
|
-
];
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* Run full health checks
|
|
108
|
-
*/
|
|
109
|
-
async function doctorFullCommand(frameworkRoot) {
|
|
110
|
-
console.log(chalk.bold('\n🔬 MORPH-SPEC Full Health Check\n'));
|
|
111
|
-
console.log('─'.repeat(60));
|
|
112
|
-
|
|
113
|
-
const checks = [];
|
|
114
|
-
let hasErrors = false;
|
|
115
|
-
let hasWarnings = false;
|
|
116
|
-
|
|
117
|
-
const check = (name, passed, warnOnly = false, msg = '') => {
|
|
118
|
-
if (passed) {
|
|
119
|
-
checks.push({ name, status: 'ok' });
|
|
120
|
-
} else {
|
|
121
|
-
checks.push({ name, status: warnOnly ? 'warn' : 'missing', msg });
|
|
122
|
-
if (warnOnly) hasWarnings = true;
|
|
123
|
-
else hasErrors = true;
|
|
124
|
-
}
|
|
125
|
-
};
|
|
126
|
-
|
|
127
|
-
// ── 1. Core Libraries ───────────────────────────────────────────────────
|
|
128
|
-
console.log(chalk.cyan(`\n src/lib/ — Core Libraries (${REQUIRED_LIB_FILES.length} files)`));
|
|
129
|
-
const missingLibs = [];
|
|
130
|
-
for (const f of REQUIRED_LIB_FILES) {
|
|
131
|
-
if (!(await pathExists(join(frameworkRoot, f)))) missingLibs.push(f.split('/').pop());
|
|
132
|
-
}
|
|
133
|
-
check(`Core Libraries (${REQUIRED_LIB_FILES.length - missingLibs.length}/${REQUIRED_LIB_FILES.length})`,
|
|
134
|
-
missingLibs.length === 0, false,
|
|
135
|
-
missingLibs.length > 0 ? `missing: ${missingLibs.join(', ')}` : '');
|
|
136
|
-
|
|
137
|
-
// ── 2. Command Files ─────────────────────────────────────────────────────
|
|
138
|
-
console.log(chalk.cyan(`\n src/commands/ — Command Files (${REQUIRED_COMMAND_FILES.length} files)`));
|
|
139
|
-
const missingCmds = [];
|
|
140
|
-
for (const f of REQUIRED_COMMAND_FILES) {
|
|
141
|
-
if (!(await pathExists(join(frameworkRoot, f)))) missingCmds.push(f.split('/').pop());
|
|
142
|
-
}
|
|
143
|
-
check(`Command Files (${REQUIRED_COMMAND_FILES.length - missingCmds.length}/${REQUIRED_COMMAND_FILES.length})`,
|
|
144
|
-
missingCmds.length === 0, false,
|
|
145
|
-
missingCmds.length > 0 ? `missing: ${missingCmds.join(', ')}` : '');
|
|
146
|
-
|
|
147
|
-
// ── 3. Framework Standards ───────────────────────────────────────────────
|
|
148
|
-
console.log(chalk.cyan(`\n framework/standards/ — Framework Standards (${FRAMEWORK_STANDARDS.length} files)`));
|
|
149
|
-
const missingStds = [];
|
|
150
|
-
for (const f of FRAMEWORK_STANDARDS) {
|
|
151
|
-
if (!(await pathExists(join(frameworkRoot, f)))) missingStds.push(f.replace('framework/standards/', ''));
|
|
152
|
-
}
|
|
153
|
-
check(`Framework Standards (${FRAMEWORK_STANDARDS.length - missingStds.length}/${FRAMEWORK_STANDARDS.length})`,
|
|
154
|
-
missingStds.length === 0, false,
|
|
155
|
-
missingStds.length > 0 ? `missing: ${missingStds.join(', ')}` : '');
|
|
156
|
-
|
|
157
|
-
// ── 5. Required Agents in agents.json ───────────────────────────────────
|
|
158
|
-
console.log(chalk.cyan(`\n .morph/framework/agents.json — Required Agents (${REQUIRED_AGENTS.length})`));
|
|
159
|
-
const agentsPath = join(frameworkRoot, '.morph/framework/agents.json');
|
|
160
|
-
if (await pathExists(agentsPath)) {
|
|
161
|
-
try {
|
|
162
|
-
const agentsConfig = JSON.parse(await fs.readFile(agentsPath, 'utf8'));
|
|
163
|
-
const agents = agentsConfig.agents || {};
|
|
164
|
-
const missingAgents = REQUIRED_AGENTS.filter(id => !agents[id]);
|
|
165
|
-
check(`Required Agents (${REQUIRED_AGENTS.length - missingAgents.length}/${REQUIRED_AGENTS.length})`,
|
|
166
|
-
missingAgents.length === 0, false,
|
|
167
|
-
missingAgents.length > 0 ? `missing: ${missingAgents.join(', ')}` : '');
|
|
168
|
-
} catch {
|
|
169
|
-
check('agents.json parse', false, false, 'invalid JSON');
|
|
170
|
-
}
|
|
171
|
-
} else {
|
|
172
|
-
check('agents.json', false, false, 'file not found');
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
// ── 6. agents.json version freshness ──────────────────────────────────
|
|
176
|
-
const canonicalAgentsPath = join(frameworkRoot, 'framework/agents.json');
|
|
177
|
-
if (await pathExists(agentsPath) && await pathExists(canonicalAgentsPath)) {
|
|
178
|
-
try {
|
|
179
|
-
const installed = JSON.parse(await fs.readFile(agentsPath, 'utf8'));
|
|
180
|
-
const canonical = JSON.parse(await fs.readFile(canonicalAgentsPath, 'utf8'));
|
|
181
|
-
const installedVer = installed.version || 'unknown';
|
|
182
|
-
const canonicalVer = canonical.version || 'unknown';
|
|
183
|
-
const isFresh = installedVer === canonicalVer;
|
|
184
|
-
check(`agents.json version (${installedVer})`, isFresh, true,
|
|
185
|
-
isFresh ? '' : `stale — canonical is v${canonicalVer}, run 'morph-spec update' to sync`);
|
|
186
|
-
} catch { /* ignore parse errors — already caught above */ }
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
// ── 7.
|
|
190
|
-
console.log(chalk.cyan('\n
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
console.log(chalk.
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
const settingsPaths
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
console.log(chalk.
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
console.log(chalk.
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
return
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
checks.push({
|
|
505
|
-
name: 'morph-spec in PATH',
|
|
506
|
-
status: '
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
checks.push({ name: 'CLAUDE.md', status: '
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
const
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
if (data.
|
|
602
|
-
if (data.
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
checks.push({ name: '.claude/skills/ links', status: '
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
}
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
}
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
const
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
checks.push({ name: 'key templates', status: '
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
}
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
logger.
|
|
922
|
-
logger.dim(
|
|
923
|
-
logger.blank();
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
logger.
|
|
927
|
-
logger.dim('
|
|
928
|
-
logger.blank();
|
|
929
|
-
}
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
}
|
|
943
|
-
} else {
|
|
944
|
-
logger.success('All checks passed!');
|
|
945
|
-
}
|
|
946
|
-
}
|
|
1
|
+
import { join, relative } from 'path';
|
|
2
|
+
import { execSync } from 'child_process';
|
|
3
|
+
import { platform, homedir } from 'os';
|
|
4
|
+
import fs from 'fs-extra';
|
|
5
|
+
import chalk from 'chalk';
|
|
6
|
+
import { logger } from '../../utils/logger.js';
|
|
7
|
+
import { pathExists, readJson } from '../../utils/file-copier.js';
|
|
8
|
+
import { checkClaudeMdImport } from '../../utils/claude-md-injector.js';
|
|
9
|
+
import {
|
|
10
|
+
checkCLIOutdated,
|
|
11
|
+
checkProjectOutdated,
|
|
12
|
+
getInstalledCLIVersion
|
|
13
|
+
} from '../../utils/version-checker.js';
|
|
14
|
+
import { resetMorphSettings } from '../../utils/claude-settings-manager.js';
|
|
15
|
+
import { checkMissingAgents } from '../../utils/agents-installer.js';
|
|
16
|
+
import { getProjectTags } from '../../lib/stack-filter.js';
|
|
17
|
+
|
|
18
|
+
const isWindows = platform() === 'win32';
|
|
19
|
+
/**
|
|
20
|
+
* Check which required Claude Code plugins are installed.
|
|
21
|
+
* @param {Object} pluginsData - The `plugins` object from installed_plugins.json
|
|
22
|
+
* @returns {{ superpowers: boolean, context7: boolean }}
|
|
23
|
+
*/
|
|
24
|
+
export function checkInstalledPlugins(pluginsData) {
|
|
25
|
+
const REQUIRED = ['superpowers', 'context7'];
|
|
26
|
+
return Object.fromEntries(
|
|
27
|
+
REQUIRED.map(name => [name, `${name}@claude-plugins-official` in (pluginsData || {})])
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Check if morph-spec command is in PATH
|
|
34
|
+
*/
|
|
35
|
+
function isMorphSpecInPath() {
|
|
36
|
+
try {
|
|
37
|
+
const command = isWindows ? 'where morph-spec' : 'which morph-spec';
|
|
38
|
+
execSync(command, { stdio: 'ignore' });
|
|
39
|
+
return true;
|
|
40
|
+
} catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Detect if nvm-windows is installed
|
|
47
|
+
*/
|
|
48
|
+
function isUsingNvmWindows() {
|
|
49
|
+
if (!isWindows) return false;
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const path = process.env.PATH || '';
|
|
53
|
+
return path.includes('nvm4w') || path.includes('\\nvm\\');
|
|
54
|
+
} catch {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Get npm global prefix
|
|
61
|
+
*/
|
|
62
|
+
function getNpmGlobalPrefix() {
|
|
63
|
+
try {
|
|
64
|
+
const prefix = execSync('npm config get prefix', { encoding: 'utf8' }).trim();
|
|
65
|
+
return prefix;
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// lib files (v5 — minimal surface)
|
|
72
|
+
const REQUIRED_LIB_FILES = [
|
|
73
|
+
'src/lib/stack-filter.js'
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
// command files (v5 CLI surface — installer-only)
|
|
77
|
+
const REQUIRED_COMMAND_FILES = [
|
|
78
|
+
'src/commands/project/init.js',
|
|
79
|
+
'src/commands/project/update.js',
|
|
80
|
+
'src/commands/project/doctor.js',
|
|
81
|
+
'src/commands/project/status.js',
|
|
82
|
+
'src/commands/project/worktree.js',
|
|
83
|
+
'src/commands/state/approve.js',
|
|
84
|
+
'src/commands/validation/validate-feature.js',
|
|
85
|
+
'src/commands/scaffold/scaffold.js',
|
|
86
|
+
'src/commands/mcp/mcp-setup.js',
|
|
87
|
+
'src/commands/context/context-refresh.js',
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
// framework standards (stack-relevant only)
|
|
91
|
+
const FRAMEWORK_STANDARDS = [
|
|
92
|
+
'framework/standards/architecture/vertical-slice/vertical-slice.md',
|
|
93
|
+
'framework/standards/data/nosql/cosmos-db.md',
|
|
94
|
+
'framework/standards/data/nosql/cache/redis.md',
|
|
95
|
+
'framework/standards/data/vector-search/rag-chunking.md',
|
|
96
|
+
'framework/standards/integration/mcp/mcp-tools.md',
|
|
97
|
+
'framework/standards/workflows/thread-management.md',
|
|
98
|
+
'framework/standards/workflows/parallel-execution.md'
|
|
99
|
+
];
|
|
100
|
+
|
|
101
|
+
// required personas expected in agents.json (v5 — at least the universal evaluator)
|
|
102
|
+
const REQUIRED_AGENTS = [
|
|
103
|
+
'evaluator'
|
|
104
|
+
];
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Run full health checks
|
|
108
|
+
*/
|
|
109
|
+
async function doctorFullCommand(frameworkRoot) {
|
|
110
|
+
console.log(chalk.bold('\n🔬 MORPH-SPEC Full Health Check\n'));
|
|
111
|
+
console.log('─'.repeat(60));
|
|
112
|
+
|
|
113
|
+
const checks = [];
|
|
114
|
+
let hasErrors = false;
|
|
115
|
+
let hasWarnings = false;
|
|
116
|
+
|
|
117
|
+
const check = (name, passed, warnOnly = false, msg = '') => {
|
|
118
|
+
if (passed) {
|
|
119
|
+
checks.push({ name, status: 'ok' });
|
|
120
|
+
} else {
|
|
121
|
+
checks.push({ name, status: warnOnly ? 'warn' : 'missing', msg });
|
|
122
|
+
if (warnOnly) hasWarnings = true;
|
|
123
|
+
else hasErrors = true;
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
// ── 1. Core Libraries ───────────────────────────────────────────────────
|
|
128
|
+
console.log(chalk.cyan(`\n src/lib/ — Core Libraries (${REQUIRED_LIB_FILES.length} files)`));
|
|
129
|
+
const missingLibs = [];
|
|
130
|
+
for (const f of REQUIRED_LIB_FILES) {
|
|
131
|
+
if (!(await pathExists(join(frameworkRoot, f)))) missingLibs.push(f.split('/').pop());
|
|
132
|
+
}
|
|
133
|
+
check(`Core Libraries (${REQUIRED_LIB_FILES.length - missingLibs.length}/${REQUIRED_LIB_FILES.length})`,
|
|
134
|
+
missingLibs.length === 0, false,
|
|
135
|
+
missingLibs.length > 0 ? `missing: ${missingLibs.join(', ')}` : '');
|
|
136
|
+
|
|
137
|
+
// ── 2. Command Files ─────────────────────────────────────────────────────
|
|
138
|
+
console.log(chalk.cyan(`\n src/commands/ — Command Files (${REQUIRED_COMMAND_FILES.length} files)`));
|
|
139
|
+
const missingCmds = [];
|
|
140
|
+
for (const f of REQUIRED_COMMAND_FILES) {
|
|
141
|
+
if (!(await pathExists(join(frameworkRoot, f)))) missingCmds.push(f.split('/').pop());
|
|
142
|
+
}
|
|
143
|
+
check(`Command Files (${REQUIRED_COMMAND_FILES.length - missingCmds.length}/${REQUIRED_COMMAND_FILES.length})`,
|
|
144
|
+
missingCmds.length === 0, false,
|
|
145
|
+
missingCmds.length > 0 ? `missing: ${missingCmds.join(', ')}` : '');
|
|
146
|
+
|
|
147
|
+
// ── 3. Framework Standards ───────────────────────────────────────────────
|
|
148
|
+
console.log(chalk.cyan(`\n framework/standards/ — Framework Standards (${FRAMEWORK_STANDARDS.length} files)`));
|
|
149
|
+
const missingStds = [];
|
|
150
|
+
for (const f of FRAMEWORK_STANDARDS) {
|
|
151
|
+
if (!(await pathExists(join(frameworkRoot, f)))) missingStds.push(f.replace('framework/standards/', ''));
|
|
152
|
+
}
|
|
153
|
+
check(`Framework Standards (${FRAMEWORK_STANDARDS.length - missingStds.length}/${FRAMEWORK_STANDARDS.length})`,
|
|
154
|
+
missingStds.length === 0, false,
|
|
155
|
+
missingStds.length > 0 ? `missing: ${missingStds.join(', ')}` : '');
|
|
156
|
+
|
|
157
|
+
// ── 5. Required Agents in agents.json ───────────────────────────────────
|
|
158
|
+
console.log(chalk.cyan(`\n .morph/framework/agents.json — Required Agents (${REQUIRED_AGENTS.length})`));
|
|
159
|
+
const agentsPath = join(frameworkRoot, '.morph/framework/agents.json');
|
|
160
|
+
if (await pathExists(agentsPath)) {
|
|
161
|
+
try {
|
|
162
|
+
const agentsConfig = JSON.parse(await fs.readFile(agentsPath, 'utf8'));
|
|
163
|
+
const agents = agentsConfig.agents || {};
|
|
164
|
+
const missingAgents = REQUIRED_AGENTS.filter(id => !agents[id]);
|
|
165
|
+
check(`Required Agents (${REQUIRED_AGENTS.length - missingAgents.length}/${REQUIRED_AGENTS.length})`,
|
|
166
|
+
missingAgents.length === 0, false,
|
|
167
|
+
missingAgents.length > 0 ? `missing: ${missingAgents.join(', ')}` : '');
|
|
168
|
+
} catch {
|
|
169
|
+
check('agents.json parse', false, false, 'invalid JSON');
|
|
170
|
+
}
|
|
171
|
+
} else {
|
|
172
|
+
check('agents.json', false, false, 'file not found');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ── 6. agents.json version freshness ──────────────────────────────────
|
|
176
|
+
const canonicalAgentsPath = join(frameworkRoot, 'framework/agents.json');
|
|
177
|
+
if (await pathExists(agentsPath) && await pathExists(canonicalAgentsPath)) {
|
|
178
|
+
try {
|
|
179
|
+
const installed = JSON.parse(await fs.readFile(agentsPath, 'utf8'));
|
|
180
|
+
const canonical = JSON.parse(await fs.readFile(canonicalAgentsPath, 'utf8'));
|
|
181
|
+
const installedVer = installed.version || 'unknown';
|
|
182
|
+
const canonicalVer = canonical.version || 'unknown';
|
|
183
|
+
const isFresh = installedVer === canonicalVer;
|
|
184
|
+
check(`agents.json version (${installedVer})`, isFresh, true,
|
|
185
|
+
isFresh ? '' : `stale — canonical is v${canonicalVer}, run 'morph-spec update' to sync`);
|
|
186
|
+
} catch { /* ignore parse errors — already caught above */ }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ── 7. state.json schema version ────────────────────────────────────────
|
|
190
|
+
console.log(chalk.cyan('\n .morph/state.json — Schema Version'));
|
|
191
|
+
const statePath = join(frameworkRoot, '.morph/state.json');
|
|
192
|
+
if (await pathExists(statePath)) {
|
|
193
|
+
try {
|
|
194
|
+
const state = JSON.parse(await fs.readFile(statePath, 'utf8'));
|
|
195
|
+
const isCurrent = state.version === '5.0.0';
|
|
196
|
+
check(`state.json schema (${state.version})`, isCurrent, true,
|
|
197
|
+
isCurrent ? '' : `expected 5.0.0, found ${state.version} — run any CLI command to auto-migrate`);
|
|
198
|
+
} catch {
|
|
199
|
+
check('state.json parse', false, false, 'invalid JSON');
|
|
200
|
+
}
|
|
201
|
+
} else {
|
|
202
|
+
check('state.json', false, true, 'not found (run: morph-spec init)');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ── Display ──────────────────────────────────────────────────────────────
|
|
206
|
+
console.log('\n' + '─'.repeat(60));
|
|
207
|
+
console.log(chalk.bold('\nResults:\n'));
|
|
208
|
+
for (const c of checks) {
|
|
209
|
+
if (c.status === 'ok') {
|
|
210
|
+
console.log(chalk.green(` ✓ ${c.name}`));
|
|
211
|
+
} else if (c.status === 'warn') {
|
|
212
|
+
console.log(chalk.yellow(` ⚠ ${c.name}`) + chalk.gray(c.msg ? ` (${c.msg})` : ''));
|
|
213
|
+
} else {
|
|
214
|
+
console.log(chalk.red(` ✗ ${c.name}`) + chalk.gray(c.msg ? ` — ${c.msg}` : ''));
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
console.log('');
|
|
219
|
+
const passed = checks.filter(c => c.status === 'ok').length;
|
|
220
|
+
const total = checks.length;
|
|
221
|
+
|
|
222
|
+
if (hasErrors) {
|
|
223
|
+
console.log(chalk.red(`\n❌ ${passed}/${total} checks passed — see errors above\n`));
|
|
224
|
+
process.exit(1);
|
|
225
|
+
} else if (hasWarnings) {
|
|
226
|
+
console.log(chalk.yellow(`\n⚠️ ${passed}/${total} checks passed (warnings only)\n`));
|
|
227
|
+
} else {
|
|
228
|
+
console.log(chalk.green(`\n✅ ${passed}/${total} — All checks passed!\n`));
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* MCP health check: reads .mcp.json, settings.json, and settings.local.json
|
|
234
|
+
* and validates each configured MCP server's command binary and env var requirements.
|
|
235
|
+
*
|
|
236
|
+
* Strategy:
|
|
237
|
+
* - "ok" : command binary exists (or is a known runtime like npx/node) AND
|
|
238
|
+
* all env vars referenced in args/env are set in process.env
|
|
239
|
+
* - "warn" : binary found but env vars missing (MCP won't auth)
|
|
240
|
+
* - "missing": command binary not found in PATH
|
|
241
|
+
*
|
|
242
|
+
* NOTE: This does NOT test actual connectivity — that requires spawning the MCP.
|
|
243
|
+
* It's a static configuration health check that catches 80% of "MCP not working" cases.
|
|
244
|
+
*/
|
|
245
|
+
async function doctorMcpCommand(targetPath) {
|
|
246
|
+
logger.header('MCP Server Health Check');
|
|
247
|
+
logger.blank();
|
|
248
|
+
|
|
249
|
+
const allMcpServers = {};
|
|
250
|
+
const sourcesFound = [];
|
|
251
|
+
|
|
252
|
+
// Primary: .mcp.json (Claude Code reads MCPs from here)
|
|
253
|
+
const mcpJsonPath = join(targetPath, '.mcp.json');
|
|
254
|
+
if (await pathExists(mcpJsonPath)) {
|
|
255
|
+
try {
|
|
256
|
+
const mcpJson = JSON.parse(await fs.readFile(mcpJsonPath, 'utf8'));
|
|
257
|
+
const mcps = mcpJson.mcpServers || {};
|
|
258
|
+
const count = Object.keys(mcps).length;
|
|
259
|
+
if (count > 0) {
|
|
260
|
+
Object.assign(allMcpServers, mcps);
|
|
261
|
+
sourcesFound.push(`.mcp.json (${count})`);
|
|
262
|
+
}
|
|
263
|
+
} catch { /* invalid JSON — ignore */ }
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Legacy: settings files (for backward compat)
|
|
267
|
+
const settingsPaths = [
|
|
268
|
+
join(targetPath, '.claude', 'settings.json'),
|
|
269
|
+
join(targetPath, '.claude', 'settings.local.json'),
|
|
270
|
+
];
|
|
271
|
+
|
|
272
|
+
for (const sp of settingsPaths) {
|
|
273
|
+
if (await pathExists(sp)) {
|
|
274
|
+
try {
|
|
275
|
+
const settings = JSON.parse(await fs.readFile(sp, 'utf8'));
|
|
276
|
+
const mcps = settings.mcpServers || {};
|
|
277
|
+
let addedCount = 0;
|
|
278
|
+
for (const [name, config] of Object.entries(mcps)) {
|
|
279
|
+
if (!allMcpServers[name]) {
|
|
280
|
+
allMcpServers[name] = config;
|
|
281
|
+
addedCount++;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
if (addedCount > 0) {
|
|
285
|
+
sourcesFound.push(`${relative(targetPath, sp)} (${addedCount})`);
|
|
286
|
+
}
|
|
287
|
+
} catch { /* invalid JSON — ignore */ }
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (Object.keys(allMcpServers).length === 0) {
|
|
292
|
+
logger.warn('No MCP servers configured');
|
|
293
|
+
logger.blank();
|
|
294
|
+
logger.dim('Configure MCPs in: .mcp.json → mcpServers: { ... }');
|
|
295
|
+
logger.dim('Or use: morph-spec mcp setup');
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
logger.dim(`Sources: ${sourcesFound.join(', ')}`);
|
|
300
|
+
logger.blank();
|
|
301
|
+
|
|
302
|
+
const KNOWN_RUNTIMES = new Set(['npx', 'node', 'python', 'python3', 'uvx', 'bun', 'deno']);
|
|
303
|
+
const checks = [];
|
|
304
|
+
|
|
305
|
+
for (const [name, config] of Object.entries(allMcpServers)) {
|
|
306
|
+
const issues = [];
|
|
307
|
+
let status = 'ok';
|
|
308
|
+
let isRemote = false;
|
|
309
|
+
|
|
310
|
+
// ── 0. Remote MCP detection ────────────────────────────────────────────
|
|
311
|
+
// Remote MCPs use HTTP transport (type: "http" or url property) — no binary to check
|
|
312
|
+
if (config.type === 'http' || (config.url && !config.command)) {
|
|
313
|
+
isRemote = true;
|
|
314
|
+
// Remote MCPs are always "ok" from a config perspective — auth happens at runtime
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ── 1. Binary check (stdio MCPs only) ──────────────────────────────────
|
|
318
|
+
const cmd = config.command || '';
|
|
319
|
+
if (!isRemote && cmd && !KNOWN_RUNTIMES.has(cmd)) {
|
|
320
|
+
try {
|
|
321
|
+
execSync(isWindows ? `where "${cmd}"` : `which "${cmd}"`, { stdio: 'ignore' });
|
|
322
|
+
} catch {
|
|
323
|
+
status = 'missing';
|
|
324
|
+
issues.push(`command "${cmd}" not found in PATH`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ── 2. Env vars from config.env ────────────────────────────────────────
|
|
329
|
+
for (const [key, val] of Object.entries(config.env || {})) {
|
|
330
|
+
if (typeof val === 'string' && (val.startsWith('${') || (val.startsWith('$') && !val.includes(' ')))) {
|
|
331
|
+
const varName = val.replace(/^\$\{?/, '').replace(/\}$/, '');
|
|
332
|
+
if (!process.env[varName]) {
|
|
333
|
+
if (status === 'ok') status = 'warn';
|
|
334
|
+
issues.push(`env "${key}" → $${varName} not set`);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ── 3. Env refs in args array ──────────────────────────────────────────
|
|
340
|
+
const argsStr = (config.args || []).join(' ');
|
|
341
|
+
const envRefsInArgs = [...argsStr.matchAll(/\$\{?([A-Z_][A-Z0-9_]*)\}?/g)].map(m => m[1]);
|
|
342
|
+
for (const varName of envRefsInArgs) {
|
|
343
|
+
if (!process.env[varName] && !(config.env || {})[varName]) {
|
|
344
|
+
if (status === 'ok') status = 'warn';
|
|
345
|
+
issues.push(`args reference $${varName} which is not set`);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
checks.push({ name, status, issues, cmd, isRemote });
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ── Display ────────────────────────────────────────────────────────────────
|
|
353
|
+
for (const c of checks) {
|
|
354
|
+
const typeLabel = c.isRemote
|
|
355
|
+
? chalk.gray(` [remote: ${allMcpServers[c.name]?.url || 'http'}]`)
|
|
356
|
+
: c.cmd ? chalk.gray(` [${c.cmd}]`) : '';
|
|
357
|
+
|
|
358
|
+
if (c.status === 'ok') {
|
|
359
|
+
console.log(chalk.green(` ✓ ${c.name}`) + typeLabel);
|
|
360
|
+
} else if (c.status === 'warn') {
|
|
361
|
+
console.log(chalk.yellow(` ⚠ ${c.name}`) + typeLabel);
|
|
362
|
+
c.issues.forEach(issue => console.log(chalk.gray(` → ${issue}`)));
|
|
363
|
+
} else {
|
|
364
|
+
console.log(chalk.red(` ✗ ${c.name}`) + typeLabel);
|
|
365
|
+
c.issues.forEach(issue => console.log(chalk.gray(` → ${issue}`)));
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
logger.blank();
|
|
370
|
+
|
|
371
|
+
const ok = checks.filter(c => c.status === 'ok').length;
|
|
372
|
+
const warn = checks.filter(c => c.status === 'warn').length;
|
|
373
|
+
const missing = checks.filter(c => c.status === 'missing').length;
|
|
374
|
+
|
|
375
|
+
if (missing > 0) {
|
|
376
|
+
console.log(chalk.red(` ❌ ${ok}/${checks.length} OK — ${missing} MCP(s) have missing binaries`));
|
|
377
|
+
process.exit(1);
|
|
378
|
+
} else if (warn > 0) {
|
|
379
|
+
console.log(chalk.yellow(` ⚠️ ${ok}/${checks.length} OK — ${warn} MCP(s) have missing env vars (won't authenticate)`));
|
|
380
|
+
} else {
|
|
381
|
+
console.log(chalk.green(` ✅ ${ok}/${checks.length} — All MCPs configured correctly`));
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
logger.blank();
|
|
385
|
+
logger.dim('Note: "configured correctly" = binary exists + env vars set.');
|
|
386
|
+
logger.dim(' Does NOT test actual connectivity or authentication.');
|
|
387
|
+
logger.blank();
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export async function doctorCommand(options = {}) {
|
|
391
|
+
// Reset mode: remove morph-managed settings entries
|
|
392
|
+
if (options.reset) {
|
|
393
|
+
const targetPath = process.cwd();
|
|
394
|
+
logger.header('MORPH-SPEC Settings Reset');
|
|
395
|
+
|
|
396
|
+
const result = await resetMorphSettings(targetPath);
|
|
397
|
+
|
|
398
|
+
if (!result.removed) {
|
|
399
|
+
logger.warn(`Nothing to reset: ${result.reason}`);
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
logger.success('Morph-managed settings removed.');
|
|
404
|
+
if (result.permissionsRemoved?.length > 0) {
|
|
405
|
+
logger.dim(` Permissions removed: ${result.permissionsRemoved.join(', ')}`);
|
|
406
|
+
}
|
|
407
|
+
if (result.envRemoved?.length > 0) {
|
|
408
|
+
logger.dim(` Env vars removed: ${result.envRemoved.join(', ')}`);
|
|
409
|
+
}
|
|
410
|
+
if (result.backupPath) {
|
|
411
|
+
logger.dim(` Backup saved: ${result.backupPath}`);
|
|
412
|
+
}
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// full health check mode
|
|
417
|
+
if (options.full) {
|
|
418
|
+
const frameworkRoot = process.cwd();
|
|
419
|
+
return doctorFullCommand(frameworkRoot);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// MCP health check mode
|
|
423
|
+
if (options.mcp) {
|
|
424
|
+
return doctorMcpCommand(process.cwd());
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const targetPath = process.cwd();
|
|
428
|
+
|
|
429
|
+
logger.header('MORPH-SPEC Health Check');
|
|
430
|
+
|
|
431
|
+
const checks = [];
|
|
432
|
+
let hasErrors = false;
|
|
433
|
+
let hasWarnings = false;
|
|
434
|
+
|
|
435
|
+
const check = (name, passed, warnOnly = false, msg = '') => {
|
|
436
|
+
if (passed) {
|
|
437
|
+
checks.push({ name, status: 'ok' });
|
|
438
|
+
} else {
|
|
439
|
+
checks.push({ name, status: warnOnly ? 'warn' : 'missing', msg });
|
|
440
|
+
if (warnOnly) hasWarnings = true;
|
|
441
|
+
else hasErrors = true;
|
|
442
|
+
}
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
// Check versions first
|
|
446
|
+
const cliCheck = await checkCLIOutdated();
|
|
447
|
+
const projectCheck = await checkProjectOutdated(targetPath);
|
|
448
|
+
|
|
449
|
+
// CLI Version
|
|
450
|
+
if (cliCheck.latest) {
|
|
451
|
+
if (cliCheck.isOutdated) {
|
|
452
|
+
checks.push({
|
|
453
|
+
name: `CLI version (${cliCheck.current})`,
|
|
454
|
+
status: 'warn',
|
|
455
|
+
msg: `outdated, ${cliCheck.latest} available`
|
|
456
|
+
});
|
|
457
|
+
hasWarnings = true;
|
|
458
|
+
} else {
|
|
459
|
+
checks.push({
|
|
460
|
+
name: `CLI version (${cliCheck.current})`,
|
|
461
|
+
status: 'ok',
|
|
462
|
+
msg: 'latest'
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
} else {
|
|
466
|
+
checks.push({
|
|
467
|
+
name: `CLI version (${cliCheck.current})`,
|
|
468
|
+
status: 'ok'
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Project MORPH version
|
|
473
|
+
if (projectCheck.current) {
|
|
474
|
+
if (projectCheck.isOutdated) {
|
|
475
|
+
checks.push({
|
|
476
|
+
name: `Project MORPH version (${projectCheck.current})`,
|
|
477
|
+
status: 'warn',
|
|
478
|
+
msg: `outdated, ${projectCheck.cliVersion} available`
|
|
479
|
+
});
|
|
480
|
+
hasWarnings = true;
|
|
481
|
+
} else {
|
|
482
|
+
checks.push({
|
|
483
|
+
name: `Project MORPH version (${projectCheck.current})`,
|
|
484
|
+
status: 'ok'
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
} else {
|
|
488
|
+
checks.push({
|
|
489
|
+
name: 'Project MORPH version',
|
|
490
|
+
status: 'warn',
|
|
491
|
+
msg: 'not found (legacy installation)'
|
|
492
|
+
});
|
|
493
|
+
hasWarnings = true;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Check if morph-spec is in PATH
|
|
497
|
+
const inPath = isMorphSpecInPath();
|
|
498
|
+
if (inPath) {
|
|
499
|
+
checks.push({
|
|
500
|
+
name: 'morph-spec in PATH',
|
|
501
|
+
status: 'ok'
|
|
502
|
+
});
|
|
503
|
+
} else {
|
|
504
|
+
checks.push({
|
|
505
|
+
name: 'morph-spec in PATH',
|
|
506
|
+
status: 'warn',
|
|
507
|
+
msg: 'command not found'
|
|
508
|
+
});
|
|
509
|
+
hasWarnings = true;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// Check CLAUDE.md has morph-spec @import
|
|
513
|
+
const claudeMd = join(targetPath, 'CLAUDE.md');
|
|
514
|
+
const claudeMdStatus = await checkClaudeMdImport(claudeMd);
|
|
515
|
+
if (claudeMdStatus === 'ok') {
|
|
516
|
+
checks.push({ name: 'CLAUDE.md', status: 'ok' });
|
|
517
|
+
} else if (claudeMdStatus === 'missing-import') {
|
|
518
|
+
checks.push({ name: 'CLAUDE.md', status: 'warn', msg: 'missing @.claude/CLAUDE.md import — run morph-spec update' });
|
|
519
|
+
hasWarnings = true;
|
|
520
|
+
} else {
|
|
521
|
+
checks.push({ name: 'CLAUDE.md', status: 'missing' });
|
|
522
|
+
hasErrors = true;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Check .morph folder
|
|
526
|
+
const morphPath = join(targetPath, '.morph');
|
|
527
|
+
if (await pathExists(morphPath)) {
|
|
528
|
+
checks.push({ name: '.morph/', status: 'ok' });
|
|
529
|
+
} else {
|
|
530
|
+
checks.push({ name: '.morph/', status: 'missing' });
|
|
531
|
+
hasErrors = true;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Check config.json
|
|
535
|
+
const configPath = join(morphPath, 'config', 'config.json');
|
|
536
|
+
if (await pathExists(configPath)) {
|
|
537
|
+
try {
|
|
538
|
+
const config = await readJson(configPath);
|
|
539
|
+
if (config.project?.name) {
|
|
540
|
+
checks.push({ name: 'config.json', status: 'ok' });
|
|
541
|
+
} else {
|
|
542
|
+
checks.push({ name: 'config.json', status: 'warn', msg: 'project.name not set' });
|
|
543
|
+
}
|
|
544
|
+
} catch {
|
|
545
|
+
checks.push({ name: 'config.json', status: 'error', msg: 'invalid JSON' });
|
|
546
|
+
hasErrors = true;
|
|
547
|
+
}
|
|
548
|
+
} else {
|
|
549
|
+
checks.push({ name: 'config.json', status: 'missing' });
|
|
550
|
+
hasErrors = true;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// Check agents.json
|
|
554
|
+
const agentsPath = join(morphPath, 'framework', 'agents.json');
|
|
555
|
+
if (await pathExists(agentsPath)) {
|
|
556
|
+
try {
|
|
557
|
+
const agentsData = JSON.parse(await fs.readFile(agentsPath, 'utf8'));
|
|
558
|
+
const agentEntries = Object.entries(agentsData.agents || {})
|
|
559
|
+
.filter(([k]) => !k.startsWith('_comment'));
|
|
560
|
+
const actualCount = agentEntries.length;
|
|
561
|
+
const declaredCount = agentsData.total_agents;
|
|
562
|
+
|
|
563
|
+
// Check 1: total_agents matches actual count
|
|
564
|
+
if (actualCount !== declaredCount) {
|
|
565
|
+
checks.push({
|
|
566
|
+
name: 'agents.json count',
|
|
567
|
+
status: 'warn',
|
|
568
|
+
msg: `total_agents declares ${declaredCount} but found ${actualCount} agents`
|
|
569
|
+
});
|
|
570
|
+
hasWarnings = true;
|
|
571
|
+
} else {
|
|
572
|
+
checks.push({ name: 'agents.json count', status: 'ok', msg: `${actualCount} agents` });
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// Check 2: required v5 personas present
|
|
576
|
+
const requiredPersonas = REQUIRED_AGENTS;
|
|
577
|
+
const missingPersonas = requiredPersonas.filter(id => !agentsData.agents[id]);
|
|
578
|
+
if (missingPersonas.length > 0) {
|
|
579
|
+
checks.push({
|
|
580
|
+
name: 'agents.json required personas',
|
|
581
|
+
status: 'error',
|
|
582
|
+
msg: `Missing required personas: ${missingPersonas.join(', ')}`
|
|
583
|
+
});
|
|
584
|
+
hasErrors = true;
|
|
585
|
+
} else {
|
|
586
|
+
checks.push({ name: 'agents.json required personas', status: 'ok' });
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// Check 3: v5 schema validation (flat personas, no tiers)
|
|
590
|
+
const version = agentsData.version || 'unknown';
|
|
591
|
+
const isV5 = version.startsWith('5.');
|
|
592
|
+
const v4Fields = [];
|
|
593
|
+
const missingV5Fields = [];
|
|
594
|
+
|
|
595
|
+
for (const [id, data] of agentEntries) {
|
|
596
|
+
if (data.tier !== undefined) v4Fields.push(`${id}.tier`);
|
|
597
|
+
if (data.active_phases !== undefined) v4Fields.push(`${id}.active_phases`);
|
|
598
|
+
if (data.reports_to !== undefined) v4Fields.push(`${id}.reports_to`);
|
|
599
|
+
if (data.teammate !== undefined) v4Fields.push(`${id}.teammate`);
|
|
600
|
+
if (!data.title) missingV5Fields.push(`${id}.title`);
|
|
601
|
+
if (!Array.isArray(data.domains)) missingV5Fields.push(`${id}.domains`);
|
|
602
|
+
if (typeof data.persona !== 'string' || !data.persona) missingV5Fields.push(`${id}.persona`);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
if (isV5) {
|
|
606
|
+
checks.push({ name: `agents.json schema (v${version})`, status: 'ok' });
|
|
607
|
+
} else {
|
|
608
|
+
checks.push({
|
|
609
|
+
name: `agents.json schema (v${version})`,
|
|
610
|
+
status: 'warn',
|
|
611
|
+
msg: 'expected v5.x — run agents migration'
|
|
612
|
+
});
|
|
613
|
+
hasWarnings = true;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
if (v4Fields.length > 0) {
|
|
617
|
+
const sample = v4Fields.slice(0, 3).join(', ');
|
|
618
|
+
checks.push({
|
|
619
|
+
name: 'agents.json deprecated v4 fields',
|
|
620
|
+
status: 'warn',
|
|
621
|
+
msg: `${v4Fields.length} v4 field(s) found: ${sample}${v4Fields.length > 3 ? '...' : ''}`
|
|
622
|
+
});
|
|
623
|
+
hasWarnings = true;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
if (missingV5Fields.length > 0 && isV5) {
|
|
627
|
+
const sample = missingV5Fields.slice(0, 3).join(', ');
|
|
628
|
+
checks.push({
|
|
629
|
+
name: 'agents.json v5 required fields',
|
|
630
|
+
status: 'warn',
|
|
631
|
+
msg: `${missingV5Fields.length} missing: ${sample}${missingV5Fields.length > 3 ? '...' : ''}`
|
|
632
|
+
});
|
|
633
|
+
hasWarnings = true;
|
|
634
|
+
}
|
|
635
|
+
} catch {
|
|
636
|
+
checks.push({ name: 'agents.json', status: 'error', msg: 'invalid JSON' });
|
|
637
|
+
hasErrors = true;
|
|
638
|
+
}
|
|
639
|
+
} else {
|
|
640
|
+
checks.push({ name: 'agents.json', status: 'missing' });
|
|
641
|
+
hasErrors = true;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// Check standards (either in project .morph/framework/standards/ or in framework package)
|
|
645
|
+
const standardsPath = join(morphPath, 'framework', 'standards');
|
|
646
|
+
const frameworkStdsRoot = join(import.meta.dirname, '..', '..', '..', 'framework', 'standards');
|
|
647
|
+
if (await pathExists(standardsPath)) {
|
|
648
|
+
// Check for VSA standard (core to the framework)
|
|
649
|
+
const vsaStd = await pathExists(join(standardsPath, 'architecture', 'vertical-slice', 'vertical-slice.md')) ||
|
|
650
|
+
await pathExists(join(standardsPath, 'vertical-slice.md'));
|
|
651
|
+
if (vsaStd) {
|
|
652
|
+
checks.push({ name: 'standards/', status: 'ok' });
|
|
653
|
+
} else {
|
|
654
|
+
checks.push({ name: 'standards/', status: 'warn', msg: 'VSA standard missing — run morph-spec init' });
|
|
655
|
+
hasWarnings = true;
|
|
656
|
+
}
|
|
657
|
+
} else if (await pathExists(frameworkStdsRoot)) {
|
|
658
|
+
// Framework package has standards (dev/global install mode)
|
|
659
|
+
checks.push({ name: 'standards/', status: 'warn', msg: 'not installed in project (run morph-spec init)' });
|
|
660
|
+
hasWarnings = true;
|
|
661
|
+
} else {
|
|
662
|
+
checks.push({ name: 'standards/', status: 'missing' });
|
|
663
|
+
hasErrors = true;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// Check templates
|
|
667
|
+
const templatesPath = join(morphPath, 'framework', 'templates');
|
|
668
|
+
if (await pathExists(templatesPath)) {
|
|
669
|
+
checks.push({ name: 'templates/', status: 'ok' });
|
|
670
|
+
} else {
|
|
671
|
+
checks.push({ name: 'templates/', status: 'missing' });
|
|
672
|
+
hasErrors = true;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// Check .claude folder
|
|
676
|
+
const claudePath = join(targetPath, '.claude');
|
|
677
|
+
if (await pathExists(claudePath)) {
|
|
678
|
+
const commandsPath = join(claudePath, 'commands');
|
|
679
|
+
const skillsPath = join(claudePath, 'skills');
|
|
680
|
+
|
|
681
|
+
const hasCommands = await pathExists(commandsPath);
|
|
682
|
+
const hasSkills = await pathExists(skillsPath);
|
|
683
|
+
|
|
684
|
+
if (hasCommands && hasSkills) {
|
|
685
|
+
checks.push({ name: '.claude/', status: 'ok' });
|
|
686
|
+
|
|
687
|
+
// Check skills link status
|
|
688
|
+
const skillEntries = await fs.readdir(skillsPath, { withFileTypes: true });
|
|
689
|
+
const skillDirs = skillEntries.filter(e => e.isDirectory() || e.isSymbolicLink());
|
|
690
|
+
let linkedCount = 0;
|
|
691
|
+
|
|
692
|
+
for (const dir of skillDirs) {
|
|
693
|
+
try {
|
|
694
|
+
const stat = await fs.lstat(join(skillsPath, dir.name));
|
|
695
|
+
if (stat.isSymbolicLink()) linkedCount++;
|
|
696
|
+
} catch { /* ignore */ }
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
if (skillDirs.length > 0) {
|
|
700
|
+
if (linkedCount === skillDirs.length) {
|
|
701
|
+
checks.push({ name: '.claude/skills/ links', status: 'ok', msg: `${linkedCount} categories linked` });
|
|
702
|
+
} else if (linkedCount > 0) {
|
|
703
|
+
checks.push({ name: '.claude/skills/ links', status: 'warn', msg: `${linkedCount}/${skillDirs.length} linked, rest copied` });
|
|
704
|
+
hasWarnings = true;
|
|
705
|
+
} else {
|
|
706
|
+
checks.push({ name: '.claude/skills/ links', status: 'warn', msg: 'all categories copied (no auto-update)' });
|
|
707
|
+
hasWarnings = true;
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// Check .claude/rules/
|
|
712
|
+
const rulesPath = join(claudePath, 'rules');
|
|
713
|
+
if (await pathExists(rulesPath)) {
|
|
714
|
+
const ruleFiles = await fs.readdir(rulesPath);
|
|
715
|
+
const mdRules = ruleFiles.filter(f => f.endsWith('.md'));
|
|
716
|
+
checks.push({ name: '.claude/rules/', status: 'ok', msg: `${mdRules.length} rules` });
|
|
717
|
+
} else {
|
|
718
|
+
checks.push({ name: '.claude/rules/', status: 'warn', msg: 'missing — run morph-spec update' });
|
|
719
|
+
hasWarnings = true;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// Check .claude/agents/
|
|
723
|
+
const agentsPathClaude = join(claudePath, 'agents');
|
|
724
|
+
if (await pathExists(agentsPathClaude)) {
|
|
725
|
+
const agentFiles = await fs.readdir(agentsPathClaude);
|
|
726
|
+
const morphAgents = agentFiles.filter(f => f.startsWith('morph-') && f.endsWith('.md'));
|
|
727
|
+
checks.push({ name: '.claude/agents/', status: 'ok', msg: `${morphAgents.length} agents` });
|
|
728
|
+
|
|
729
|
+
// Check for missing agents based on stack tags
|
|
730
|
+
try {
|
|
731
|
+
const config = await readJson(configPath);
|
|
732
|
+
const projectTags = getProjectTags(config);
|
|
733
|
+
if (projectTags.length > 0) {
|
|
734
|
+
const frameworkDir = join(import.meta.dirname, '..', '..', '..', 'framework');
|
|
735
|
+
const { missing } = checkMissingAgents(targetPath, frameworkDir, projectTags);
|
|
736
|
+
if (missing.length > 0) {
|
|
737
|
+
const names = missing.map(m => m.name).join(', ');
|
|
738
|
+
checks.push({
|
|
739
|
+
name: 'agents completeness',
|
|
740
|
+
status: 'warn',
|
|
741
|
+
msg: `${missing.length} agent(s) missing for [${projectTags.join(', ')}]: ${names} — run morph-spec setup-infra`
|
|
742
|
+
});
|
|
743
|
+
hasWarnings = true;
|
|
744
|
+
} else {
|
|
745
|
+
checks.push({ name: 'agents completeness', status: 'ok', msg: `all agents for [${projectTags.join(', ')}]` });
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
} catch { /* config not readable — skip completeness check */ }
|
|
749
|
+
} else {
|
|
750
|
+
checks.push({ name: '.claude/agents/', status: 'warn', msg: 'missing — run morph-spec update' });
|
|
751
|
+
hasWarnings = true;
|
|
752
|
+
}
|
|
753
|
+
// ── Hook health: verify hook scripts exist ───────────────────────────────
|
|
754
|
+
const settingsLocalPath = join(claudePath, 'settings.local.json');
|
|
755
|
+
if (await pathExists(settingsLocalPath)) {
|
|
756
|
+
try {
|
|
757
|
+
const settings = JSON.parse(await fs.readFile(settingsLocalPath, 'utf8'));
|
|
758
|
+
const hookCommands = [];
|
|
759
|
+
for (const eventHooks of Object.values(settings.hooks || {})) {
|
|
760
|
+
for (const group of (Array.isArray(eventHooks) ? eventHooks : [])) {
|
|
761
|
+
for (const h of (group.hooks || [])) {
|
|
762
|
+
if (h.type === 'command' && h.command) hookCommands.push(h.command);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
const hookPathRe = /\$CLAUDE_PROJECT_DIR\/([^\s"]+\.js)/;
|
|
767
|
+
const missingHooks = [];
|
|
768
|
+
for (const cmd of hookCommands) {
|
|
769
|
+
const m = cmd.match(hookPathRe);
|
|
770
|
+
if (m && !(await pathExists(join(targetPath, m[1])))) {
|
|
771
|
+
missingHooks.push(m[1].split('/').pop());
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
if (missingHooks.length === 0) {
|
|
775
|
+
checks.push({ name: 'hook scripts', status: 'ok', msg: `${hookCommands.length} hooks configured` });
|
|
776
|
+
} else {
|
|
777
|
+
checks.push({ name: 'hook scripts', status: 'warn', msg: `missing: ${missingHooks.join(', ')} — run morph-spec update` });
|
|
778
|
+
hasWarnings = true;
|
|
779
|
+
}
|
|
780
|
+
} catch {
|
|
781
|
+
checks.push({ name: 'hook scripts', status: 'warn', msg: 'could not parse settings.local.json' });
|
|
782
|
+
hasWarnings = true;
|
|
783
|
+
}
|
|
784
|
+
} else {
|
|
785
|
+
checks.push({ name: 'hook scripts', status: 'warn', msg: 'settings.local.json missing — run morph-spec init' });
|
|
786
|
+
hasWarnings = true;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// ── Skills cliVersion: warn if installed skills reference older CLI ──────
|
|
790
|
+
try {
|
|
791
|
+
const cliVersion = getInstalledCLIVersion();
|
|
792
|
+
if (cliVersion) {
|
|
793
|
+
const skillDirsForVersion = await fs.readdir(skillsPath);
|
|
794
|
+
const staleSkills = [];
|
|
795
|
+
for (const skillDir of skillDirsForVersion) {
|
|
796
|
+
const skillMdPath = join(skillsPath, skillDir, 'SKILL.md');
|
|
797
|
+
if (!(await pathExists(skillMdPath))) continue;
|
|
798
|
+
const content = await fs.readFile(skillMdPath, 'utf8');
|
|
799
|
+
const m = content.match(/^cliVersion:\s*"?([^"\n]+)"?/m);
|
|
800
|
+
if (m && m[1].trim() !== cliVersion) {
|
|
801
|
+
staleSkills.push(skillDir);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
if (staleSkills.length === 0) {
|
|
805
|
+
checks.push({ name: 'skill cliVersion', status: 'ok' });
|
|
806
|
+
} else {
|
|
807
|
+
checks.push({ name: 'skill cliVersion', status: 'warn', msg: `${staleSkills.length} skill(s) behind CLI v${cliVersion} — run morph-spec update` });
|
|
808
|
+
hasWarnings = true;
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
} catch { /* non-fatal */ }
|
|
812
|
+
|
|
813
|
+
// ── Claude Code Plugins ──────────────────────────────────────────────────
|
|
814
|
+
console.log(chalk.cyan('\n Claude Code Plugins (required for /morph-init)'));
|
|
815
|
+
const pluginsFile = join(homedir(), '.claude', 'plugins', 'installed_plugins.json');
|
|
816
|
+
let pluginData = { plugins: {} };
|
|
817
|
+
try {
|
|
818
|
+
pluginData = JSON.parse(await fs.readFile(pluginsFile, 'utf8'));
|
|
819
|
+
} catch { /* file may not exist */ }
|
|
820
|
+
const pluginStatus = checkInstalledPlugins(pluginData.plugins || {});
|
|
821
|
+
check('superpowers plugin', pluginStatus.superpowers, false,
|
|
822
|
+
'Run: morph-spec install-plugin superpowers');
|
|
823
|
+
check('context7 plugin', pluginStatus.context7, false,
|
|
824
|
+
'Run: morph-spec install-plugin context7');
|
|
825
|
+
} else {
|
|
826
|
+
checks.push({ name: '.claude/', status: 'warn', msg: 'incomplete structure' });
|
|
827
|
+
}
|
|
828
|
+
} else {
|
|
829
|
+
checks.push({ name: '.claude/', status: 'missing' });
|
|
830
|
+
hasErrors = true;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// Check state.json
|
|
834
|
+
const statePath = join(targetPath, '.morph', 'state.json');
|
|
835
|
+
if (await pathExists(statePath)) {
|
|
836
|
+
try {
|
|
837
|
+
const stateContent = await readJson(statePath);
|
|
838
|
+
if (stateContent.version && stateContent.features) {
|
|
839
|
+
const featureCount = Object.keys(stateContent.features).length;
|
|
840
|
+
checks.push({ name: 'state.json', status: 'ok', msg: `${featureCount} feature(s)` });
|
|
841
|
+
} else {
|
|
842
|
+
checks.push({ name: 'state.json', status: 'warn', msg: 'invalid schema' });
|
|
843
|
+
hasWarnings = true;
|
|
844
|
+
}
|
|
845
|
+
} catch {
|
|
846
|
+
checks.push({ name: 'state.json', status: 'error', msg: 'invalid JSON' });
|
|
847
|
+
hasErrors = true;
|
|
848
|
+
}
|
|
849
|
+
} else {
|
|
850
|
+
checks.push({ name: 'state.json', status: 'warn', msg: 'not initialized (run: morph-spec init)' });
|
|
851
|
+
hasWarnings = true;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// Check key templates exist (in framework package, not project copy)
|
|
855
|
+
const frameworkTemplatesRoot = join(import.meta.dirname, '..', '..', '..', 'framework', 'templates');
|
|
856
|
+
const keyTemplates = [
|
|
857
|
+
{ name: 'proposal.md', path: join(frameworkTemplatesRoot, 'docs', 'proposal.md') },
|
|
858
|
+
{ name: 'spec.md', path: join(frameworkTemplatesRoot, 'docs', 'spec.md') },
|
|
859
|
+
{ name: 'tasks.template.json', path: join(frameworkTemplatesRoot, 'tasks.template.json') },
|
|
860
|
+
{ name: 'decisions.md', path: join(frameworkTemplatesRoot, 'feature', 'decisions.md') }
|
|
861
|
+
];
|
|
862
|
+
const missingTemplates = [];
|
|
863
|
+
for (const template of keyTemplates) {
|
|
864
|
+
if (!(await pathExists(template.path))) {
|
|
865
|
+
missingTemplates.push(template.name);
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
if (missingTemplates.length === 0) {
|
|
870
|
+
checks.push({ name: 'key templates', status: 'ok' });
|
|
871
|
+
} else if (missingTemplates.length < keyTemplates.length) {
|
|
872
|
+
checks.push({ name: 'key templates', status: 'warn', msg: `missing: ${missingTemplates.join(', ')}` });
|
|
873
|
+
hasWarnings = true;
|
|
874
|
+
} else {
|
|
875
|
+
checks.push({ name: 'key templates', status: 'missing' });
|
|
876
|
+
hasErrors = true;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
// Check Node.js version
|
|
880
|
+
const nodeVersion = process.version;
|
|
881
|
+
const majorVersion = parseInt(nodeVersion.slice(1).split('.')[0], 10);
|
|
882
|
+
if (majorVersion >= 18) {
|
|
883
|
+
checks.push({ name: `Node.js ${nodeVersion}`, status: 'ok' });
|
|
884
|
+
} else {
|
|
885
|
+
checks.push({ name: `Node.js ${nodeVersion}`, status: 'warn', msg: 'v18+ recommended' });
|
|
886
|
+
hasWarnings = true;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
// Display results
|
|
890
|
+
for (const check of checks) {
|
|
891
|
+
if (check.status === 'ok') {
|
|
892
|
+
console.log(chalk.green(` ✓ ${check.name}`));
|
|
893
|
+
} else if (check.status === 'warn') {
|
|
894
|
+
console.log(chalk.yellow(` ⚠ ${check.name}`) + chalk.gray(` (${check.msg})`));
|
|
895
|
+
} else if (check.status === 'missing') {
|
|
896
|
+
console.log(chalk.red(` ✗ ${check.name}`) + chalk.gray(' (missing)'));
|
|
897
|
+
} else if (check.status === 'error') {
|
|
898
|
+
console.log(chalk.red(` ✗ ${check.name}`) + chalk.gray(` (${check.msg})`));
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
logger.blank();
|
|
903
|
+
|
|
904
|
+
if (hasErrors) {
|
|
905
|
+
logger.error('Some checks failed. Run "morph-spec setup-infra" to fix.');
|
|
906
|
+
process.exit(1);
|
|
907
|
+
} else if (hasWarnings) {
|
|
908
|
+
logger.warn('Some checks need attention.');
|
|
909
|
+
logger.blank();
|
|
910
|
+
|
|
911
|
+
if (!inPath) {
|
|
912
|
+
const usingNvm = isUsingNvmWindows();
|
|
913
|
+
const prefix = getNpmGlobalPrefix();
|
|
914
|
+
|
|
915
|
+
if (usingNvm && prefix) {
|
|
916
|
+
logger.info('PATH Issue Detected (nvm-windows):');
|
|
917
|
+
logger.dim(` Add to PATH: ${prefix}`);
|
|
918
|
+
logger.blank();
|
|
919
|
+
logger.dim(' PowerShell (as Administrator):');
|
|
920
|
+
console.log(chalk.gray(` [Environment]::SetEnvironmentVariable("Path", [Environment]::GetEnvironmentVariable("Path", "User") + ";${prefix}", "User")`));
|
|
921
|
+
logger.blank();
|
|
922
|
+
logger.dim(' Then open a new terminal.');
|
|
923
|
+
logger.blank();
|
|
924
|
+
} else if (!inPath) {
|
|
925
|
+
logger.info('morph-spec not in PATH:');
|
|
926
|
+
logger.dim(' Open a new terminal — the installer adds npm global to $PROFILE automatically.');
|
|
927
|
+
logger.dim(' If it persists, add manually: npm config get prefix → add to PATH');
|
|
928
|
+
logger.blank();
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
if (cliCheck.isOutdated || projectCheck.isOutdated) {
|
|
933
|
+
logger.info('To update:');
|
|
934
|
+
logger.dim(' 1. Update the CLI: npm install -g @polymorphism-tech/morph-spec@latest');
|
|
935
|
+
logger.dim(' 2. Update the project: morph-spec update');
|
|
936
|
+
logger.blank();
|
|
937
|
+
}
|
|
938
|
+
} else {
|
|
939
|
+
logger.success('All checks passed!');
|
|
940
|
+
}
|
|
941
|
+
}
|