@goodandready/dsh-moa 0.2.40 → 0.2.42

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.
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  import { slotLabel } from './moa-prompts.js'
7
+ import { estimateTokenCost } from './pricing.js'
7
8
 
8
9
  /**
9
10
  * Extracts code fences and block-level assets from markdown text.
@@ -40,8 +41,10 @@ export function extractCodeBlocks(text = '') {
40
41
 
41
42
  /**
42
43
  * Builds composite block directives highlighting complementary modules across candidates.
44
+ * Supports blind evaluation (#167).
43
45
  */
44
- export function buildCompositeBlockDirectives(candidates = []) {
46
+ export function buildCompositeBlockDirectives(candidates = [], options = {}) {
47
+ const isBlind = Boolean(options === true || options?.blind)
45
48
  const fileMap = new Map()
46
49
 
47
50
  for (const c of candidates) {
@@ -52,7 +55,7 @@ export function buildCompositeBlockDirectives(candidates = []) {
52
55
  if (!fileMap.has(b.filename)) {
53
56
  fileMap.set(b.filename, [])
54
57
  }
55
- fileMap.get(b.filename).push(c.label || `Candidate ${c.index}`)
58
+ fileMap.get(b.filename).push(isBlind ? `Candidate ${c.index}` : (c.label || `Candidate ${c.index}`))
56
59
  }
57
60
  }
58
61
  }
@@ -79,17 +82,38 @@ ${fileOverview}============================================
79
82
 
80
83
  /**
81
84
  * Parses judge output into structured scores and candidate choice.
85
+ * Accepts either candidateCount or candidate array/set to support active candidate indexing (#166).
82
86
  */
83
- export function parseJudgeEvaluation(rawText = '', candidateCount = 2) {
87
+ export function parseJudgeEvaluation(rawText = '', candidatesOrCount = 2) {
88
+ let allowedIndices = null
89
+ let defaultCandidate = 1
90
+
91
+ if (Array.isArray(candidatesOrCount)) {
92
+ const active = candidatesOrCount.filter((c) => c && c.ok !== false)
93
+ allowedIndices = new Set(active.map((c) => c.index))
94
+ if (active.length > 0 && typeof active[0].index === 'number') {
95
+ defaultCandidate = active[0].index
96
+ }
97
+ } else if (candidatesOrCount instanceof Set) {
98
+ allowedIndices = candidatesOrCount
99
+ defaultCandidate = Array.from(allowedIndices)[0] || 1
100
+ } else if (typeof candidatesOrCount === 'number') {
101
+ allowedIndices = new Set(Array.from({ length: candidatesOrCount }, (_, i) => i + 1))
102
+ defaultCandidate = 1
103
+ } else {
104
+ allowedIndices = new Set([1, 2])
105
+ defaultCandidate = 1
106
+ }
107
+
84
108
  const scores = {}
85
- let bestCandidate = 1
109
+ let bestCandidate = defaultCandidate
86
110
  let reasoning = ''
87
111
 
88
112
  const scoreMatches = rawText.matchAll(/Candidate\s*(\d+)[:\s]+(?:Score\s*[:\s]*)?([0-9]+(?:\.[0-9]+)?)/gi)
89
113
  for (const m of scoreMatches) {
90
114
  const idx = parseInt(m[1], 10)
91
115
  const val = parseFloat(m[2])
92
- if (idx >= 1 && idx <= candidateCount && !Number.isNaN(val)) {
116
+ if (allowedIndices.has(idx) && !Number.isNaN(val)) {
93
117
  scores[idx] = Math.min(10, Math.max(0, val))
94
118
  }
95
119
  }
@@ -97,16 +121,17 @@ export function parseJudgeEvaluation(rawText = '', candidateCount = 2) {
97
121
  const bestMatch = rawText.match(/BEST_CANDIDATE\s*[:\s]+(?:Candidate\s*)?(\d+)/i)
98
122
  if (bestMatch) {
99
123
  const parsedBest = parseInt(bestMatch[1], 10)
100
- if (parsedBest >= 1 && parsedBest <= candidateCount) {
124
+ if (allowedIndices.has(parsedBest)) {
101
125
  bestCandidate = parsedBest
102
126
  }
103
127
  } else {
104
- // Find candidate with max parsed score
128
+ // Find candidate with max parsed score among allowed indices
105
129
  let maxScore = -1
106
130
  for (const [idxStr, s] of Object.entries(scores)) {
107
- if (s > maxScore) {
131
+ const idxNum = parseInt(idxStr, 10)
132
+ if (allowedIndices.has(idxNum) && s > maxScore) {
108
133
  maxScore = s
109
- bestCandidate = parseInt(idxStr, 10)
134
+ bestCandidate = idxNum
110
135
  }
111
136
  }
112
137
  }
@@ -114,25 +139,27 @@ export function parseJudgeEvaluation(rawText = '', candidateCount = 2) {
114
139
  const reasonMatch = rawText.match(/CONSENSUS_REASONING\s*[:\s]+([^\n\r]+)/i)
115
140
  if (reasonMatch) {
116
141
  reasoning = reasonMatch[1].trim()
117
- } else {
118
- reasoning = rawText.slice(0, 180).replace(/\n/g, ' ').trim()
119
142
  }
120
143
 
121
144
  return { scores, bestCandidate, reasoning }
122
145
  }
123
146
 
124
147
  /**
125
- * Executes a panel of judges to reach consensus on candidate solutions.
148
+ * Runs a multi-judge consensus panel across candidate solutions.
149
+ * Supports majority, unanimous, and highest_score strategies.
150
+ * Returns consensus, votes, usage, and cost tracking (#165, #167, #168).
126
151
  */
127
152
  export async function executeMultiJudgePanel({
128
153
  judges = [],
129
154
  candidates = [],
130
155
  userPrompt = '',
131
156
  callLlm,
132
- strategy = 'majority', // 'majority' | 'highest_score' | 'unanimous'
157
+ strategy = 'majority',
133
158
  timeoutMs = 45000,
134
159
  signal,
135
160
  onProgress,
161
+ prices = {},
162
+ blind = false,
136
163
  }) {
137
164
  const activeCandidates = (candidates || []).filter((c) => c && c.ok)
138
165
  if (activeCandidates.length === 0) {
@@ -140,6 +167,12 @@ export async function executeMultiJudgePanel({
140
167
  consensus: false,
141
168
  winningCandidateIndex: 1,
142
169
  strategy,
170
+ isUnanimous: false,
171
+ votes: {},
172
+ averageScores: {},
173
+ judgesResults: [],
174
+ totalUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0, costUsd: 0 },
175
+ totalCostUsd: 0,
143
176
  reason: 'no_active_candidates',
144
177
  }
145
178
  }
@@ -150,46 +183,52 @@ export async function executeMultiJudgePanel({
150
183
  winningCandidateIndex: activeCandidates[0].index,
151
184
  winningCandidateLabel: activeCandidates[0].label,
152
185
  strategy,
186
+ isUnanimous: true,
187
+ votes: { [activeCandidates[0].index]: 1 },
188
+ averageScores: { [activeCandidates[0].index]: 10.0 },
189
+ judgesResults: [],
190
+ totalUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0, costUsd: 0 },
191
+ totalCostUsd: 0,
153
192
  reason: 'single_active_candidate',
154
193
  }
155
194
  }
156
195
 
157
- const judgeSlots = Array.isArray(judges) && judges.length > 0
196
+ const judgePanel = (judges && judges.length > 0)
158
197
  ? judges
159
198
  : [{ provider: 'default', model: 'aggregator-judge', label: 'Primary Judge' }]
160
199
 
200
+ // Blind evaluation: mask model labels in judge prompt (#167)
161
201
  const candidateSummary = activeCandidates.map((c) => {
162
- return `### Candidate ${c.index} (${c.label}):\n${(c.text || '').slice(0, 4000)}`
202
+ const label = blind ? `Candidate ${c.index}` : `Candidate ${c.index} (${c.label})`
203
+ return `### ${label}:\n${(c.text || '').slice(0, 4000)}`
163
204
  }).join('\n\n---\n\n')
164
205
 
165
206
  const judgePrompt = `You are an impartial Expert Code & Logic Judge evaluating multiple AI candidate solutions.
166
- User Prompt:
167
- ${userPrompt.slice(0, 1000)}
207
+ User Task:
208
+ ${userPrompt}
168
209
 
169
- Candidate Solutions:
210
+ Candidates Under Review:
170
211
  ${candidateSummary}
171
212
 
172
- Evaluate the solutions on correctness, architecture, edge cases, and maintainability.
173
- Rate each candidate on a scale of 1 to 10.
174
- Pick the single best candidate.
213
+ INSTRUCTIONS:
214
+ 1. Carefully compare all candidates on correctness, efficiency, architectural cleanliness, and edge-case resilience.
215
+ 2. Assign each candidate a score from 0.0 to 10.0.
216
+ 3. State your chosen best candidate and a brief reasoning sentence.
175
217
 
176
- Your response MUST follow this exact format:
177
- SCORES:
178
- ${activeCandidates.map((c) => `Candidate ${c.index}: [1-10]`).join('\n')}
179
- BEST_CANDIDATE: [index number]
218
+ Format your response EXACTLY as follows:
219
+ CANDIDATE_EVALUATION:
220
+ Candidate [index]: Score [0-10]
221
+ ...
222
+ BEST_CANDIDATE: Candidate [winning index]
180
223
  CONSENSUS_REASONING: [one concise sentence explaining your pick]
181
224
  `
182
225
 
183
- if (typeof onProgress === 'function') {
184
- onProgress(`⚖️ *Convening Multi-Judge Panel (${judgeSlots.length} judges, strategy: ${strategy})...*\n`)
185
- }
186
-
187
- const judgePromises = judgeSlots.map(async (judge, jIdx) => {
188
- const label = slotLabel(judge)
226
+ const judgePromises = judgePanel.map(async (judge, jIdx) => {
227
+ const label = judge.label || slotLabel(judge)
189
228
  try {
190
229
  const abortCtrl = new AbortController()
191
230
  if (signal) {
192
- signal.addEventListener('abort', () => abortCtrl.abort(), { once: true })
231
+ signal.addEventListener('abort', () => abortCtrl.abort(signal.reason || new Error('Turn aborted')), { once: true })
193
232
  }
194
233
  const timer = setTimeout(() => abortCtrl.abort(new Error('Judge evaluation timeout')), timeoutMs)
195
234
  timer.unref?.()
@@ -198,22 +237,31 @@ CONSENSUS_REASONING: [one concise sentence explaining your pick]
198
237
  provider: judge.provider,
199
238
  model: judge.model,
200
239
  messages: [
201
- { role: 'system', content: 'You are an objective expert judge in an ensemble evaluation system.' },
240
+ { role: 'system', content: 'You are an objective AI code evaluation judge.' },
202
241
  { role: 'user', content: judgePrompt },
203
242
  ],
204
- temperature: 0.1,
205
- maxTokens: 512,
206
243
  signal: abortCtrl.signal,
207
244
  })
245
+
208
246
  clearTimeout(timer)
209
247
 
210
248
  const text = typeof res === 'string' ? res : (res?.content || res?.text || '')
211
- const evalResult = parseJudgeEvaluation(text, candidates.length)
249
+ // Preserve active candidate indices rather than assuming 1..N (#166)
250
+ const evalResult = parseJudgeEvaluation(text, activeCandidates)
251
+
252
+ // Track usage and cost (#168)
253
+ const usage = (typeof res === 'object' && res?.usage) ? res.usage : {
254
+ inputTokens: Math.max(1, Math.round((judgePrompt.length + 50) / 4)),
255
+ outputTokens: Math.max(1, Math.round(text.length / 4)),
256
+ }
257
+ const costInfo = estimateTokenCost(judge, usage, prices)
212
258
 
213
259
  return {
214
260
  judgeIndex: jIdx + 1,
215
261
  judgeLabel: label,
216
262
  ok: true,
263
+ usage,
264
+ costUsd: costInfo.costUsd || 0,
217
265
  ...evalResult,
218
266
  }
219
267
  } catch (err) {
@@ -221,8 +269,10 @@ CONSENSUS_REASONING: [one concise sentence explaining your pick]
221
269
  judgeIndex: jIdx + 1,
222
270
  judgeLabel: label,
223
271
  ok: false,
224
- bestCandidate: 1,
272
+ bestCandidate: activeCandidates[0].index,
225
273
  scores: {},
274
+ usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
275
+ costUsd: 0,
226
276
  reasoning: err?.message || String(err),
227
277
  }
228
278
  }
@@ -231,64 +281,104 @@ CONSENSUS_REASONING: [one concise sentence explaining your pick]
231
281
  const results = await Promise.all(judgePromises)
232
282
  const validResults = results.filter((r) => r.ok)
233
283
 
284
+ // Accumulate total usage and cost across all judges (#168)
285
+ let sumInTokens = 0
286
+ let sumOutTokens = 0
287
+ let sumCostUsd = 0
288
+ for (const r of results) {
289
+ if (r.usage) {
290
+ sumInTokens += r.usage.inputTokens || r.usage.promptTokens || 0
291
+ sumOutTokens += r.usage.outputTokens || r.usage.completionTokens || 0
292
+ }
293
+ if (typeof r.costUsd === 'number') {
294
+ sumCostUsd += r.costUsd
295
+ }
296
+ }
297
+ const totalUsage = {
298
+ inputTokens: sumInTokens,
299
+ outputTokens: sumOutTokens,
300
+ totalTokens: sumInTokens + sumOutTokens,
301
+ costUsd: Number(sumCostUsd.toFixed(5)),
302
+ }
303
+ const totalCostUsd = totalUsage.costUsd
304
+
305
+ // #165: If all judges failed, consensus is false with 0 votes
306
+ if (validResults.length === 0) {
307
+ return {
308
+ consensus: false,
309
+ winningCandidateIndex: activeCandidates[0].index,
310
+ winningCandidateLabel: activeCandidates[0].label,
311
+ strategy,
312
+ isUnanimous: false,
313
+ votes: {},
314
+ averageScores: {},
315
+ judgesResults: results,
316
+ totalUsage,
317
+ totalCostUsd,
318
+ reason: 'all_judges_failed',
319
+ }
320
+ }
321
+
234
322
  // Tally votes and compute average scores
235
323
  const votes = {}
236
324
  const totalScores = {}
237
325
  const scoreCounts = {}
238
326
 
239
- for (const r of validResults) {
240
- votes[r.bestCandidate] = (votes[r.bestCandidate] || 0) + 1
241
- for (const [cIdx, s] of Object.entries(r.scores || {})) {
242
- totalScores[cIdx] = (totalScores[cIdx] || 0) + s
243
- scoreCounts[cIdx] = (scoreCounts[cIdx] || 0) + 1
327
+ for (const vr of validResults) {
328
+ votes[vr.bestCandidate] = (votes[vr.bestCandidate] || 0) + 1
329
+ for (const [candIdx, sc] of Object.entries(vr.scores)) {
330
+ totalScores[candIdx] = (totalScores[candIdx] || 0) + sc
331
+ scoreCounts[candIdx] = (scoreCounts[candIdx] || 0) + 1
244
332
  }
245
333
  }
246
334
 
247
335
  const averageScores = {}
248
- for (const c of activeCandidates) {
249
- const cnt = scoreCounts[c.index] || 0
250
- averageScores[c.index] = cnt > 0 ? Number((totalScores[c.index] / cnt).toFixed(2)) : 5.0
336
+ for (const [candIdx, sum] of Object.entries(totalScores)) {
337
+ averageScores[candIdx] = Number((sum / (scoreCounts[candIdx] || 1)).toFixed(2))
251
338
  }
252
339
 
253
340
  let winner = activeCandidates[0].index
254
341
  let isUnanimous = false
342
+ let hasConsensus = true
255
343
 
256
344
  if (strategy === 'highest_score') {
257
345
  let highestAvg = -1
258
346
  for (const c of activeCandidates) {
259
- const avg = averageScores[c.index] || 0
347
+ const avg = averageScores[c.index] ?? -1
260
348
  if (avg > highestAvg) {
261
349
  highestAvg = avg
262
350
  winner = c.index
263
351
  }
264
352
  }
353
+ hasConsensus = validResults.length > 0
265
354
  } else {
266
355
  // majority or unanimous
267
356
  let maxVotes = -1
268
- for (const c of activeCandidates) {
269
- const v = votes[c.index] || 0
270
- if (v > maxVotes) {
271
- maxVotes = v
272
- winner = c.index
273
- } else if (v === maxVotes && (averageScores[c.index] || 0) > (averageScores[winner] || 0)) {
274
- winner = c.index
357
+ for (const [cIdxStr, count] of Object.entries(votes)) {
358
+ const cIdx = parseInt(cIdxStr, 10)
359
+ if (count > maxVotes) {
360
+ maxVotes = count
361
+ winner = cIdx
275
362
  }
276
363
  }
277
364
  isUnanimous = (votes[winner] || 0) === validResults.length && validResults.length > 0
365
+ if (strategy === 'unanimous') {
366
+ hasConsensus = isUnanimous && validResults.length > 0
367
+ } else {
368
+ hasConsensus = maxVotes > (validResults.length / 2) || validResults.length === 1
369
+ }
278
370
  }
279
371
 
280
372
  const winningCand = activeCandidates.find((c) => c.index === winner) || activeCandidates[0]
281
-
282
- const consensusReport = `Multi-Judge Consensus (${validResults.length}/${judgeSlots.length} judges): ` +
283
- `Winner is Candidate ${winningCand.index} (${winningCand.label}) with ${votes[winner] || 0} votes, ` +
284
- `avg score ${averageScores[winner] || 0}/10. Strategy: ${strategy}.`
373
+ const winnerVotes = votes[winner] || 0
374
+ const consensusReport = `Multi-judge consensus (${strategy}): Candidate ${winner} (${winningCand.label}) won with ${winnerVotes}/${validResults.length} votes.`
285
375
 
286
376
  if (typeof onProgress === 'function') {
287
- onProgress(`🏁 *${consensusReport}*\n`)
377
+ onProgress(`⚖️ *${consensusReport}*\n`)
288
378
  }
289
379
 
290
380
  return {
291
- consensus: true,
381
+ consensus: hasConsensus,
292
382
  winningCandidateIndex: winningCand.index,
293
383
  winningCandidateLabel: winningCand.label,
294
384
  strategy,
@@ -296,6 +386,8 @@ CONSENSUS_REASONING: [one concise sentence explaining your pick]
296
386
  votes,
297
387
  averageScores,
298
388
  judgesResults: results,
389
+ totalUsage,
390
+ totalCostUsd,
299
391
  consensusReport,
300
392
  }
301
393
  }
package/lib/moa-report.js CHANGED
@@ -20,17 +20,19 @@ export function generateMoABenchmarkReport({
20
20
  }) {
21
21
  const durationSec = (durationMs / 1000).toFixed(2)
22
22
  const totalCost = typeof costUsd === 'number' ? costUsd.toFixed(4) : '0.0000'
23
- const inTokens = usage?.inputTokens || 0
24
- const outTokens = usage?.outputTokens || 0
25
- const totalTokens = usage?.totalTokens || (inTokens + outTokens)
23
+ const inTokens = usage?.inputTokens ?? usage?.totalInputTokens ?? usage?.prompt_tokens ?? 0
24
+ const outTokens = usage?.outputTokens ?? usage?.totalOutputTokens ?? usage?.completion_tokens ?? 0
25
+ const totalTokens = usage?.totalTokens ?? (inTokens + outTokens)
26
26
 
27
27
  const candidateRows = (candidates || []).map((c) => {
28
28
  const idx = c.index || '?'
29
29
  const label = c.label || `${c.slot?.provider}:${c.slot?.model}`
30
- const role = c.role_persona || 'general'
30
+ const role = c.role_persona || c.role || 'general'
31
31
  const status = c.ok ? '✅ OK' : '❌ Failed'
32
- const candTokens = c.usage?.totalTokens || (c.usage?.inputTokens || 0) + (c.usage?.outputTokens || 0)
33
- const candCost = c.costUsd ? `$${c.costUsd.toFixed(4)}` : '$0.0000'
32
+ const cIn = c.usage?.inputTokens ?? c.usage?.totalInputTokens ?? c.usage?.prompt_tokens ?? 0
33
+ const cOut = c.usage?.outputTokens ?? c.usage?.totalOutputTokens ?? c.usage?.completion_tokens ?? 0
34
+ const candTokens = c.usage?.totalTokens ?? (cIn + cOut)
35
+ const candCost = typeof c.costUsd === 'number' ? `$${c.costUsd.toFixed(4)}` : '$0.0000'
34
36
  const score = consensus?.averageScores?.[c.index] !== undefined
35
37
  ? `${consensus.averageScores[c.index]}/10`
36
38
  : 'N/A'
@@ -39,16 +41,31 @@ export function generateMoABenchmarkReport({
39
41
  }).join('\n')
40
42
 
41
43
  let testGateSection = '### 🧪 Automated Test Gate\n- **Status**: Not Configured / Skipped'
42
- if (testGate && testGate.enabled) {
43
- const tgStatus = testGate.passed ? '✅ PASSED' : '❌ FAILED'
44
+ const gates = Array.isArray(testGate) ? testGate : (testGate ? [testGate] : [])
45
+ const activeGates = gates.filter((tg) => tg && (tg.enabled !== false || tg.passed !== undefined))
46
+ if (activeGates.length === 1) {
47
+ const tg = activeGates[0]
48
+ const tgStatus = tg.passed ? '✅ PASSED' : '❌ FAILED'
44
49
  testGateSection = `### 🧪 Automated Test Gate
45
50
  - **Status**: ${tgStatus}
46
- - **Test Command**: \`${testGate.command || 'N/A'}\`
47
- - **Exit Code**: \`${testGate.exitCode ?? 0}\`
51
+ - **Test Command**: \`${tg.command || 'N/A'}\`
52
+ - **Exit Code**: \`${tg.exitCode ?? 0}\`
48
53
  - **Output Snippet**:
49
54
  \`\`\`
50
- ${(testGate.output || 'No output recorded').slice(0, 500)}
55
+ ${(tg.output || tg.summary || 'No output recorded').slice(0, 500)}
51
56
  \`\`\``
57
+ } else if (activeGates.length > 1) {
58
+ const allPassed = activeGates.every((tg) => tg.passed)
59
+ const passedCount = activeGates.filter((tg) => tg.passed).length
60
+ const overallStatus = allPassed ? '✅ ALL PASSED' : `❌ ${activeGates.length - passedCount}/${activeGates.length} FAILED`
61
+ const details = activeGates.map((tg, idx) => {
62
+ const label = tg.label || tg.model || `Candidate ${tg.candidateIndex ?? idx + 1}`
63
+ const tgStatus = tg.passed ? '✅ PASSED' : '❌ FAILED'
64
+ return `- **${label}**: ${tgStatus} (Command: \`${tg.command || 'N/A'}\`, Exit Code: \`${tg.exitCode ?? 0}\`)\n\`\`\`\n${(tg.output || tg.summary || 'No output recorded').slice(0, 300)}\n\`\`\``
65
+ }).join('\n')
66
+ testGateSection = `### 🧪 Automated Test Gate
67
+ - **Status**: ${overallStatus}
68
+ ${details}`
52
69
  }
53
70
 
54
71
  let consensusSection = '### ⚖️ Multi-Judge Consensus\n- **Status**: Standard single-aggregator synthesis'
@@ -104,15 +121,19 @@ ${testGateSection}
104
121
  durationMs,
105
122
  usage: { inputTokens: inTokens, outputTokens: outTokens, totalTokens },
106
123
  costUsd,
107
- candidates: (candidates || []).map((c) => ({
108
- index: c.index,
109
- label: c.label,
110
- role: c.role_persona,
111
- ok: c.ok,
112
- costUsd: c.costUsd || 0,
113
- usage: c.usage,
114
- score: consensus?.averageScores?.[c.index] ?? null,
115
- })),
124
+ candidates: (candidates || []).map((c) => {
125
+ const cIn = c.usage?.inputTokens ?? c.usage?.totalInputTokens ?? c.usage?.prompt_tokens ?? 0
126
+ const cOut = c.usage?.outputTokens ?? c.usage?.totalOutputTokens ?? c.usage?.completion_tokens ?? 0
127
+ return {
128
+ index: c.index,
129
+ label: c.label,
130
+ role: c.role_persona || c.role,
131
+ ok: c.ok,
132
+ costUsd: typeof c.costUsd === 'number' ? c.costUsd : 0,
133
+ usage: { inputTokens: cIn, outputTokens: cOut, totalTokens: c.usage?.totalTokens ?? (cIn + cOut) },
134
+ score: consensus?.averageScores?.[c.index] ?? null,
135
+ }
136
+ }),
116
137
  winningIndex,
117
138
  winningLabel,
118
139
  consensus,
package/lib/moa-runner.js CHANGED
@@ -18,7 +18,7 @@ export { parseWinnerIndex, parseRecommendedAssembler, parseMoACommand, stripOrSu
18
18
  export { estimateTokenCost, summarizeMoAUsage } from './pricing.js'
19
19
  export { REFERENCE_SYSTEM_PROMPT, callWithTransientRetry, runReferencesParallel } from './moa-candidates.js'
20
20
  export { executeTestGateForCandidates, runCandidateTestGate } from './moa-test-gate.js'
21
- export { streamMoATurn } from './moa-stream.js'
21
+ export { streamMoATurn, streamPromoteTurn } from './moa-stream.js'
22
22
  /**
23
23
  * Executes the full Mixture of Agents pipeline.
24
24
  */
@@ -229,6 +229,7 @@ export async function runMoAPipeline({
229
229
  const durationMs = Date.now() - startTime
230
230
  try {
231
231
  await recordMoaRunAsync({
232
+ cwd,
232
233
  prompt: userPrompt,
233
234
  preset: preset?.name || 'default',
234
235
  isRefinement,
@@ -281,13 +282,14 @@ export async function runMoAPipeline({
281
282
  await cleanMoaWorkspaces(cwd, { runId })
282
283
  }
283
284
 
284
- const livePreview = await createPromotedPreview(liveCanvas, cwd, promotedFiles, referenceOutputs)
285
+ const livePreview = await createPromotedPreview(liveCanvas, cwd, promotedFiles, referenceOutputs, { winningIndex: single.index })
285
286
  const totalTokens = single.usage?.totalTokens || 0
286
287
  const totalCostUsd = Number((single.costUsd || 0).toFixed(5))
287
288
  const durationMs = Date.now() - startTime
288
289
 
289
290
  try {
290
291
  await recordMoaRunAsync({
292
+ cwd,
291
293
  prompt: userPrompt,
292
294
  preset: preset?.name || 'default',
293
295
  isRefinement,
@@ -402,6 +404,8 @@ export async function runMoAPipeline({
402
404
  userPrompt,
403
405
  callLlm,
404
406
  strategy: preset.judge_voting_strategy || 'majority',
407
+ prices: prices || {},
408
+ blind: Boolean(preset.blind_evaluation),
405
409
  signal,
406
410
  onProgress,
407
411
  })
@@ -409,7 +413,7 @@ export async function runMoAPipeline({
409
413
 
410
414
  // 7d. Feature 3: Composite Block Merge Directive
411
415
  const compositeMergeDirective = (preset?.composite_merge_enabled && successfulRefs.length > 1)
412
- ? buildCompositeBlockDirectives(successfulRefs)
416
+ ? buildCompositeBlockDirectives(successfulRefs, { blind: Boolean(preset.blind_evaluation) })
413
417
  : null
414
418
 
415
419
  // 8. Synthesis phase via primary judge or fallback chain
@@ -510,9 +514,9 @@ export async function runMoAPipeline({
510
514
  await cleanMoaWorkspaces(cwd, { runId })
511
515
  }
512
516
 
513
- const livePreview = await createPromotedPreview(liveCanvas, cwd, promotedFiles, referenceOutputs)
517
+ const livePreview = await createPromotedPreview(liveCanvas, cwd, promotedFiles, referenceOutputs, { winningIndex, synthesizedFiles })
514
518
  const durationMs = Date.now() - startTime
515
- const usageSummary = summarizeMoAUsage(referenceOutputs, aggUsage)
519
+ const usageSummary = summarizeMoAUsage(referenceOutputs, aggUsage, multiJudgeResult?.totalUsage)
516
520
  const winningRef = referenceOutputs[winningIndex - 1]
517
521
  const winnerModel = winningRef?.label || slotLabel(effectiveReferences[0] || referenceModels[0])
518
522
  const finalAggLabel = slotLabel(chosenJudge)
@@ -532,7 +536,7 @@ export async function runMoAPipeline({
532
536
  winningIndex,
533
537
  winningLabel: winnerModel,
534
538
  consensus: multiJudgeResult,
535
- testGate: referenceOutputs.find((r) => r.testResult)?.testResult || null,
539
+ testGate: referenceOutputs.filter((r) => r.testResult).map((r) => r.testResult),
536
540
  isComposite: Boolean(preset?.composite_merge_enabled),
537
541
  })
538
542
  }
@@ -540,6 +544,7 @@ export async function runMoAPipeline({
540
544
  try {
541
545
  await recordMoaRunAsync({
542
546
  id: runId,
547
+ cwd,
543
548
  prompt: userPrompt,
544
549
  preset: preset?.name || 'default',
545
550
  isRefinement,
package/lib/moa-stream.js CHANGED
@@ -3,7 +3,8 @@ import crypto from 'node:crypto'
3
3
  * Zero-latency Live Streaming Generator for MoA Turns
4
4
  */
5
5
 
6
- import { cleanMoaWorkspaces } from './file-workspace.js'
6
+ import { cleanMoaWorkspaces, promoteCandidateWorkspace } from './file-workspace.js'
7
+ import { isTestEnvironment } from './history.js'
7
8
  import { formatMoAResponse } from './moa-parser.js'
8
9
  import { runMoAPipeline } from './moa-runner.js'
9
10
 
@@ -132,3 +133,58 @@ export async function* streamMoATurn({ targetPreset, userPrompt, messages, callL
132
133
  }
133
134
  }
134
135
  }
136
+
137
+ /**
138
+ * Streams a /moa promote candidate turn directly to chat with zero LLM calls (#177).
139
+ */
140
+ export async function* streamPromoteTurn({ runId, candidateIndex, cwd, webServerPort, timeMachineEngine, force = isTestEnvironment() }, options = {}) {
141
+ const signal = options?.signal
142
+ if (signal?.aborted) return
143
+
144
+ yield { type: 'block-start', index: 0, blockType: 'text' }
145
+
146
+ if (typeof candidateIndex !== 'number' || isNaN(candidateIndex) || candidateIndex < 1) {
147
+ const errText = '⚠️ **MoA Promote Error**: Valid candidate index required (>= 1).'
148
+ yield { type: 'text-delta', index: 0, text: errText }
149
+ yield { type: 'block-end', index: 0, block: { type: 'text', text: errText } }
150
+ yield { type: 'finish', reason: { kind: 'stop' } }
151
+ return
152
+ }
153
+
154
+ try {
155
+ yield { type: 'text-delta', index: 0, text: `🔄 *Promoting candidate ${candidateIndex}${runId ? ` for run \`${runId}\`` : ''}...*\n\n` }
156
+
157
+ const promotedFiles = await promoteCandidateWorkspace(cwd, candidateIndex, {
158
+ runId,
159
+ keepMoa: true,
160
+ port: webServerPort,
161
+ timeMachineEngine,
162
+ force: Boolean(force || options?.force),
163
+ })
164
+
165
+ if (!promotedFiles || promotedFiles.length === 0) {
166
+ const noFilesText = `⚠️ **MoA Promote Notice**: No candidate files found for candidate ${candidateIndex}${runId ? ` in run \`${runId}\`` : ''}. Workspace may have expired or run does not exist.`
167
+ yield { type: 'text-delta', index: 0, text: noFilesText }
168
+ yield { type: 'block-end', index: 0, block: { type: 'text', text: noFilesText } }
169
+ yield { type: 'finish', reason: { kind: 'stop' } }
170
+ return
171
+ }
172
+
173
+ const fileList = promotedFiles.map((f) => `\`${f}\``).join(', ')
174
+ const checkpointId = promotedFiles.checkpoint || null
175
+ let successMsg = `✅ **MoA Candidate ${candidateIndex} promoted successfully!**\n\n`
176
+ successMsg += `> 📦 **Files applied to workspace**: ${fileList}\n`
177
+ if (checkpointId) {
178
+ successMsg += `> 🛡️ **Time Machine Checkpoint**: \`${checkpointId}\`\n`
179
+ }
180
+
181
+ yield { type: 'text-delta', index: 0, text: successMsg }
182
+ yield { type: 'block-end', index: 0, block: { type: 'text', text: successMsg } }
183
+ yield { type: 'finish', reason: { kind: 'stop' } }
184
+ } catch (err) {
185
+ const errText = `⚠️ **MoA Promote Failed**: ${err?.message || String(err)}`
186
+ yield { type: 'text-delta', index: 0, text: errText }
187
+ yield { type: 'block-end', index: 0, block: { type: 'text', text: errText } }
188
+ yield { type: 'finish', reason: { kind: 'stop' } }
189
+ }
190
+ }
package/lib/pricing.js CHANGED
@@ -310,12 +310,16 @@ export function estimateTokenCost(slot = {}, usage = {}, customPrices = {}, cach
310
310
 
311
311
 
312
312
 
313
- export function summarizeMoAUsage(referenceOutputs = [], aggUsage = null) {
313
+ export function summarizeMoAUsage(referenceOutputs = [], aggUsage = null, multiJudgeUsage = null) {
314
314
  const normAgg = aggUsage || { inputTokens: 0, outputTokens: 0, totalTokens: 0, costUsd: 0 }
315
- const totalInputTokens = (referenceOutputs || []).reduce((acc, r) => acc + (r.usage?.inputTokens || r.usage?.promptTokens || 0), 0) + (normAgg.inputTokens || normAgg.promptTokens || 0)
316
- const totalOutputTokens = (referenceOutputs || []).reduce((acc, r) => acc + (r.usage?.outputTokens || r.usage?.completionTokens || 0), 0) + (normAgg.outputTokens || normAgg.completionTokens || 0)
317
- const totalTokens = (referenceOutputs || []).reduce((acc, r) => acc + (r.usage?.totalTokens || 0), 0) + (normAgg.totalTokens || (totalInputTokens + totalOutputTokens))
318
- const totalCostUsd = Number(((referenceOutputs || []).reduce((acc, r) => acc + (r.costUsd || 0), 0) + normAgg.costUsd).toFixed(5))
315
+ const normMj = multiJudgeUsage || { inputTokens: 0, outputTokens: 0, totalTokens: 0, costUsd: 0 }
316
+ const totalInputTokens = (referenceOutputs || []).reduce((acc, r) => acc + (r.usage?.inputTokens || r.usage?.promptTokens || r.usage?.prompt_tokens || 0), 0) + (normAgg.inputTokens || normAgg.promptTokens || normAgg.prompt_tokens || 0) + (normMj.inputTokens || normMj.promptTokens || normMj.prompt_tokens || 0)
317
+ const totalOutputTokens = (referenceOutputs || []).reduce((acc, r) => acc + (r.usage?.outputTokens || r.usage?.completionTokens || r.usage?.completion_tokens || 0), 0) + (normAgg.outputTokens || normAgg.completionTokens || normAgg.completion_tokens || 0) + (normMj.outputTokens || normMj.completionTokens || normMj.completion_tokens || 0)
318
+ const candTokens = (referenceOutputs || []).reduce((acc, r) => acc + (r.usage?.totalTokens ?? ((r.usage?.inputTokens || 0) + (r.usage?.outputTokens || 0))), 0)
319
+ const aggTokens = normAgg.totalTokens ?? ((normAgg.inputTokens || 0) + (normAgg.outputTokens || 0))
320
+ const mjTokens = normMj.totalTokens ?? ((normMj.inputTokens || 0) + (normMj.outputTokens || 0))
321
+ const totalTokens = candTokens + aggTokens + mjTokens || (totalInputTokens + totalOutputTokens)
322
+ const totalCostUsd = Number(((referenceOutputs || []).reduce((acc, r) => acc + (r.costUsd || 0), 0) + (normAgg.costUsd || 0) + (normMj.costUsd || 0)).toFixed(5))
319
323
  const candidates = (referenceOutputs || []).map((r) => ({ label: r.label, usage: r.usage, costUsd: r.costUsd }))
320
- return { totalTokens, totalInputTokens, totalOutputTokens, totalCostUsd, candidates, aggregator: normAgg }
324
+ return { totalTokens, totalInputTokens, totalOutputTokens, totalCostUsd, candidates, aggregator: normAgg, multiJudge: normMj }
321
325
  }