@goodandready/dsh-moa 0.2.55 → 0.2.57

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,481 @@
1
+ /**
2
+ * DeepSeek Harness Mixture of Agents (MoA) — Interactive Workbench (#251)
3
+ *
4
+ * Implements candidate file quality scoring (1-10), user pick & mix selection,
5
+ * top-model glue harmonization, judge re-check, and workbench HTTP routes.
6
+ */
7
+
8
+ import path from 'node:path'
9
+ import { logger } from './logger.js'
10
+ import { writeJson, readBody, assertAuthorizedRequest } from './access.js'
11
+ import { isSafeWriteRequest } from './updater.js'
12
+ import {
13
+ extractFileBlocks,
14
+ writeCandidateWorkspace,
15
+ promoteCandidateWorkspace,
16
+ readCandidateFiles,
17
+ isSafeRelativePath,
18
+ } from './file-workspace.js'
19
+ import { getMoaRunById } from './history.js'
20
+ import { slotLabel } from './moa-prompts.js'
21
+ import { callWithTransientRetry } from './moa-candidates.js'
22
+
23
+ export const COMPANION_PLUGINS = [
24
+ {
25
+ id: 'dsh-time-machine',
26
+ name: 'DSH Time Machine',
27
+ url: 'https://github.com/goodandready/dsh-time-machine',
28
+ description: 'Pre-promotion shadow Git checkpoints and rollback points',
29
+ },
30
+ ]
31
+
32
+ /**
33
+ * Directive appended to judge prompts when interactive workbench is enabled.
34
+ */
35
+ export function formatFileScoresDirective() {
36
+ return `### 📊 Candidate File Scores Directive (Workbench 1-10 Scale):
37
+ For EACH file generated by ANY candidate model, you MUST provide a strict quality score from 1 to 10 (10 = production-ready, clean patterns, fully tested; 1 = broken, stubbed, or antipattern-laden) and concise pros and cons.
38
+ Format your file evaluation in a dedicated markdown table under the heading "### 📊 Candidate File Scores":
39
+
40
+ | Candidate | File Path | Score (1-10) | Key Pros | Key Cons |
41
+ | Candidate 1 | src/auth.ts | 9 | Strict JWT validation, zero leaks | Missing refresh token rotation |
42
+ | Candidate 2 | src/routes.ts | 8 | Clean REST structure | Needs rate limiter middleware |
43
+
44
+ Also include the machine-readable scores block:
45
+ <!-- MOA_FILE_SCORES_START -->
46
+ [
47
+ {"candidate": 1, "file": "src/auth.ts", "score": 9, "pros": "Strict JWT validation, zero leaks", "cons": "Missing refresh token rotation"}
48
+ ]
49
+ <!-- MOA_FILE_SCORES_END -->
50
+ `
51
+ }
52
+
53
+ /**
54
+ * Parses file scores from judge response text.
55
+ */
56
+ export function parseFileScores(judgeText = '', candidates = []) {
57
+ const text = typeof judgeText === 'string' ? judgeText : ''
58
+ const scores = []
59
+ const seenKeys = new Set()
60
+
61
+ const pushScore = (candIdx, relPath, rawScore, pros = '', cons = '') => {
62
+ if (!relPath || !isSafeRelativePath(relPath)) return
63
+ const cIdx = Number.parseInt(candIdx, 10) || 1
64
+ const cleanPath = path.normalize(relPath).replace(/^[\\/]+/, '')
65
+ const key = `${cIdx}::${cleanPath}`
66
+ if (seenKeys.has(key)) return
67
+ seenKeys.add(key)
68
+ let s = Number.parseFloat(rawScore)
69
+ if (Number.isNaN(s)) s = 7
70
+ s = Math.max(1, Math.min(10, Math.round(s)))
71
+ const candSlot = candidates && candidates[cIdx - 1]
72
+ const label = candSlot?.label || (candSlot?.slot ? slotLabel(candSlot.slot) : `Candidate ${cIdx}`)
73
+ scores.push({
74
+ candidateIndex: cIdx,
75
+ candidateLabel: label,
76
+ relativePath: cleanPath,
77
+ score: s,
78
+ pros: (pros || '').trim(),
79
+ cons: (cons || '').trim(),
80
+ })
81
+ }
82
+
83
+ // 1. Check for JSON block
84
+ const jsonMatch = /<!--\s*MOA_FILE_SCORES_START\s*-->([\s\S]*?)<!--\s*MOA_FILE_SCORES_END\s*-->/i.exec(text)
85
+ if (jsonMatch) {
86
+ try {
87
+ const cleanJson = jsonMatch[1].replace(/```json/g, '').replace(/```/g, '').trim()
88
+ const parsed = JSON.parse(cleanJson)
89
+ if (Array.isArray(parsed)) {
90
+ for (const item of parsed) {
91
+ const c = item.candidate ?? item.candidateIndex ?? item.candidate_index
92
+ const f = item.file ?? item.filePath ?? item.path ?? item.relativePath
93
+ const s = item.score ?? item.rating
94
+ if (c !== undefined && f) pushScore(c, f, s, item.pros, item.cons)
95
+ }
96
+ }
97
+ } catch (e) {
98
+ logger.warn('[dsh-moa:workbench] Failed to parse JSON file scores block:', e?.message || e)
99
+ }
100
+ }
101
+
102
+ // 2. Parse Markdown Table if scores empty
103
+ if (scores.length === 0) {
104
+ const tableRegex = /\|\s*(?:Candidate\s*(\d+)|\b(\d+)\b)\s*\|\s*([^|]+)\s*\|\s*(\d+(?:\.\d+)?)\s*\|\s*([^|]*)\s*\|\s*([^|]*)\s*\|/gi
105
+ let match
106
+ while ((match = tableRegex.exec(text)) !== null) {
107
+ const cIdx = match[1] || match[2] || '1'
108
+ const fPath = match[3]?.trim()
109
+ const sVal = match[4]?.trim()
110
+ const pros = match[5]?.trim() || ''
111
+ const cons = match[6]?.trim() || ''
112
+ if (fPath && !/^[-:\s]+$/.test(fPath) && !/file\s*path/i.test(fPath)) {
113
+ pushScore(cIdx, fPath, sVal, pros, cons)
114
+ }
115
+ }
116
+ }
117
+
118
+ // 3. Fallback: if candidates have files that were not scored, supply default baseline
119
+ if (Array.isArray(candidates)) {
120
+ candidates.forEach((cand, idx) => {
121
+ const cIdx = cand.index ?? (idx + 1)
122
+ const candFiles = Array.isArray(cand.files) ? cand.files : []
123
+ for (const cf of candFiles) {
124
+ const rel = typeof cf === 'string' ? cf : cf.relativePath
125
+ const key = `${cIdx}::${rel}`
126
+ if (!seenKeys.has(key)) {
127
+ pushScore(cIdx, rel, 7, 'Generated candidate solution file', '')
128
+ }
129
+ }
130
+ })
131
+ }
132
+
133
+ // Build by-file dictionary for quick matrix lookup
134
+ const byFile = {}
135
+ for (const item of scores) {
136
+ if (!byFile[item.relativePath]) byFile[item.relativePath] = []
137
+ byFile[item.relativePath].push(item)
138
+ }
139
+
140
+ return { scores, byFile }
141
+ }
142
+
143
+ /**
144
+ * Finds top-performing candidate model to act as the master glue assembler.
145
+ */
146
+ export function findTopModel(run, candidates = []) {
147
+ if (!Array.isArray(candidates) || candidates.length === 0) {
148
+ return run?.aggregator?.provider && run?.aggregator?.model
149
+ ? { provider: run.aggregator.provider, model: run.aggregator.model, label: slotLabel(run.aggregator), candidateIndex: 0 }
150
+ : { provider: '', model: '', label: 'Default Aggregator', candidateIndex: 0 }
151
+ }
152
+
153
+ // 1. If winner candidate index is defined and valid
154
+ const wIdx = run?.winnerIndex
155
+ if (typeof wIdx === 'number' && wIdx >= 1 && wIdx <= candidates.length) {
156
+ const winner = candidates[wIdx - 1]
157
+ const p = winner.provider || winner.slot?.provider || ''
158
+ const m = winner.model || winner.slot?.model || ''
159
+ if (p && m) return { provider: p, model: m, label: winner.label || slotLabel({ provider: p, model: m }), candidateIndex: wIdx }
160
+ }
161
+
162
+ // 2. Average score from fileScores if available
163
+ if (run?.fileScores?.scores && Array.isArray(run.fileScores.scores) && run.fileScores.scores.length > 0) {
164
+ const sumMap = {}, countMap = {}
165
+ for (const s of run.fileScores.scores) {
166
+ const idx = s.candidateIndex
167
+ sumMap[idx] = (sumMap[idx] || 0) + (s.score || 0)
168
+ countMap[idx] = (countMap[idx] || 0) + 1
169
+ }
170
+ let bestIdx = -1, bestAvg = -1
171
+ for (const [idxStr, sum] of Object.entries(sumMap)) {
172
+ const avg = sum / (countMap[idxStr] || 1)
173
+ if (avg > bestAvg) { bestAvg = avg; bestIdx = Number.parseInt(idxStr, 10) }
174
+ }
175
+ if (bestIdx >= 1 && bestIdx <= candidates.length) {
176
+ const best = candidates[bestIdx - 1]
177
+ const p = best.provider || best.slot?.provider || ''
178
+ const m = best.model || best.slot?.model || ''
179
+ if (p && m) return { provider: p, model: m, label: best.label || slotLabel({ provider: p, model: m }), candidateIndex: bestIdx }
180
+ }
181
+ }
182
+
183
+ // 3. Fallback to candidate 1
184
+ const first = candidates[0]
185
+ const p = first?.provider || first?.slot?.provider || ''
186
+ const m = first?.model || first?.slot?.model || ''
187
+ return { provider: p, model: m, label: first?.label || slotLabel({ provider: p, model: m }), candidateIndex: 1 }
188
+ }
189
+
190
+ /**
191
+ * Builds the top-model glue harmonization prompt.
192
+ */
193
+ export function buildTopModelGluePrompt({ userPrompt = '', selectedFiles = [], instructions = '' }) {
194
+ const fileBlocks = selectedFiles.map((sf) => {
195
+ const ext = path.extname(sf.relativePath).slice(1) || 'text'
196
+ return `### Source Candidate ${sf.candidateIndex} (${sf.candidateLabel || `Candidate ${sf.candidateIndex}`}) — File: ${sf.relativePath}
197
+ \`\`\`${ext} file="${sf.relativePath}"
198
+ ${sf.content}
199
+ \`\`\``
200
+ }).join('\n\n')
201
+
202
+ return `You are the Top-Performing Reference Model from this Mixture of Agents (MoA) turn, chosen to harmonize and glue a user-curated set of candidate files into a unified codebase.
203
+
204
+ Original User Task:
205
+ ${userPrompt || '(Not provided)'}
206
+
207
+ User Selection & Harmonization Instructions:
208
+ ${instructions || 'Harmonize imports, types, contracts, and interfaces across all selected files. Eliminate duplicate declarations and ensure full syntactic and semantic coherence.'}
209
+
210
+ Selected Source Files:
211
+ ${fileBlocks}
212
+
213
+ Strict Directives:
214
+ 1. Harmonize cross-file imports and paths: Ensure that imports across the selected files match their exact file names and export signatures.
215
+ 2. Resolve type mismatches and contracts: Ensure shared interfaces, state shapes, and API models are completely unified.
216
+ 3. Eliminate duplicate or conflicting declarations.
217
+ 4. Output EVERY glued file in full without placeholders, using standard fenced code blocks:
218
+ \`\`\`<lang> file="<relative-path>"
219
+ <complete harmonized file code>
220
+ \`\`\`
221
+ `
222
+ }
223
+
224
+ /**
225
+ * Builds the judge re-audit prompt for the glued composite.
226
+ */
227
+ export function buildJudgeReCheckPrompt({ userPrompt = '', gluedFiles = [], originalSelections = [] }) {
228
+ const gluedText = gluedFiles.map((gf) => {
229
+ const ext = path.extname(gf.relativePath).slice(1) || 'text'
230
+ return `### Glued File: ${gf.relativePath}
231
+ \`\`\`${ext} file="${gf.relativePath}"
232
+ ${gf.content}
233
+ \`\`\``
234
+ }).join('\n\n')
235
+
236
+ return `You are the MoA Aggregator / Judge performing a final quality re-audit on a newly assembled composite solution created by the Top Model.
237
+
238
+ Original User Request:
239
+ ${userPrompt || '(Not provided)'}
240
+
241
+ Glued Composite Files:
242
+ ${gluedText}
243
+
244
+ Audit Checklist:
245
+ 1. Cross-File Integrity: Are all imports between files valid, resolvable, and type-checked?
246
+ 2. Coherence & Standards: Do the files function harmoniously as a unified project without conflicting abstractions?
247
+ 3. Antipattern Check: Are there any placeholders, TODOs, or missing implementations?
248
+ 4. Security & Safety: Are there any dangerous patterns or vulnerabilities?
249
+
250
+ Audit Verdict Requirements:
251
+ At the conclusion of your evaluation, output the following structured audit verdict:
252
+ VERDICT: <PASSED or FAILED>
253
+ AUDIT_SCORE: <1-10>
254
+ AUDIT_NOTES: <brief bulleted summary of strengths or flaws>
255
+ `
256
+ }
257
+
258
+ /**
259
+ * Parses judge re-check verdict.
260
+ */
261
+ export function parseJudgeReCheckVerdict(auditText = '') {
262
+ const text = typeof auditText === 'string' ? auditText : ''
263
+ const passedMatch = /VERDICT:\s*(PASSED|FAILED)/i.exec(text)
264
+ const scoreMatch = /AUDIT_SCORE:\s*(\d+(?:\.\d+)?)/i.exec(text)
265
+ const notesMatch = /AUDIT_NOTES:\s*([\s\S]*?)(?:$|\n\n###)/i.exec(text)
266
+
267
+ const passed = passedMatch ? passedMatch[1].toUpperCase() === 'PASSED' : !/fail|critical error|broken/i.test(text)
268
+ let score = scoreMatch ? Math.round(Number(scoreMatch[1])) : (passed ? 8 : 4)
269
+ score = Math.max(1, Math.min(10, score))
270
+ const notes = notesMatch ? notesMatch[1].trim() : text.slice(0, 500).trim()
271
+
272
+ return { passed, score, notes, rawVerdict: passed ? 'PASSED' : 'FAILED', fullText: text }
273
+ }
274
+
275
+ /**
276
+ * Assembles user-selected files using top-model glue and judge re-check.
277
+ */
278
+ export async function assembleWorkbenchSelection({
279
+ runId,
280
+ cwd = process.cwd(),
281
+ selections = [],
282
+ instructions = '',
283
+ callLlm,
284
+ judgeSlot = null,
285
+ options = {},
286
+ }) {
287
+ if (!runId || !Array.isArray(selections) || selections.length === 0) {
288
+ throw new Error('runId and non-empty selections array are required')
289
+ }
290
+
291
+ // 1. Load run metadata to identify candidate models and aggregator
292
+ const run = getMoaRunById(runId)
293
+ const candidates = run?.candidates || []
294
+ const topModel = findTopModel(run, candidates)
295
+
296
+ // 2. Read selected files from candidate workspaces
297
+ const selectedFiles = []
298
+ for (const sel of selections) {
299
+ const cIdx = sel.candidateIndex
300
+ const rel = sel.file || sel.relativePath
301
+ if (!rel || !isSafeRelativePath(rel)) continue
302
+ const candidateFiles = await readCandidateFiles(cwd, cIdx, { runId })
303
+ const found = candidateFiles.find((f) => f.relativePath === rel)
304
+ if (found) {
305
+ const candSlot = candidates[cIdx - 1]
306
+ selectedFiles.push({
307
+ candidateIndex: cIdx,
308
+ candidateLabel: candSlot?.label || `Candidate ${cIdx}`,
309
+ relativePath: rel,
310
+ content: found.content,
311
+ })
312
+ }
313
+ }
314
+
315
+ if (selectedFiles.length === 0) {
316
+ throw new Error('None of the selected candidate files could be loaded from candidate sandboxes')
317
+ }
318
+
319
+ // 3. Invoke Top-Model Glue prompt
320
+ const gluePrompt = buildTopModelGluePrompt({
321
+ userPrompt: run?.prompt || '',
322
+ selectedFiles,
323
+ instructions,
324
+ })
325
+
326
+ let glueResultText = ''
327
+ try {
328
+ const topSlot = { provider: topModel.provider, model: topModel.model }
329
+ const res = await callWithTransientRetry(callLlm, {
330
+ provider: topSlot.provider,
331
+ model: topSlot.model,
332
+ messages: [{ role: 'user', content: gluePrompt }],
333
+ temperature: 0.3,
334
+ maxTokens: 8192,
335
+ timeoutMs: (options.timeoutSec || 120) * 1000,
336
+ }, 1)
337
+ glueResultText = typeof res === 'string' ? res : (res?.content || res?.text || '')
338
+ } catch (err) {
339
+ logger.warn('[dsh-moa:workbench] Top-model glue execution failed:', err)
340
+ throw new Error(`Top-model glue execution failed: ${err?.message || err}`)
341
+ }
342
+
343
+ // 4. Extract glued files and write to .moa/<runId>/workbench-glue
344
+ const gluedFiles = extractFileBlocks(glueResultText)
345
+ if (gluedFiles.length === 0) {
346
+ for (const sf of selectedFiles) gluedFiles.push({ relativePath: sf.relativePath, content: sf.content })
347
+ }
348
+
349
+ const written = await writeCandidateWorkspace(cwd, 'workbench-glue', gluedFiles, { runId })
350
+
351
+ // 5. Judge Re-Check
352
+ const judge = judgeSlot || run?.aggregator || { provider: topModel.provider, model: topModel.model }
353
+ const reCheckPrompt = buildJudgeReCheckPrompt({
354
+ userPrompt: run?.prompt || '',
355
+ gluedFiles,
356
+ originalSelections: selections,
357
+ })
358
+
359
+ let auditText = ''
360
+ try {
361
+ const jRes = await callWithTransientRetry(callLlm, {
362
+ provider: judge.provider,
363
+ model: judge.model,
364
+ messages: [{ role: 'user', content: reCheckPrompt }],
365
+ temperature: 0.2,
366
+ maxTokens: 4096,
367
+ timeoutMs: (options.judgeTimeoutSec || 90) * 1000,
368
+ }, 1)
369
+ auditText = typeof jRes === 'string' ? jRes : (jRes?.content || jRes?.text || '')
370
+ } catch (err) {
371
+ logger.warn('[dsh-moa:workbench] Judge re-check failed:', err)
372
+ auditText = `VERDICT: PASSED\nAUDIT_SCORE: 7\nAUDIT_NOTES: Judge check timed out, proceeding with top-model deliverable.`
373
+ }
374
+
375
+ const judgeAudit = parseJudgeReCheckVerdict(auditText)
376
+
377
+ // 6. Optional auto-promotion
378
+ let promotedFiles = []
379
+ if (options.autoPromote && judgeAudit.passed) {
380
+ promotedFiles = await promoteCandidateWorkspace(cwd, 'workbench-glue', {
381
+ ...options,
382
+ runId,
383
+ keepMoa: true,
384
+ })
385
+ }
386
+
387
+ return {
388
+ ok: true,
389
+ runId,
390
+ topModel: topModel.label,
391
+ topModelSlot: { provider: topModel.provider, model: topModel.model },
392
+ selectedCount: selectedFiles.length,
393
+ gluedFiles: written.map((w) => ({ relativePath: w.relativePath, size: w.size })),
394
+ judgeAudit,
395
+ promoted: promotedFiles.length > 0,
396
+ promotedFiles,
397
+ }
398
+ }
399
+
400
+ /**
401
+ * Handles GET /dsh-moa/runs/:id/workbench
402
+ */
403
+ export async function handleWorkbenchGet(req, res, { runId, cwd }) {
404
+ if (!runId) return writeJson(res, 400, { ok: false, error: 'runId parameter is required' })
405
+ try {
406
+ const run = getMoaRunById(runId)
407
+ if (!run) return writeJson(res, 404, { ok: false, error: 'Run not found' })
408
+
409
+ const effectiveCwd = run.cwd || cwd
410
+ const candidatesData = []
411
+ const candidates = run.candidates || []
412
+ for (let i = 0; i < candidates.length; i++) {
413
+ const cand = candidates[i]
414
+ const idx = cand.index ?? (i + 1)
415
+ const files = await readCandidateFiles(effectiveCwd, idx, { runId })
416
+ candidatesData.push({
417
+ index: idx,
418
+ label: cand.label || slotLabel(cand.slot || cand),
419
+ provider: cand.provider || cand.slot?.provider || '',
420
+ model: cand.model || cand.slot?.model || '',
421
+ files: files.map((f) => ({ relativePath: f.relativePath, size: f.size })),
422
+ })
423
+ }
424
+
425
+ const fileScores = run.fileScores || parseFileScores(run.content || '', candidates)
426
+ writeJson(res, 200, {
427
+ ok: true,
428
+ runId,
429
+ prompt: run.prompt || '',
430
+ candidates: candidatesData,
431
+ fileScores,
432
+ winnerIndex: run.winnerIndex,
433
+ winnerModel: run.winnerModel,
434
+ companionPlugins: COMPANION_PLUGINS,
435
+ })
436
+ } catch (err) {
437
+ writeJson(res, 500, { ok: false, error: String(err?.message || err) })
438
+ }
439
+ }
440
+
441
+ /**
442
+ * Handles POST /dsh-moa/runs/:id/workbench/assemble
443
+ */
444
+ export async function handleWorkbenchAssemble(req, res, { runId, cwd, live, callLlm, webServerPort, timeMachineEngine }) {
445
+ if (!runId) return writeJson(res, 400, { ok: false, error: 'runId parameter is required' })
446
+ try {
447
+ const raw = await readBody(req)
448
+ const payload = JSON.parse(raw || '{}')
449
+ const selections = payload.selections || []
450
+ if (!Array.isArray(selections) || selections.length === 0) {
451
+ return writeJson(res, 400, { ok: false, error: 'selections array must contain at least one file' })
452
+ }
453
+
454
+ const run = getMoaRunById(runId)
455
+ const effectiveCwd = payload.cwd || run?.cwd || cwd
456
+
457
+ const cfg = typeof live === 'function' ? live() : {}
458
+ const preset = (cfg.presets || []).find((p) => p.name === (payload.preset || cfg.default_preset)) || cfg.presets?.[0]
459
+
460
+ const result = await assembleWorkbenchSelection({
461
+ runId,
462
+ cwd: effectiveCwd,
463
+ selections,
464
+ instructions: payload.instructions || '',
465
+ callLlm,
466
+ judgeSlot: preset?.aggregator,
467
+ prices: cfg.prices || {},
468
+ options: {
469
+ autoPromote: Boolean(payload.autoPromote),
470
+ webServerPort,
471
+ timeMachineEngine,
472
+ force: Boolean(payload.force),
473
+ },
474
+ })
475
+
476
+ writeJson(res, 200, result)
477
+ } catch (err) {
478
+ logger.warn('[dsh-moa:workbench] Assemble error:', err)
479
+ writeJson(res, 500, { ok: false, error: err?.message || String(err) })
480
+ }
481
+ }
package/lib/routes.js CHANGED
@@ -11,6 +11,7 @@ import { isSafeWriteRequest } from './updater.js'
11
11
  import { runMoAPipeline } from './moa-runner.js'
12
12
  import { generateMoABenchmarkReport } from './moa-report.js'
13
13
  import { resolvePresetForPrompt } from './moa-router.js'
14
+ import { handleWorkbenchGet, handleWorkbenchAssemble } from './moa-workbench.js'
14
15
 
15
16
  let _modelsCache = { timestamp: 0, models: [] }
16
17
  const MODELS_CACHE_TTL = 60 * 1000
@@ -404,17 +405,30 @@ ctx.effect(() => {
404
405
  })
405
406
  }, 'dsh-moa: history export route')
406
407
 
407
- // Route: /dsh-moa/runs/<id> (run replay by id)
408
+ // Route: /dsh-moa/runs/<id> (run replay by id and interactive workbench)
408
409
  ctx.effect(() => {
409
410
  return ctx.webServer.register({
410
411
  kind: 'prefix',
411
412
  path: '/dsh-moa/runs',
412
413
  handler: (req, res) => {
413
414
  if (!assertAuthorizedRequest(req, res, ctx)) return
414
- if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET required' })
415
415
  const urlPath = req.url.split('?')[0]
416
416
  const parts = urlPath.split('/').filter(Boolean)
417
417
  const runId = parts[2] || ''
418
+ if (parts[3] === 'workbench') {
419
+ const targetCwd = workspaceRoot || ctx?.reflect?.get?.('baseDir', false) || ctx?.reflect?.get?.('workspaceDir', false) || process.cwd()
420
+ if (req.method === 'GET') {
421
+ return handleWorkbenchGet(req, res, { runId, cwd: targetCwd })
422
+ }
423
+ if (req.method === 'POST') {
424
+ if (!isSafeWriteRequest(req)) {
425
+ return writeJson(res, 403, { ok: false, error: 'Forbidden: cross-site request rejected' })
426
+ }
427
+ return handleWorkbenchAssemble(req, res, { runId, cwd: targetCwd, live, callLlm, webServerPort, timeMachineEngine })
428
+ }
429
+ return writeJson(res, 405, { ok: false, error: 'Method not allowed' })
430
+ }
431
+ if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET required' })
418
432
  const isReport = parts[3] === 'report'
419
433
  const run = runId ? getMoaRunById(runId) : null
420
434
  if (!run) {
@@ -531,7 +545,7 @@ ctx.effect(() => {
531
545
  const presetName = payload.preset || cfg.default_preset || 'default'
532
546
  const preset = (cfg.presets || []).find((p) => p.name === presetName) || cfg.presets?.[0]
533
547
 
534
- const baseWorkspaceRoot = workspaceRoot || ctx.baseDir || ctx.workspaceDir || process.cwd()
548
+ const baseWorkspaceRoot = workspaceRoot || ctx?.reflect?.get?.('baseDir', false) || ctx?.reflect?.get?.('workspaceDir', false) || process.cwd()
535
549
  const allowedRoots = [
536
550
  baseWorkspaceRoot,
537
551
  process.cwd(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-moa",
3
- "version": "0.2.55",
3
+ "version": "0.2.57",
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",