@goodandready/dsh-moa 0.2.56 → 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.
package/README.md CHANGED
@@ -51,6 +51,7 @@ Single-model AI generation often suffers from blind spots, single-perspective bi
51
51
  8. **Fast Mode & Custom Judge Criteria**: Ultra-fast single-model preset for quick tasks and customizable evaluation guidelines for the judge.
52
52
  9. **Run History & Win-Rate Leaderboard**: Persistent logging of every run kind (synthesis, fast mode, questionnaire) with built-in REST endpoints (`/dsh-moa/history`, `/dsh-moa/leaderboard`, `/dsh-moa/runs/<id>`).
53
53
  10. **Live Canvas 1-Click Preview (optional)**: when the `@goodandready/dsh-live-canvas` plugin is installed in the same profile, the promoted HTML is pushed to its sandbox and the MoA answer carries a one-click preview link; without it the step is skipped silently.
54
+ 11. **Interactive Workbench (File Scoring 1–10, User Pick & Mix, Top-Model Glue & Judge Re-Check)**: Judge scores each candidate file (1–10) with pros and cons. Users can mix and match files across proposals in the WebUI. The turn's top-performing reference model harmonizes cross-file imports and contracts, followed by a final judge re-audit before promotion. Companion integration with [`dsh-time-machine`](https://github.com/goodandready/dsh-time-machine) for safe pre-promotion rollback checkpoints.
54
55
 
55
56
  ---
56
57
 
package/README.ru.md CHANGED
@@ -51,6 +51,7 @@
51
51
  8. **Быстрый режим (Fast Mode) и критерии судьи**: Режим для одиночных быстрых задач без судьи и гибкая настройка фокуса оценки (безопасность, производительность, минимализм).
52
52
  9. **История запусков и лидерборд моделей**: Персистентное логирование всех видов запусков (синтез, fast mode, опросник) и REST-эндпоинты (`/dsh-moa/history`, `/dsh-moa/leaderboard`, `/dsh-moa/runs/<id>`).
53
53
  10. **Live Canvas 1-клик предпросмотр (опционально)**: если в профиле установлен `@goodandready/dsh-live-canvas`, промоученный HTML отправляется в его песочницу, а ответ MoA содержит ссылку на предпросмотр в 1 клик; без плагина шаг тихо пропускается.
54
+ 11. **Интерактивный воркбенч (Оценка файлов 1–10, выбор в UI, склейка лучшей моделью и повторный аудит)**: Судья оценивает каждый сгенерированный файл каждого кандидата (1–10) с указанием ключевых плюсов и минусов. Пользователь комбинирует желаемый набор файлов в WebUI; лучшая модель раунда вызывается для гармонизации импортов и контрактов, после чего судья выполняет финальную валидацию перед промоушеном. Совместимо с [`dsh-time-machine`](https://github.com/goodandready/dsh-time-machine) для безопасных теневых снимков Git.
54
55
 
55
56
  ---
56
57
 
package/README.zh.md CHANGED
@@ -51,6 +51,7 @@
51
51
  8. **快速模式与自定义评审标准**:面向快速任务的单模型极简管线,以及可自定义的裁判评审准则。
52
52
  9. **运行历史与胜率排行榜**:对每一类运行(综合、快速模式、问卷)进行持久化记录,并内置 REST 端点(`/dsh-moa/history`、`/dsh-moa/leaderboard`、`/dsh-moa/runs/<id>`)。
53
53
  10. **Live Canvas 一键预览(可选)**:当同一 profile 中安装了 `@goodandready/dsh-live-canvas` 时,提升到项目根目录的 HTML 会被推入其沙箱,MoA 回答附带一键预览链接;未安装时该步骤静默跳过。
54
+ 11. **交互式工作台 (1-10分文件打分、自由混搭、最优模型粘合与裁判复审)**:裁判对每个候选模型生成的每个文件进行 1–10 分打分并列出优缺点。用户可在 WebUI 中自由勾选组合所需文件;当轮最高分模型被调用以粘合协同跨文件导入与类型接口,随后由裁判进行最终复审。支持配套插件 [`dsh-time-machine`](https://github.com/goodandready/dsh-time-machine) 在文件提升前创建影子 Git 回滚快照。
54
55
 
55
56
  ---
56
57
 
package/lib/client.js CHANGED
@@ -204,6 +204,31 @@ window.__ModuleLoader__.load({
204
204
  'diff.select_run': 'Run:',
205
205
  'diff.latest_run': 'Latest / Active Workspace',
206
206
  'updater.success': 'Successfully updated to v{version}. Restart DSH to apply.',
207
+
208
+ // Interactive Workbench (#251)
209
+ 'aggregator.workbench_title': '🛠️ Interactive Workbench (1-10 File Scoring & Top-Model Glue)',
210
+ 'aggregator.workbench_desc': 'Judge scores each candidate file from 1 to 10 with pros/cons. Select desired files across candidates; top model harmonizes and glues them, followed by final judge audit.',
211
+ 'aggregator.workbench_companion': 'Companion Plugin:',
212
+ 'aggregator.workbench_companion_note': 'safe shadow Git checkpoints before file promotion',
213
+ 'workbench.title': '🛠️ Interactive Synthesis Workbench',
214
+ 'workbench.desc': 'Inspect candidate file quality scores (1–10), mix and match files across proposals, harmonize with top-model glue, and run a final judge audit.',
215
+ 'workbench.select_run': 'MoA Run:',
216
+ 'workbench.latest_run': 'Select Run…',
217
+ 'workbench.no_runs': 'No MoA runs with file candidates recorded yet.',
218
+ 'workbench.empty_files': 'No candidate files found for this run.',
219
+ 'workbench.file_header': 'Project File',
220
+ 'workbench.candidate_header': 'Candidate Versions & Scores (1-10)',
221
+ 'workbench.instructions_label': 'Harmonization Instructions (Optional):',
222
+ 'workbench.instructions_placeholder': 'e.g. Ensure all imports match, unify TypeScript types, align function signatures...',
223
+ 'workbench.assemble_btn': '🚀 Harmonize & Assemble (Top-Model Glue)',
224
+ 'workbench.assembling': '⏳ Harmonizing files with Top Model...',
225
+ 'workbench.recheck_title': '⚖️ Judge Re-Audit Verdict',
226
+ 'workbench.audit_passed': 'PASSED',
227
+ 'workbench.audit_failed': 'FAILED',
228
+ 'workbench.promote_btn': 'Promote Harmonized Files to Workspace',
229
+ 'workbench.promoted_ok': '✅ Harmonized deliverable promoted to workspace root!',
230
+ 'workbench.pros': 'Pros:',
231
+ 'workbench.cons': 'Cons:',
207
232
  }
208
233
 
209
234
  const zh = {
@@ -383,6 +408,31 @@ window.__ModuleLoader__.load({
383
408
  'diff.select_run': '运行记录:',
384
409
  'diff.latest_run': '最新 / 当前工作区',
385
410
  'updater.success': '成功更新至 v{version},请重启 DSH 使其生效。',
411
+
412
+ // Interactive Workbench (#251)
413
+ 'aggregator.workbench_title': '🛠️ 交互式工作台 (1-10分文件打分与最优模型粘合)',
414
+ 'aggregator.workbench_desc': '裁判为每个候选文件打分 (1-10) 并标注优缺点。在 UI 中自由组合各候选文件,由当轮最高分模型进行代码粘合统一,最终由裁判复审。',
415
+ 'aggregator.workbench_companion': '配套插件:',
416
+ 'aggregator.workbench_companion_note': '文件提升前安全创建影子 Git 快照',
417
+ 'workbench.title': '🛠️ 交互式合成工作台',
418
+ 'workbench.desc': '查看候选模型的文件质量打分 (1–10),自由混搭组合目标文件,由最高分模型粘合统一并执行最终裁判复审。',
419
+ 'workbench.select_run': 'MoA 运行:',
420
+ 'workbench.latest_run': '选择运行记录…',
421
+ 'workbench.no_runs': '暂无生成文件的 MoA 记录。',
422
+ 'workbench.empty_files': '此运行记录未发现候选文件。',
423
+ 'workbench.file_header': '项目文件',
424
+ 'workbench.candidate_header': '候选版本与评分 (1-10)',
425
+ 'workbench.instructions_label': '粘合协同指令(可选):',
426
+ 'workbench.instructions_placeholder': '例如:对齐导入路径、统一 TypeScript 类型、规范导出接口...',
427
+ 'workbench.assemble_btn': '🚀 粘合与装配 (最优模型)',
428
+ 'workbench.assembling': '⏳ 正在通过最优模型协同粘合文件...',
429
+ 'workbench.recheck_title': '⚖️ 裁判复审判定',
430
+ 'workbench.audit_passed': '通过 (PASSED)',
431
+ 'workbench.audit_failed': '未通过 (FAILED)',
432
+ 'workbench.promote_btn': '提升粘合结果至项目根目录',
433
+ 'workbench.promoted_ok': '✅ 粘合方案已成功提升至项目工作区!',
434
+ 'workbench.pros': '优势:',
435
+ 'workbench.cons': '不足:',
386
436
  }
387
437
 
388
438
 
@@ -1023,6 +1073,408 @@ window.__ModuleLoader__.load({
1023
1073
  )
1024
1074
  )
1025
1075
  }
1076
+ function InteractiveWorkbenchSection({ t, currentRefs = [] }) {
1077
+ const [historyRuns, setHistoryRuns] = React.useState([])
1078
+ const [selectedRunId, setSelectedRunId] = React.useState('')
1079
+ const [workbenchData, setWorkbenchData] = React.useState(null)
1080
+ const [loading, setLoading] = React.useState(false)
1081
+ const [error, setError] = React.useState(null)
1082
+ const [selections, setSelections] = React.useState({})
1083
+ const [instructions, setInstructions] = React.useState('')
1084
+ const [assembling, setAssembling] = React.useState(false)
1085
+ const [assembleResult, setAssembleResult] = React.useState(null)
1086
+ const [assembleError, setAssembleError] = React.useState(null)
1087
+ const [promoting, setPromoting] = React.useState(false)
1088
+ const [promoted, setPromoted] = React.useState(false)
1089
+
1090
+ React.useEffect(() => {
1091
+ fetch('/dsh-moa/history?limit=30', { cache: 'no-store' })
1092
+ .then((r) => r.json())
1093
+ .then((data) => {
1094
+ if (data && data.ok && Array.isArray(data.runs)) {
1095
+ setHistoryRuns(data.runs)
1096
+ if (!selectedRunId && data.runs.length > 0) {
1097
+ const runWithFiles = data.runs.find((r) => r.candidates?.some((c) => c.files?.length > 0)) || data.runs[0]
1098
+ if (runWithFiles?.id) setSelectedRunId(runWithFiles.id)
1099
+ }
1100
+ }
1101
+ })
1102
+ .catch(() => {})
1103
+ }, [])
1104
+
1105
+ React.useEffect(() => {
1106
+ if (!selectedRunId) return
1107
+ setLoading(true)
1108
+ setError(null)
1109
+ setAssembleResult(null)
1110
+ setAssembleError(null)
1111
+ setPromoted(false)
1112
+
1113
+ fetch('/dsh-moa/runs/' + encodeURIComponent(selectedRunId) + '/workbench', { cache: 'no-store' })
1114
+ .then(async (r) => {
1115
+ if (!r.ok) {
1116
+ const errJson = await r.json().catch(() => ({}))
1117
+ throw new Error(errJson.error || ('HTTP ' + r.status))
1118
+ }
1119
+ return r.json()
1120
+ })
1121
+ .then((data) => {
1122
+ setWorkbenchData(data)
1123
+ const initial = {}
1124
+ if (data && data.fileScores && data.fileScores.byFile) {
1125
+ for (const [file, scores] of Object.entries(data.fileScores.byFile)) {
1126
+ if (Array.isArray(scores) && scores.length > 0) {
1127
+ const sorted = [...scores].sort((a, b) => (b.score || 0) - (a.score || 0))
1128
+ initial[file] = sorted[0].candidateIndex
1129
+ }
1130
+ }
1131
+ } else if (data && Array.isArray(data.candidates)) {
1132
+ for (const cand of data.candidates) {
1133
+ for (const f of (cand.files || [])) {
1134
+ if (!initial[f.relativePath]) initial[f.relativePath] = cand.index
1135
+ }
1136
+ }
1137
+ }
1138
+ setSelections(initial)
1139
+ })
1140
+ .catch((err) => {
1141
+ setError(err?.message || String(err))
1142
+ })
1143
+ .finally(() => setLoading(false))
1144
+ }, [selectedRunId])
1145
+
1146
+ const handleAssemble = () => {
1147
+ if (!selectedRunId || Object.keys(selections).length === 0) return
1148
+ setAssembling(true)
1149
+ setAssembleResult(null)
1150
+ setAssembleError(null)
1151
+ setPromoted(false)
1152
+
1153
+ const selArray = Object.entries(selections).map(([file, candidateIndex]) => ({
1154
+ file,
1155
+ candidateIndex: Number(candidateIndex),
1156
+ }))
1157
+
1158
+ fetch('/dsh-moa/runs/' + encodeURIComponent(selectedRunId) + '/workbench/assemble', {
1159
+ method: 'POST',
1160
+ headers: { 'Content-Type': 'application/json' },
1161
+ body: JSON.stringify({
1162
+ selections: selArray,
1163
+ instructions,
1164
+ autoPromote: false,
1165
+ }),
1166
+ })
1167
+ .then(async (r) => {
1168
+ const resJson = await r.json().catch(() => ({}))
1169
+ if (!r.ok || !resJson.ok) {
1170
+ throw new Error(resJson.error || ('HTTP ' + r.status))
1171
+ }
1172
+ return resJson
1173
+ })
1174
+ .then((data) => {
1175
+ setAssembleResult(data)
1176
+ })
1177
+ .catch((err) => {
1178
+ setAssembleError(err?.message || String(err))
1179
+ })
1180
+ .finally(() => setAssembling(false))
1181
+ }
1182
+
1183
+ const handlePromote = () => {
1184
+ if (!selectedRunId) return
1185
+ setPromoting(true)
1186
+ fetch('/dsh-moa/promote', {
1187
+ method: 'POST',
1188
+ headers: { 'Content-Type': 'application/json' },
1189
+ body: JSON.stringify({
1190
+ runId: selectedRunId,
1191
+ candidate: 'workbench-glue',
1192
+ force: true,
1193
+ }),
1194
+ })
1195
+ .then(async (r) => {
1196
+ const resJson = await r.json().catch(() => ({}))
1197
+ if (!r.ok || !resJson.ok) throw new Error(resJson.error || ('HTTP ' + r.status))
1198
+ setPromoted(true)
1199
+ })
1200
+ .catch((err) => {
1201
+ setAssembleError('Promotion failed: ' + (err?.message || String(err)))
1202
+ })
1203
+ .finally(() => setPromoting(false))
1204
+ }
1205
+
1206
+ // Collect all files from byFile or candidates
1207
+ const allFiles = []
1208
+ if (workbenchData?.fileScores?.byFile) {
1209
+ for (const file of Object.keys(workbenchData.fileScores.byFile)) {
1210
+ allFiles.push(file)
1211
+ }
1212
+ } else if (Array.isArray(workbenchData?.candidates)) {
1213
+ const fileSet = new Set()
1214
+ for (const c of workbenchData.candidates) {
1215
+ for (const f of (c.files || [])) fileSet.add(f.relativePath)
1216
+ }
1217
+ allFiles.push(...Array.from(fileSet))
1218
+ }
1219
+
1220
+ return React.createElement(
1221
+ 'div',
1222
+ { className: 'moa-section-card', style: { marginTop: 12 } },
1223
+ React.createElement('div', { className: 'moa-section-title' }, t('workbench.title') || '🛠️ Interactive Synthesis Workbench'),
1224
+ React.createElement('div', { className: 'moa-section-desc' }, t('workbench.desc') || 'Inspect 1–10 file scores across candidate models, pick & mix desired files, harmonize with top-model glue, and execute judge re-audit.'),
1225
+
1226
+ /* Run Selection Bar */
1227
+ React.createElement(
1228
+ 'div',
1229
+ { style: { display: 'flex', alignItems: 'center', gap: 8, marginTop: 10, flexWrap: 'wrap' } },
1230
+ React.createElement('span', { style: { fontSize: 12, fontWeight: 600, color: 'var(--dsw-alias-label-secondary)' } }, t('workbench.select_run') || 'MoA Run:'),
1231
+ React.createElement(
1232
+ 'select',
1233
+ {
1234
+ className: 'moa-select',
1235
+ style: { height: 28, fontSize: 12, padding: '0 8px', minWidth: 260, maxWidth: '100%' },
1236
+ value: selectedRunId,
1237
+ onChange: (e) => setSelectedRunId(e.target.value),
1238
+ },
1239
+ historyRuns.length === 0 && React.createElement('option', { value: '' }, t('workbench.no_runs') || 'No runs recorded'),
1240
+ historyRuns.map((r) => {
1241
+ const fileCount = (r.candidates || []).reduce((acc, c) => acc + (c.files?.length || 0), 0)
1242
+ const label = `${(r.prompt || '').slice(0, 36)}... [${fileCount} files, winner: ${r.winnerModel || 'none'}]`
1243
+ return React.createElement('option', { key: r.id, value: r.id }, label)
1244
+ })
1245
+ ),
1246
+ /* Companion Plugin Badge */
1247
+ React.createElement(
1248
+ 'div',
1249
+ { style: { marginLeft: 'auto', fontSize: 11, color: 'var(--dsw-alias-label-tertiary, #888)', display: 'flex', alignItems: 'center', gap: 4 } },
1250
+ '🔗 Companion: ',
1251
+ React.createElement(
1252
+ 'a',
1253
+ {
1254
+ href: 'https://github.com/goodandready/dsh-time-machine',
1255
+ target: '_blank',
1256
+ rel: 'noopener noreferrer',
1257
+ style: { color: 'var(--dsw-alias-state-brand-primary, #3b82f6)', textDecoration: 'underline' },
1258
+ },
1259
+ 'dsh-time-machine'
1260
+ )
1261
+ )
1262
+ ),
1263
+
1264
+ loading && React.createElement('div', { style: { padding: '16px 0', fontSize: 12, color: 'var(--dsw-alias-label-secondary)' } }, '⏳ Loading workbench data...'),
1265
+ error && React.createElement('div', { style: { padding: '8px 12px', background: '#fee2e2', color: '#b91c1c', borderRadius: 6, fontSize: 12, marginTop: 8 } }, error),
1266
+
1267
+ /* File Matrix */
1268
+ !loading && !error && allFiles.length === 0 && React.createElement(
1269
+ 'div',
1270
+ { style: { padding: '14px', background: 'var(--dsw-alias-bg-layer-2)', borderRadius: 8, fontSize: 12, color: 'var(--dsw-alias-label-secondary)', marginTop: 8 } },
1271
+ t('workbench.empty_files') || 'No candidate files found for this run.'
1272
+ ),
1273
+
1274
+ !loading && !error && allFiles.length > 0 && React.createElement(
1275
+ 'div',
1276
+ { style: { display: 'flex', flexDirection: 'column', gap: 10, marginTop: 12 } },
1277
+ allFiles.map((file) => {
1278
+ const fileScores = workbenchData?.fileScores?.byFile?.[file] || []
1279
+ const selectedCand = selections[file]
1280
+
1281
+ return React.createElement(
1282
+ 'div',
1283
+ {
1284
+ key: file,
1285
+ style: {
1286
+ padding: '10px 12px',
1287
+ background: 'var(--dsw-alias-bg-layer-2)',
1288
+ borderRadius: 8,
1289
+ border: '1px solid var(--dsw-alias-border-l2)',
1290
+ },
1291
+ },
1292
+ /* File Title Bar */
1293
+ React.createElement(
1294
+ 'div',
1295
+ { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 } },
1296
+ React.createElement(
1297
+ 'span',
1298
+ { style: { fontFamily: 'monospace', fontSize: 13, fontWeight: 700, color: 'var(--dsw-alias-state-brand-primary, var(--dsw-alias-label-primary))' } },
1299
+ '📄 ' + file
1300
+ ),
1301
+ React.createElement(
1302
+ 'span',
1303
+ { style: { fontSize: 11, color: 'var(--dsw-alias-label-secondary)' } },
1304
+ fileScores.length + ' candidate proposal(s)'
1305
+ )
1306
+ ),
1307
+
1308
+ /* Candidate Choices for this file */
1309
+ React.createElement(
1310
+ 'div',
1311
+ { style: { display: 'flex', flexDirection: 'column', gap: 6 } },
1312
+ fileScores.map((scoreItem) => {
1313
+ const isChecked = selectedCand === scoreItem.candidateIndex
1314
+ const scoreVal = scoreItem.score || 7
1315
+ const scoreBg = scoreVal >= 8 ? '#10b981' : (scoreVal >= 5 ? '#f59e0b' : '#ef4444')
1316
+
1317
+ return React.createElement(
1318
+ 'label',
1319
+ {
1320
+ key: scoreItem.candidateIndex,
1321
+ style: {
1322
+ display: 'flex',
1323
+ alignItems: 'flex-start',
1324
+ gap: 8,
1325
+ cursor: 'pointer',
1326
+ padding: '6px 8px',
1327
+ background: isChecked ? 'rgba(59, 130, 246, 0.08)' : 'transparent',
1328
+ borderRadius: 6,
1329
+ border: isChecked ? '1px solid var(--dsw-alias-state-brand-primary, #3b82f6)' : '1px solid transparent',
1330
+ },
1331
+ },
1332
+ React.createElement('input', {
1333
+ type: 'radio',
1334
+ name: 'file-' + file,
1335
+ checked: isChecked,
1336
+ onChange: () => setSelections((prev) => ({ ...prev, [file]: scoreItem.candidateIndex })),
1337
+ style: { marginTop: 3, accentColor: 'var(--dsw-alias-state-brand-primary, #3b82f6)' },
1338
+ }),
1339
+ React.createElement(
1340
+ 'div',
1341
+ { style: { flex: 1, fontSize: 12 } },
1342
+ React.createElement(
1343
+ 'div',
1344
+ { style: { display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' } },
1345
+ React.createElement('span', { style: { fontWeight: 600 } }, scoreItem.candidateLabel || ('Candidate ' + scoreItem.candidateIndex)),
1346
+ React.createElement(
1347
+ 'span',
1348
+ {
1349
+ style: {
1350
+ fontSize: 10,
1351
+ fontWeight: 700,
1352
+ color: '#fff',
1353
+ background: scoreBg,
1354
+ padding: '1px 6px',
1355
+ borderRadius: 4,
1356
+ },
1357
+ },
1358
+ 'Score: ' + scoreVal + '/10'
1359
+ )
1360
+ ),
1361
+ scoreItem.pros && React.createElement(
1362
+ 'div',
1363
+ { style: { fontSize: 11, color: '#16a34a', marginTop: 2 } },
1364
+ '⭐ ' + (t('workbench.pros') || 'Pros:') + ' ' + scoreItem.pros
1365
+ ),
1366
+ scoreItem.cons && React.createElement(
1367
+ 'div',
1368
+ { style: { fontSize: 11, color: '#dc2626', marginTop: 1 } },
1369
+ '⚠️ ' + (t('workbench.cons') || 'Cons:') + ' ' + scoreItem.cons
1370
+ )
1371
+ )
1372
+ )
1373
+ })
1374
+ )
1375
+ )
1376
+ }),
1377
+
1378
+ /* Harmonization Instructions Input */
1379
+ React.createElement(
1380
+ 'div',
1381
+ { style: { marginTop: 6, display: 'flex', flexDirection: 'column', gap: 4 } },
1382
+ React.createElement('span', { style: { fontSize: 12, fontWeight: 600, color: 'var(--dsw-alias-label-secondary)' } }, t('workbench.instructions_label') || 'Harmonization Instructions (Optional):'),
1383
+ React.createElement('input', {
1384
+ type: 'text',
1385
+ className: 'moa-input',
1386
+ style: { height: 32, fontSize: 12, padding: '0 8px' },
1387
+ placeholder: t('workbench.instructions_placeholder') || 'e.g. Ensure all imports match, unify TypeScript types, align function signatures...',
1388
+ value: instructions,
1389
+ onChange: (e) => setInstructions(e.target.value),
1390
+ })
1391
+ ),
1392
+
1393
+ /* Action Button */
1394
+ React.createElement(
1395
+ 'button',
1396
+ {
1397
+ className: 'moa-btn moa-btn-primary',
1398
+ style: { height: 36, fontSize: 13, fontWeight: 600, marginTop: 6 },
1399
+ disabled: assembling || Object.keys(selections).length === 0,
1400
+ onClick: handleAssemble,
1401
+ },
1402
+ assembling ? (t('workbench.assembling') || '⏳ Harmonizing files with Top Model...') : (t('workbench.assemble_btn') || '🚀 Harmonize & Assemble (Top-Model Glue)')
1403
+ ),
1404
+
1405
+ assembleError && React.createElement(
1406
+ 'div',
1407
+ { style: { padding: '8px 12px', background: '#fee2e2', color: '#b91c1c', borderRadius: 6, fontSize: 12, marginTop: 8 } },
1408
+ assembleError
1409
+ ),
1410
+
1411
+ /* Harmonization Result Card */
1412
+ assembleResult && React.createElement(
1413
+ 'div',
1414
+ {
1415
+ style: {
1416
+ marginTop: 10,
1417
+ padding: '12px',
1418
+ background: 'rgba(16, 185, 129, 0.05)',
1419
+ border: '1px solid #10b981',
1420
+ borderRadius: 8,
1421
+ },
1422
+ },
1423
+ React.createElement(
1424
+ 'div',
1425
+ { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 } },
1426
+ React.createElement('div', { style: { fontWeight: 700, fontSize: 13, color: '#047857' } }, '✨ Harmonization Complete! Top Model: ' + (assembleResult.topModel || 'Master')),
1427
+ React.createElement(
1428
+ 'span',
1429
+ {
1430
+ style: {
1431
+ fontSize: 11,
1432
+ fontWeight: 700,
1433
+ color: '#fff',
1434
+ background: assembleResult.judgeAudit?.passed ? '#10b981' : '#ef4444',
1435
+ padding: '2px 8px',
1436
+ borderRadius: 4,
1437
+ },
1438
+ },
1439
+ 'Judge: ' + (assembleResult.judgeAudit?.rawVerdict || (assembleResult.judgeAudit?.passed ? 'PASSED' : 'FAILED')) + ' (' + (assembleResult.judgeAudit?.score || 8) + '/10)'
1440
+ )
1441
+ ),
1442
+
1443
+ /* Audit notes */
1444
+ assembleResult.judgeAudit?.notes && React.createElement(
1445
+ 'div',
1446
+ { style: { fontSize: 11, color: 'var(--dsw-alias-label-secondary)', marginBottom: 8, background: 'var(--dsw-alias-bg-layer-2)', padding: '6px 8px', borderRadius: 4 } },
1447
+ assembleResult.judgeAudit.notes
1448
+ ),
1449
+
1450
+ /* Glued files */
1451
+ React.createElement(
1452
+ 'div',
1453
+ { style: { fontSize: 11, color: 'var(--dsw-alias-label-primary)', marginBottom: 8 } },
1454
+ 'Glued Files: ' + (assembleResult.gluedFiles || []).map((f) => `${f.relativePath} (${f.size}B)`).join(', ')
1455
+ ),
1456
+
1457
+ /* Promotion Button */
1458
+ !promoted && React.createElement(
1459
+ 'button',
1460
+ {
1461
+ className: 'moa-btn moa-btn-success',
1462
+ style: { height: 32, fontSize: 12, fontWeight: 600 },
1463
+ disabled: promoting,
1464
+ onClick: handlePromote,
1465
+ },
1466
+ promoting ? '⏳ Promoting to workspace...' : (t('workbench.promote_btn') || 'Promote Harmonized Files to Workspace')
1467
+ ),
1468
+
1469
+ promoted && React.createElement(
1470
+ 'div',
1471
+ { style: { fontSize: 12, fontWeight: 600, color: '#059669', marginTop: 4 } },
1472
+ t('workbench.promoted_ok') || '✅ Harmonized deliverable promoted to workspace root!'
1473
+ )
1474
+ )
1475
+ )
1476
+ )
1477
+ }
1026
1478
  function MoAEditor({
1027
1479
  t,
1028
1480
  presets,
@@ -1713,6 +2165,44 @@ window.__ModuleLoader__.load({
1713
2165
  )
1714
2166
  ),
1715
2167
 
2168
+ /* Interactive Workbench & File Scoring (#251) */
2169
+ React.createElement(
2170
+ 'div',
2171
+ { style: { display: 'flex', flexDirection: 'column', gap: 6, padding: '8px 12px', background: 'var(--dsw-alias-bg-layer-2)', borderRadius: 8, border: '1px solid var(--dsw-alias-border-l2)', marginTop: 8 } },
2172
+ React.createElement(
2173
+ 'label',
2174
+ { style: { display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: 13, fontWeight: 500 } },
2175
+ React.createElement('input', {
2176
+ type: 'checkbox',
2177
+ checked: Boolean(currentPreset.interactive_workbench_enabled),
2178
+ onChange: (e) => updateCurrentPreset((p) => ({ ...p, interactive_workbench_enabled: e.target.checked })),
2179
+ style: { accentColor: 'var(--dsw-alias-state-brand-primary, var(--dsw-alias-label-primary))', cursor: 'pointer' },
2180
+ }),
2181
+ t('aggregator.workbench_title') || '🛠️ Interactive Workbench (1-10 File Scoring & Top-Model Glue)'
2182
+ ),
2183
+ React.createElement(
2184
+ 'div',
2185
+ { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)', marginLeft: 22 } },
2186
+ t('aggregator.workbench_desc') || 'Judge scores each candidate file (1–10). Mix and match candidate files in the UI; the top model harmonizes them into a unified project followed by judge re-audit.'
2187
+ ),
2188
+ React.createElement(
2189
+ 'div',
2190
+ { style: { fontSize: 11, color: 'var(--dsw-alias-label-tertiary, #888)', marginLeft: 22, marginTop: 2, display: 'flex', alignItems: 'center', gap: 4 } },
2191
+ '🔗 ' + (t('aggregator.workbench_companion') || 'Companion Plugin:'),
2192
+ React.createElement(
2193
+ 'a',
2194
+ {
2195
+ href: 'https://github.com/goodandready/dsh-time-machine',
2196
+ target: '_blank',
2197
+ rel: 'noopener noreferrer',
2198
+ style: { color: 'var(--dsw-alias-state-brand-primary, #3b82f6)', textDecoration: 'underline' },
2199
+ },
2200
+ 'dsh-time-machine'
2201
+ ),
2202
+ '(' + (t('aggregator.workbench_companion_note') || 'safe shadow Git checkpoints before file promotion') + ')'
2203
+ )
2204
+ ),
2205
+
1716
2206
  /* Graceful Degradation & Local Fallback (#175, #176) */
1717
2207
  React.createElement(
1718
2208
  'div',
@@ -2204,6 +2694,11 @@ window.__ModuleLoader__.load({
2204
2694
  t,
2205
2695
  currentRefs,
2206
2696
  }),
2697
+ /* ── SECTION 4c: INTERACTIVE WORKBENCH (FILE PICK & MIX) ── */
2698
+ React.createElement(InteractiveWorkbenchSection, {
2699
+ t,
2700
+ currentRefs,
2701
+ }),
2207
2702
  /* ── PLUGIN UPDATES ── */
2208
2703
  React.createElement(
2209
2704
  'div',
package/lib/index.js CHANGED
@@ -325,3 +325,12 @@ export function apply(ctx, config) {
325
325
  }
326
326
 
327
327
  export { executeTestGateForCandidates, runCandidateTestGate } from './moa-test-gate.js'
328
+ export {
329
+ COMPANION_PLUGINS,
330
+ formatFileScoresDirective,
331
+ parseFileScores,
332
+ findTopModel,
333
+ assembleWorkbenchSelection,
334
+ handleWorkbenchGet,
335
+ handleWorkbenchAssemble,
336
+ } from './moa-workbench.js'
@@ -272,6 +272,7 @@ ${referenceOutputs.some((r) => r.testResult) ? `\n${TEST_GATE_DIRECTIVE}\n` : ''
272
272
  ${referenceOutputs.some((r) => r.syntaxWarning) ? `
273
273
  ${SYNTAX_CORRECTION_DIRECTIVE}
274
274
  ` : ''}
275
+ ${options.fileScoringDirective ? `\n${options.fileScoringDirective}\n` : ''}
275
276
  Instructions:
276
277
  Your response MUST be structured into three clear parts:
277
278
 
@@ -350,6 +351,7 @@ ${referenceOutputs.some((r) => r.syntaxWarning) ? `\n${SYNTAX_CORRECTION_DIRECTI
350
351
  ${options.priorTurnBaseline ? `\n### 🔄 Prior Turn Context Baseline:\n${options.priorTurnBaseline}\n` : ''}
351
352
  ${consensusReportText ? `\n### ⚖️ Multi-Judge Consensus Advisory:\n${consensusReportText}\n` : ''}
352
353
  ${options.compositeMergeDirective ? `\n${options.compositeMergeDirective}\n` : ''}
354
+ ${options.fileScoringDirective ? `\n${options.fileScoringDirective}\n` : ''}
353
355
  Instructions:
354
356
  Your response MUST be structured into three clear parts:
355
357
 
package/lib/moa-runner.js CHANGED
@@ -12,6 +12,7 @@ import { executeMultiJudgePanel, buildCompositeBlockDirectives, runMultiJudgePha
12
12
  import { applyBudgetGuardrails, checkPhaseBudget, estimateRound2Cost, estimateDownstreamCost, calcSynthesisPromptTokens, createBudgetAbortPayload } from './moa-budget.js'
13
13
  import { extractPriorTurnBaseline, pruneMultiTurnMessages } from './moa-context.js'
14
14
  import { generateMoABenchmarkReport } from './moa-report.js'
15
+ import { formatFileScoresDirective, parseFileScores } from './moa-workbench.js'
15
16
  // Re-exports for consumers & backward compatibility
16
17
  export { slotLabel, cleanAdvisoryMessages, isBroadPromptRequiringQuestions, buildQuestionSynthesisPrompt, buildCuratorSynthesisPrompt, buildSynthesisPrompt, ANTIPATTERNS_RUBRIC } from './moa-prompts.js'
17
18
  export { parseWinnerIndex, parseRecommendedAssembler, parseMoACommand, stripOrSummarizeCode, formatMoAResponse } from './moa-parser.js'
@@ -19,6 +20,7 @@ export { estimateTokenCost, summarizeMoAUsage } from './pricing.js'
19
20
  export { REFERENCE_SYSTEM_PROMPT, callWithTransientRetry, runReferencesParallel } from './moa-candidates.js'
20
21
  export { executeTestGateForCandidates, runCandidateTestGate } from './moa-test-gate.js'
21
22
  export { streamMoATurn, streamPromoteTurn } from './moa-stream.js'
23
+ export { formatFileScoresDirective, parseFileScores } from './moa-workbench.js'
22
24
  /**
23
25
  * Executes the full Mixture of Agents pipeline.
24
26
  */
@@ -403,12 +405,18 @@ export async function runMoAPipeline({
403
405
  ? buildCompositeBlockDirectives(successfulRefs, { blind: Boolean(preset.blind_evaluation) })
404
406
  : null
405
407
 
408
+ // 7e. Interactive Workbench File Scoring Directive
409
+ const fileScoringDirective = (preset?.interactive_workbench_enabled)
410
+ ? formatFileScoresDirective()
411
+ : null
412
+
406
413
  // 8. Synthesis phase via primary judge or fallback chain
407
414
  const synthesisPrompt = buildSynthesisPrompt(userPrompt, referenceOutputs, judgeCriteria, {
408
415
  curatorSynthesis: isCuratorSynthesis,
409
416
  blindEvaluation: isBlindEvaluation,
410
417
  priorTurnBaseline,
411
418
  compositeMergeDirective,
419
+ fileScoringDirective,
412
420
  consensusReport: multiJudgeResult?.consensusReport,
413
421
  })
414
422
  const synthPromptTokens = Math.max(50, Math.ceil(synthesisPrompt.length / 4))
@@ -550,29 +558,26 @@ export async function runMoAPipeline({
550
558
  let benchmarkReport = null
551
559
  if (preset?.report_generation_enabled) {
552
560
  benchmarkReport = generateMoABenchmarkReport({
553
- runId,
554
- timestamp: new Date().toISOString(),
555
- preset: preset?.name || 'default',
556
- prompt: userPrompt,
557
- durationMs,
558
- usage: usageSummary,
559
- costUsd: usageSummary.totalCostUsd,
560
- candidates: referenceOutputs,
561
- winningIndex,
562
- winningLabel: winnerModel,
563
- consensus: multiJudgeResult,
564
- testGate: referenceOutputs.filter((r) => r.testResult).map((r) => r.testResult),
561
+ runId, timestamp: new Date().toISOString(), preset: preset?.name || 'default',
562
+ prompt: userPrompt, durationMs, usage: usageSummary, costUsd: usageSummary.totalCostUsd,
563
+ candidates: referenceOutputs, winningIndex, winningLabel: winnerModel,
564
+ consensus: multiJudgeResult, testGate: referenceOutputs.filter((r) => r.testResult).map((r) => r.testResult),
565
565
  isComposite: Boolean(preset?.composite_merge_enabled),
566
566
  })
567
567
  }
568
568
 
569
+ // Feature 10: Interactive Workbench candidate file quality scores
570
+ const fileScores = (preset?.interactive_workbench_enabled)
571
+ ? parseFileScores(synthesizedText, referenceOutputs)
572
+ : null
573
+
569
574
  try {
570
575
  await recordMoaRunAsync({
571
576
  id: runId, cwd, prompt: userPrompt, preset: preset?.name || 'default', isRefinement,
572
577
  candidates: candidatesForHistory(referenceOutputs),
573
578
  aggregator: { provider: chosenJudge.provider, model: chosenJudge.model, usage: aggUsage, costUsd: aggUsage.costUsd },
574
579
  winnerIndex: winningIndex, winnerModel, promotedFiles, benchmarkReport, consensus: multiJudgeResult,
575
- totalTokens: usageSummary.totalTokens, totalCostUsd: usageSummary.totalCostUsd, durationMs,
580
+ fileScores, totalTokens: usageSummary.totalTokens, totalCostUsd: usageSummary.totalCostUsd, durationMs,
576
581
  }, historyFilePath || undefined)
577
582
  } catch (histErr) {
578
583
  logger.warn('[dsh-moa] Failed to record run in history:', histErr)
@@ -582,7 +587,7 @@ export async function runMoAPipeline({
582
587
  kind: 'synthesis', content: synthesizedText, aggregator: isFastMode ? 'Fast Mode (Direct)' : finalAggLabel,
583
588
  references: referenceOutputs, presetName: preset?.name || 'default', isRefinement, isFastMode, isCuratorSynthesis,
584
589
  recommendedAssembler, winningIndex, winnerModel, promotedFiles, runId, allowCandidateOverride,
585
- benchmarkReport, consensus: multiJudgeResult, ...(livePreview ? { liveCanvas: livePreview } : {}),
590
+ benchmarkReport, consensus: multiJudgeResult, fileScores, ...(livePreview ? { liveCanvas: livePreview } : {}),
586
591
  usage: usageSummary, skippedFiles: collectedCtx?.skippedFiles || 0, skippedList: collectedCtx?.skippedList || [], durationMs,
587
592
  }
588
593
  }
package/lib/moa-schema.js CHANGED
@@ -62,6 +62,7 @@ export const PresetSchema = z.object({
62
62
  report_generation_enabled: z.boolean().default(false),
63
63
  local_fallback_enabled: z.boolean().default(false),
64
64
  local_fallback_models: z.array(ModelSlotSchema).default([]),
65
+ interactive_workbench_enabled: z.boolean().default(false),
65
66
  })
66
67
 
67
68
  export const Config = z.object({
@@ -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.56",
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",