@polymorphism-tech/morph-spec 4.10.5 → 4.10.7

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 (54) hide show
  1. package/CLAUDE.md +3 -0
  2. package/README.md +3 -3
  3. package/bin/morph-spec.js +90 -1
  4. package/bin/task-manager.js +59 -6
  5. package/claude-plugin.json +1 -1
  6. package/docs/CHEATSHEET.md +1 -1
  7. package/docs/QUICKSTART.md +1 -1
  8. package/framework/agents/backend/hangfire-orchestrator.md +1 -1
  9. package/framework/agents/backend/ms-agent-expert.md +2 -1
  10. package/framework/agents/integrations/asaas-financial.md +1 -1
  11. package/framework/agents/integrations/clerk-auth.md +1 -2
  12. package/framework/agents/integrations/hangfire-integration.md +1 -1
  13. package/framework/agents/integrations/resend-email.md +1 -1
  14. package/framework/agents.json +345 -708
  15. package/framework/hooks/claude-code/post-tool-use/dispatch.js +10 -5
  16. package/framework/skills/README.md +0 -1
  17. package/framework/skills/level-1-workflows/morph-phase-clarify/SKILL.md +1 -1
  18. package/framework/skills/level-1-workflows/morph-phase-codebase-analysis/SKILL.md +1 -1
  19. package/framework/skills/level-1-workflows/morph-phase-design/SKILL.md +1 -1
  20. package/framework/skills/level-1-workflows/morph-phase-implement/SKILL.md +42 -13
  21. package/framework/skills/level-1-workflows/morph-phase-plan/SKILL.md +1 -1
  22. package/framework/skills/level-1-workflows/morph-phase-setup/SKILL.md +16 -12
  23. package/framework/skills/level-1-workflows/morph-phase-tasks/SKILL.md +1 -1
  24. package/framework/skills/level-1-workflows/morph-phase-uiux/SKILL.md +1 -1
  25. package/framework/skills/level-1-workflows/morph-scope-escalation/SKILL.md +1 -1
  26. package/package.json +1 -1
  27. package/src/commands/agents/agent-metrics.js +174 -0
  28. package/src/commands/agents/audit-dispatch.js +82 -0
  29. package/src/commands/agents/detect-agents.js +275 -0
  30. package/src/commands/agents/dispatch-agents.js +41 -135
  31. package/src/commands/agents/index.js +9 -0
  32. package/src/commands/agents/manage-agents.js +352 -0
  33. package/src/commands/project/doctor.js +61 -2
  34. package/src/commands/project/init.js +10 -2
  35. package/src/commands/project/update.js +12 -25
  36. package/src/commands/state/advance-phase.js +11 -3
  37. package/src/core/state/state-manager.js +81 -0
  38. package/src/lib/agents/agents-manager.js +349 -0
  39. package/src/lib/agents/metrics-calculator.js +125 -0
  40. package/src/lib/agents/output-collector.js +75 -0
  41. package/src/lib/generators/context-generator.js +8 -7
  42. package/src/lib/tasks/task-parser.js +94 -3
  43. package/src/lib/validators/checkpoint-persistence.js +39 -0
  44. package/src/lib/validators/content/content-validator.js +9 -1
  45. package/src/lib/validators/spec-validator.js +2 -2
  46. package/src/lib/validators/validation-runner.js +20 -0
  47. package/src/scripts/setup-infra.js +1 -17
  48. package/src/utils/agent-id-mapper.js +11 -0
  49. package/src/utils/agents-installer.js +40 -8
  50. package/src/utils/claude-settings-manager.js +1 -1
  51. package/src/utils/hooks-installer.js +0 -245
  52. package/src/utils/tier-helpers.js +24 -0
  53. package/framework/agents/architecture/po-pm-advisor.md +0 -203
  54. package/framework/hooks/claude-code/user-prompt/set-terminal-title.js +0 -68
@@ -0,0 +1,352 @@
1
+ /**
2
+ * manage-agents.js — CLI command handlers for agent management
3
+ *
4
+ * Thin wrappers around agents-manager.js core functions.
5
+ * Each handler: parses args -> calls core -> formats output via logger.
6
+ *
7
+ * @module manage-agents
8
+ */
9
+
10
+ import { join, dirname } from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+ import { createInterface } from 'node:readline';
13
+ import { existsSync, readFileSync } from 'node:fs';
14
+ import chalk from 'chalk';
15
+ import { logger } from '../../utils/logger.js';
16
+ import {
17
+ loadAgentsFile,
18
+ saveAgentsFile,
19
+ listAgents,
20
+ showAgent,
21
+ addAgent,
22
+ editAgent,
23
+ removeAgent,
24
+ TIER_LABELS,
25
+ KNOWN_PHASES,
26
+ } from '../../lib/agents/agents-manager.js';
27
+
28
+ const __dirname = dirname(fileURLToPath(import.meta.url));
29
+ const AGENTS_PATH = join(__dirname, '../../../framework/agents.json');
30
+
31
+ /**
32
+ * Resolve the agents.json path.
33
+ * Uses the canonical framework/agents.json (v4 schema).
34
+ * If running inside a client project (not the framework repo), falls back
35
+ * to .morph/framework/agents.json.
36
+ */
37
+ function getAgentsPath() {
38
+ // Prefer the framework source file (always v4 schema)
39
+ if (existsSync(AGENTS_PATH)) return AGENTS_PATH;
40
+ // Fallback for client projects
41
+ const clientPath = join(process.cwd(), '.morph', 'framework', 'agents.json');
42
+ if (existsSync(clientPath)) return clientPath;
43
+ return AGENTS_PATH;
44
+ }
45
+
46
+ // ── List ────────────────────────────────────────────────────────────────────────
47
+
48
+ export async function agentListCommand(options) {
49
+ const agentsData = loadAgentsFile(getAgentsPath());
50
+ const filters = {};
51
+ if (options.group) filters.group = options.group;
52
+ if (options.tier) filters.tier = parseInt(options.tier, 10);
53
+
54
+ const result = listAgents(agentsData, filters);
55
+
56
+ if (options.json) {
57
+ console.log(JSON.stringify(result.agents, null, 2));
58
+ return;
59
+ }
60
+
61
+ if (result.total === 0) {
62
+ logger.info('No agents found matching filters.');
63
+ return;
64
+ }
65
+
66
+ logger.header(`Agents (${result.total} total)`);
67
+
68
+ // Table header
69
+ const header = ` ${'ID'.padEnd(28)} ${'Tier'.padEnd(6)} ${'Group'.padEnd(18)} ${'Phases'.padEnd(16)} Keywords`;
70
+ console.log(chalk.dim(header));
71
+ console.log(chalk.dim(' ' + '\u2500'.repeat(90)));
72
+
73
+ for (const a of result.agents) {
74
+ const tierLabel = `T${a.tier}`;
75
+ const group = (a.group || '\u2014').padEnd(18);
76
+ const phases = (a.phases || '*').padEnd(16);
77
+ console.log(` ${a.id.padEnd(28)} ${tierLabel.padEnd(6)} ${group} ${phases} ${a.keywords || '\u2014'}`);
78
+ }
79
+
80
+ logger.blank();
81
+ const groups = Object.entries(result.groupCounts).map(([g, c]) => `${g}(${c})`).join(' ');
82
+ logger.dim(`Groups: ${groups}`);
83
+ }
84
+
85
+ // ── Show ────────────────────────────────────────────────────────────────────────
86
+
87
+ export async function agentShowCommand(id, options) {
88
+ const agentsData = loadAgentsFile(getAgentsPath());
89
+ const agent = showAgent(agentsData, id);
90
+
91
+ if (!agent) {
92
+ const ids = Object.keys(agentsData.agents || {});
93
+ const similar = ids.filter(aid => levenshtein(aid, id) <= 3);
94
+ logger.error(`Agent '${id}' not found.`);
95
+ if (similar.length > 0) {
96
+ logger.dim(`Did you mean: ${similar.join(', ')}?`);
97
+ }
98
+ process.exitCode = 1;
99
+ return;
100
+ }
101
+
102
+ if (options.json) {
103
+ console.log(JSON.stringify({ [id]: agent }, null, 2));
104
+ return;
105
+ }
106
+
107
+ logger.header(`Agent: ${id}`);
108
+ logger.info(`Title: ${agent.title}`);
109
+ logger.info(`Tier: ${agent.tier} (${TIER_LABELS[agent.tier] || '?'})`);
110
+ logger.info(`Group: ${agent.group}`);
111
+ logger.info(`Phases: ${(agent.active_phases || []).join(', ')}`);
112
+ logger.info(`Reports to: ${agent.reports_to}`);
113
+ logger.info(`Domains: ${(agent.domains || []).join(', ') || '\u2014'}`);
114
+ logger.info(`Keywords: ${(agent.keywords || []).join(', ') || '\u2014'}`);
115
+ logger.info(`Validators: ${(agent.validators || []).join(', ') || '\u2014'}`);
116
+
117
+ if (agent.stacks?.length > 0) {
118
+ logger.info(`Stacks: ${agent.stacks.join(', ')}`);
119
+ }
120
+
121
+ if (agent.standards?.length > 0) {
122
+ logger.blank();
123
+ logger.info('Standards:');
124
+ for (const s of agent.standards) {
125
+ logger.dim(` ${s.id} (scope: ${s.scope}, priority: ${s.priority})`);
126
+ }
127
+ }
128
+
129
+ if (agent.teammate) {
130
+ logger.blank();
131
+ logger.info('Teammate:');
132
+ logger.dim(` Role: ${agent.teammate.role}`);
133
+ logger.dim(` Icon: ${agent.teammate.icon}`);
134
+ const prompt = agent.teammate.spawn_prompt || '';
135
+ const truncated = options.full ? prompt : (prompt.length > 200 ? prompt.slice(0, 200) + '...' : prompt);
136
+ logger.dim(` Prompt: ${truncated}`);
137
+ } else {
138
+ logger.blank();
139
+ logger.dim('No teammate configured.');
140
+ }
141
+ }
142
+
143
+ // ── Add ─────────────────────────────────────────────────────────────────────────
144
+
145
+ export async function agentAddCommand(id, options) {
146
+ const agentsData = loadAgentsFile(getAgentsPath());
147
+
148
+ let fields;
149
+ if (options.nonInteractive) {
150
+ fields = parseNonInteractiveFlags(options);
151
+ } else {
152
+ fields = await runAddWizard(id, agentsData);
153
+ }
154
+
155
+ const result = addAgent(agentsData, id, fields);
156
+
157
+ if (!result.success) {
158
+ for (const e of result.errors) logger.error(e);
159
+ process.exitCode = 1;
160
+ return;
161
+ }
162
+
163
+ if (result.warnings?.length > 0) {
164
+ for (const w of result.warnings) logger.warn(w);
165
+ }
166
+
167
+ saveAgentsFile(getAgentsPath(), result.data);
168
+ logger.success(`Created agent '${id}' in agents.json`);
169
+ logger.dim(`Run 'morph-spec setup-infra' to install the .claude/agents/ file`);
170
+ }
171
+
172
+ // ── Edit ────────────────────────────────────────────────────────────────────────
173
+
174
+ export async function agentEditCommand(id, options) {
175
+ const agentsData = loadAgentsFile(getAgentsPath());
176
+ const changes = {};
177
+
178
+ if (options.tier) changes.tier = parseInt(options.tier, 10);
179
+ if (options.title) changes.title = options.title;
180
+ if (options.group) changes.group = options.group;
181
+ if (options.activePhases) changes.active_phases = options.activePhases.split(',').map(s => s.trim());
182
+ if (options.reportsTo) changes.reports_to = options.reportsTo;
183
+ if (options.keywords) changes.keywords = options.keywords.split(',').map(s => s.trim());
184
+ if (options.domains) changes.domains = options.domains.split(',').map(s => s.trim());
185
+ if (options.spawnPrompt) changes.spawn_prompt = options.spawnPrompt;
186
+
187
+ if (Object.keys(changes).length === 0) {
188
+ logger.error('No changes specified. Use flags like --title, --tier, --keywords, etc.');
189
+ process.exitCode = 1;
190
+ return;
191
+ }
192
+
193
+ const result = editAgent(agentsData, id, changes);
194
+
195
+ if (!result.success) {
196
+ for (const e of result.errors) logger.error(e);
197
+ process.exitCode = 1;
198
+ return;
199
+ }
200
+
201
+ saveAgentsFile(getAgentsPath(), result.data);
202
+ logger.success(`Updated agent '${id}'`);
203
+ for (const c of result.changes) logger.dim(` ${c}`);
204
+ }
205
+
206
+ // ── Remove ──────────────────────────────────────────────────────────────────────
207
+
208
+ export async function agentRemoveCommand(id, options) {
209
+ const agentsData = loadAgentsFile(getAgentsPath());
210
+ const agent = showAgent(agentsData, id);
211
+
212
+ if (!agent) {
213
+ logger.error(`Agent '${id}' not found.`);
214
+ process.exitCode = 1;
215
+ return;
216
+ }
217
+
218
+ // Show summary
219
+ logger.info(`Agent: ${id} (T${agent.tier} ${agent.group})`);
220
+
221
+ // Check for dependents
222
+ const dependents = Object.entries(agentsData.agents)
223
+ .filter(([aid, a]) => aid !== id && a.reports_to === id)
224
+ .map(([aid]) => aid);
225
+
226
+ if (dependents.length > 0) {
227
+ logger.warn(`These agents report to '${id}': ${dependents.join(', ')}`);
228
+ }
229
+
230
+ // Check state.json for active references (advisory)
231
+ checkActiveFeatures(id);
232
+
233
+ if (!options.force) {
234
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
235
+ const answer = await new Promise(resolve => {
236
+ rl.question(chalk.yellow(' Remove this agent? (y/N) '), resolve);
237
+ });
238
+ rl.close();
239
+ if (answer.toLowerCase() !== 'y') {
240
+ logger.info('Cancelled.');
241
+ return;
242
+ }
243
+ }
244
+
245
+ const result = removeAgent(agentsData, id);
246
+ saveAgentsFile(getAgentsPath(), result.data);
247
+ logger.success(`Removed agent '${id}' from agents.json`);
248
+ logger.dim(`Run 'morph-spec setup-infra' to clean up .claude/agents/ files`);
249
+ }
250
+
251
+ // ── Helpers ─────────────────────────────────────────────────────────────────────
252
+
253
+ /**
254
+ * Levenshtein distance between two strings.
255
+ * Used for "did you mean?" suggestions on typos.
256
+ */
257
+ export function levenshtein(a, b) {
258
+ const m = a.length;
259
+ const n = b.length;
260
+ const dp = Array.from({ length: m + 1 }, (_, i) => Array(n + 1).fill(0));
261
+ for (let i = 0; i <= m; i++) dp[i][0] = i;
262
+ for (let j = 0; j <= n; j++) dp[0][j] = j;
263
+ for (let i = 1; i <= m; i++) {
264
+ for (let j = 1; j <= n; j++) {
265
+ dp[i][j] = a[i - 1] === b[j - 1]
266
+ ? dp[i - 1][j - 1]
267
+ : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
268
+ }
269
+ }
270
+ return dp[m][n];
271
+ }
272
+
273
+ /**
274
+ * Interactive wizard for adding an agent.
275
+ */
276
+ async function runAddWizard(id, agentsData) {
277
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
278
+ const ask = (q) => new Promise(resolve => rl.question(q, resolve));
279
+
280
+ console.log(chalk.bold(`\n Adding agent: ${id}\n`));
281
+
282
+ const knownGroups = [...new Set(Object.values(agentsData.agents || {}).map(a => a.group))];
283
+
284
+ const tier = parseInt(await ask(` ? Tier (1=${TIER_LABELS[1]}, 2=${TIER_LABELS[2]}, 3=${TIER_LABELS[3]}, 4=${TIER_LABELS[4]}): `), 10);
285
+ const title = await ask(' ? Title: ');
286
+ console.log(chalk.dim(` Known groups: ${knownGroups.join(', ')}`));
287
+ const group = await ask(' ? Group: ');
288
+ const phasesRaw = await ask(' ? Active phases (comma-sep, or * for all): ');
289
+ const active_phases = phasesRaw.trim() === '*' ? ['*'] : phasesRaw.split(',').map(s => s.trim());
290
+
291
+ const existingIds = Object.keys(agentsData.agents || {});
292
+ console.log(chalk.dim(` Valid: ${existingIds.slice(0, 5).join(', ')}... or "user"`));
293
+ const reports_to = await ask(' ? Reports to: ');
294
+
295
+ const domainsRaw = await ask(' ? Domains (comma-sep): ');
296
+ const domains = domainsRaw.split(',').map(s => s.trim()).filter(Boolean);
297
+ const keywordsRaw = await ask(' ? Keywords (comma-sep): ');
298
+ const keywords = keywordsRaw.split(',').map(s => s.trim()).filter(Boolean);
299
+
300
+ let teammate = null;
301
+ if (tier >= 3) {
302
+ const role = await ask(' ? Teammate role: ');
303
+ const icon = await ask(' ? Teammate icon (emoji): ');
304
+ const spawn_prompt = await ask(' ? Spawn prompt: ');
305
+ teammate = { role, icon, spawn_prompt };
306
+ }
307
+
308
+ rl.close();
309
+
310
+ return { tier, title, group, active_phases, reports_to, domains, keywords, teammate };
311
+ }
312
+
313
+ /**
314
+ * Parse --non-interactive flags into fields object.
315
+ */
316
+ function parseNonInteractiveFlags(options) {
317
+ return {
318
+ tier: options.tier ? parseInt(options.tier, 10) : undefined,
319
+ title: options.title,
320
+ group: options.group,
321
+ active_phases: options.activePhases ? options.activePhases.split(',').map(s => s.trim()) : ['implement'],
322
+ reports_to: options.reportsTo || 'user',
323
+ domains: options.domains ? options.domains.split(',').map(s => s.trim()) : [],
324
+ keywords: options.keywords ? options.keywords.split(',').map(s => s.trim()) : [],
325
+ teammate: options.spawnPrompt ? {
326
+ role: options.role || 'specialist',
327
+ icon: options.icon || '\u{1F527}',
328
+ spawn_prompt: options.spawnPrompt,
329
+ } : null,
330
+ };
331
+ }
332
+
333
+ /**
334
+ * Advisory check: is this agent referenced in active features?
335
+ * Fail-open — any errors are silently ignored.
336
+ */
337
+ function checkActiveFeatures(agentId) {
338
+ try {
339
+ const statePath = join(process.cwd(), '.morph', 'state.json');
340
+ if (!existsSync(statePath)) return;
341
+ const state = JSON.parse(readFileSync(statePath, 'utf8'));
342
+ const activeIn = [];
343
+ for (const [fname, fdata] of Object.entries(state.features || {})) {
344
+ if (fdata.activeAgents?.includes(agentId)) activeIn.push(fname);
345
+ }
346
+ if (activeIn.length > 0) {
347
+ logger.warn(`Agent '${agentId}' is active in features: ${activeIn.join(', ')}. Removing from agents.json does not affect state.`);
348
+ }
349
+ } catch {
350
+ /* ignore — state check is advisory */
351
+ }
352
+ }
@@ -199,6 +199,20 @@ async function doctorFullCommand(frameworkRoot) {
199
199
  check('agents.json', false, false, 'file not found');
200
200
  }
201
201
 
202
+ // ── 6. agents.json version freshness ──────────────────────────────────
203
+ const canonicalAgentsPath = join(frameworkRoot, 'framework/agents.json');
204
+ if (await pathExists(agentsPath) && await pathExists(canonicalAgentsPath)) {
205
+ try {
206
+ const installed = JSON.parse(await fs.readFile(agentsPath, 'utf8'));
207
+ const canonical = JSON.parse(await fs.readFile(canonicalAgentsPath, 'utf8'));
208
+ const installedVer = installed.version || 'unknown';
209
+ const canonicalVer = canonical.version || 'unknown';
210
+ const isFresh = installedVer === canonicalVer;
211
+ check(`agents.json version (${installedVer})`, isFresh, true,
212
+ isFresh ? '' : `stale — canonical is v${canonicalVer}, run 'morph-spec update' to sync`);
213
+ } catch { /* ignore parse errors — already caught above */ }
214
+ }
215
+
202
216
  // ── 7. Workflow Configs ──────────────────────────────────────────────────
203
217
  console.log(chalk.cyan('\n framework/workflows/ — Workflow Configs'));
204
218
  const stdPath = join(frameworkRoot, 'framework/workflows/configs/standard.json');
@@ -599,6 +613,51 @@ export async function doctorCommand(options = {}) {
599
613
  } else {
600
614
  checks.push({ name: 'agents.json tier counts', status: 'ok' });
601
615
  }
616
+
617
+ // Check 4: v4 schema validation
618
+ const version = agentsData.version || 'unknown';
619
+ const isV4 = version.startsWith('4.');
620
+ const v3Fields = [];
621
+ const missingV4Fields = [];
622
+
623
+ for (const [id, data] of agentEntries) {
624
+ if (data.role !== undefined) v3Fields.push(`${id}.role`);
625
+ if (data.always_active !== undefined) v3Fields.push(`${id}.always_active`);
626
+ if (data.relationships !== undefined) v3Fields.push(`${id}.relationships`);
627
+ if (!data.group) missingV4Fields.push(`${id}.group`);
628
+ if (!Array.isArray(data.active_phases)) missingV4Fields.push(`${id}.active_phases`);
629
+ }
630
+
631
+ if (isV4) {
632
+ checks.push({ name: `agents.json schema (v${version})`, status: 'ok' });
633
+ } else {
634
+ checks.push({
635
+ name: `agents.json schema (v${version})`,
636
+ status: 'warn',
637
+ msg: 'expected v4.x — run agents migration'
638
+ });
639
+ hasWarnings = true;
640
+ }
641
+
642
+ if (v3Fields.length > 0) {
643
+ const sample = v3Fields.slice(0, 3).join(', ');
644
+ checks.push({
645
+ name: 'agents.json deprecated fields',
646
+ status: 'warn',
647
+ msg: `${v3Fields.length} v3 field(s) found: ${sample}${v3Fields.length > 3 ? '...' : ''}`
648
+ });
649
+ hasWarnings = true;
650
+ }
651
+
652
+ if (missingV4Fields.length > 0 && isV4) {
653
+ const sample = missingV4Fields.slice(0, 3).join(', ');
654
+ checks.push({
655
+ name: 'agents.json v4 required fields',
656
+ status: 'warn',
657
+ msg: `${missingV4Fields.length} missing: ${sample}${missingV4Fields.length > 3 ? '...' : ''}`
658
+ });
659
+ hasWarnings = true;
660
+ }
602
661
  } catch {
603
662
  checks.push({ name: 'agents.json', status: 'error', msg: 'invalid JSON' });
604
663
  hasErrors = true;
@@ -707,7 +766,7 @@ export async function doctorCommand(options = {}) {
707
766
  checks.push({
708
767
  name: 'agents completeness',
709
768
  status: 'warn',
710
- msg: `${missing.length} agent(s) missing for [${projectTags.join(', ')}]: ${names} — run morph-spec init --force`
769
+ msg: `${missing.length} agent(s) missing for [${projectTags.join(', ')}]: ${names} — run morph-spec setup-infra`
711
770
  });
712
771
  hasWarnings = true;
713
772
  } else {
@@ -871,7 +930,7 @@ export async function doctorCommand(options = {}) {
871
930
  logger.blank();
872
931
 
873
932
  if (hasErrors) {
874
- logger.error('Some checks failed. Run "morph-spec init --force" to fix.');
933
+ logger.error('Some checks failed. Run "morph-spec setup-infra" to fix.');
875
934
  process.exit(1);
876
935
  } else if (hasWarnings) {
877
936
  logger.warn('Some checks need attention.');
@@ -10,7 +10,7 @@ import {
10
10
  writeJson,
11
11
  writeFile
12
12
  } from '../../utils/file-copier.js';
13
- import { getInstalledCLIVersion } from '../../utils/version-checker.js';
13
+ import { getInstalledCLIVersion, checkProjectOutdated } from '../../utils/version-checker.js';
14
14
  import { installClaudeHooks } from '../../utils/claude-settings-manager.js';
15
15
  import inquirer from 'inquirer';
16
16
  import { detectProject } from '../../lib/detectors/index.js';
@@ -27,6 +27,7 @@ import { setupInfra, installRulesForStack } from '../../scripts/setup-infra.js';
27
27
  import { installSkills } from '../../utils/skills-installer.js';
28
28
  import { installAgents, installDomainAgents } from '../../utils/agents-installer.js';
29
29
  import { getProjectTags } from '../../lib/stack-filter.js';
30
+ import { updateCommand } from './update.js';
30
31
 
31
32
  export async function initCommand(options) {
32
33
  const targetPath = options.path || process.cwd();
@@ -39,7 +40,14 @@ export async function initCommand(options) {
39
40
  const morphPath = join(targetPath, '.morph');
40
41
  if (await pathExists(morphPath)) {
41
42
  if (!options.force) {
42
- logger.warn('MORPH already initialized in this directory.');
43
+ // Check if project version is outdated — auto-update instead of blocking
44
+ const projectCheck = await checkProjectOutdated(targetPath);
45
+ if (projectCheck.isOutdated) {
46
+ logger.info(`MORPH already initialized (v${projectCheck.current}). Framework update available (v${projectCheck.cliVersion}).`);
47
+ logger.blank();
48
+ return updateCommand(options);
49
+ }
50
+ logger.warn('MORPH already initialized in this directory (up to date).');
43
51
  logger.dim('Use --force to overwrite existing installation.');
44
52
  process.exit(1);
45
53
  }
@@ -25,10 +25,11 @@ import {
25
25
  getUpdateInstructions,
26
26
  detectInstallMethod
27
27
  } from '../../utils/version-checker.js';
28
- import { installClaudeHooks, installGlobalStatusline, installVSCodeTerminalSettings, installShellIntegration } from '../../utils/claude-settings-manager.js';
28
+ import { installClaudeHooks, installGlobalStatusline } from '../../utils/claude-settings-manager.js';
29
29
  import { installSkills } from '../../utils/skills-installer.js';
30
30
  import { installAgents, installDomainAgents } from '../../utils/agents-installer.js';
31
31
  import { injectMorphImport } from '../../utils/claude-md-injector.js';
32
+ import { getProjectTags } from '../../lib/stack-filter.js';
32
33
 
33
34
  /**
34
35
  * Backup user's config.json before cleaning
@@ -262,19 +263,21 @@ export async function updateCommand(options) {
262
263
  logger.warn(' ⚠ framework/commands/ source missing — commands not updated');
263
264
  }
264
265
 
266
+ // Compute project tags for stack-aware installation of skills, agents, and rules
267
+ let projectTags = [];
268
+ try {
269
+ const cfg = await readJson(join(morphPath, 'config', 'config.json'));
270
+ projectTags = getProjectTags(cfg);
271
+ } catch { /* ignore — config may not exist yet */ }
272
+
265
273
  // Sync morph skills to .claude/skills/ for native Claude Code discovery
266
274
  updateSpinner.text = 'Syncing morph skills to .claude/skills/...';
267
- await installSkills(targetPath);
275
+ await installSkills(targetPath, { projectTags });
268
276
 
269
277
  // Sync native subagents to .claude/agents/
270
278
  updateSpinner.text = 'Syncing agents to .claude/agents/...';
271
- let projectStack = null;
272
- try {
273
- const cfg = await readJson(join(morphPath, 'config', 'config.json'));
274
- projectStack = cfg?.project?.stack ?? null;
275
- } catch { /* ignore — config may not exist yet */ }
276
- await installAgents(targetPath, frameworkDir, { projectStack });
277
- await installDomainAgents(targetPath, frameworkDir);
279
+ await installAgents(targetPath, frameworkDir, { projectTags });
280
+ await installDomainAgents(targetPath, frameworkDir, { projectTags });
278
281
 
279
282
  // Sync path-scoped rules to .claude/rules/
280
283
  updateSpinner.text = 'Syncing rules to .claude/rules/...';
@@ -306,22 +309,6 @@ export async function updateCommand(options) {
306
309
  updateSpinner.text = 'Updating Claude Code hooks...';
307
310
  const hooksResult = await installClaudeHooks(targetPath);
308
311
 
309
- // Install shell integration (terminal title watcher)
310
- updateSpinner.text = 'Installing shell integration...';
311
- try {
312
- await installShellIntegration();
313
- } catch {
314
- logger.dim(' ⚠ Could not install shell integration (non-critical)');
315
- }
316
-
317
- // Configure VS Code terminal settings (enable OSC title sequences)
318
- updateSpinner.text = 'Configuring VS Code terminal settings...';
319
- try {
320
- await installVSCodeTerminalSettings();
321
- } catch {
322
- logger.dim(' ⚠ Could not configure VS Code terminal settings (non-critical)');
323
- }
324
-
325
312
  // Ensure root CLAUDE.md has morph-spec @import (preserves user content)
326
313
  updateSpinner.text = 'Checking CLAUDE.md import...';
327
314
  const claudeMdDest = join(targetPath, 'CLAUDE.md');
@@ -269,9 +269,6 @@ export async function advancePhaseCommand(feature, options = {}) {
269
269
 
270
270
  if (!specContentValidation.valid) {
271
271
  console.log(chalk.red('\n✗ Spec content validation failed:'));
272
- specContentValidation.missing.forEach(section => {
273
- console.log(chalk.red(` - Missing section: ${section}`));
274
- });
275
272
  specContentValidation.errors.forEach(error => {
276
273
  console.log(chalk.red(` - ${error}`));
277
274
  });
@@ -441,6 +438,17 @@ export async function advancePhaseCommand(feature, options = {}) {
441
438
  // Hook executor unavailable — non-blocking
442
439
  }
443
440
 
441
+ // Agent detection refresh (fail-open)
442
+ try {
443
+ const { refreshAgents, displayDetectionDiff } = await import('../agents/detect-agents.js');
444
+ const refreshResult = await refreshAgents(process.cwd(), feature);
445
+ if (refreshResult.added.length > 0) {
446
+ displayDetectionDiff(refreshResult, currentPhase, nextPhase);
447
+ }
448
+ } catch {
449
+ // Refresh is diagnostic — fail-open, phase advance already completed
450
+ }
451
+
444
452
  // Emit Tier-4 validator dispatch when advancing into implement or sync phase
445
453
  const phasesNeedingValidation = ['implement', 'sync'];
446
454
  if (phasesNeedingValidation.includes(nextPhase) || phasesNeedingValidation.includes(currentPhase)) {
@@ -279,6 +279,7 @@ async function ensureFeature(featureName, options = {}) {
279
279
  pending: 0
280
280
  },
281
281
  checkpoints: [],
282
+ dispatchLog: [],
282
283
  trustConfig: {},
283
284
  contextBundles: [],
284
285
  fileChanges: []
@@ -420,6 +421,29 @@ export function removeAgent(featureName, agentId) {
420
421
  return false;
421
422
  }
422
423
 
424
+ /**
425
+ * Log a dispatch event to state.json.
426
+ * Appends entry to feature.dispatchLog, capped at 50 entries (oldest trimmed).
427
+ *
428
+ * @param {string} featureName - Feature name
429
+ * @param {{ timestamp: string, phase: string, mode: string, agents: Array<{ id: string, subagentType: string, group: string, tasks: string[] }> }} entry - Dispatch event to log
430
+ */
431
+ export function logDispatch(featureName, entry) {
432
+ const state = loadState();
433
+ const feature = state.features?.[featureName];
434
+ if (!feature) return;
435
+
436
+ if (!Array.isArray(feature.dispatchLog)) feature.dispatchLog = [];
437
+ feature.dispatchLog.push(entry);
438
+
439
+ // Cap at 50 entries
440
+ if (feature.dispatchLog.length > 50) {
441
+ feature.dispatchLog = feature.dispatchLog.slice(-50);
442
+ }
443
+
444
+ saveState(state);
445
+ }
446
+
423
447
  /**
424
448
  * Normalize output type from kebab-case to camelCase (BUG-002 fix - Enhanced)
425
449
  * Supports both kebab-case and camelCase input formats for all output types
@@ -807,3 +831,60 @@ export function getApprovalHistory(featureName) {
807
831
  // Sort by timestamp
808
832
  return history.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
809
833
  }
834
+
835
+ /**
836
+ * Find which agent was assigned a specific task in the latest dispatch.
837
+ * Searches only the last dispatchLog entry.
838
+ *
839
+ * @param {string} featureName
840
+ * @param {string} taskId
841
+ * @returns {string|null} Agent ID or null if not found
842
+ */
843
+ export function resolveAgentForTask(featureName, taskId) {
844
+ const state = loadState();
845
+ const feature = state.features?.[featureName];
846
+ if (!feature) return null;
847
+
848
+ const log = feature.dispatchLog;
849
+ if (!Array.isArray(log) || log.length === 0) return null;
850
+
851
+ const latest = log[log.length - 1];
852
+ for (const agent of latest.agents || []) {
853
+ if (Array.isArray(agent.tasks) && agent.tasks.includes(taskId)) {
854
+ return agent.id;
855
+ }
856
+ }
857
+ return null;
858
+ }
859
+
860
+ /**
861
+ * Update timing fields on a specific agent in the latest dispatch entry.
862
+ * Fail-open: returns silently if feature/dispatch/agent not found.
863
+ *
864
+ * @param {string} featureName
865
+ * @param {string} agentId - Agent ID to update
866
+ * @param {Object} updates - Fields to merge (startedAt, completedAt, tasksCompleted)
867
+ */
868
+ export function updateDispatchAgentTiming(featureName, agentId, updates) {
869
+ const state = loadState();
870
+ const feature = state.features?.[featureName];
871
+ if (!feature) return;
872
+
873
+ const log = feature.dispatchLog;
874
+ if (!Array.isArray(log) || log.length === 0) return;
875
+
876
+ const latest = log[log.length - 1];
877
+ const agent = (latest.agents || []).find(a => a.id === agentId);
878
+ if (!agent) return;
879
+
880
+ Object.assign(agent, updates);
881
+
882
+ // If all agents now have completedAt, set entry.completedAt to the latest
883
+ const allComplete = (latest.agents || []).every(a => a.completedAt);
884
+ if (allComplete && latest.agents.length > 0) {
885
+ const timestamps = latest.agents.map(a => new Date(a.completedAt).getTime());
886
+ latest.completedAt = new Date(Math.max(...timestamps)).toISOString();
887
+ }
888
+
889
+ saveState(state);
890
+ }