chati-dev 3.2.5 → 3.3.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.
Files changed (98) hide show
  1. package/LICENSE +96 -0
  2. package/bin/chati.js +46 -0
  3. package/framework/agents/build/dev.md +122 -1
  4. package/framework/agents/deploy/devops.md +128 -3
  5. package/framework/agents/discover/brief.md +77 -15
  6. package/framework/agents/discover/brownfield-wu.md +2 -2
  7. package/framework/agents/discover/greenfield-wu.md +3 -3
  8. package/framework/agents/plan/architect.md +2 -2
  9. package/framework/agents/plan/detail.md +3 -3
  10. package/framework/agents/plan/phases.md +127 -2
  11. package/framework/agents/plan/tasks.md +127 -2
  12. package/framework/agents/plan/ux.md +269 -22
  13. package/framework/agents/quality/qa-implementation.md +172 -8
  14. package/framework/agents/quality/qa-planning.md +147 -2
  15. package/framework/config.yaml +9 -5
  16. package/framework/constitution.md +7 -1
  17. package/framework/context/quality.md +1 -1
  18. package/framework/context/root.md +1 -1
  19. package/framework/hooks/constitution-guard.js +18 -2
  20. package/framework/hooks/mode-governance.js +3 -3
  21. package/framework/hooks/read-protection.js +10 -2
  22. package/framework/i18n/en.yaml +6 -0
  23. package/framework/i18n/es.yaml +6 -0
  24. package/framework/i18n/fr.yaml +6 -0
  25. package/framework/i18n/pt.yaml +6 -0
  26. package/framework/orchestrator/chati.md +102 -6
  27. package/framework/schemas/task.schema.json +1 -1
  28. package/framework/tasks/architect-dep-audit.md +128 -0
  29. package/framework/tasks/architect-stack-selection.md +28 -0
  30. package/framework/workflows/brownfield-fullstack.yaml +2 -2
  31. package/framework/workflows/brownfield-service.yaml +2 -2
  32. package/framework/workflows/brownfield-ui.yaml +2 -2
  33. package/framework/workflows/greenfield-fullstack.yaml +6 -2
  34. package/framework/workflows/quick-flow.yaml +7 -5
  35. package/framework/workflows/standard-flow.yaml +171 -0
  36. package/package.json +4 -2
  37. package/src/api/index.js +129 -0
  38. package/src/autonomy/build-loop.js +93 -6
  39. package/src/autonomy/build-state.js +20 -2
  40. package/src/autonomy/cause-analyzer.js +177 -0
  41. package/src/autonomy/escalation.js +214 -0
  42. package/src/autonomy/safety-net.js +23 -5
  43. package/src/autonomy/worktree-manager.js +245 -0
  44. package/src/config/agent-customizer.js +227 -0
  45. package/src/config/ide-configs.js +57 -27
  46. package/src/decision/analyzer.js +148 -0
  47. package/src/decision/registry-healer.js +38 -21
  48. package/src/extensions/loader.js +151 -0
  49. package/src/extensions/registry.js +134 -0
  50. package/src/gates/circuit-breaker.js +32 -0
  51. package/src/gates/g3-implementation.js +30 -4
  52. package/src/gates/g4-qa-implementation.js +34 -5
  53. package/src/gates/gate-base.js +9 -0
  54. package/src/health/auto-fix.js +216 -0
  55. package/src/installer/core.js +24 -11
  56. package/src/installer/provider-overlay.js +82 -0
  57. package/src/installer/templates.js +22 -10
  58. package/src/installer/transaction.js +3 -2
  59. package/src/installer/validator.js +74 -0
  60. package/src/intelligence/context-status.js +9 -5
  61. package/src/intelligence/document-sharder.js +221 -0
  62. package/src/intelligence/elicitation.js +265 -0
  63. package/src/intelligence/timeline.js +5 -0
  64. package/src/memory/gotchas.js +78 -2
  65. package/src/merger/semantic-merger.js +292 -0
  66. package/src/orchestrator/agent-selector.js +20 -0
  67. package/src/orchestrator/handoff-engine.js +77 -0
  68. package/src/orchestrator/index.js +0 -8
  69. package/src/orchestrator/intent-classifier.js +182 -0
  70. package/src/orchestrator/pipeline-manager.js +125 -1
  71. package/src/orchestrator/session-manager.js +164 -2
  72. package/src/quality/metrics-collector.js +283 -0
  73. package/src/quality/test-runner.js +368 -0
  74. package/src/telemetry/collector.js +83 -0
  75. package/src/telemetry/config.js +119 -0
  76. package/src/telemetry/index.js +11 -0
  77. package/src/telemetry/schema.js +104 -0
  78. package/src/telemetry/sender.js +60 -0
  79. package/src/terminal/cli-registry.js +7 -1
  80. package/src/terminal/cost-tracker.js +197 -0
  81. package/src/terminal/handoff-parser.js +61 -4
  82. package/src/terminal/prompt-builder.js +56 -18
  83. package/src/terminal/rate-limiter.js +172 -0
  84. package/src/terminal/run-agent.js +39 -0
  85. package/src/terminal/run-parallel.js +22 -1
  86. package/src/terminal/spawner.js +181 -3
  87. package/src/upgrade/migrator.js +2 -2
  88. package/src/utils/event-bus.js +126 -0
  89. package/src/utils/file-lock.js +291 -0
  90. package/src/utils/schema-validator.js +226 -0
  91. package/src/wizard/i18n.js +11 -0
  92. package/src/wizard/index.js +42 -20
  93. package/src/wizard/questions.js +200 -39
  94. package/src/autonomy/execution-profile.js +0 -151
  95. package/src/intelligence/file-tracker.js +0 -117
  96. package/src/memory/gotchas-auto-capture.js +0 -253
  97. package/src/orchestrator/pipeline-state.js +0 -223
  98. package/src/terminal/wave-analyzer.js +0 -143
@@ -13,6 +13,9 @@ import { join } from 'path';
13
13
  import { runPrism } from '../context/engine.js';
14
14
  import { loadHandoff, formatHandoff } from '../tasks/handoff.js';
15
15
  import { getWriteScope } from './isolation.js';
16
+ import { resolveOverlayPath } from '../installer/provider-overlay.js';
17
+ import { resolveProviderForAgent } from './cli-registry.js';
18
+ import { buildCompactGotchasSummary } from '../memory/gotchas-injector.js';
16
19
 
17
20
  // Import AGENT_MODELS from model-governance (safe — named export,
18
21
  // does not trigger main() which is guarded by fileURLToPath check).
@@ -46,6 +49,7 @@ export const AGENT_FILE_MAP = {
46
49
  * @property {object} [sessionState] - Parsed session.yaml fields
47
50
  * @property {string[]} [writeScope] - Override write scope
48
51
  * @property {string} [additionalContext] - Extra context (e.g. user answer to needs_input)
52
+ * @property {string} [provider] - Active CLI provider (for overlay resolution)
49
53
  */
50
54
 
51
55
  /**
@@ -70,8 +74,8 @@ export function buildAgentPrompt(config) {
70
74
  sections.push(prismResult.xml);
71
75
  }
72
76
 
73
- // 2. Agent definition (.md file)
74
- const agentDef = loadAgentDefinition(config.agent, config.projectDir);
77
+ // 2. Agent definition (.md file — resolved via overlay for secondary providers)
78
+ const agentDef = loadAgentDefinition(config.agent, config.projectDir, config.provider);
75
79
  if (agentDef) {
76
80
  sections.push('<!-- AGENT DEFINITION -->\n' + agentDef);
77
81
  }
@@ -82,7 +86,20 @@ export function buildAgentPrompt(config) {
82
86
  sections.push(handoffSection);
83
87
  }
84
88
 
85
- // 4. Additional context (user input relay for needs_input)
89
+ // 4. Gotchas (relevant past errors)
90
+ try {
91
+ const gotchasSection = buildCompactGotchasSummary(config.projectDir, {
92
+ agent: config.agent,
93
+ task: config.taskId,
94
+ });
95
+ if (gotchasSection) {
96
+ sections.push('<!-- GOTCHAS -->\n' + gotchasSection);
97
+ }
98
+ } catch {
99
+ // Gotchas are advisory — never block prompt building
100
+ }
101
+
102
+ // 5. Additional context (user input relay for needs_input)
86
103
  if (config.additionalContext) {
87
104
  sections.push(
88
105
  '<!-- ADDITIONAL CONTEXT -->\n' +
@@ -91,23 +108,29 @@ export function buildAgentPrompt(config) {
91
108
  );
92
109
  }
93
110
 
94
- // 5. Write scope instructions
111
+ // 6. Write scope instructions
95
112
  sections.push(buildWriteScopeSection(config));
96
113
 
97
- // 6. Session context
98
- sections.push(buildSessionSection(config));
114
+ // 7. Resolve provider/model (needed by session context AND output instructions)
115
+ const staticAssignment = AGENT_MODELS[config.agent] || { provider: 'claude', model: 'sonnet', tier: 'sonnet' };
116
+ const resolved = (config.projectDir && AGENT_MODELS[config.agent])
117
+ ? resolveProviderForAgent(config.agent, config.projectDir, AGENT_MODELS)
118
+ : { provider: staticAssignment.provider, model: staticAssignment.model };
119
+ const resolvedProvider = config.provider || resolved.provider || 'claude';
120
+ const model = config.model || resolved.model || staticAssignment.model || 'sonnet';
121
+
122
+ // 8. Session context (uses resolved model/provider)
123
+ sections.push(buildSessionSection(config, { model, provider: resolvedProvider }));
99
124
 
100
- // 7. Output format instructions (handoff template)
101
- sections.push(buildOutputInstructions());
125
+ // 9. Output format instructions (handoff template — includes provider/model for audit)
126
+ sections.push(buildOutputInstructions({ provider: resolvedProvider, model }));
102
127
 
103
128
  const prompt = sections.join('\n\n---\n\n');
104
- const assignment = AGENT_MODELS[config.agent] || { provider: 'claude', model: 'sonnet', tier: 'sonnet' };
105
- const model = assignment.model || assignment;
106
129
 
107
130
  return {
108
131
  prompt,
109
132
  model,
110
- provider: assignment.provider || 'claude',
133
+ provider: resolvedProvider,
111
134
  metadata: {
112
135
  agent: config.agent,
113
136
  layers: prismResult.layerCount || 0,
@@ -163,14 +186,23 @@ function buildPrismSection(config) {
163
186
 
164
187
  /**
165
188
  * Load the agent's full .md definition file.
189
+ * For non-primary providers, resolves via overlay directory.
166
190
  */
167
- function loadAgentDefinition(agent, projectDir) {
191
+ function loadAgentDefinition(agent, projectDir, provider = null) {
168
192
  const relativePath = AGENT_FILE_MAP[agent];
169
193
  if (!relativePath) return null;
170
194
 
171
- const fullPath = join(projectDir, relativePath);
172
- if (!existsSync(fullPath)) return null;
195
+ // Strip 'chati.dev/' prefix for overlay resolution
196
+ const relToFramework = relativePath.replace(/^chati\.dev\//, '');
197
+
198
+ let fullPath;
199
+ if (provider && provider !== 'claude') {
200
+ fullPath = resolveOverlayPath(projectDir, relToFramework, provider);
201
+ } else {
202
+ fullPath = join(projectDir, relativePath);
203
+ }
173
204
 
205
+ if (!existsSync(fullPath)) return null;
174
206
  return readFileSync(fullPath, 'utf-8');
175
207
  }
176
208
 
@@ -238,7 +270,7 @@ function buildWriteScopeSection(config) {
238
270
  /**
239
271
  * Build session context section.
240
272
  */
241
- function buildSessionSection(config) {
273
+ function buildSessionSection(config, resolvedModelInfo = {}) {
242
274
  const state = config.sessionState || {};
243
275
  const project = state.project || {};
244
276
 
@@ -253,8 +285,8 @@ function buildSessionSection(config) {
253
285
  `- **User Level**: ${state.user_level || 'auto'}`,
254
286
  `- **Execution Mode**: ${state.execution_mode || 'autonomous'}`,
255
287
  `- **Your Agent**: ${config.agent}`,
256
- `- **Your Model**: ${(AGENT_MODELS[config.agent]?.model) || 'sonnet'}`,
257
- `- **Your Provider**: ${(AGENT_MODELS[config.agent]?.provider) || 'claude'}`,
288
+ `- **Your Model**: ${resolvedModelInfo.model || 'sonnet'}`,
289
+ `- **Your Provider**: ${resolvedModelInfo.provider || 'claude'}`,
258
290
  ];
259
291
 
260
292
  return lines.join('\n');
@@ -263,7 +295,10 @@ function buildSessionSection(config) {
263
295
  /**
264
296
  * Build output format instructions so the agent produces a parseable handoff.
265
297
  */
266
- function buildOutputInstructions() {
298
+ function buildOutputInstructions(config) {
299
+ const providerValue = config?.provider || 'claude';
300
+ const modelValue = config?.model || 'unknown';
301
+
267
302
  return `<!-- OUTPUT INSTRUCTIONS -->
268
303
  ## Output Instructions (MANDATORY)
269
304
 
@@ -274,6 +309,8 @@ This block is how the orchestrator reads your results. Without it, your work can
274
309
  <chati-handoff>
275
310
  status: complete
276
311
  score: 95
312
+ provider: ${providerValue}
313
+ model: ${modelValue}
277
314
  summary: One to three sentence summary of what was accomplished.
278
315
  outputs:
279
316
  - path/to/artifact1.md
@@ -297,5 +334,6 @@ needs_input_question: null
297
334
  - The \`<chati-handoff>\` block MUST appear in your response
298
335
  - Score must be 0-100 (95+ to pass quality gate)
299
336
  - List ALL artifacts you created/modified in outputs
337
+ - The \`provider\` and \`model\` fields MUST match: provider: ${providerValue}, model: ${modelValue}
300
338
  - If you need user input, set status to "needs_input" and write your question in needs_input_question`;
301
339
  }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * @fileoverview Rate limiting for terminal spawner.
3
+ *
4
+ * Prevents exceeding API rate limits when spawning multiple agent
5
+ * processes. Uses a sliding window counter per provider.
6
+ *
7
+ * Constitution Article XIX — Multi-CLI Governance.
8
+ */
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Default Limits
12
+ // ---------------------------------------------------------------------------
13
+
14
+ /**
15
+ * Default rate limits per provider (requests per minute).
16
+ * Conservative defaults — can be overridden at creation time.
17
+ */
18
+ export const DEFAULT_LIMITS = {
19
+ claude: 20,
20
+ gemini: 15,
21
+ codex: 10,
22
+ copilot: 10,
23
+ };
24
+
25
+ /** Sliding window size in milliseconds (1 minute). */
26
+ const WINDOW_MS = 60_000;
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // RateLimiter Class
30
+ // ---------------------------------------------------------------------------
31
+
32
+ /**
33
+ * @typedef {object} RateLimiterStats
34
+ * @property {number} used - Requests used in current window
35
+ * @property {number} limit - Maximum requests per window
36
+ * @property {number} windowStart - Window start timestamp (ms)
37
+ * @property {number|null} nextAvailable - Timestamp when next slot opens (null if available now)
38
+ * @property {string} provider - Provider name
39
+ */
40
+
41
+ /**
42
+ * Create a rate limiter instance for a specific provider.
43
+ *
44
+ * @param {string} provider - Provider name (claude, gemini, codex, copilot)
45
+ * @param {{ limit?: number }} [customLimits={}]
46
+ * @returns {{ canSpawn: () => boolean, recordSpawn: () => void, waitForSlot: () => Promise<void>, getStats: () => RateLimiterStats, reset: () => void }}
47
+ */
48
+ export function createRateLimiter(provider, customLimits = {}) {
49
+ const limit = customLimits.limit || DEFAULT_LIMITS[provider] || 10;
50
+
51
+ /** @type {number[]} Timestamps of recorded spawns */
52
+ let timestamps = [];
53
+
54
+ /**
55
+ * Prune timestamps outside the sliding window.
56
+ */
57
+ function prune() {
58
+ const cutoff = Date.now() - WINDOW_MS;
59
+ timestamps = timestamps.filter(t => t > cutoff);
60
+ }
61
+
62
+ /**
63
+ * Check if a spawn is allowed under current rate limits.
64
+ * @returns {boolean}
65
+ */
66
+ function canSpawn() {
67
+ prune();
68
+ return timestamps.length < limit;
69
+ }
70
+
71
+ /**
72
+ * Record a spawn event.
73
+ */
74
+ function recordSpawn() {
75
+ timestamps.push(Date.now());
76
+ }
77
+
78
+ /**
79
+ * Wait until a rate limit slot becomes available.
80
+ * Resolves immediately if a slot is available.
81
+ *
82
+ * @returns {Promise<void>}
83
+ */
84
+ function waitForSlot() {
85
+ if (canSpawn()) {
86
+ return Promise.resolve();
87
+ }
88
+
89
+ // Calculate when the oldest timestamp exits the window
90
+ prune();
91
+ if (timestamps.length === 0) {
92
+ return Promise.resolve();
93
+ }
94
+
95
+ const oldestInWindow = timestamps[0];
96
+ const waitTime = Math.max(0, (oldestInWindow + WINDOW_MS) - Date.now() + 10);
97
+
98
+ return new Promise(resolve => {
99
+ setTimeout(() => {
100
+ prune();
101
+ resolve();
102
+ }, waitTime);
103
+ });
104
+ }
105
+
106
+ /**
107
+ * Get current rate limiter statistics.
108
+ * @returns {RateLimiterStats}
109
+ */
110
+ function getStats() {
111
+ prune();
112
+
113
+ const now = Date.now();
114
+ const windowStart = now - WINDOW_MS;
115
+
116
+ let nextAvailable = null;
117
+ if (timestamps.length >= limit && timestamps.length > 0) {
118
+ nextAvailable = timestamps[0] + WINDOW_MS;
119
+ }
120
+
121
+ return {
122
+ used: timestamps.length,
123
+ limit,
124
+ windowStart,
125
+ nextAvailable,
126
+ provider,
127
+ };
128
+ }
129
+
130
+ /**
131
+ * Reset the rate limiter (clear all recorded timestamps).
132
+ */
133
+ function reset() {
134
+ timestamps = [];
135
+ }
136
+
137
+ return { canSpawn, recordSpawn, waitForSlot, getStats, reset };
138
+ }
139
+
140
+ // ---------------------------------------------------------------------------
141
+ // Global Registry
142
+ // ---------------------------------------------------------------------------
143
+
144
+ /** @type {Map<string, ReturnType<typeof createRateLimiter>>} */
145
+ const _registry = new Map();
146
+
147
+ /**
148
+ * Get or create a rate limiter for a provider.
149
+ * Ensures one limiter per provider across the application.
150
+ *
151
+ * @param {string} provider - Provider name
152
+ * @param {{ limit?: number }} [customLimits={}]
153
+ * @returns {ReturnType<typeof createRateLimiter>}
154
+ */
155
+ export function getRateLimiter(provider, customLimits = {}) {
156
+ if (!_registry.has(provider)) {
157
+ _registry.set(provider, createRateLimiter(provider, customLimits));
158
+ }
159
+ return _registry.get(provider);
160
+ }
161
+
162
+ /**
163
+ * Clear all registered rate limiters (useful in tests).
164
+ */
165
+ export function clearAllLimiters() {
166
+ _registry.clear();
167
+ }
168
+
169
+ /**
170
+ * Exported for testing.
171
+ */
172
+ export { WINDOW_MS };
@@ -17,6 +17,8 @@ import { fileURLToPath } from 'url';
17
17
  import { buildAgentPrompt } from './prompt-builder.js';
18
18
  import { spawnTerminal } from './spawner.js';
19
19
  import { parseAgentOutput } from './handoff-parser.js';
20
+ import { createCostTracker } from './cost-tracker.js';
21
+ import { track as telemetryTrack } from '../telemetry/collector.js';
20
22
 
21
23
  // ---------------------------------------------------------------------------
22
24
  // CLI argument parsing (no external deps)
@@ -85,6 +87,7 @@ async function main() {
85
87
  workflow: args.workflow || null,
86
88
  sessionState,
87
89
  additionalContext: args['additional-context'] || null,
90
+ provider: args.provider || null,
88
91
  });
89
92
  } catch (err) {
90
93
  outputError(`Failed to build prompt: ${err.message}`);
@@ -100,6 +103,7 @@ async function main() {
100
103
  agent: args.agent,
101
104
  taskId: args['task-id'],
102
105
  model: promptResult.model,
106
+ provider: promptResult.provider,
103
107
  prompt: promptResult.prompt,
104
108
  workingDir: projectDir,
105
109
  timeout,
@@ -121,6 +125,37 @@ async function main() {
121
125
  const stdout = handle.stdout.join('');
122
126
  const stderr = handle.stderr.join('');
123
127
 
128
+ // Track cost metrics
129
+ const tracker = createCostTracker();
130
+ const costRecord = tracker.recordExecution({
131
+ agent: args.agent,
132
+ model: promptResult.model,
133
+ provider: promptResult.provider || args.provider || 'claude',
134
+ taskId: args['task-id'],
135
+ inputText: promptResult.prompt || '',
136
+ outputText: stdout,
137
+ duration: elapsed,
138
+ });
139
+
140
+ const costEstimate = {
141
+ inputTokens: costRecord.inputTokens,
142
+ outputTokens: costRecord.outputTokens,
143
+ totalCost: costRecord.cost,
144
+ model: costRecord.model,
145
+ provider: costRecord.provider,
146
+ };
147
+
148
+ // Track telemetry (fire-and-forget, non-blocking)
149
+ telemetryTrack('agent_completed', {
150
+ agent: args.agent,
151
+ provider: costRecord.provider,
152
+ model: costRecord.model,
153
+ duration: elapsed,
154
+ score: null, // Score is determined by the gate, not the agent
155
+ retryCount: 0,
156
+ pipelineType: sessionState?.isQuickFlow ? 'quick-flow' : 'standard',
157
+ });
158
+
124
159
  // Parse the handoff from stdout
125
160
  const parsed = parseAgentOutput(stdout);
126
161
 
@@ -129,9 +164,11 @@ async function main() {
129
164
  status: parsed.handoff.status,
130
165
  agent: args.agent,
131
166
  model: promptResult.model,
167
+ provider: promptResult.provider || args.provider || 'claude',
132
168
  exitCode: handle.exitCode,
133
169
  handoff: parsed.handoff,
134
170
  elapsed,
171
+ costEstimate,
135
172
  });
136
173
  } else {
137
174
  // No handoff block found — return raw output
@@ -139,11 +176,13 @@ async function main() {
139
176
  status: handle.exitCode === 0 ? 'partial' : 'error',
140
177
  agent: args.agent,
141
178
  model: promptResult.model,
179
+ provider: promptResult.provider || args.provider || 'claude',
142
180
  exitCode: handle.exitCode,
143
181
  handoff: null,
144
182
  rawOutput: stdout.slice(0, 5000), // Truncate to avoid huge JSON
145
183
  stderr: stderr.slice(0, 2000),
146
184
  elapsed,
185
+ costEstimate,
147
186
  });
148
187
  }
149
188
 
@@ -20,6 +20,7 @@ import { spawnParallelGroup } from './spawner.js';
20
20
  import { TerminalMonitor } from './monitor.js';
21
21
  import { collectResults, mergeHandoffs, buildConsolidatedHandoff } from './collector.js';
22
22
  import { parseAgentOutput } from './handoff-parser.js';
23
+ import { estimateTokens, COST_PER_1K } from './cost-tracker.js';
23
24
 
24
25
  // ---------------------------------------------------------------------------
25
26
  // CLI argument parsing
@@ -100,12 +101,14 @@ async function main() {
100
101
  projectDir,
101
102
  previousAgent,
102
103
  sessionState,
104
+ provider: args.provider || null,
103
105
  });
104
106
 
105
107
  configs.push({
106
108
  agent: agents[i],
107
109
  taskId: taskIds[i],
108
110
  model: promptResult.model,
111
+ provider: promptResult.provider,
109
112
  prompt: promptResult.prompt,
110
113
  workingDir: projectDir,
111
114
  timeout,
@@ -150,6 +153,23 @@ async function main() {
150
153
 
151
154
  const elapsed = Date.now() - startTime;
152
155
 
156
+ // Cost estimation per agent
157
+ const costEstimates = configs.map((cfg, i) => {
158
+ const terminal = group.terminals[i];
159
+ const inputTokens = estimateTokens(cfg.prompt || '');
160
+ const outputTokens = estimateTokens((terminal?.stdout || []).join(''));
161
+ const modelKey = cfg.model || 'sonnet';
162
+ const rate = COST_PER_1K[modelKey] || COST_PER_1K.sonnet || 0.015;
163
+ return {
164
+ agent: cfg.agent,
165
+ model: modelKey,
166
+ provider: cfg.provider || 'claude',
167
+ inputTokens,
168
+ outputTokens,
169
+ estimatedCost: ((inputTokens + outputTokens) / 1000) * rate,
170
+ };
171
+ });
172
+
153
173
  // Collect and merge results
154
174
  const rawResults = collectResults(group.groupId, group.terminals);
155
175
 
@@ -197,6 +217,7 @@ async function main() {
197
217
  parallelActual: elapsed,
198
218
  timeSaved: elapsed * (agents.length - 1),
199
219
  },
220
+ costEstimate: costEstimates,
200
221
  };
201
222
 
202
223
  process.stdout.write(JSON.stringify(output, null, 2) + '\n');
@@ -213,7 +234,7 @@ async function main() {
213
234
  * GROUP 2 (dev tasks) → qa-implementation
214
235
  */
215
236
  function determineNextAgent(agents) {
216
- const groupKey = agents.sort().join(',');
237
+ const groupKey = [...agents].sort().join(',');
217
238
  if (groupKey.includes('architect') && groupKey.includes('detail') && groupKey.includes('ux')) {
218
239
  return 'phases';
219
240
  }