@polymorphism-tech/morph-spec 4.10.6 → 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.
- package/CLAUDE.md +3 -0
- package/README.md +2 -2
- package/bin/morph-spec.js +90 -1
- package/bin/task-manager.js +59 -6
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/agents.json +345 -708
- package/framework/hooks/claude-code/post-tool-use/dispatch.js +10 -5
- package/framework/skills/README.md +0 -1
- package/framework/skills/level-1-workflows/morph-phase-clarify/SKILL.md +1 -1
- package/framework/skills/level-1-workflows/morph-phase-codebase-analysis/SKILL.md +1 -1
- package/framework/skills/level-1-workflows/morph-phase-design/SKILL.md +1 -1
- package/framework/skills/level-1-workflows/morph-phase-implement/SKILL.md +42 -13
- package/framework/skills/level-1-workflows/morph-phase-plan/SKILL.md +1 -1
- package/framework/skills/level-1-workflows/morph-phase-setup/SKILL.md +14 -10
- package/framework/skills/level-1-workflows/morph-phase-tasks/SKILL.md +1 -1
- package/framework/skills/level-1-workflows/morph-phase-uiux/SKILL.md +1 -1
- package/framework/skills/level-1-workflows/morph-scope-escalation/SKILL.md +1 -1
- package/package.json +1 -1
- package/src/commands/agents/agent-metrics.js +174 -0
- package/src/commands/agents/audit-dispatch.js +82 -0
- package/src/commands/agents/detect-agents.js +275 -0
- package/src/commands/agents/dispatch-agents.js +41 -135
- package/src/commands/agents/index.js +9 -0
- package/src/commands/agents/manage-agents.js +352 -0
- package/src/commands/project/doctor.js +59 -0
- package/src/commands/project/update.js +1 -17
- package/src/commands/state/advance-phase.js +11 -3
- package/src/core/state/state-manager.js +81 -0
- package/src/lib/agents/agents-manager.js +349 -0
- package/src/lib/agents/metrics-calculator.js +125 -0
- package/src/lib/agents/output-collector.js +75 -0
- package/src/lib/generators/context-generator.js +8 -7
- package/src/lib/tasks/task-parser.js +94 -3
- package/src/lib/validators/checkpoint-persistence.js +39 -0
- package/src/lib/validators/content/content-validator.js +9 -1
- package/src/lib/validators/spec-validator.js +2 -2
- package/src/lib/validators/validation-runner.js +20 -0
- package/src/scripts/setup-infra.js +1 -17
- package/src/utils/agent-id-mapper.js +11 -0
- package/src/utils/agents-installer.js +40 -8
- package/src/utils/claude-settings-manager.js +1 -1
- package/src/utils/hooks-installer.js +0 -245
- package/src/utils/tier-helpers.js +24 -0
- package/framework/agents/architecture/po-pm-advisor.md +0 -203
- package/framework/hooks/claude-code/user-prompt/set-terminal-title.js +0 -68
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agents-manager.js — Core logic for agent CRUD operations
|
|
3
|
+
*
|
|
4
|
+
* Pure functions that accept agentsData object. File I/O only in
|
|
5
|
+
* loadAgentsFile / saveAgentsFile. All other functions are testable
|
|
6
|
+
* with fixture data.
|
|
7
|
+
*
|
|
8
|
+
* @module agents-manager
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
|
|
12
|
+
import { mkdirSync } from 'node:fs';
|
|
13
|
+
import { dirname } from 'node:path';
|
|
14
|
+
|
|
15
|
+
// ── Constants ──────────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
/** Known valid phases for active_phases validation */
|
|
18
|
+
export const KNOWN_PHASES = [
|
|
19
|
+
'proposal', 'setup', 'uiux', 'design',
|
|
20
|
+
'clarify', 'plan', 'tasks', 'implement',
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
/** Tier labels for display and validation */
|
|
24
|
+
export const TIER_LABELS = {
|
|
25
|
+
1: 'orchestrator',
|
|
26
|
+
2: 'leader',
|
|
27
|
+
3: 'specialist',
|
|
28
|
+
4: 'validator',
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** Tier metadata key names in agents.json */
|
|
32
|
+
export const TIER_KEYS = {
|
|
33
|
+
1: 'tier_1_orchestrators',
|
|
34
|
+
2: 'tier_2_leaders',
|
|
35
|
+
3: 'tier_3_specialists',
|
|
36
|
+
4: 'tier_4_validators',
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** Agent ID pattern: kebab-case starting with a letter */
|
|
40
|
+
export const AGENT_ID_PATTERN = /^[a-z][a-z0-9-]*$/;
|
|
41
|
+
|
|
42
|
+
// ── Validation ─────────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Validate agent fields.
|
|
46
|
+
* @param {string} id
|
|
47
|
+
* @param {object} fields
|
|
48
|
+
* @param {string[]} existingIds - IDs already in agents.json (for uniqueness check)
|
|
49
|
+
* @param {string[]} [knownGroups] - Known group names (for warnings)
|
|
50
|
+
* @param {object} [allAgents] - Full agents map (for circular reports_to check)
|
|
51
|
+
* @returns {{ valid: boolean, errors: string[], warnings: string[] }}
|
|
52
|
+
*/
|
|
53
|
+
export function validateAgent(id, fields, existingIds = [], knownGroups = [], allAgents = {}) {
|
|
54
|
+
const errors = [];
|
|
55
|
+
const warnings = [];
|
|
56
|
+
|
|
57
|
+
// ID validation
|
|
58
|
+
if (!AGENT_ID_PATTERN.test(id)) {
|
|
59
|
+
errors.push('Agent ID must be kebab-case (lowercase letters, numbers, hyphens) and start with a letter');
|
|
60
|
+
}
|
|
61
|
+
if (existingIds.includes(id)) {
|
|
62
|
+
errors.push(`Agent ID '${id}' is not unique — already exists in agents.json`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Tier
|
|
66
|
+
const tier = fields.tier;
|
|
67
|
+
if (tier == null || !Number.isInteger(tier) || tier < 1 || tier > 4) {
|
|
68
|
+
errors.push('Tier must be 1 (orchestrator), 2 (leader), 3 (specialist), or 4 (validator)');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Title
|
|
72
|
+
if (!fields.title || typeof fields.title !== 'string' || fields.title.trim() === '') {
|
|
73
|
+
errors.push('Title is required');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Group
|
|
77
|
+
if (!fields.group || typeof fields.group !== 'string' || fields.group.trim() === '') {
|
|
78
|
+
errors.push('Group is required');
|
|
79
|
+
} else if (knownGroups.length > 0 && !knownGroups.includes(fields.group)) {
|
|
80
|
+
warnings.push(`Unknown group '${fields.group}' — known groups: ${knownGroups.join(', ')}`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Active phases
|
|
84
|
+
if (fields.active_phases) {
|
|
85
|
+
const phases = fields.active_phases;
|
|
86
|
+
if (!Array.isArray(phases)) {
|
|
87
|
+
errors.push('active_phases must be an array');
|
|
88
|
+
} else if (phases.length === 1 && phases[0] === '*') {
|
|
89
|
+
// wildcard — valid
|
|
90
|
+
} else {
|
|
91
|
+
for (const p of phases) {
|
|
92
|
+
if (!KNOWN_PHASES.includes(p)) {
|
|
93
|
+
errors.push(`Invalid phase '${p}' — valid: ${KNOWN_PHASES.join(', ')}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// reports_to — circular check
|
|
100
|
+
if (fields.reports_to) {
|
|
101
|
+
if (fields.reports_to === id) {
|
|
102
|
+
errors.push('Agent cannot report to itself');
|
|
103
|
+
} else if (fields.reports_to !== 'user' && allAgents && Object.keys(allAgents).length > 0) {
|
|
104
|
+
// Walk the chain to detect cycles
|
|
105
|
+
const visited = new Set([id]);
|
|
106
|
+
let current = fields.reports_to;
|
|
107
|
+
while (current && current !== 'user') {
|
|
108
|
+
if (visited.has(current)) {
|
|
109
|
+
errors.push(`Circular reports_to chain detected: ${[...visited, current].join(' \u2192 ')}`);
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
visited.add(current);
|
|
113
|
+
current = allAgents[current]?.reports_to;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// spawn_prompt required for tier >= 3
|
|
119
|
+
if (Number.isInteger(tier) && tier >= 3) {
|
|
120
|
+
const hasPrompt = fields.teammate?.spawn_prompt ||
|
|
121
|
+
(typeof fields.spawn_prompt === 'string' && fields.spawn_prompt.trim());
|
|
122
|
+
if (!hasPrompt) {
|
|
123
|
+
errors.push('spawn_prompt is required for tier 3+ agents');
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return { valid: errors.length === 0, errors, warnings };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── Metadata Sync ──────────────────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Recalculate total_agents and tier counts.
|
|
134
|
+
* @param {object} agentsData - Full agents.json data
|
|
135
|
+
* @returns {object} Updated agentsData (mutated in place + returned)
|
|
136
|
+
*/
|
|
137
|
+
export function syncMetadata(agentsData) {
|
|
138
|
+
const agents = agentsData.agents || {};
|
|
139
|
+
const entries = Object.values(agents);
|
|
140
|
+
|
|
141
|
+
agentsData.total_agents = entries.length;
|
|
142
|
+
agentsData.tiers = {
|
|
143
|
+
tier_1_orchestrators: entries.filter(a => a.tier === 1).length,
|
|
144
|
+
tier_2_leaders: entries.filter(a => a.tier === 2).length,
|
|
145
|
+
tier_3_specialists: entries.filter(a => a.tier === 3).length,
|
|
146
|
+
tier_4_validators: entries.filter(a => a.tier === 4).length,
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
return agentsData;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ── List + Show ────────────────────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* List agents with optional filters.
|
|
156
|
+
* @param {object} agentsData
|
|
157
|
+
* @param {{ group?: string, tier?: number }} [filters]
|
|
158
|
+
* @returns {{ agents: Array, groupCounts: Record<string, number>, total: number }}
|
|
159
|
+
*/
|
|
160
|
+
export function listAgents(agentsData, filters = {}) {
|
|
161
|
+
const agents = agentsData.agents || {};
|
|
162
|
+
let entries = Object.entries(agents).map(([id, data]) => ({ id, ...data }));
|
|
163
|
+
|
|
164
|
+
if (filters.group) {
|
|
165
|
+
entries = entries.filter(a => a.group === filters.group);
|
|
166
|
+
}
|
|
167
|
+
if (filters.tier != null) {
|
|
168
|
+
entries = entries.filter(a => a.tier === filters.tier);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
entries.sort((a, b) => a.tier - b.tier || a.id.localeCompare(b.id));
|
|
172
|
+
|
|
173
|
+
// groupCounts over ALL agents (not filtered)
|
|
174
|
+
const groupCounts = {};
|
|
175
|
+
for (const a of Object.values(agents)) {
|
|
176
|
+
groupCounts[a.group] = (groupCounts[a.group] || 0) + 1;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
agents: entries.map(a => ({
|
|
181
|
+
id: a.id,
|
|
182
|
+
tier: a.tier,
|
|
183
|
+
title: a.title,
|
|
184
|
+
group: a.group,
|
|
185
|
+
phases: Array.isArray(a.active_phases) ? a.active_phases.join(',') : '*',
|
|
186
|
+
keywords: (a.keywords || []).join(', ') || '\u2014',
|
|
187
|
+
})),
|
|
188
|
+
groupCounts,
|
|
189
|
+
total: entries.length,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Show detailed info for a single agent.
|
|
195
|
+
* @param {object} agentsData
|
|
196
|
+
* @param {string} id
|
|
197
|
+
* @returns {object|null}
|
|
198
|
+
*/
|
|
199
|
+
export function showAgent(agentsData, id) {
|
|
200
|
+
return agentsData.agents?.[id] ?? null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ── Mutations ──────────────────────────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Add a new agent.
|
|
207
|
+
* @param {object} agentsData
|
|
208
|
+
* @param {string} id
|
|
209
|
+
* @param {object} fields
|
|
210
|
+
* @returns {{ success: boolean, data?: object, changes?: string[], errors?: string[], warnings?: string[] }}
|
|
211
|
+
*/
|
|
212
|
+
export function addAgent(agentsData, id, fields) {
|
|
213
|
+
const existingIds = Object.keys(agentsData.agents || {});
|
|
214
|
+
const knownGroups = [...new Set(Object.values(agentsData.agents || {}).map(a => a.group))];
|
|
215
|
+
const validation = validateAgent(id, fields, existingIds, knownGroups, agentsData.agents);
|
|
216
|
+
|
|
217
|
+
if (!validation.valid) {
|
|
218
|
+
return { success: false, errors: validation.errors, warnings: validation.warnings };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const entry = {
|
|
222
|
+
tier: fields.tier,
|
|
223
|
+
title: fields.title,
|
|
224
|
+
group: fields.group,
|
|
225
|
+
active_phases: fields.active_phases || ['implement'],
|
|
226
|
+
reports_to: fields.reports_to || 'user',
|
|
227
|
+
domains: fields.domains || [],
|
|
228
|
+
keywords: fields.keywords || [],
|
|
229
|
+
validators: fields.validators || [],
|
|
230
|
+
standards: fields.standards || [],
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
if (fields.stacks) entry.stacks = fields.stacks;
|
|
234
|
+
if (fields.teammate) entry.teammate = fields.teammate;
|
|
235
|
+
|
|
236
|
+
agentsData.agents[id] = entry;
|
|
237
|
+
syncMetadata(agentsData);
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
success: true,
|
|
241
|
+
data: agentsData,
|
|
242
|
+
changes: [`Added agent '${id}' (tier ${fields.tier}, group '${fields.group}')`],
|
|
243
|
+
warnings: validation.warnings,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Edit existing agent fields.
|
|
249
|
+
* @param {object} agentsData
|
|
250
|
+
* @param {string} id
|
|
251
|
+
* @param {object} changes
|
|
252
|
+
* @returns {{ success: boolean, data?: object, changes?: string[], errors?: string[] }}
|
|
253
|
+
*/
|
|
254
|
+
export function editAgent(agentsData, id, changes) {
|
|
255
|
+
const agent = agentsData.agents?.[id];
|
|
256
|
+
if (!agent) {
|
|
257
|
+
return { success: false, errors: [`Agent '${id}' not found`] };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const changedFields = [];
|
|
261
|
+
|
|
262
|
+
// Handle spawn_prompt shortcut -> nested teammate
|
|
263
|
+
if (changes.spawn_prompt) {
|
|
264
|
+
if (!agent.teammate) {
|
|
265
|
+
agent.teammate = { role: 'specialist', icon: '\u{1F527}', spawn_prompt: changes.spawn_prompt };
|
|
266
|
+
} else {
|
|
267
|
+
agent.teammate.spawn_prompt = changes.spawn_prompt;
|
|
268
|
+
}
|
|
269
|
+
changedFields.push('teammate.spawn_prompt');
|
|
270
|
+
delete changes.spawn_prompt;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Apply direct field changes
|
|
274
|
+
for (const [key, value] of Object.entries(changes)) {
|
|
275
|
+
if (value !== undefined && agent[key] !== value) {
|
|
276
|
+
changedFields.push(key);
|
|
277
|
+
agent[key] = value;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (changedFields.length === 0) {
|
|
282
|
+
return { success: true, data: agentsData, changes: ['No changes detected'] };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
syncMetadata(agentsData);
|
|
286
|
+
return { success: true, data: agentsData, changes: changedFields.map(f => `${f} updated`) };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Remove an agent.
|
|
291
|
+
* @param {object} agentsData
|
|
292
|
+
* @param {string} id
|
|
293
|
+
* @returns {{ success: boolean, data?: object, dependents?: string[], errors?: string[] }}
|
|
294
|
+
*/
|
|
295
|
+
export function removeAgent(agentsData, id) {
|
|
296
|
+
if (!agentsData.agents?.[id]) {
|
|
297
|
+
return { success: false, errors: [`Agent '${id}' not found`] };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Find dependents
|
|
301
|
+
const dependents = Object.entries(agentsData.agents)
|
|
302
|
+
.filter(([aid, a]) => aid !== id && a.reports_to === id)
|
|
303
|
+
.map(([aid]) => aid);
|
|
304
|
+
|
|
305
|
+
delete agentsData.agents[id];
|
|
306
|
+
syncMetadata(agentsData);
|
|
307
|
+
|
|
308
|
+
return { success: true, data: agentsData, dependents };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ── File I/O ───────────────────────────────────────────────────────────────────
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Load and parse agents.json.
|
|
315
|
+
* @param {string} filePath
|
|
316
|
+
* @returns {object}
|
|
317
|
+
* @throws {Error} If file not found or invalid JSON
|
|
318
|
+
*/
|
|
319
|
+
export function loadAgentsFile(filePath) {
|
|
320
|
+
if (!existsSync(filePath)) {
|
|
321
|
+
throw new Error(`agents.json not found at ${filePath}. Run 'morph-spec doctor' to diagnose.`);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
try {
|
|
325
|
+
const content = readFileSync(filePath, 'utf8');
|
|
326
|
+
return JSON.parse(content);
|
|
327
|
+
} catch (err) {
|
|
328
|
+
if (err instanceof SyntaxError) {
|
|
329
|
+
throw new Error(`Failed to parse agents.json: ${err.message}. Run 'morph-spec doctor' to diagnose.`);
|
|
330
|
+
}
|
|
331
|
+
throw err;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Write agents.json atomically (write to .tmp, then rename).
|
|
337
|
+
* @param {string} filePath
|
|
338
|
+
* @param {object} agentsData
|
|
339
|
+
*/
|
|
340
|
+
export function saveAgentsFile(filePath, agentsData) {
|
|
341
|
+
const dir = dirname(filePath);
|
|
342
|
+
if (!existsSync(dir)) {
|
|
343
|
+
mkdirSync(dir, { recursive: true });
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const tmpPath = `${filePath}.tmp.${process.pid}`;
|
|
347
|
+
writeFileSync(tmpPath, JSON.stringify(agentsData, null, 2) + '\n', 'utf8');
|
|
348
|
+
renameSync(tmpPath, filePath);
|
|
349
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent Metrics Calculator — Pure computation, no I/O.
|
|
3
|
+
*
|
|
4
|
+
* Calculates per-agent performance metrics from dispatch log entries.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Calculate per-agent metrics from dispatch log entries.
|
|
9
|
+
* @param {Array} dispatchLog - Array of dispatch entries
|
|
10
|
+
* @returns {{ agents: Array, summary: Object }}
|
|
11
|
+
*/
|
|
12
|
+
export function calculateMetrics(dispatchLog) {
|
|
13
|
+
if (!Array.isArray(dispatchLog) || dispatchLog.length === 0) {
|
|
14
|
+
return {
|
|
15
|
+
agents: [],
|
|
16
|
+
summary: { totalDispatches: 0, totalTasks: 0, completedTasks: 0, completionRate: 0, avgTaskDurationMs: null }
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const agentMap = new Map();
|
|
21
|
+
|
|
22
|
+
for (const entry of dispatchLog) {
|
|
23
|
+
for (const agent of entry.agents || []) {
|
|
24
|
+
if (!agentMap.has(agent.id)) {
|
|
25
|
+
agentMap.set(agent.id, { dispatches: 0, tasksAssigned: 0, tasksCompleted: 0, durations: [] });
|
|
26
|
+
}
|
|
27
|
+
const acc = agentMap.get(agent.id);
|
|
28
|
+
acc.dispatches += 1;
|
|
29
|
+
acc.tasksAssigned += (agent.tasks || []).length;
|
|
30
|
+
acc.tasksCompleted += (agent.tasksCompleted || []).length;
|
|
31
|
+
|
|
32
|
+
if (agent.startedAt && agent.completedAt) {
|
|
33
|
+
const start = new Date(agent.startedAt).getTime();
|
|
34
|
+
const end = new Date(agent.completedAt).getTime();
|
|
35
|
+
if (!isNaN(start) && !isNaN(end) && end >= start) {
|
|
36
|
+
acc.durations.push(end - start);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const agents = [];
|
|
43
|
+
let totalDurationAll = 0;
|
|
44
|
+
let agentsWithDuration = 0;
|
|
45
|
+
|
|
46
|
+
for (const [id, acc] of agentMap) {
|
|
47
|
+
const totalDurationMs = acc.durations.length > 0
|
|
48
|
+
? acc.durations.reduce((s, d) => s + d, 0)
|
|
49
|
+
: null;
|
|
50
|
+
const avgDurationMs = acc.durations.length > 0
|
|
51
|
+
? Math.round(totalDurationMs / acc.durations.length)
|
|
52
|
+
: null;
|
|
53
|
+
|
|
54
|
+
if (totalDurationMs !== null) {
|
|
55
|
+
totalDurationAll += totalDurationMs;
|
|
56
|
+
agentsWithDuration++;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
agents.push({
|
|
60
|
+
id,
|
|
61
|
+
dispatches: acc.dispatches,
|
|
62
|
+
tasksAssigned: acc.tasksAssigned,
|
|
63
|
+
tasksCompleted: acc.tasksCompleted,
|
|
64
|
+
completionRate: acc.tasksAssigned > 0 ? acc.tasksCompleted / acc.tasksAssigned : 0,
|
|
65
|
+
avgDurationMs,
|
|
66
|
+
totalDurationMs
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
agents.sort((a, b) => b.tasksCompleted - a.tasksCompleted);
|
|
71
|
+
|
|
72
|
+
const totalTasks = agents.reduce((s, a) => s + a.tasksAssigned, 0);
|
|
73
|
+
const completedTasks = agents.reduce((s, a) => s + a.tasksCompleted, 0);
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
agents,
|
|
77
|
+
summary: {
|
|
78
|
+
totalDispatches: dispatchLog.length,
|
|
79
|
+
totalTasks,
|
|
80
|
+
completedTasks,
|
|
81
|
+
completionRate: totalTasks > 0 ? completedTasks / totalTasks : 0,
|
|
82
|
+
avgTaskDurationMs: completedTasks > 0 && totalDurationAll > 0
|
|
83
|
+
? Math.round(totalDurationAll / completedTasks)
|
|
84
|
+
: null
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Format milliseconds as human-readable duration.
|
|
91
|
+
* @param {number|null} ms
|
|
92
|
+
* @returns {string}
|
|
93
|
+
*/
|
|
94
|
+
export function formatDuration(ms) {
|
|
95
|
+
if (ms == null || ms < 0) return '\u2014';
|
|
96
|
+
if (ms === 0) return '0s';
|
|
97
|
+
|
|
98
|
+
const totalSeconds = Math.floor(ms / 1000);
|
|
99
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
100
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
101
|
+
const seconds = totalSeconds % 60;
|
|
102
|
+
|
|
103
|
+
if (hours > 0) return `${hours}h ${minutes}m`;
|
|
104
|
+
if (minutes > 0) return `${minutes}m`;
|
|
105
|
+
return `${seconds}s`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Calculate cross-feature aggregated metrics.
|
|
110
|
+
* @param {Object} features - state.features object
|
|
111
|
+
* @returns {{ agents: Array, summary: Object, featureCount: number }}
|
|
112
|
+
*/
|
|
113
|
+
export function calculateAggregatedMetrics(features) {
|
|
114
|
+
const allEntries = [];
|
|
115
|
+
let featureCount = 0;
|
|
116
|
+
|
|
117
|
+
for (const feature of Object.values(features || {})) {
|
|
118
|
+
featureCount++;
|
|
119
|
+
const log = feature.dispatchLog || [];
|
|
120
|
+
allEntries.push(...log);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const result = calculateMetrics(allEntries);
|
|
124
|
+
return { ...result, featureCount };
|
|
125
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* output-collector — Collect phase output files for multi-source agent detection
|
|
3
|
+
*
|
|
4
|
+
* Reads all existing phase outputs for a feature and returns them as
|
|
5
|
+
* weighted TextSource[] for use in detectAgents().
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFileSync, existsSync } from 'fs';
|
|
9
|
+
import { OUTPUT_SCHEMA, getAbsoluteOutputPath } from '../../core/paths/output-schema.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Default source weights per output type.
|
|
13
|
+
* Keys match OUTPUT_SCHEMA keys (camelCase).
|
|
14
|
+
*/
|
|
15
|
+
export const SOURCE_WEIGHTS = {
|
|
16
|
+
proposal: 1.0,
|
|
17
|
+
schemaAnalysis: 1.0,
|
|
18
|
+
spec: 1.5,
|
|
19
|
+
contracts: 1.0,
|
|
20
|
+
contractsTs: 1.0,
|
|
21
|
+
contractsVsa: 1.0,
|
|
22
|
+
clarifications: 1.0,
|
|
23
|
+
decisions: 1.0,
|
|
24
|
+
plan: 1.2,
|
|
25
|
+
tasks: 1.3,
|
|
26
|
+
uiDesignSystem: 1.0,
|
|
27
|
+
uiMockups: 1.0,
|
|
28
|
+
uiComponents: 1.0,
|
|
29
|
+
uiFlows: 1.0,
|
|
30
|
+
recap: 1.0,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** Phase order for sorting collected outputs */
|
|
34
|
+
const PHASE_ORDER = ['proposal', 'uiux', 'design', 'clarify', 'plan', 'tasks', 'implement'];
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Collect all existing phase output files for a feature.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} projectRoot - Absolute path to project root
|
|
40
|
+
* @param {string} featureName - Feature name
|
|
41
|
+
* @returns {Array<{source: string, content: string, weight: number}>}
|
|
42
|
+
*/
|
|
43
|
+
export function collectPhaseOutputs(projectRoot, featureName) {
|
|
44
|
+
const results = [];
|
|
45
|
+
|
|
46
|
+
for (const [key, entry] of Object.entries(OUTPUT_SCHEMA)) {
|
|
47
|
+
const absPath = getAbsoluteOutputPath(projectRoot, featureName, key);
|
|
48
|
+
if (!existsSync(absPath)) continue;
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
const content = readFileSync(absPath, 'utf8');
|
|
52
|
+
if (!content.trim()) continue; // Skip empty files
|
|
53
|
+
|
|
54
|
+
results.push({
|
|
55
|
+
source: entry.filename,
|
|
56
|
+
content,
|
|
57
|
+
weight: SOURCE_WEIGHTS[key] ?? 1.0,
|
|
58
|
+
_phase: entry.phase,
|
|
59
|
+
});
|
|
60
|
+
} catch {
|
|
61
|
+
// Skip unreadable files — fail-open
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Sort by phase order
|
|
67
|
+
results.sort((a, b) => {
|
|
68
|
+
const orderA = PHASE_ORDER.indexOf(a._phase);
|
|
69
|
+
const orderB = PHASE_ORDER.indexOf(b._phase);
|
|
70
|
+
return (orderA === -1 ? 99 : orderA) - (orderB === -1 ? 99 : orderB);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// Remove internal _phase field
|
|
74
|
+
return results.map(({ source, content, weight }) => ({ source, content, weight }));
|
|
75
|
+
}
|
|
@@ -15,6 +15,7 @@ import fs from 'fs/promises';
|
|
|
15
15
|
import path from 'path';
|
|
16
16
|
import { existsSync } from 'fs';
|
|
17
17
|
import { getOutputPath } from '../../core/paths/output-schema.js';
|
|
18
|
+
import { tierToRole } from '../../utils/tier-helpers.js';
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
21
|
* Resolve a template path: check .morph/framework/templates/ first, then framework/templates/.
|
|
@@ -259,7 +260,7 @@ export async function generateProjectContext(projectPath) {
|
|
|
259
260
|
.map(([id, agent]) => ({
|
|
260
261
|
id,
|
|
261
262
|
title: agent.title,
|
|
262
|
-
always_active: agent.always_active
|
|
263
|
+
always_active: Array.isArray(agent.active_phases) ? agent.active_phases.includes('*') : !!agent.always_active
|
|
263
264
|
})),
|
|
264
265
|
TIER_2_AGENTS: Object.entries(agents?.agents || {})
|
|
265
266
|
.filter(([id, agent]) => !id.startsWith('_') && agent.tier === 2)
|
|
@@ -273,7 +274,7 @@ export async function generateProjectContext(projectPath) {
|
|
|
273
274
|
.map(([id, agent]) => ({
|
|
274
275
|
id,
|
|
275
276
|
title: agent.title,
|
|
276
|
-
reports_to: agent.relationships?.reports_to || 'N/A'
|
|
277
|
+
reports_to: agent.reports_to || agent.relationships?.reports_to || 'N/A'
|
|
277
278
|
})),
|
|
278
279
|
|
|
279
280
|
// Agent Teams config
|
|
@@ -381,10 +382,10 @@ export async function generateFeatureContext(projectPath, featureName) {
|
|
|
381
382
|
.map(id => ({ id, ...agents.agents[id] }));
|
|
382
383
|
|
|
383
384
|
// Group by tier and build squads
|
|
384
|
-
const teamLead = activeAgents.find(a => a.tier ===
|
|
385
|
-
const domainLeaders = activeAgents.filter(a => a.tier ===
|
|
386
|
-
const specialists = activeAgents.filter(a => a.tier ===
|
|
387
|
-
const validators = activeAgents.filter(a => a.tier ===
|
|
385
|
+
const teamLead = activeAgents.find(a => tierToRole(a.tier) === 'orchestrator');
|
|
386
|
+
const domainLeaders = activeAgents.filter(a => tierToRole(a.tier) === 'leader');
|
|
387
|
+
const specialists = activeAgents.filter(a => tierToRole(a.tier) === 'specialist');
|
|
388
|
+
const validators = activeAgents.filter(a => tierToRole(a.tier) === 'validator');
|
|
388
389
|
|
|
389
390
|
const squads = domainLeaders.map(leader => ({
|
|
390
391
|
leader: {
|
|
@@ -392,7 +393,7 @@ export async function generateFeatureContext(projectPath, featureName) {
|
|
|
392
393
|
title: leader.title
|
|
393
394
|
},
|
|
394
395
|
members: specialists
|
|
395
|
-
.filter(s => s.relationships?.reports_to === leader.id)
|
|
396
|
+
.filter(s => (s.reports_to || s.relationships?.reports_to) === leader.id)
|
|
396
397
|
.map(s => ({
|
|
397
398
|
id: s.id,
|
|
398
399
|
title: s.title
|