@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,275 @@
1
+ /**
2
+ * detect-agents — Detect relevant agents for a feature based on keywords
3
+ *
4
+ * Supports single string input (legacy) and multi-source TextSource[] input
5
+ * with weighted keyword scoring. Also provides refreshAgents() for additive
6
+ * agent detection at phase boundaries.
7
+ *
8
+ * Usage:
9
+ * morph-spec detect-agents <feature> [--proposal <path>] [--refresh] [--json] [--table]
10
+ */
11
+
12
+ import { readFileSync, existsSync } from 'fs';
13
+ import { join, dirname } from 'path';
14
+ import { fileURLToPath } from 'url';
15
+ import chalk from 'chalk';
16
+ import { collectPhaseOutputs } from '../../lib/agents/output-collector.js';
17
+ import { loadAgentsFile } from '../../lib/agents/agents-manager.js';
18
+ import { loadState, saveState, addAgent } from '../../core/state/state-manager.js';
19
+
20
+ const __dirname = dirname(fileURLToPath(import.meta.url));
21
+
22
+ /** Default path to agents.json in the installed package */
23
+ const AGENTS_JSON_PATH = join(__dirname, '../../../framework/agents.json');
24
+
25
+ /** Match weight constants */
26
+ const MATCH_WEIGHTS = {
27
+ heading: 3,
28
+ body: 1,
29
+ domain: 2,
30
+ };
31
+
32
+ /**
33
+ * Detect agents relevant to a feature.
34
+ *
35
+ * @param {string|Array<{source: string, content: string, weight: number}>} sourcesOrText - Text sources or legacy string
36
+ * @param {Object} agentsJson - Parsed agents.json content
37
+ * @param {Object} [opts] - Options
38
+ * @param {string[]} [opts.excludeIds] - Agent IDs to exclude from detection
39
+ * @returns {{ detected: Array<{id: string, reason: string, keywords_matched: string[], score: number, sources: Array}>, skipped: Array<{id: string, reason: string}> }}
40
+ */
41
+ export function detectAgents(sourcesOrText, agentsJson, opts = {}) {
42
+ // Backward compatibility: wrap string as single source
43
+ const sources = typeof sourcesOrText === 'string'
44
+ ? [{ source: 'proposal.md', content: sourcesOrText, weight: 1.0 }]
45
+ : sourcesOrText;
46
+
47
+ const allAgents = agentsJson.agents || {};
48
+ const excludeIds = new Set(opts.excludeIds || []);
49
+
50
+ const detected = [];
51
+ const skipped = [];
52
+
53
+ for (const [agentId, agentData] of Object.entries(allAgents)) {
54
+ if (excludeIds.has(agentId)) continue;
55
+ if (!agentData || typeof agentData !== 'object' || !agentData.tier) continue;
56
+
57
+ // Always-active agents (active_phases includes '*')
58
+ if (Array.isArray(agentData.active_phases) && agentData.active_phases.includes('*')) {
59
+ detected.push({
60
+ id: agentId,
61
+ reason: 'active_phases: *',
62
+ keywords_matched: [],
63
+ score: 0,
64
+ sources: [],
65
+ });
66
+ continue;
67
+ }
68
+
69
+ // Score across all sources
70
+ const keywords = agentData.keywords || [];
71
+ const domains = agentData.domains || [];
72
+ let totalScore = 0;
73
+ const allMatched = new Set();
74
+ const sourceBreakdown = [];
75
+
76
+ for (const src of sources) {
77
+ const textLower = src.content.toLowerCase();
78
+ const lines = src.content.split('\n');
79
+ let srcScore = 0;
80
+ const srcKeywords = [];
81
+
82
+ // Check keywords
83
+ for (const kw of keywords) {
84
+ const kwLower = kw.toLowerCase();
85
+ if (!textLower.includes(kwLower)) continue;
86
+
87
+ allMatched.add(kw);
88
+ srcKeywords.push(kw);
89
+
90
+ // Check if keyword appears in a heading line
91
+ const inHeading = lines.some(line =>
92
+ /^#{1,6}\s/.test(line) && line.toLowerCase().includes(kwLower)
93
+ );
94
+ srcScore += (inHeading ? MATCH_WEIGHTS.heading : MATCH_WEIGHTS.body) * src.weight;
95
+ }
96
+
97
+ // Check domains
98
+ for (const domain of domains) {
99
+ const domLower = domain.toLowerCase();
100
+ if (!textLower.includes(domLower)) continue;
101
+
102
+ allMatched.add(domain);
103
+ srcKeywords.push(domain);
104
+ srcScore += MATCH_WEIGHTS.domain * src.weight;
105
+ }
106
+
107
+ if (srcScore > 0) {
108
+ sourceBreakdown.push({
109
+ source: src.source,
110
+ keywords: srcKeywords,
111
+ score: srcScore,
112
+ });
113
+ totalScore += srcScore;
114
+ }
115
+ }
116
+
117
+ if (totalScore > 0) {
118
+ detected.push({
119
+ id: agentId,
120
+ reason: 'keyword',
121
+ keywords_matched: [...allMatched],
122
+ score: totalScore,
123
+ sources: sourceBreakdown,
124
+ });
125
+ } else {
126
+ skipped.push({
127
+ id: agentId,
128
+ reason: 'no_keyword_match',
129
+ });
130
+ }
131
+ }
132
+
133
+ return { detected, skipped };
134
+ }
135
+
136
+ /**
137
+ * Refresh agent detection using all available phase outputs.
138
+ * Additive only — never removes existing agents.
139
+ *
140
+ * @param {string} projectRoot - Absolute path
141
+ * @param {string} featureName - Feature name
142
+ * @returns {Promise<{added: Array, unchanged: number, total: number}>}
143
+ */
144
+ export async function refreshAgents(projectRoot, featureName) {
145
+ const state = loadState(false);
146
+ const feature = state?.features?.[featureName];
147
+ if (!feature) {
148
+ return { added: [], unchanged: 0, total: 0 };
149
+ }
150
+
151
+ const currentAgents = new Set(feature.activeAgents || []);
152
+
153
+ const sources = collectPhaseOutputs(projectRoot, featureName);
154
+ if (sources.length === 0) {
155
+ return { added: [], unchanged: currentAgents.size, total: currentAgents.size };
156
+ }
157
+
158
+ let agentsJson;
159
+ try {
160
+ agentsJson = loadAgentsFile(AGENTS_JSON_PATH);
161
+ } catch {
162
+ return { added: [], unchanged: currentAgents.size, total: currentAgents.size };
163
+ }
164
+ const { detected } = detectAgents(sources, agentsJson);
165
+
166
+ const added = detected.filter(a => !currentAgents.has(a.id));
167
+
168
+ // Additive: add new agents to state
169
+ for (const agent of added) {
170
+ await addAgent(featureName, agent.id);
171
+ }
172
+
173
+ return {
174
+ added,
175
+ unchanged: currentAgents.size,
176
+ total: currentAgents.size + added.length,
177
+ };
178
+ }
179
+
180
+ /**
181
+ * Display detection diff after refresh.
182
+ *
183
+ * @param {{added: Array, unchanged: number, total: number}} result
184
+ * @param {string} fromPhase
185
+ * @param {string} toPhase
186
+ */
187
+ export function displayDetectionDiff(result, fromPhase, toPhase) {
188
+ console.log(chalk.cyan(`\nAgent Detection Refresh (${fromPhase} \u2192 ${toPhase}):`));
189
+
190
+ for (const agent of result.added) {
191
+ const kws = agent.keywords_matched.join('", "');
192
+ console.log(chalk.green(` + ${agent.id.padEnd(24)} (keyword: "${kws}" | score: ${agent.score})`));
193
+ }
194
+
195
+ console.log(chalk.gray(` = ${result.unchanged} agents unchanged`));
196
+ console.log(chalk.white(` Total: ${result.total} active agents\n`));
197
+ }
198
+
199
+ /**
200
+ * CLI command: morph-spec detect-agents <feature>
201
+ */
202
+ export async function detectAgentsCommand(featureName, options = {}) {
203
+ if (!featureName) {
204
+ console.error(chalk.red('Usage: morph-spec detect-agents <feature> [--proposal <path>] [--refresh] [--json] [--table]'));
205
+ process.exit(1);
206
+ }
207
+
208
+ // --refresh mode: use all phase outputs and mutate state
209
+ if (options.refresh) {
210
+ const result = await refreshAgents(process.cwd(), featureName);
211
+ if (options.json) {
212
+ console.log(JSON.stringify(result, null, 2));
213
+ } else {
214
+ displayDetectionDiff(result, 'manual', 'refresh');
215
+ }
216
+ return;
217
+ }
218
+
219
+ // Resolve proposal path
220
+ const proposalPath = options.proposal
221
+ || join(process.cwd(), `.morph/features/${featureName}/0-proposal/proposal.md`);
222
+
223
+ if (!existsSync(proposalPath)) {
224
+ console.error(chalk.red(`Proposal not found: ${proposalPath}`));
225
+ console.error(chalk.gray('Create the proposal first or use --proposal <path> to specify a custom path.'));
226
+ process.exit(1);
227
+ }
228
+
229
+ // Load agents.json
230
+ const agentsPath = options.agentsJson || AGENTS_JSON_PATH;
231
+ let agentsJson;
232
+ try {
233
+ agentsJson = loadAgentsFile(agentsPath);
234
+ } catch (err) {
235
+ console.error(chalk.red(err.message));
236
+ process.exit(1);
237
+ }
238
+
239
+ const proposalText = readFileSync(proposalPath, 'utf8');
240
+
241
+ const result = detectAgents(proposalText, agentsJson, {
242
+ excludeIds: options.exclude ? options.exclude.split(',') : [],
243
+ });
244
+
245
+ const output = {
246
+ feature: featureName,
247
+ ...result,
248
+ };
249
+
250
+ if (options.table) {
251
+ // Human-readable table
252
+ console.log(chalk.bold(`\n Detected Agents \u2014 ${featureName}`));
253
+ console.log(' ' + '\u2500'.repeat(62));
254
+
255
+ if (result.detected.length > 0) {
256
+ console.log(` ${'ID'.padEnd(28)} ${'Reason'.padEnd(16)} Keywords`);
257
+ console.log(' ' + '\u2500'.repeat(62));
258
+ for (const agent of result.detected) {
259
+ const reason = agent.reason === 'active_phases: *' ? 'always' : agent.reason;
260
+ const kw = agent.keywords_matched.length > 0 ? agent.keywords_matched.join(', ') : '\u2014';
261
+ console.log(
262
+ ` ${agent.id.padEnd(28)} ${chalk.cyan(reason.padEnd(16))} ${kw}`
263
+ );
264
+ }
265
+ } else {
266
+ console.log(chalk.gray(' No agents detected.'));
267
+ }
268
+
269
+ console.log(`\n Skipped: ${result.skipped.length} agents (no keyword match)`);
270
+ console.log();
271
+ } else {
272
+ // JSON output (default)
273
+ console.log(JSON.stringify(output, null, 2));
274
+ }
275
+ }
@@ -21,6 +21,9 @@ import { join, dirname } from 'path';
21
21
  import { fileURLToPath } from 'url';
22
22
  import { loadState, stateExists } from '../../core/state/state-manager.js';
23
23
  import { buildAgentBriefing, buildValidatorPrompt } from '../../lib/standards/digest-builder.js';
24
+ import { agentIdToSubagentType } from '../../utils/agent-id-mapper.js';
25
+ import { parseAndGroupTasks } from '../../lib/tasks/task-parser.js';
26
+ export { parseAndGroupTasks } from '../../lib/tasks/task-parser.js';
24
27
  import chalk from 'chalk';
25
28
 
26
29
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -34,62 +37,6 @@ const PHASES_JSON_PATH = join(__dirname, '../../../framework/phases.json');
34
37
  /** Phases where agent dispatch is meaningful */
35
38
  const DISPATCHABLE_PHASES = ['design', 'tasks', 'implement'];
36
39
 
37
- /**
38
- * Domain → parallel group mapping.
39
- * Agents inherit group from their first matching domain.
40
- */
41
- const DOMAIN_TO_GROUP = {
42
- 'backend': 'backend',
43
- 'backend-data': 'backend',
44
- 'backend-jobs': 'backend',
45
- 'backend-events': 'backend',
46
- 'frontend': 'frontend',
47
- 'frontend-blazor': 'frontend',
48
- 'frontend-nextjs': 'frontend',
49
- 'frontend-css': 'frontend',
50
- 'frontend-design': 'frontend',
51
- 'infrastructure': 'infra',
52
- 'cloud-azure': 'infra',
53
- 'devops': 'infra',
54
- 'domain': 'domain-analysis',
55
- 'domain-modeling': 'domain-analysis',
56
- 'architecture': 'domain-analysis',
57
- 'ai-orchestration': 'domain-analysis',
58
- 'testing': 'tests',
59
- 'qa': 'tests',
60
- };
61
-
62
- /** Agent-level group overrides (higher priority than domain mapping) */
63
- const AGENT_GROUP_OVERRIDES = {
64
- 'domain-architect': 'domain-analysis',
65
- 'standards-architect': 'domain-analysis',
66
- 'ai-system-architect': 'domain-analysis',
67
- 'popm-advisor': 'domain-analysis',
68
- 'dotnet-senior': 'backend',
69
- 'infra-architect': 'infra',
70
- 'ui-designer': 'frontend',
71
- 'testing-specialist': 'tests',
72
- 'code-analyzer': 'tests',
73
- 'load-testing-expert': 'tests',
74
- 'documentation-specialist': 'docs',
75
- 'troubleshooting-expert': 'support',
76
- };
77
-
78
- /**
79
- * Task category → parallel group mapping for implement-phase task splitting.
80
- * Matches task categories defined in phase-tasks SKILL.md.
81
- */
82
- const TASK_CATEGORY_TO_GROUP = {
83
- 'domain': 'backend',
84
- 'domain-bc': 'backend',
85
- 'infrastructure': 'backend',
86
- 'application': 'backend',
87
- 'presentation': 'frontend',
88
- 'tests': 'tests',
89
- 'infra': 'infra',
90
- 'docs': 'docs',
91
- };
92
-
93
40
  // ─────────────────────────────────────────────────────────────────────────────
94
41
  // Required Skills Injection
95
42
  // ─────────────────────────────────────────────────────────────────────────────
@@ -175,34 +122,24 @@ export function buildMcpBlock(phase, phasesData, opts = {}) {
175
122
 
176
123
  /**
177
124
  * Determine the parallel execution group for an agent.
178
- * Priority: explicit override → domain mapping → tier fallback.
125
+ * Reads from the `group` field in agents.json v4; falls back to 'backend'.
179
126
  *
180
127
  * @param {string} agentId
181
128
  * @param {Object} agentData
182
129
  * @returns {string} group name
183
130
  */
184
131
  function getAgentGroup(agentId, agentData) {
185
- if (AGENT_GROUP_OVERRIDES[agentId]) return AGENT_GROUP_OVERRIDES[agentId];
186
-
187
- const domains = agentData.domains || [];
188
- for (const domain of domains) {
189
- if (DOMAIN_TO_GROUP[domain]) return DOMAIN_TO_GROUP[domain];
190
- }
191
-
192
- // Tier-2 domain leaders default to backend if no domain matched
193
- if (agentData.tier === 2) return 'backend';
194
- return 'backend';
132
+ return agentData.group || 'backend';
195
133
  }
196
134
 
197
135
  /**
198
136
  * Check whether an agent should be dispatched for the given phase.
199
137
  *
200
- * Rules (in priority order):
201
- * 1. Tier-4 validators → never dispatch (no task work) [overridden by validate mode]
202
- * 2. Agent-level `active_phases` array → use that list
203
- * 3. Relationships-level `active_phases` array → use that list
204
- * 4. `always_active: true` → dispatchable for all DISPATCHABLE_PHASES
205
- * 5. Default: specialists without active_phases → implement only
138
+ * Rules:
139
+ * 1. Tier-4 validators → never dispatch (validate mode bypasses this)
140
+ * 2. `active_phases` includes '*' → all dispatchable phases
141
+ * 3. `active_phases` includes the phase → dispatch
142
+ * 4. Default (no active_phases): implement only
206
143
  *
207
144
  * @param {string} agentId
208
145
  * @param {Object} agentData
@@ -210,72 +147,16 @@ function getAgentGroup(agentId, agentData) {
210
147
  * @returns {boolean}
211
148
  */
212
149
  function isRelevantForPhase(agentId, agentData, phase) {
213
- // Tier-4 validators have no implementation tasks (validate mode bypasses this)
214
150
  if (agentData.tier === 4) return false;
215
151
 
216
- // Explicit active_phases on agent root
217
- if (Array.isArray(agentData.active_phases)) {
218
- return agentData.active_phases.includes(phase);
152
+ const phases = agentData.active_phases;
153
+ if (Array.isArray(phases)) {
154
+ return phases.includes('*') || phases.includes(phase);
219
155
  }
220
156
 
221
- // active_phases nested in relationships
222
- const relPhases = agentData.relationships?.active_phases;
223
- if (Array.isArray(relPhases)) {
224
- return relPhases.includes(phase);
225
- }
226
-
227
- // always_active agents: dispatch for all recognised phases
228
- if (agentData.always_active && DISPATCHABLE_PHASES.includes(phase)) return true;
229
-
230
- // Specialists without active_phases: implement phase only
231
157
  return phase === 'implement';
232
158
  }
233
159
 
234
- /**
235
- * Parse tasks.md and return a map of { parallelGroup → taskId[] }.
236
- * Gracefully returns {} if the file doesn't exist or is unparseable.
237
- *
238
- * @param {string} tasksPath - Absolute path to tasks.md
239
- * @returns {Object}
240
- */
241
- export function parseAndGroupTasks(tasksPath) {
242
- if (!existsSync(tasksPath)) return {};
243
-
244
- try {
245
- const content = readFileSync(tasksPath, 'utf8');
246
- const groups = {};
247
-
248
- // Match task headings: ### T001 — Title or ### T001: Title
249
- const taskHeadingRe = /^###\s+(T\d{3,})\s*[—–:\-]\s*(.+)$/gm;
250
- // Match category line within a task block: **category**: `domain` or - category: application
251
- const categoryRe = /(?:\*\*category\*\*\s*:\s*`?([a-z-]+)`?|category:\s*([a-z-]+))/i;
252
-
253
- let headingMatch;
254
- const taskPositions = [];
255
-
256
- while ((headingMatch = taskHeadingRe.exec(content)) !== null) {
257
- taskPositions.push({ id: headingMatch[1], pos: headingMatch.index });
258
- }
259
-
260
- for (let i = 0; i < taskPositions.length; i++) {
261
- const { id, pos } = taskPositions[i];
262
- const end = i + 1 < taskPositions.length ? taskPositions[i + 1].pos : content.length;
263
- const block = content.slice(pos, end);
264
-
265
- const catMatch = categoryRe.exec(block);
266
- const category = (catMatch?.[1] || catMatch?.[2] || 'application').toLowerCase();
267
- const group = TASK_CATEGORY_TO_GROUP[category] || 'backend';
268
-
269
- if (!groups[group]) groups[group] = [];
270
- groups[group].push(id);
271
- }
272
-
273
- return groups;
274
- } catch {
275
- return {};
276
- }
277
- }
278
-
279
160
  // ─────────────────────────────────────────────────────────────────────────────
280
161
  // Core Logic
281
162
  // ─────────────────────────────────────────────────────────────────────────────
@@ -345,16 +226,15 @@ export async function buildDispatchConfig(projectPath, featureName, phase, opts
345
226
  const activeAgentIds = new Set(feature.activeAgents || []);
346
227
 
347
228
  // Collect dispatchable agents
348
- // Process all agents: active ones from state + always_active ones not yet in state
349
229
  const isValidateMode = opts.mode === 'validate';
350
230
  const processed = new Set();
351
231
  const dispatchableAgents = [];
352
232
 
353
233
  const candidateIds = [
354
234
  ...activeAgentIds,
355
- // Add always_active agents that might not have been explicitly detected
235
+ // Add always-active agents (active_phases includes '*')
356
236
  ...Object.entries(allAgents)
357
- .filter(([id, data]) => !id.startsWith('_comment') && data.always_active)
237
+ .filter(([id, data]) => Array.isArray(data.active_phases) && data.active_phases.includes('*'))
358
238
  .map(([id]) => id),
359
239
  // In validate mode, also include tier-4 validators
360
240
  ...(isValidateMode
@@ -402,6 +282,7 @@ export async function buildDispatchConfig(projectPath, featureName, phase, opts
402
282
 
403
283
  dispatchableAgents.push({
404
284
  id: agentId,
285
+ subagentType: agentIdToSubagentType(agentId, agentData.tier),
405
286
  title: agentData.title,
406
287
  tier: agentData.tier,
407
288
  icon: agentData.teammate?.icon || '🤖',
@@ -452,6 +333,7 @@ export async function buildDispatchConfig(projectPath, featureName, phase, opts
452
333
  const groupAgent = dispatchableAgents.find(a => a.parallelGroup === group);
453
334
  taskGroups[group] = {
454
335
  agentId: groupAgent?.id ?? null,
336
+ subagentType: groupAgent?.subagentType ?? null,
455
337
  description: `${group} implementation tasks for feature '${featureName}'`,
456
338
  tasks: taskIds,
457
339
  canRunParallel: true,
@@ -467,6 +349,7 @@ export async function buildDispatchConfig(projectPath, featureName, phase, opts
467
349
 
468
350
  taskGroups[agent.parallelGroup] = {
469
351
  agentId: agent.id,
352
+ subagentType: agent.subagentType,
470
353
  description: agent.role,
471
354
  tasks: [],
472
355
  canRunParallel: true,
@@ -475,6 +358,19 @@ export async function buildDispatchConfig(projectPath, featureName, phase, opts
475
358
  }
476
359
  }
477
360
 
361
+ // ── Pre-flight: check agent files exist ──────────────────────────────────
362
+ const warnings = [];
363
+ for (const agent of dispatchableAgents) {
364
+ const agentFile = join(projectPath, '.claude', 'agents', `${agent.subagentType}.md`);
365
+ if (!existsSync(agentFile)) {
366
+ warnings.push({
367
+ agentId: agent.id,
368
+ subagentType: agent.subagentType,
369
+ message: `File .claude/agents/${agent.subagentType}.md not found`,
370
+ });
371
+ }
372
+ }
373
+
478
374
  return {
479
375
  feature: featureName,
480
376
  phase,
@@ -487,6 +383,7 @@ export async function buildDispatchConfig(projectPath, featureName, phase, opts
487
383
  : `No dispatchable agents found for phase '${phase}'`,
488
384
  agents: dispatchableAgents,
489
385
  taskGroups,
386
+ warnings,
490
387
  };
491
388
  }
492
389
 
@@ -563,6 +460,15 @@ export async function dispatchAgentsCommand(featureName, phase, options = {}) {
563
460
  // JSON (default — consumed by phase skills / LLM)
564
461
  console.log(JSON.stringify(config, null, 2));
565
462
  }
463
+
464
+ // Pre-flight mode: exit with code 1 if warnings exist
465
+ if (options.preflight && config.warnings?.length > 0) {
466
+ console.error(chalk.yellow(`\n ⚠ Pre-flight: ${config.warnings.length} missing agent file(s):`));
467
+ for (const w of config.warnings) {
468
+ console.error(chalk.yellow(` - ${w.agentId}: ${w.message}`));
469
+ }
470
+ process.exit(1);
471
+ }
566
472
  } catch (err) {
567
473
  console.error(chalk.red(`Error: ${err.message}`));
568
474
  if (options.verbose) console.error(err.stack);
@@ -2,3 +2,12 @@
2
2
  * Agent Commands
3
3
  */
4
4
  export { dispatchAgentsCommand, buildDispatchConfig } from './dispatch-agents.js';
5
+ export { auditDispatchCommand } from './audit-dispatch.js';
6
+ export { detectAgentsCommand, detectAgents } from './detect-agents.js';
7
+ export {
8
+ agentListCommand,
9
+ agentShowCommand,
10
+ agentAddCommand,
11
+ agentEditCommand,
12
+ agentRemoveCommand,
13
+ } from './manage-agents.js';