@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,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;
|
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
getUpdateInstructions,
|
|
26
26
|
detectInstallMethod
|
|
27
27
|
} from '../../utils/version-checker.js';
|
|
28
|
-
import { installClaudeHooks, installGlobalStatusline
|
|
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';
|
|
@@ -309,22 +309,6 @@ export async function updateCommand(options) {
|
|
|
309
309
|
updateSpinner.text = 'Updating Claude Code hooks...';
|
|
310
310
|
const hooksResult = await installClaudeHooks(targetPath);
|
|
311
311
|
|
|
312
|
-
// Install shell integration (terminal title watcher)
|
|
313
|
-
updateSpinner.text = 'Installing shell integration...';
|
|
314
|
-
try {
|
|
315
|
-
await installShellIntegration();
|
|
316
|
-
} catch {
|
|
317
|
-
logger.dim(' ⚠ Could not install shell integration (non-critical)');
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
// Configure VS Code terminal settings (enable OSC title sequences)
|
|
321
|
-
updateSpinner.text = 'Configuring VS Code terminal settings...';
|
|
322
|
-
try {
|
|
323
|
-
await installVSCodeTerminalSettings();
|
|
324
|
-
} catch {
|
|
325
|
-
logger.dim(' ⚠ Could not configure VS Code terminal settings (non-critical)');
|
|
326
|
-
}
|
|
327
|
-
|
|
328
312
|
// Ensure root CLAUDE.md has morph-spec @import (preserves user content)
|
|
329
313
|
updateSpinner.text = 'Checking CLAUDE.md import...';
|
|
330
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
|
+
}
|