echelon-dev 1.0.0

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.
@@ -0,0 +1,610 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Echelon Dev Team - Multi-Agent Pair Programming
5
+ *
6
+ * Coordinates Knoxis, Solan, and Astrahelm as a dev team working
7
+ * together on a shared codebase. Each agent is a generalist engineer
8
+ * with a specialty — they contribute what's useful, nothing more.
9
+ *
10
+ * Usage:
11
+ * node echelon-pair-program.js --workspace /path --prompt "task"
12
+ * node echelon-pair-program.js -w my-project --prompt "add auth" --agents knoxis,astrahelm
13
+ * node echelon-pair-program.js --workspace /path --prompt-base64 "..." --timeline-base64 "..."
14
+ *
15
+ * ZERO EXTERNAL DEPENDENCIES
16
+ */
17
+
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+ const os = require('os');
21
+ const { spawn, spawnSync } = require('child_process');
22
+ const { getAgent, getAllAgents, getAgentIds, colorize, buildTeamSystemPrompt, RESET } = require('./agents');
23
+ const { SessionRecorder } = require('./session-recorder');
24
+
25
+ // ===== ARGUMENT PARSING =====
26
+
27
+ function parseArgs(argv) {
28
+ const args = {};
29
+ const multi = {};
30
+ for (let i = 2; i < argv.length; i++) {
31
+ const arg = argv[i];
32
+ if (arg.startsWith('--')) {
33
+ const key = arg.slice(2);
34
+ const next = argv[i + 1];
35
+ if (!next || next.startsWith('--')) {
36
+ args[key] = true;
37
+ } else {
38
+ if (multi[key]) {
39
+ multi[key].push(next);
40
+ } else if (args[key]) {
41
+ multi[key] = [args[key], next];
42
+ delete args[key];
43
+ } else {
44
+ args[key] = next;
45
+ }
46
+ i++;
47
+ }
48
+ }
49
+ }
50
+ Object.entries(multi).forEach(([key, list]) => { args[key] = list; });
51
+ return args;
52
+ }
53
+
54
+ function decodeBase64(value) {
55
+ try {
56
+ return Buffer.from(value, 'base64').toString('utf8');
57
+ } catch (err) {
58
+ console.error('Failed to decode base64 value:', err.message);
59
+ process.exit(1);
60
+ }
61
+ }
62
+
63
+ function decodeJsonBase64(value, label) {
64
+ const decoded = decodeBase64(value);
65
+ try {
66
+ return JSON.parse(decoded);
67
+ } catch (err) {
68
+ console.error(`Failed to parse ${label} JSON: ${err.message}`);
69
+ process.exit(1);
70
+ }
71
+ }
72
+
73
+ // ===== UTILITY =====
74
+
75
+ function commandExists(cmd) {
76
+ const detector = process.platform === 'win32' ? 'where' : 'which';
77
+ const result = spawnSync(detector, [cmd], { stdio: 'ignore' });
78
+ return result.status === 0;
79
+ }
80
+
81
+ function resolveWorkspacePath(nameOrPath) {
82
+ if (fs.existsSync(nameOrPath)) {
83
+ return path.resolve(nameOrPath);
84
+ }
85
+
86
+ // Try workspace registries (echelon + knoxis)
87
+ const registries = [
88
+ path.join(os.homedir(), '.echelon', 'workspaces.json'),
89
+ path.join(os.homedir(), '.knoxis', 'workspaces.json')
90
+ ];
91
+
92
+ for (const registryFile of registries) {
93
+ if (fs.existsSync(registryFile)) {
94
+ try {
95
+ const workspaces = JSON.parse(fs.readFileSync(registryFile, 'utf8'));
96
+ if (workspaces[nameOrPath]) return workspaces[nameOrPath];
97
+ const lower = nameOrPath.toLowerCase();
98
+ for (const [name, wsPath] of Object.entries(workspaces)) {
99
+ if (name.toLowerCase().includes(lower)) {
100
+ console.log(`Matched workspace: ${name} -> ${wsPath}`);
101
+ return wsPath;
102
+ }
103
+ }
104
+ } catch (e) {}
105
+ }
106
+ }
107
+
108
+ return null;
109
+ }
110
+
111
+ function resolveAiProvider(preference) {
112
+ const normalized = (preference || 'auto').toLowerCase();
113
+ if (normalized === 'claude' && commandExists('claude')) {
114
+ return { cmd: 'claude', args: ['--dangerously-skip-permissions'], label: 'Claude Code' };
115
+ }
116
+ if (normalized === 'codex' && commandExists('codex')) {
117
+ return { cmd: 'codex', args: [], label: 'Codex' };
118
+ }
119
+ if (normalized === 'auto') {
120
+ if (commandExists('claude')) {
121
+ return { cmd: 'claude', args: ['--dangerously-skip-permissions'], label: 'Claude Code' };
122
+ }
123
+ if (commandExists('codex')) {
124
+ return { cmd: 'codex', args: [], label: 'Codex' };
125
+ }
126
+ }
127
+ console.error('No supported AI provider found. Install the Claude or Codex CLI and try again.');
128
+ process.exit(1);
129
+ }
130
+
131
+ function toArray(value) {
132
+ if (!value) return [];
133
+ if (Array.isArray(value)) {
134
+ const result = [];
135
+ value.forEach(item => result.push(...toArray(item)));
136
+ return result;
137
+ }
138
+ return [value];
139
+ }
140
+
141
+ function formatSection(title, body) {
142
+ const border = '-'.repeat(title.length + 4);
143
+ return `${border}\n| ${title} |\n${border}\n${body.trim()}\n`;
144
+ }
145
+
146
+ function gatherContext(workspace, inputs) {
147
+ const sections = [];
148
+ const labels = [];
149
+ const seen = new Set();
150
+
151
+ toArray(inputs).forEach(entry => {
152
+ if (typeof entry !== 'string') return;
153
+ const trimmed = entry.trim();
154
+ if (!trimmed) return;
155
+ const absolute = path.isAbsolute(trimmed) ? trimmed : path.join(workspace, trimmed);
156
+ if (!fs.existsSync(absolute)) return;
157
+ if (seen.has(absolute)) return;
158
+ seen.add(absolute);
159
+ const content = fs.readFileSync(absolute, 'utf8');
160
+ const title = path.relative(workspace, absolute) || path.basename(absolute);
161
+ labels.push(title);
162
+ sections.push(formatSection(title, content));
163
+ });
164
+
165
+ return { sections, labels };
166
+ }
167
+
168
+ // ===== DEFAULT MULTI-AGENT TIMELINES =====
169
+
170
+ /**
171
+ * Build the default multi-agent timeline.
172
+ * The timeline adapts based on which agents are included.
173
+ *
174
+ * With all 3 agents: understand → team plan → implement → review
175
+ * With 2 agents: understand → plan → implement → review
176
+ * With 1 agent: falls back to knoxis-style 4-step
177
+ */
178
+ function buildDefaultTimeline(task, activeAgents) {
179
+ const agentIds = activeAgents.map(a => a.id);
180
+ const hasKnoxis = agentIds.includes('knoxis');
181
+ const hasSolan = agentIds.includes('solan');
182
+ const hasAstrahelm = agentIds.includes('astrahelm');
183
+
184
+ // Pick a lead agent — first available in priority order
185
+ const lead = hasKnoxis ? 'knoxis' : hasSolan ? 'solan' : 'astrahelm';
186
+ const leadAgent = getAgent(lead);
187
+
188
+ const steps = [];
189
+
190
+ // Step 1: Lead agent understands the codebase
191
+ steps.push({
192
+ key: 'understand',
193
+ title: 'Understanding',
194
+ agentId: lead,
195
+ displayName: leadAgent.name,
196
+ persona: leadAgent.persona,
197
+ instruction: `Let's understand what we're working with before diving in.
198
+
199
+ The task: ${task}
200
+
201
+ First:
202
+ 1. Read the relevant code to understand the current state
203
+ 2. Identify what needs to change
204
+ 3. Note any potential issues or dependencies
205
+
206
+ Share your understanding briefly. For any ambiguities, state your assumption and move on - do not ask questions.`,
207
+ includeContext: true
208
+ });
209
+
210
+ // Step 2: Team weighs in on approach (if multiple agents)
211
+ if (activeAgents.length > 1) {
212
+ const otherAgents = activeAgents.filter(a => a.id !== lead);
213
+ const othersIntro = otherAgents.map(a => `${a.name} (${a.specialty})`).join(' and ');
214
+
215
+ steps.push({
216
+ key: 'team-input',
217
+ title: 'Team Input',
218
+ agentId: otherAgents[0].id,
219
+ displayName: otherAgents.map(a => a.name).join(' & '),
220
+ persona: otherAgents.length === 1
221
+ ? otherAgents[0].persona
222
+ : `You are providing perspective from ${othersIntro}. Think from each angle but only contribute what's genuinely useful for this specific task. If your specialty doesn't apply here, say so briefly and focus on general engineering quality.`,
223
+ instruction: `You've seen ${leadAgent.name}'s assessment. Now weigh in from your perspective${otherAgents.length > 1 ? 's' : ''}.
224
+
225
+ Rules:
226
+ - Only raise points that are actually relevant to THIS task
227
+ - If your specialty doesn't apply, say "nothing specific from my angle" and move on
228
+ - Don't repeat what ${leadAgent.name} already covered
229
+ - If you agree with the approach, say so briefly and add any considerations
230
+ - Be concrete — if you flag something, suggest what to do about it`,
231
+ includeContext: false
232
+ });
233
+ }
234
+
235
+ // Step 3: Lead plans and implements
236
+ steps.push({
237
+ key: 'plan',
238
+ title: 'Planning',
239
+ agentId: lead,
240
+ displayName: leadAgent.name,
241
+ persona: leadAgent.persona,
242
+ instruction: `Good.${activeAgents.length > 1 ? " Incorporate the team's input where it makes sense." : ''} Now create a concrete implementation plan for: ${task}
243
+
244
+ Plan requirements:
245
+ 1. What files need to be created or modified?
246
+ 2. What's the order of changes?
247
+ 3. What could go wrong?
248
+ 4. How will we verify it works?
249
+
250
+ For any open questions, make your best judgment call and note the decision. Do not ask - decide and move forward.`,
251
+ includeContext: false
252
+ });
253
+
254
+ // Step 4: Implement
255
+ steps.push({
256
+ key: 'implement',
257
+ title: 'Implementation',
258
+ agentId: lead,
259
+ displayName: leadAgent.name,
260
+ persona: leadAgent.persona,
261
+ instruction: `Now implement the plan. Work through it step by step.
262
+
263
+ Rules:
264
+ - Make one logical change at a time
265
+ - For any decisions or unknowns, pick the most standard approach and note your choice
266
+ - If you flagged questions earlier, answer them yourself with reasonable defaults and proceed
267
+ - Flag if you hit something truly blocking (missing credentials, broken dependencies)
268
+ - Otherwise, keep building
269
+
270
+ Start implementing now.`,
271
+ includeContext: false
272
+ });
273
+
274
+ // Step 5: Team review (if multiple agents)
275
+ if (activeAgents.length > 1) {
276
+ // Each non-lead agent reviews from their angle
277
+ const reviewAgents = activeAgents.filter(a => a.id !== lead);
278
+
279
+ steps.push({
280
+ key: 'review',
281
+ title: 'Team Review',
282
+ agentId: reviewAgents[0].id,
283
+ displayName: reviewAgents.map(a => a.name).join(' & '),
284
+ persona: reviewAgents.length === 1
285
+ ? reviewAgents[0].persona
286
+ : `You are reviewing the implementation from the perspectives of ${reviewAgents.map(a => `${a.name} (${a.specialty})`).join(' and ')}. Be pragmatic — only flag real issues.`,
287
+ instruction: `Review what ${leadAgent.name} built.
288
+
289
+ For each perspective you represent:
290
+ - Does it solve the original problem correctly?
291
+ - Are there any genuine issues from your angle? (Only flag real problems, not theoretical ones)
292
+ - Any quick improvements that are clearly worth making?
293
+
294
+ If everything looks good from your angle, say so. Don't invent problems.
295
+ If you find real issues, be specific about what to fix and why.`,
296
+ includeContext: false
297
+ });
298
+ }
299
+
300
+ // Final step: Verify
301
+ steps.push({
302
+ key: 'verify',
303
+ title: 'Final Check',
304
+ agentId: lead,
305
+ displayName: leadAgent.name,
306
+ persona: leadAgent.persona,
307
+ instruction: `Let's do a final check on what we built.
308
+
309
+ Quick checklist:
310
+ - Does it solve the original problem?
311
+ - Any edge cases we missed?
312
+ - Is the code clean and following project patterns?
313
+ - Anything we should test?
314
+ ${activeAgents.length > 1 ? `- Address any issues the team flagged in the review\n` : ''}
315
+ Give me the summary and any follow-up recommendations.`,
316
+ includeContext: false
317
+ });
318
+
319
+ return steps;
320
+ }
321
+
322
+ // ===== PROMPT BUILDING =====
323
+
324
+ function buildPrompt(options) {
325
+ const {
326
+ systemIntro,
327
+ personaIntro,
328
+ conversation,
329
+ instructionLabel,
330
+ instructionTitle,
331
+ instruction,
332
+ includeContextBlock,
333
+ globalContext,
334
+ stepContext,
335
+ agentLabel
336
+ } = options;
337
+
338
+ const sections = [];
339
+
340
+ if (systemIntro) sections.push(systemIntro);
341
+ if (personaIntro) sections.push(`Your role for this step:\n${personaIntro}`);
342
+ if (includeContextBlock && globalContext) sections.push(`Project context:\n${globalContext}`);
343
+ if (includeContextBlock && stepContext) sections.push(`Step-specific context:\n${stepContext}`);
344
+ if (conversation) sections.push(`Conversation so far:\n${conversation}`);
345
+
346
+ const heading = instructionTitle
347
+ ? `${instructionLabel} -> ${agentLabel} (${instructionTitle})`
348
+ : `${instructionLabel} -> ${agentLabel}`;
349
+
350
+ sections.push(`${heading}:\n${instruction}`);
351
+ sections.push(`${agentLabel}:`);
352
+
353
+ return sections.join('\n\n');
354
+ }
355
+
356
+ // ===== AI EXECUTION =====
357
+
358
+ async function callAi(aiConfig, prompt, livePrinter) {
359
+ return new Promise((resolve, reject) => {
360
+ const proc = spawn(aiConfig.cmd, aiConfig.args, { stdio: ['pipe', 'pipe', 'pipe'] });
361
+ let stdout = '';
362
+ let stderr = '';
363
+ let pendingLine = '';
364
+
365
+ proc.stdout.on('data', chunk => {
366
+ const text = chunk.toString();
367
+ stdout += text;
368
+ pendingLine += text;
369
+ let index;
370
+ while ((index = pendingLine.indexOf('\n')) !== -1) {
371
+ const line = pendingLine.slice(0, index);
372
+ pendingLine = pendingLine.slice(index + 1);
373
+ livePrinter(line);
374
+ }
375
+ });
376
+
377
+ proc.stderr.on('data', chunk => {
378
+ stderr += chunk.toString();
379
+ });
380
+
381
+ proc.on('close', code => {
382
+ if (pendingLine.length) {
383
+ livePrinter(pendingLine);
384
+ pendingLine = '';
385
+ }
386
+ if (code === 0) {
387
+ resolve(stdout.trim());
388
+ } else {
389
+ reject(new Error(stderr.trim() || `AI command exited with status ${code}`));
390
+ }
391
+ });
392
+
393
+ proc.stdin.write(prompt);
394
+ proc.stdin.end();
395
+ });
396
+ }
397
+
398
+ // ===== MAIN =====
399
+
400
+ async function run() {
401
+ const args = parseArgs(process.argv);
402
+ const workspaceArg = args.workspace || args['workspace-dir'] || args['working-directory'] || args.w;
403
+
404
+ if (!workspaceArg) {
405
+ console.error('Missing required --workspace argument.');
406
+ console.error('Usage: echelon-pair-program --workspace <path> --prompt "task"');
407
+ console.error(' echelon-pair-program -w my-project --prompt "build auth" --agents knoxis,solan');
408
+ process.exit(1);
409
+ }
410
+
411
+ let workspace = resolveWorkspacePath(workspaceArg);
412
+ if (!workspace) {
413
+ workspace = path.resolve(workspaceArg);
414
+ console.log(`Creating new workspace: ${workspace}`);
415
+ }
416
+ if (!fs.existsSync(workspace)) {
417
+ fs.mkdirSync(workspace, { recursive: true });
418
+ }
419
+
420
+ // Parse timeline (custom steps from backend)
421
+ const timeline = args['timeline-base64'] ? decodeJsonBase64(args['timeline-base64'], 'timeline') : null;
422
+
423
+ // Parse task
424
+ let task = args['prompt-base64'] ? decodeBase64(args['prompt-base64']) : args.prompt;
425
+ if ((!task || !task.trim()) && timeline && typeof timeline.task === 'string') {
426
+ task = timeline.task;
427
+ }
428
+ if (!task || !task.trim()) {
429
+ console.error('A task prompt is required via --prompt, --prompt-base64, or timeline.task.');
430
+ process.exit(1);
431
+ }
432
+ task = task.trim();
433
+
434
+ // Resolve AI provider
435
+ const aiConfig = resolveAiProvider(args['ai-provider'] || args.provider);
436
+
437
+ // Resolve which agents are active
438
+ let activeAgents;
439
+ const agentArg = args.agents;
440
+ if (agentArg) {
441
+ const requested = (Array.isArray(agentArg) ? agentArg.join(',') : agentArg)
442
+ .split(',')
443
+ .map(s => s.trim().toLowerCase())
444
+ .filter(Boolean);
445
+ activeAgents = requested.map(id => getAgent(id)).filter(Boolean);
446
+ } else if (timeline && Array.isArray(timeline.agents)) {
447
+ activeAgents = timeline.agents.map(id => getAgent(id)).filter(Boolean);
448
+ } else {
449
+ activeAgents = getAllAgents();
450
+ }
451
+
452
+ if (activeAgents.length === 0) {
453
+ console.error('No valid agents specified. Available: ' + getAgentIds().join(', '));
454
+ process.exit(1);
455
+ }
456
+
457
+ // Gather context files
458
+ const globalContextInputs = [];
459
+ if (args['context']) globalContextInputs.push(args['context']);
460
+ if (args['context-file']) globalContextInputs.push(args['context-file']);
461
+ if (timeline && Array.isArray(timeline.sharedContext)) {
462
+ globalContextInputs.push(timeline.sharedContext);
463
+ }
464
+ const globalContext = gatherContext(workspace, globalContextInputs);
465
+ const globalContextBlock = globalContext.sections.join('\n\n');
466
+
467
+ // Build steps
468
+ let scheduledSteps;
469
+ if (timeline && Array.isArray(timeline.steps) && timeline.steps.length) {
470
+ // Custom timeline from backend
471
+ scheduledSteps = timeline.steps.map((step, index) => ({
472
+ key: step.key || `step-${index + 1}`,
473
+ title: step.title || '',
474
+ agentId: step.agentId || step.displayName?.toLowerCase() || activeAgents[0].id,
475
+ displayName: step.displayName || step.agentId || `Agent ${index + 1}`,
476
+ persona: step.persona || null,
477
+ instruction: typeof step.instruction === 'string' && step.instruction.trim().length
478
+ ? step.instruction
479
+ : `Proceed with the shared task: ${task}`,
480
+ contextPaths: step.contextFiles || [],
481
+ includeContext: Boolean(step.includeContext)
482
+ }));
483
+ } else {
484
+ // Smart default timeline
485
+ scheduledSteps = buildDefaultTimeline(task, activeAgents);
486
+ }
487
+
488
+ if (!scheduledSteps.length) {
489
+ console.error('No steps configured for the session.');
490
+ process.exit(1);
491
+ }
492
+
493
+ // Disable recording with --no-record
494
+ const recordingEnabled = !args['no-record'];
495
+
496
+ // Initialize session recorder
497
+ let recorder = null;
498
+ if (recordingEnabled) {
499
+ recorder = new SessionRecorder({
500
+ task,
501
+ workspace,
502
+ agents: activeAgents.map(a => a.id),
503
+ aiProvider: aiConfig.label
504
+ });
505
+ }
506
+
507
+ // Print session header
508
+ const agentNames = activeAgents.map(a => colorize(a.id, a.name)).join(', ');
509
+ console.log('==============================================');
510
+ console.log('Echelon Dev Team - Pair Programming Session');
511
+ console.log(`Workspace: ${workspace}`);
512
+ console.log(`AI Partner: ${aiConfig.label}`);
513
+ console.log(`Team: ${agentNames}${RESET}`);
514
+ console.log(`Task: ${task}`);
515
+ if (recordingEnabled) console.log(`Recording: ON`);
516
+ console.log('==============================================');
517
+ console.log('');
518
+
519
+ if (globalContext.labels.length) {
520
+ console.log(`Shared context files: ${globalContext.labels.join(', ')}`);
521
+ console.log('');
522
+ }
523
+
524
+ // Build team system prompt
525
+ const systemIntro = buildTeamSystemPrompt(activeAgents);
526
+
527
+ // Execute steps
528
+ const history = [];
529
+ const conversationLines = () => history.map(entry => `${entry.role}: ${entry.content}`).join('\n\n');
530
+
531
+ for (let i = 0; i < scheduledSteps.length; i++) {
532
+ const step = scheduledSteps[i];
533
+ const agent = getAgent(step.agentId);
534
+
535
+ // Gather step-specific context
536
+ const stepContext = gatherContext(workspace, step.contextPaths || []);
537
+ if (stepContext.labels.length) {
538
+ console.log(`Step context for ${step.displayName}: ${stepContext.labels.join(', ')}`);
539
+ console.log('');
540
+ }
541
+
542
+ // Print step header
543
+ const titleSuffix = step.title ? ` (${step.title})` : '';
544
+ const agentColor = agent ? agent.color : '';
545
+ console.log(`Coordinator -> ${agentColor}${step.displayName}${RESET}${titleSuffix}:`);
546
+ console.log(step.instruction);
547
+ console.log('');
548
+
549
+ history.push({ role: 'Coordinator', content: step.instruction });
550
+
551
+ console.log(`${agentColor}${step.displayName}${RESET}:`);
552
+
553
+ // Build prompt
554
+ const includeContext = history.length <= 2 || step.includeContext || (stepContext.sections.length > 0);
555
+ const prompt = buildPrompt({
556
+ systemIntro,
557
+ personaIntro: step.persona,
558
+ conversation: conversationLines(),
559
+ instructionLabel: 'Coordinator',
560
+ instructionTitle: step.title,
561
+ instruction: step.instruction,
562
+ includeContextBlock: includeContext,
563
+ globalContext: globalContextBlock,
564
+ stepContext: stepContext.sections.join('\n\n'),
565
+ agentLabel: step.displayName
566
+ });
567
+
568
+ // Record step
569
+ let stepIndex = -1;
570
+ if (recorder) {
571
+ stepIndex = recorder.startStep(step.key, step.agentId, step.instruction);
572
+ recorder.setStepPrompt(stepIndex, prompt);
573
+ }
574
+
575
+ try {
576
+ const response = await callAi(aiConfig, prompt, line => {
577
+ console.log(` ${line}`);
578
+ });
579
+ history.push({ role: step.displayName, content: response });
580
+
581
+ if (recorder) {
582
+ recorder.completeStep(stepIndex, response, null);
583
+ }
584
+
585
+ console.log('');
586
+ } catch (err) {
587
+ console.error(`Failed to complete step "${step.title || step.key}": ${err.message}`);
588
+
589
+ if (recorder) {
590
+ recorder.completeStep(stepIndex, null, err.message);
591
+ }
592
+
593
+ process.exit(1);
594
+ }
595
+ }
596
+
597
+ // Save recording
598
+ if (recorder) {
599
+ const recordPath = recorder.save();
600
+ console.log(`Session recorded: ${recordPath}`);
601
+ console.log('');
602
+ }
603
+
604
+ console.log('Session complete. The Echelon dev team is standing by for further instructions.');
605
+ }
606
+
607
+ run().catch(err => {
608
+ console.error(err.message);
609
+ process.exit(1);
610
+ });