@aion0/forge 0.5.22 → 0.5.24

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.
@@ -32,7 +32,8 @@ export interface SessionEntry {
32
32
  * Claude uses: ~/.claude/projects/<path-with-slashes-replaced-by-dashes>/
33
33
  */
34
34
  export function projectPathToClaudeDir(projectPath: string): string {
35
- const hash = projectPath.replace(/\//g, '-');
35
+ // Claude Code encodes paths by replacing all non-alphanumeric chars with '-'
36
+ const hash = projectPath.replace(/[^a-zA-Z0-9]/g, '-');
36
37
  return join(getClaudeDir(), 'projects', hash);
37
38
  }
38
39
 
@@ -117,9 +117,10 @@ function createForgeMcpServer(sessionId: string): McpServer {
117
117
  const snapshot = orch.getSnapshot();
118
118
  const getLabel = (id: string) => snapshot.agents.find((a: any) => a.id === id)?.label || id;
119
119
 
120
- const formatted = messages.map((m: any) =>
121
- `[${m.status}] From ${getLabel(m.from)}: ${m.payload?.content || m.payload?.action || '(no content)'} (${m.id.slice(0, 8)})`
122
- ).join('\n');
120
+ const formatted = messages.map((m: any) => {
121
+ const refInfo = m.payload?.ref ? ` [ref: ${m.payload.ref}]` : '';
122
+ return `[${m.status}] From ${getLabel(m.from)}: ${m.payload?.content || m.payload?.action || '(no content)'}${refInfo} (${m.id.slice(0, 8)})`;
123
+ }).join('\n');
123
124
 
124
125
  return { content: [{ type: 'text', text: formatted }] };
125
126
  } catch (err: any) {
@@ -157,7 +158,7 @@ function createForgeMcpServer(sessionId: string): McpServer {
157
158
  // ── get_status ────────────────────────────
158
159
  server.tool(
159
160
  'get_status',
160
- 'Get status of all agents in the workspace',
161
+ 'Get live status of all agents in the workspace (from topology cache)',
161
162
  {},
162
163
  async () => {
163
164
  const { workspaceId } = ctx();
@@ -165,18 +166,15 @@ function createForgeMcpServer(sessionId: string): McpServer {
165
166
 
166
167
  try {
167
168
  const orch = getOrch(workspaceId);
168
- const snapshot = orch.getSnapshot();
169
- const states = orch.getAllAgentStates();
170
-
171
- const lines = snapshot.agents
172
- .filter((a: any) => a.type !== 'input')
173
- .map((a: any) => {
174
- const s = states[a.id];
175
- const smith = s?.smithStatus || 'down';
176
- const task = s?.taskStatus || 'idle';
177
- const icon = smith === 'active' ? (task === 'running' ? '🔵' : task === 'done' ? '✅' : task === 'failed' ? '🔴' : '🟢') : '⬚';
178
- return `${icon} ${a.label}: smith=${smith} task=${task}${s?.error ? ` error=${s.error}` : ''}`;
179
- });
169
+ const topo = orch.getWorkspaceTopo();
170
+
171
+ const lines = topo.agents.map((a: any) => {
172
+ const icon = a.smithStatus === 'active'
173
+ ? (a.taskStatus === 'running' ? '🔵' : a.taskStatus === 'done' ? '✅' : a.taskStatus === 'failed' ? '🔴' : '🟢')
174
+ : '⬚';
175
+ return `${icon} ${a.label}: smith=${a.smithStatus} task=${a.taskStatus}`;
176
+ });
177
+ lines.unshift(`Flow: ${topo.flow}\n`);
180
178
 
181
179
  return { content: [{ type: 'text', text: lines.join('\n') || 'No agents configured.' }] };
182
180
  } catch (err: any) {
@@ -188,7 +186,7 @@ function createForgeMcpServer(sessionId: string): McpServer {
188
186
  // ── get_agents ────────────────────────────
189
187
  server.tool(
190
188
  'get_agents',
191
- 'Get all agents in the workspace with their roles and relationships. Use this to understand who does what before sending messages.',
189
+ 'Get workspace topology — all agents, their roles, relationships, current status, and execution flow. Cached and auto-refreshed on any agent change. Call this to understand the full team composition before planning work.',
192
190
  {},
193
191
  async () => {
194
192
  const { workspaceId, agentId } = ctx();
@@ -196,24 +194,35 @@ function createForgeMcpServer(sessionId: string): McpServer {
196
194
 
197
195
  try {
198
196
  const orch = getOrch(workspaceId);
199
- const snapshot = orch.getSnapshot();
197
+ const topo = orch.getWorkspaceTopo();
198
+
199
+ const lines: string[] = [];
200
+ lines.push(`## Workspace Topology (${topo.agents.length} agents)`);
201
+ lines.push(`Flow: ${topo.flow}\n`);
202
+
203
+ // Identify present and missing standard roles
204
+ const labels = new Set(topo.agents.map((a: any) => a.label.toLowerCase()));
205
+ const standardRoles = ['architect', 'engineer', 'qa', 'reviewer', 'pm', 'lead'];
206
+ const present = standardRoles.filter(r => labels.has(r));
207
+ const missing = standardRoles.filter(r => !labels.has(r));
208
+ if (missing.length > 0) {
209
+ lines.push(`Present roles: ${present.join(', ') || 'none'}`);
210
+ lines.push(`Missing roles: ${missing.join(', ')} — these responsibilities must be covered by existing agents\n`);
211
+ }
200
212
 
201
- const agents = snapshot.agents
202
- .filter((a: any) => a.type !== 'input')
203
- .map((a: any) => {
204
- const deps = a.dependsOn
205
- .map((depId: string) => snapshot.agents.find((d: any) => d.id === depId)?.label || depId)
206
- .join(', ');
207
- const isMe = a.id === agentId;
208
- return [
209
- `${a.icon} ${a.label}${isMe ? ' (you)' : ''}${a.primary ? ' [PRIMARY]' : ''}`,
210
- ` Role: ${a.role || '(no role defined)'}`,
211
- deps ? ` Depends on: ${deps}` : null,
212
- a.workDir && a.workDir !== './' ? ` Work dir: ${a.workDir}` : null,
213
- ].filter(Boolean).join('\n');
214
- });
213
+ for (const a of topo.agents as any[]) {
214
+ const isMe = a.id === agentId;
215
+ lines.push(`### ${a.icon} ${a.label}${isMe ? ' ← YOU' : ''}${a.primary ? ' [PRIMARY]' : ''}`);
216
+ lines.push(`Status: smith=${a.smithStatus} task=${a.taskStatus}`);
217
+ lines.push(`Role: ${a.roleSummary}`);
218
+ if (a.dependsOn.length > 0) lines.push(`Depends on: ${a.dependsOn.join(', ')}`);
219
+ if (a.workDir !== './') lines.push(`Work dir: ${a.workDir}`);
220
+ if (a.outputs.length > 0) lines.push(`Outputs: ${a.outputs.join(', ')}`);
221
+ if (a.steps.length > 0) lines.push(`Steps: ${a.steps.join(' ')}`);
222
+ lines.push('');
223
+ }
215
224
 
216
- return { content: [{ type: 'text', text: agents.join('\n\n') || 'No agents configured.' }] };
225
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
217
226
  } catch (err: any) {
218
227
  return { content: [{ type: 'text', text: `Error: ${err.message}` }] };
219
228
  }
@@ -401,6 +410,211 @@ function createForgeMcpServer(sessionId: string): McpServer {
401
410
  }
402
411
  );
403
412
 
413
+ // ─── Request/Response Document Tools ─────────────────────
414
+
415
+ server.tool(
416
+ 'create_request',
417
+ 'Create a new request document for implementation. Auto-notifies downstream agents via DAG.',
418
+ {
419
+ title: z.string().describe('Short title for the request'),
420
+ description: z.string().describe('Detailed description of what to implement'),
421
+ type: z.enum(['feature', 'bugfix', 'refactor', 'task']).optional().describe('Request type (default: feature)'),
422
+ modules: z.array(z.object({
423
+ name: z.string(),
424
+ description: z.string(),
425
+ acceptance_criteria: z.array(z.string()),
426
+ })).describe('Feature modules with acceptance criteria'),
427
+ batch: z.string().optional().describe('Batch name to group related requests (default: auto-generated from date)'),
428
+ priority: z.enum(['high', 'medium', 'low']).optional().describe('Priority level (default: medium)'),
429
+ },
430
+ async (params) => {
431
+ const { workspaceId, agentId } = ctx();
432
+ if (!workspaceId) return { content: [{ type: 'text', text: 'Error: No workspace context.' }] };
433
+ try {
434
+ const orch = getOrch(workspaceId);
435
+ const { createRequest } = await import('./workspace/requests') as any;
436
+ const batch = params.batch || `delivery-${new Date().toISOString().slice(0, 10)}`;
437
+ const agentLabel = orch.getSnapshot().agents.find((a: any) => a.id === agentId)?.label || agentId;
438
+
439
+ const ref = createRequest(orch.projectPath, {
440
+ title: params.title,
441
+ description: params.description,
442
+ type: params.type || 'feature',
443
+ modules: params.modules,
444
+ batch,
445
+ priority: params.priority || 'medium',
446
+ status: 'open',
447
+ assigned_to: '',
448
+ created_by: agentLabel,
449
+ });
450
+
451
+ // Auto-notify downstream agents via DAG
452
+ const snapshot = orch.getSnapshot();
453
+ const notified: string[] = [];
454
+ for (const agent of snapshot.agents) {
455
+ if (agent.type === 'input') continue;
456
+ if (!agent.dependsOn?.includes(agentId)) continue;
457
+ orch.getBus().send(agentId, agent.id, 'notify', {
458
+ action: 'new_request',
459
+ content: `New request: ${params.title} [${params.priority || 'medium'}] — ${params.modules.length} module(s). Use list_requests and claim_request to pick it up.`,
460
+ ref,
461
+ });
462
+ notified.push(agent.label);
463
+ }
464
+
465
+ return { content: [{ type: 'text', text: `Created request: ${ref}\nBatch: ${batch}\nModules: ${params.modules.length}\nNotified: ${notified.length > 0 ? notified.join(', ') : '(no downstream agents)'}` }] };
466
+ } catch (err: any) {
467
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }] };
468
+ }
469
+ }
470
+ );
471
+
472
+ server.tool(
473
+ 'claim_request',
474
+ 'Claim an open request for implementation. Prevents other agents from working on the same request.',
475
+ {
476
+ request_id: z.string().describe('Request ID to claim (e.g., REQ-20260403-001)'),
477
+ },
478
+ async (params) => {
479
+ const { workspaceId, agentId } = ctx();
480
+ if (!workspaceId) return { content: [{ type: 'text', text: 'Error: No workspace context.' }] };
481
+ try {
482
+ const orch = getOrch(workspaceId);
483
+ const { claimRequest } = await import('./workspace/requests') as any;
484
+ const agentLabel = orch.getSnapshot().agents.find((a: any) => a.id === agentId)?.label || agentId;
485
+
486
+ const result = claimRequest(orch.projectPath, params.request_id, agentLabel);
487
+ if (!result.ok) {
488
+ return { content: [{ type: 'text', text: `Cannot claim ${params.request_id}: already claimed by ${result.claimedBy}. Use list_requests(status: "open") to find available requests.` }] };
489
+ }
490
+ return { content: [{ type: 'text', text: `Claimed ${params.request_id}. Status: in_progress. You can now implement it and use update_response when done.` }] };
491
+ } catch (err: any) {
492
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }] };
493
+ }
494
+ }
495
+ );
496
+
497
+ server.tool(
498
+ 'update_response',
499
+ 'Update a response document with your work results. Auto-advances status and notifies downstream agents via DAG.',
500
+ {
501
+ request_id: z.string().describe('Request ID (e.g., REQ-20260403-001)'),
502
+ section: z.enum(['engineer', 'review', 'qa']).describe('Which section to update'),
503
+ data: z.object({
504
+ files_changed: z.array(z.string()).optional().describe('Files modified (engineer)'),
505
+ notes: z.string().optional().describe('Implementation notes (engineer)'),
506
+ result: z.string().optional().describe('Result: approved/changes_requested/rejected (review) or passed/failed (qa)'),
507
+ findings: z.array(z.object({
508
+ severity: z.string(),
509
+ description: z.string(),
510
+ })).optional().describe('Issues found (review/qa)'),
511
+ test_files: z.array(z.string()).optional().describe('Test files run (qa)'),
512
+ }).describe('Response data for your section'),
513
+ },
514
+ async (params) => {
515
+ const { workspaceId, agentId } = ctx();
516
+ if (!workspaceId) return { content: [{ type: 'text', text: 'Error: No workspace context.' }] };
517
+ try {
518
+ const orch = getOrch(workspaceId);
519
+ const { updateResponse, getRequest } = await import('./workspace/requests') as any;
520
+
521
+ const ref = updateResponse(orch.projectPath, params.request_id, params.section, params.data);
522
+ const updated = getRequest(orch.projectPath, params.request_id);
523
+ const newStatus = updated?.request?.status || 'unknown';
524
+
525
+ // Auto-notify downstream agents via DAG
526
+ const snapshot = orch.getSnapshot();
527
+ const notified: string[] = [];
528
+ for (const agent of snapshot.agents) {
529
+ if (agent.type === 'input') continue;
530
+ if (!agent.dependsOn?.includes(agentId)) continue;
531
+ orch.getBus().send(agentId, agent.id, 'notify', {
532
+ action: 'response_updated',
533
+ content: `${params.section} completed for ${params.request_id} → status: ${newStatus}. Use get_request to review details.`,
534
+ ref,
535
+ });
536
+ notified.push(agent.label);
537
+ }
538
+
539
+ return { content: [{ type: 'text', text: `Updated ${params.request_id} [${params.section}] → status: ${newStatus}\nNotified: ${notified.length > 0 ? notified.join(', ') : '(no downstream agents)'}` }] };
540
+ } catch (err: any) {
541
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }] };
542
+ }
543
+ }
544
+ );
545
+
546
+ server.tool(
547
+ 'list_requests',
548
+ 'List all request documents in the project, optionally filtered by batch or status.',
549
+ {
550
+ batch: z.string().optional().describe('Filter by batch/delivery name'),
551
+ status: z.enum(['open', 'in_progress', 'review', 'qa', 'done', 'rejected']).optional().describe('Filter by status'),
552
+ },
553
+ async (params) => {
554
+ const { workspaceId } = ctx();
555
+ if (!workspaceId) return { content: [{ type: 'text', text: 'Error: No workspace context.' }] };
556
+ try {
557
+ const orch = getOrch(workspaceId);
558
+ const { listRequests, getBatchStatus } = await import('./workspace/requests') as any;
559
+
560
+ const requests = listRequests(orch.projectPath, { batch: params.batch, status: params.status as any });
561
+ if (requests.length === 0) {
562
+ return { content: [{ type: 'text', text: params.batch || params.status ? 'No requests match the filter.' : 'No requests found. Use create_request to create one.' }] };
563
+ }
564
+
565
+ const lines = requests.map((r: any) =>
566
+ `[${r.status}] ${r.id}: ${r.title} (${r.priority}) — ${r.modules?.length || 0} module(s)${r.assigned_to ? ` → ${r.assigned_to}` : ''}`
567
+ );
568
+
569
+ // Add batch summary if filtering by batch
570
+ if (params.batch) {
571
+ const bs = getBatchStatus(orch.projectPath, params.batch);
572
+ lines.push(`\nBatch "${params.batch}": ${bs.done}/${bs.total} done${bs.allDone ? ' ✓ ALL COMPLETE' : ''}`);
573
+ }
574
+
575
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
576
+ } catch (err: any) {
577
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }] };
578
+ }
579
+ }
580
+ );
581
+
582
+ server.tool(
583
+ 'get_request',
584
+ 'Get full details of a request document and its response.',
585
+ {
586
+ request_id: z.string().describe('Request ID (e.g., REQ-20260403-001)'),
587
+ },
588
+ async (params) => {
589
+ const { workspaceId } = ctx();
590
+ if (!workspaceId) return { content: [{ type: 'text', text: 'Error: No workspace context.' }] };
591
+ try {
592
+ const orch = getOrch(workspaceId);
593
+ const { getRequest } = await import('./workspace/requests') as any;
594
+
595
+ const result = getRequest(orch.projectPath, params.request_id);
596
+ if (!result) return { content: [{ type: 'text', text: `Request "${params.request_id}" not found.` }] };
597
+
598
+ const YAML = (await import('yaml')).default;
599
+ let text = `# Request: ${result.request.title}\n\n`;
600
+ text += YAML.stringify(result.request);
601
+ if (result.response) {
602
+ text += `\n---\n# Response\n\n`;
603
+ text += YAML.stringify(result.response);
604
+ } else {
605
+ text += `\n---\nNo response yet.`;
606
+ }
607
+
608
+ return { content: [{ type: 'text', text }] };
609
+ } catch (err: any) {
610
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }] };
611
+ }
612
+ }
613
+ );
614
+
615
+ // Update get_inbox to show ref field
616
+ // (Already handled — ref is part of payload, shown via content)
617
+
404
618
  return server;
405
619
  }
406
620