@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.
Files changed (47) hide show
  1. package/CLAUDE.md +3 -0
  2. package/README.md +2 -2
  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.json +345 -708
  9. package/framework/hooks/claude-code/post-tool-use/dispatch.js +10 -5
  10. package/framework/skills/README.md +0 -1
  11. package/framework/skills/level-1-workflows/morph-phase-clarify/SKILL.md +1 -1
  12. package/framework/skills/level-1-workflows/morph-phase-codebase-analysis/SKILL.md +1 -1
  13. package/framework/skills/level-1-workflows/morph-phase-design/SKILL.md +1 -1
  14. package/framework/skills/level-1-workflows/morph-phase-implement/SKILL.md +42 -13
  15. package/framework/skills/level-1-workflows/morph-phase-plan/SKILL.md +1 -1
  16. package/framework/skills/level-1-workflows/morph-phase-setup/SKILL.md +14 -10
  17. package/framework/skills/level-1-workflows/morph-phase-tasks/SKILL.md +1 -1
  18. package/framework/skills/level-1-workflows/morph-phase-uiux/SKILL.md +1 -1
  19. package/framework/skills/level-1-workflows/morph-scope-escalation/SKILL.md +1 -1
  20. package/package.json +1 -1
  21. package/src/commands/agents/agent-metrics.js +174 -0
  22. package/src/commands/agents/audit-dispatch.js +82 -0
  23. package/src/commands/agents/detect-agents.js +275 -0
  24. package/src/commands/agents/dispatch-agents.js +41 -135
  25. package/src/commands/agents/index.js +9 -0
  26. package/src/commands/agents/manage-agents.js +352 -0
  27. package/src/commands/project/doctor.js +59 -0
  28. package/src/commands/project/update.js +1 -17
  29. package/src/commands/state/advance-phase.js +11 -3
  30. package/src/core/state/state-manager.js +81 -0
  31. package/src/lib/agents/agents-manager.js +349 -0
  32. package/src/lib/agents/metrics-calculator.js +125 -0
  33. package/src/lib/agents/output-collector.js +75 -0
  34. package/src/lib/generators/context-generator.js +8 -7
  35. package/src/lib/tasks/task-parser.js +94 -3
  36. package/src/lib/validators/checkpoint-persistence.js +39 -0
  37. package/src/lib/validators/content/content-validator.js +9 -1
  38. package/src/lib/validators/spec-validator.js +2 -2
  39. package/src/lib/validators/validation-runner.js +20 -0
  40. package/src/scripts/setup-infra.js +1 -17
  41. package/src/utils/agent-id-mapper.js +11 -0
  42. package/src/utils/agents-installer.js +40 -8
  43. package/src/utils/claude-settings-manager.js +1 -1
  44. package/src/utils/hooks-installer.js +0 -245
  45. package/src/utils/tier-helpers.js +24 -0
  46. package/framework/agents/architecture/po-pm-advisor.md +0 -203
  47. 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, 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';
@@ -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
+ }