@goodandready/dsh-moa 0.2.39 → 0.2.41

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.
package/lib/history.js CHANGED
@@ -41,7 +41,6 @@ export function getDefaultHistoryFile() {
41
41
  return path.join(getDefaultHistoryDir(), 'moa-history.jsonl')
42
42
  }
43
43
 
44
-
45
44
  /** Max history file size before rotation (10 MB) */
46
45
  export const MAX_HISTORY_BYTES = 10 * 1024 * 1024
47
46
 
@@ -77,6 +76,7 @@ export function rotateHistoryFileIfNeeded(filePath = getDefaultHistoryFile(), ma
77
76
 
78
77
  /**
79
78
  * Prepares a normalized history record entry.
79
+ * Preserves candidate persona, temperature, fallback, testResult, and slot details (#169).
80
80
  */
81
81
  function normalizeRunRecord(record) {
82
82
  return {
@@ -87,18 +87,31 @@ function normalizeRunRecord(record) {
87
87
  isRefinement: Boolean(record.isRefinement),
88
88
  isFastMode: Boolean(record.isFastMode),
89
89
  candidates: Array.isArray(record.candidates)
90
- ? record.candidates.map((c) => ({
91
- provider: c.provider || '',
92
- model: c.model || '',
90
+ ? record.candidates.map((c, idx) => ({
91
+ index: typeof c.index === 'number' ? c.index : (idx + 1),
92
+ provider: c.provider || c.slot?.provider || '',
93
+ model: c.model || c.slot?.model || '',
94
+ slot: c.slot ? { provider: c.slot.provider, model: c.slot.model } : (c.provider && c.model ? { provider: c.provider, model: c.model } : undefined),
95
+ role: c.role || c.role_persona || 'general',
96
+ temperature: typeof c.temperature === 'number' ? c.temperature : undefined,
97
+ wasFallback: Boolean(c.wasFallback || c.was_fallback),
98
+ originalModel: c.originalModel || c.original_model || null,
93
99
  filesCount: Array.isArray(c.files) ? c.files.length : (c.filesCount || 0),
100
+ files: Array.isArray(c.files) ? c.files : undefined,
94
101
  usage: c.usage || { inputTokens: 0, outputTokens: 0 },
95
102
  costUsd: Number(c.costUsd || 0),
103
+ testResult: c.testResult ? {
104
+ passed: Boolean(c.testResult.passed),
105
+ exitCode: c.testResult.exitCode ?? 0,
106
+ summary: c.testResult.summary || '',
107
+ durationMs: c.testResult.durationMs ?? 0,
108
+ } : null,
96
109
  }))
97
110
  : [],
98
111
  aggregator: record.aggregator
99
112
  ? {
100
- provider: record.aggregator.provider || '',
101
- model: record.aggregator.model || '',
113
+ provider: record.aggregator.provider || record.aggregator.slot?.provider || '',
114
+ model: record.aggregator.model || record.aggregator.slot?.model || '',
102
115
  usage: record.aggregator.usage || { inputTokens: 0, outputTokens: 0 },
103
116
  costUsd: Number(record.aggregator.costUsd || 0),
104
117
  }
@@ -162,6 +175,7 @@ export async function recordMoaRunAsync(record, filePath = getDefaultHistoryFile
162
175
  /**
163
176
  * Reads lines from the end of a file in reverse order in chunks.
164
177
  * Avoids loading multi-megabyte JSONL files into V8 heap strings.
178
+ * Buffer-safe: never splits multi-byte UTF-8 sequences at chunk boundaries (#184).
165
179
  */
166
180
  export function readTailLinesSync(filePath, countNeeded = 50) {
167
181
  const CHUNK_SIZE = 64 * 1024
@@ -169,7 +183,7 @@ export function readTailLinesSync(filePath, countNeeded = 50) {
169
183
  try {
170
184
  const stat = fs.fstatSync(fd)
171
185
  let pos = stat.size
172
- let remainder = ''
186
+ let remainderBuf = Buffer.alloc(0)
173
187
  const collectedLines = []
174
188
 
175
189
  while (pos > 0 && collectedLines.length < countNeeded) {
@@ -177,29 +191,107 @@ export function readTailLinesSync(filePath, countNeeded = 50) {
177
191
  pos -= bytesToRead
178
192
  const buf = Buffer.alloc(bytesToRead)
179
193
  fs.readSync(fd, buf, 0, bytesToRead, pos)
180
- const chunkStr = buf.toString('utf8') + remainder
181
- const lines = chunkStr.split('\n')
182
- if (pos > 0) {
183
- remainder = lines.shift()
184
- } else {
185
- remainder = ''
194
+ const totalBuf = remainderBuf.length > 0 ? Buffer.concat([buf, remainderBuf]) : buf
195
+
196
+ let endIdx = totalBuf.length
197
+ for (let i = totalBuf.length - 1; i >= 0; i--) {
198
+ if (totalBuf[i] === 0x0A) {
199
+ if (i + 1 < endIdx) {
200
+ const lineBuf = totalBuf.subarray(i + 1, endIdx)
201
+ const lineStr = lineBuf.toString('utf8').trim()
202
+ if (lineStr) {
203
+ collectedLines.push(lineStr)
204
+ if (collectedLines.length >= countNeeded) break
205
+ }
206
+ }
207
+ endIdx = i
208
+ }
209
+ }
210
+
211
+ if (collectedLines.length >= countNeeded) {
212
+ remainderBuf = Buffer.alloc(0)
213
+ break
186
214
  }
187
215
 
188
- for (let i = lines.length - 1; i >= 0; i--) {
189
- const trimmed = lines[i].trim()
190
- if (trimmed) {
191
- collectedLines.push(trimmed)
192
- if (collectedLines.length >= countNeeded) break
216
+ if (pos > 0) {
217
+ remainderBuf = totalBuf.subarray(0, endIdx)
218
+ } else {
219
+ if (endIdx > 0) {
220
+ const firstLineStr = totalBuf.subarray(0, endIdx).toString('utf8').trim()
221
+ if (firstLineStr && collectedLines.length < countNeeded) {
222
+ collectedLines.push(firstLineStr)
223
+ }
193
224
  }
225
+ remainderBuf = Buffer.alloc(0)
194
226
  }
195
227
  }
196
- if (remainder && remainder.trim() && collectedLines.length < countNeeded) {
197
- collectedLines.push(remainder.trim())
228
+
229
+ if (remainderBuf.length > 0 && collectedLines.length < countNeeded) {
230
+ const lineStr = remainderBuf.toString('utf8').trim()
231
+ if (lineStr) collectedLines.push(lineStr)
198
232
  }
233
+
199
234
  return collectedLines
200
235
  } finally {
201
- fs.closeSync(fd)
236
+ try { fs.closeSync(fd) } catch { /* best effort */ }
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Counts lines in a file by scanning for 0x0A newline bytes in chunks.
242
+ * Fast, memory-efficient, and caches result in _historyCache (#183).
243
+ */
244
+ export function countFileLinesSync(filePath, stat) {
245
+ if (
246
+ _historyCache &&
247
+ _historyCache.filePath === filePath &&
248
+ _historyCache.mtimeMs === stat.mtimeMs &&
249
+ _historyCache.size === stat.size &&
250
+ typeof _historyCache.totalCount === 'number'
251
+ ) {
252
+ return _historyCache.totalCount
253
+ }
254
+
255
+ let count = 0
256
+ const fd = fs.openSync(filePath, 'r')
257
+ try {
258
+ const CHUNK_SIZE = 64 * 1024
259
+ const buf = Buffer.alloc(CHUNK_SIZE)
260
+ let pos = 0
261
+ let lastByte = 0
262
+ while (pos < stat.size) {
263
+ const bytesToRead = Math.min(CHUNK_SIZE, stat.size - pos)
264
+ fs.readSync(fd, buf, 0, bytesToRead, pos)
265
+ for (let i = 0; i < bytesToRead; i++) {
266
+ if (buf[i] === 0x0A) count++
267
+ }
268
+ lastByte = buf[bytesToRead - 1]
269
+ pos += bytesToRead
270
+ }
271
+ if (stat.size > 0 && lastByte !== 0x0A) {
272
+ count++
273
+ }
274
+ } finally {
275
+ try { fs.closeSync(fd) } catch { /* best effort */ }
202
276
  }
277
+
278
+ if (
279
+ !_historyCache ||
280
+ _historyCache.filePath !== filePath ||
281
+ _historyCache.mtimeMs !== stat.mtimeMs ||
282
+ _historyCache.size !== stat.size
283
+ ) {
284
+ _historyCache = {
285
+ filePath,
286
+ mtimeMs: stat.mtimeMs,
287
+ size: stat.size,
288
+ leaderboards: new Map(),
289
+ totalCount: count,
290
+ }
291
+ } else {
292
+ _historyCache.totalCount = count
293
+ }
294
+ return count
203
295
  }
204
296
 
205
297
  /**
@@ -220,6 +312,23 @@ export function getMoaHistory(limit = 20, offset = 0, filePath = getDefaultHisto
220
312
  const lines = raw.trim().split('\n').filter(Boolean)
221
313
  const total = lines.length
222
314
 
315
+ if (
316
+ !_historyCache ||
317
+ _historyCache.filePath !== filePath ||
318
+ _historyCache.mtimeMs !== stat.mtimeMs ||
319
+ _historyCache.size !== stat.size
320
+ ) {
321
+ _historyCache = {
322
+ filePath,
323
+ mtimeMs: stat.mtimeMs,
324
+ size: stat.size,
325
+ leaderboards: new Map(),
326
+ totalCount: total,
327
+ }
328
+ } else {
329
+ _historyCache.totalCount = total
330
+ }
331
+
223
332
  const sliced = lines
224
333
  .slice(Math.max(0, total - offset - limit), total - offset)
225
334
  .reverse()
@@ -250,7 +359,7 @@ export function getMoaHistory(limit = 20, offset = 0, filePath = getDefaultHisto
250
359
 
251
360
  let total = _historyCache?.totalCount
252
361
  if (typeof total !== 'number') {
253
- total = tailLines.length >= needed ? Math.max(tailLines.length, Math.round(stat.size / 250)) : tailLines.length
362
+ total = tailLines.length < needed ? tailLines.length : countFileLinesSync(filePath, stat)
254
363
  }
255
364
 
256
365
  return { total, runs }
@@ -263,6 +372,7 @@ export function getMoaHistory(limit = 20, offset = 0, filePath = getDefaultHisto
263
372
  /**
264
373
  * Calculates win-rate leaderboard and model performance statistics across history.
265
374
  * Employs in-memory caching keyed by file mtime and size for O(1) repeated queries.
375
+ * Robust matching for aliases, fallbacks, and winnerIndex (#185).
266
376
  */
267
377
  export function getMoaLeaderboard(filePath = getDefaultHistoryFile(), options = {}) {
268
378
  try {
@@ -307,10 +417,15 @@ export function getMoaLeaderboard(filePath = getDefaultHistoryFile(), options =
307
417
  if (presetFilter && presetFilter !== 'all' && item.preset !== presetFilter) continue
308
418
  filteredRuns++
309
419
 
310
- const winner = item.winnerModel ? String(item.winnerModel).trim() : null
420
+ const rawWinner = item.winnerModel ? String(item.winnerModel).trim() : null
421
+ const cleanWinner = rawWinner ? rawWinner.replace(/\s*\[fallback for .*\]$/i, '').trim() : null
422
+ const normWinner = cleanWinner && cleanWinner !== 'none' && cleanWinner !== 'unknown'
423
+ ? cleanWinner.replace(/[\/:]/g, ':').toLowerCase()
424
+ : null
311
425
 
312
426
  if (Array.isArray(item.candidates)) {
313
- for (const cand of item.candidates) {
427
+ for (let candIdx = 0; candIdx < item.candidates.length; candIdx++) {
428
+ const cand = item.candidates[candIdx]
314
429
  const key = cand.provider && cand.model ? `${cand.provider}:${cand.model}` : (cand.model || 'unknown')
315
430
  if (!modelStats.has(key)) {
316
431
  modelStats.set(key, {
@@ -326,12 +441,25 @@ export function getMoaLeaderboard(filePath = getDefaultHistoryFile(), options =
326
441
  const st = modelStats.get(key)
327
442
  st.runs += 1
328
443
 
329
- const inTok = cand.usage?.inputTokens || 0
330
- const outTok = cand.usage?.outputTokens || 0
444
+ const inTok = cand.usage?.inputTokens ?? cand.usage?.totalInputTokens ?? cand.usage?.prompt_tokens ?? 0
445
+ const outTok = cand.usage?.outputTokens ?? cand.usage?.totalOutputTokens ?? cand.usage?.completion_tokens ?? 0
331
446
  st.totalTokens += inTok + outTok
332
447
  st.totalCostUsd += Number(cand.costUsd || 0)
333
448
 
334
- if (winner && cand.model && (winner === key || winner === cand.model)) {
449
+ let isWin = false
450
+ if (normWinner) {
451
+ const normKey = key.replace(/[\/:]/g, ':').toLowerCase()
452
+ const normModel = String(cand.model || '').replace(/[\/:]/g, ':').toLowerCase()
453
+ if (normWinner === normKey || normWinner === normModel || normWinner.endsWith(`:${normModel}`)) {
454
+ isWin = true
455
+ }
456
+ }
457
+ if (!isWin && typeof item.winnerIndex === 'number' && item.winnerIndex > 0) {
458
+ if (cand.index === item.winnerIndex || candIdx === item.winnerIndex - 1) {
459
+ isWin = true
460
+ }
461
+ }
462
+ if (isWin) {
335
463
  st.wins += 1
336
464
  }
337
465
  }
@@ -433,22 +561,27 @@ export function exportMoaHistory(filePath = getDefaultHistoryFile(), options = {
433
561
  }
434
562
  }
435
563
 
564
+ /**
565
+ * Formats candidate outputs for history storage (#169).
566
+ */
436
567
  export function candidatesForHistory(referenceOutputs) {
437
- return (referenceOutputs || []).map((r) => ({
438
- provider: r.slot?.provider || "",
439
- model: r.slot?.model || "",
568
+ return (referenceOutputs || []).map((r, i) => ({
569
+ index: r.index ?? (i + 1),
570
+ provider: r.slot?.provider || r.provider || "",
571
+ model: r.slot?.model || r.model || "",
572
+ slot: r.slot ? { provider: r.slot.provider, model: r.slot.model } : undefined,
440
573
  role: r.role_persona || r.slot?.role_persona || r.slot?.role || "general",
441
574
  temperature: r.temperature,
442
575
  wasFallback: Boolean(r.was_fallback),
443
576
  originalModel: r.original_model || null,
444
- files: (r.files || []).map((f) => f.relativePath),
577
+ files: (r.files || []).map((f) => f.relativePath || f),
445
578
  usage: r.usage || { inputTokens: 0, outputTokens: 0 },
446
579
  costUsd: r.costUsd || 0,
447
580
  testResult: r.testResult ? {
448
- passed: r.testResult.passed,
449
- exitCode: r.testResult.exitCode,
450
- summary: r.testResult.summary,
451
- durationMs: r.testResult.durationMs,
581
+ passed: Boolean(r.testResult.passed),
582
+ exitCode: r.testResult.exitCode ?? 0,
583
+ summary: r.testResult.summary || '',
584
+ durationMs: r.testResult.durationMs ?? 0,
452
585
  } : null,
453
586
  }))
454
587
  }
package/lib/index.js CHANGED
@@ -17,6 +17,7 @@ import { registerMoaRoutes } from './routes.js'
17
17
  import { resolvePresetForPrompt } from './moa-router.js'
18
18
  import { createLiveCanvasClient } from './live-canvas.js'
19
19
  import { registerPluginUpdater } from './updater.js'
20
+ import { bestEffort } from './best-effort.js'
20
21
 
21
22
  export const name = '@goodandready/dsh-moa'
22
23
  export { collectProjectContext, formatProjectContext } from './file-workspace.js'
@@ -38,7 +39,11 @@ export function apply(ctx, config) {
38
39
 
39
40
  let localOverrides = null
40
41
  const getSourceConfig = () => {
41
- const liveSource = plainConfig(ctx.config ?? config ?? {})
42
+ let liveCandidate = ctx.fiber?.config
43
+ if (!liveCandidate && ('config' in ctx)) {
44
+ bestEffort('ctx.config probe', () => { liveCandidate = ctx.config }, ctx.logger)
45
+ }
46
+ const liveSource = plainConfig(liveCandidate ?? config ?? {})
42
47
  return localOverrides ? { ...liveSource, ...localOverrides } : liveSource
43
48
  }
44
49
  const live = () => {
@@ -110,7 +115,7 @@ export function apply(ctx, config) {
110
115
  Config,
111
116
  plainConfig,
112
117
  webServerPort: ctx.webServer?.port,
113
- timeMachineEngine: ctx.get?.('timeMachineEngine') || ctx.timeMachineEngine,
118
+ timeMachineEngine: ctx.reflect?.get?.('timeMachineEngine', false),
114
119
  })
115
120
 
116
121
  // Session turn interceptor: route turn to real aggregator model & stream MoA synthesis
@@ -207,7 +212,7 @@ export function apply(ctx, config) {
207
212
  prices: cfg.prices || {},
208
213
  liveCanvas,
209
214
  webServerPort: ctx.webServer?.port,
210
- timeMachineEngine: ctx.get?.('timeMachineEngine') || ctx.timeMachineEngine,
215
+ timeMachineEngine: ctx.reflect?.get?.('timeMachineEngine', false),
211
216
  }
212
217
 
213
218
  if (event.signal) {
@@ -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
@@ -402,6 +402,8 @@ export async function runMoAPipeline({
402
402
  userPrompt,
403
403
  callLlm,
404
404
  strategy: preset.judge_voting_strategy || 'majority',
405
+ prices: prices || {},
406
+ blind: Boolean(preset.blind_evaluation),
405
407
  signal,
406
408
  onProgress,
407
409
  })
@@ -409,7 +411,7 @@ export async function runMoAPipeline({
409
411
 
410
412
  // 7d. Feature 3: Composite Block Merge Directive
411
413
  const compositeMergeDirective = (preset?.composite_merge_enabled && successfulRefs.length > 1)
412
- ? buildCompositeBlockDirectives(successfulRefs)
414
+ ? buildCompositeBlockDirectives(successfulRefs, { blind: Boolean(preset.blind_evaluation) })
413
415
  : null
414
416
 
415
417
  // 8. Synthesis phase via primary judge or fallback chain
@@ -512,7 +514,7 @@ export async function runMoAPipeline({
512
514
 
513
515
  const livePreview = await createPromotedPreview(liveCanvas, cwd, promotedFiles, referenceOutputs)
514
516
  const durationMs = Date.now() - startTime
515
- const usageSummary = summarizeMoAUsage(referenceOutputs, aggUsage)
517
+ const usageSummary = summarizeMoAUsage(referenceOutputs, aggUsage, multiJudgeResult?.totalUsage)
516
518
  const winningRef = referenceOutputs[winningIndex - 1]
517
519
  const winnerModel = winningRef?.label || slotLabel(effectiveReferences[0] || referenceModels[0])
518
520
  const finalAggLabel = slotLabel(chosenJudge)
@@ -532,7 +534,7 @@ export async function runMoAPipeline({
532
534
  winningIndex,
533
535
  winningLabel: winnerModel,
534
536
  consensus: multiJudgeResult,
535
- testGate: referenceOutputs.find((r) => r.testResult)?.testResult || null,
537
+ testGate: referenceOutputs.filter((r) => r.testResult).map((r) => r.testResult),
536
538
  isComposite: Boolean(preset?.composite_merge_enabled),
537
539
  })
538
540
  }
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
  }
package/lib/routes.js CHANGED
@@ -333,7 +333,7 @@ ctx.effect(() => {
333
333
  runId,
334
334
  keepMoa: true,
335
335
  port: ctx.webServer?.port,
336
- timeMachineEngine: ctx.get?.('timeMachineEngine') || ctx.timeMachineEngine,
336
+ timeMachineEngine: timeMachineEngine || ctx.reflect?.get?.('timeMachineEngine', false),
337
337
  force: Boolean(body?.force),
338
338
  })
339
339
  writeJson(res, 200, { ok: true, candidateIndex, promotedFiles, checkpoint: promotedFiles?.checkpoint || null })
@@ -571,7 +571,7 @@ ctx.effect(() => {
571
571
  userPrompt: prompt, messages: payload.messages || [], preset, callLlm,
572
572
  cwd: payload.cwd || process.cwd(), prices: cfg.prices || {}, liveCanvas,
573
573
  webServerPort: webServerPort || ctx.webServer?.port,
574
- timeMachineEngine: timeMachineEngine || ctx.get?.('timeMachineEngine') || ctx.timeMachineEngine,
574
+ timeMachineEngine: timeMachineEngine || ctx.reflect?.get?.('timeMachineEngine', false),
575
575
  timeMachineUrl: payload.timeMachineUrl, force: payload.force,
576
576
  checkpoint: payload.checkpoint, checkpointFn: payload.checkpointFn,
577
577
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-moa",
3
- "version": "0.2.39",
3
+ "version": "0.2.41",
4
4
  "description": "Mixture of Agents (MoA) plugin for DeepSeek Harness with /moa slash command",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",