@goodandready/dsh-moa 0.2.18 → 0.2.20

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
@@ -136,6 +136,12 @@ Configure your models in `Settings → Plugins → Mixture of Agents`:
136
136
  * Configure named presets (`default`, `fast`, `deep-reasoning`), judge criteria and temperatures.
137
137
  * Enable or disable MoA and see the real host status chip; the telemetry grid shows total runs and average run cost.
138
138
 
139
+ ### 7. Candidate Diff Viewer
140
+ Inspect line-by-line differences between candidate proposals and the curator's synthesized deliverable directly in the UI. Features file selection, delta line highlights (added, removed, same), and unified diff rendering via a zero-dependency in-memory LCS algorithm.
141
+
142
+ ### 8. Pre-Promotion Git Checkpoints (`dsh-time-machine`)
143
+ Before promoting any winning candidate files over the workspace root, `dsh-moa` invokes the local `dsh-time-machine` service to create a shadow Git checkpoint (`moa-pre-promotion: candidate-N`). If `dsh-time-machine` is absent or unreachable, file promotion proceeds seamlessly via best-effort fallback.
144
+
139
145
  ### 6. Live Canvas 1-Click Preview (optional)
140
146
  If `@goodandready/dsh-live-canvas` is installed in the same profile, `dsh-moa` pushes the promoted HTML file to the Live Canvas REST contract (`POST /dsh-live-canvas/api/preview`, served by the same harness webServer) and appends a one-click preview link (`/dsh-live-canvas/sandbox/<id>`) to the answer. Without the plugin the step is skipped silently — no errors in the log, no dead links.
141
147
 
@@ -269,6 +275,9 @@ dsh-moa:
269
275
  | `/dsh-moa/leaderboard` | `GET` | Computes model win-rate leaderboard and average execution costs |
270
276
  | `/dsh-moa/runs/<id>` | `GET` | Returns a single recorded run by id |
271
277
  | `/dsh-moa/run` | `POST` | Runs the full MoA pipeline over HTTP (400 when `enabled: false`) |
278
+ | `/dsh-moa/diff` | `GET` | Computes line-by-line diff between candidate runs or curator synthesis |
279
+ | `/dsh-moa/promote` | `POST` | Manually promotes candidate workspace files to project root |
280
+ | `/api/dsh-moa/update` | `POST` | One-click plugin updater from npm with safe-write verification |
272
281
 
273
282
  ---
274
283
 
package/README.ru.md CHANGED
@@ -136,6 +136,12 @@ graph TD
136
136
  * Конфигурация именованных пресетов (`default`, `fast`, `deep-reasoning`), критериев судьи и температур.
137
137
  * Включение/отключение MoA и фактический статус-бейдж хоста; сетка телеметрии показывает общее число запусков и среднюю стоимость.
138
138
 
139
+ ### 7. Интерактивный просмотр различий (Candidate Diff Viewer)
140
+ Позволяет визуально сопоставлять построчные различия между решениями моделей-советников и итоговым синтезом судьи прямо в интерфейсе. Поддерживает выбор конкретного файла, подсветку добавленных/удаленных строк и вычисление дельт с помощью легковесного алгоритма LCS без внешних зависимостей.
141
+
142
+ ### 8. Теневые Git-чекпоинты (`dsh-time-machine`)
143
+ Перед промоушном файлов победителя в корень рабочей области `dsh-moa` автоматически обращается к сервису `dsh-time-machine` для создания снимка (`moa-pre-promotion: candidate-N`). Если плагин недоступен, промоушн штатно продолжается по схеме best-effort.
144
+
139
145
  ### 6. Live Canvas 1-клик предпросмотр (опционально)
140
146
  Если в профиле установлен `@goodandready/dsh-live-canvas`, `dsh-moa` отправляет промоученный HTML-файл в REST-контракт Live Canvas (`POST /dsh-live-canvas/api/preview`, тот же webServer харнесса) и добавляет к ответу ссылку на предпросмотр (`/dsh-live-canvas/sandbox/<id>`). Без плагина шаг пропускается тихо — без ошибок в журнале и без битых ссылок.
141
147
 
@@ -269,6 +275,9 @@ dsh-moa:
269
275
  | `/dsh-moa/leaderboard` | `GET` | Лидерборд побед моделей и средняя стоимость генерации |
270
276
  | `/dsh-moa/runs/<id>` | `GET` | Возвращает один записанный запуск по id |
271
277
  | `/dsh-moa/run` | `POST` | Запускает полный пайплайн MoA по HTTP (400 при `enabled: false`) |
278
+ | `/dsh-moa/diff` | `GET` | Вычисляет построчный diff между решениями кандидатов или синтезом куратора |
279
+ | `/dsh-moa/promote` | `POST` | Ручной промоушн файлов кандидата в корень проекта с созданием снимка |
280
+ | `/api/dsh-moa/update` | `POST` | One-click обновление плагина из npm с проверкой безопасности источника |
272
281
 
273
282
  ---
274
283
 
package/README.zh.md CHANGED
@@ -136,6 +136,12 @@ graph TD
136
136
  * 自定义命名预设 (`default`, `fast`, `deep-reasoning`)、裁判评审准则与温度。
137
137
  * 启用/停用 MoA 开关并查看真实的主机状态徽章;遥测网格展示总运行次数与平均运行成本。
138
138
 
139
+ ### 7. 候选方案差异对比器 (Candidate Diff Viewer)
140
+ 直接在界面中可视化对比顾问方案之间以及顾问与裁判综合方案之间的逐行差异。支持文件筛选、增删改高亮,基于纯 JavaScript LCS 差异算法,零第三方依赖。
141
+
142
+ ### 8. 提升前 Git 快照检查点 (`dsh-time-machine`)
143
+ 在将胜出候选者的文件提升并覆盖至项目根目录前,`dsh-moa` 自动调用本地 `dsh-time-machine` 建立影子 Git 检查点 (`moa-pre-promotion: candidate-N`)。如未安装则静默回退,不阻断提升流程。
144
+
139
145
  ### 6. Live Canvas 一键预览(可选)
140
146
  若同一 profile 中安装了 `@goodandready/dsh-live-canvas`,`dsh-moa` 会将提升后的 HTML 文件推送到 Live Canvas 的 REST 契约(`POST /dsh-live-canvas/api/preview`,由同一 harness webServer 提供服务),并在回答中附上一键预览链接(`/dsh-live-canvas/sandbox/<id>`)。未安装该插件时此步骤静默跳过——日志无报错,也不会出现死链接。
141
147
 
package/lib/client.js CHANGED
@@ -127,6 +127,17 @@ window.__ModuleLoader__.load({
127
127
  'updater.available': 'Update available: v{version}',
128
128
  'updater.upToDate': 'Up to date',
129
129
  'updater.checkFailed': 'Registry check failed',
130
+ 'diff.title': 'Candidate Diff Viewer',
131
+ 'diff.desc': 'Visually inspect differences between candidate solutions and judge synthesis in the project workspace.',
132
+ 'diff.select_from': 'Base:',
133
+ 'diff.select_to': 'Compare with:',
134
+ 'diff.select_file': 'File:',
135
+ 'diff.no_files': 'No files found in candidate workspaces (.moa). Run a task with /moa to generate candidate code.',
136
+ 'diff.refresh': 'Refresh Diff',
137
+ 'diff.loading': 'Loading differences...',
138
+ 'diff.curator': 'Curator Synthesis',
139
+ 'diff.candidate': 'Candidate {num}',
140
+ 'diff.empty': 'Selected files are identical (zero diff).',
130
141
  'updater.success': 'Successfully updated to v{version}. Restart DSH to apply.',
131
142
  }
132
143
 
@@ -230,6 +241,17 @@ window.__ModuleLoader__.load({
230
241
  'updater.available': '发现新版本:v{version}',
231
242
  'updater.upToDate': '已是最新版本',
232
243
  'updater.checkFailed': '检查版本失败',
244
+ 'diff.title': '候选方案差异查看器 (Diff Viewer)',
245
+ 'diff.desc': '在项目工作区中可视化检查候选方案与裁判合成方案之间的文件差异。',
246
+ 'diff.select_from': '基准方案:',
247
+ 'diff.select_to': '对比目标:',
248
+ 'diff.select_file': '文件:',
249
+ 'diff.no_files': '在候选工作区 (.moa) 中未发现文件。请先运行 /moa 生成候选代码。',
250
+ 'diff.refresh': '刷新差异',
251
+ 'diff.loading': '正在加载差异...',
252
+ 'diff.curator': '裁判合成 (Curator Synthesis)',
253
+ 'diff.candidate': '候选方案 {num}',
254
+ 'diff.empty': '选中的文件完全一致(无差异)。',
233
255
  'updater.success': '成功更新至 v{version},请重启 DSH 使其生效。',
234
256
  }
235
257
 
@@ -360,6 +382,20 @@ window.__ModuleLoader__.load({
360
382
  .moa-chev{transition:transform .2s ease;color:var(--dsw-alias-label-secondary)}
361
383
  .moa-chev-open{transform:rotate(180deg)}
362
384
  .moa-body{padding:0 18px 20px;border-top:1px solid var(--dsw-alias-border-l2)}
385
+ /* Candidate Diff Viewer */
386
+ .moa-diff-container{display:flex;flex-direction:column;gap:10px;margin-top:10px}
387
+ .moa-diff-controls{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
388
+ .moa-diff-select{height:32px;padding:0 8px;border-radius:6px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font-size:12px;outline:none}
389
+ .moa-diff-box{background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:8px 0;font-family:monospace, ui-monospace, Menlo, Consolas, monospace;font-size:12px;line-height:1.5;max-height:400px;overflow-y:auto;overflow-x:auto}
390
+ .moa-diff-line{display:flex;padding:1px 10px;white-space:pre}
391
+ .moa-diff-line-same{color:var(--dsw-alias-label-secondary)}
392
+ .moa-diff-line-add{background:color-mix(in srgb, var(--dsw-alias-state-success-primary) 14%, transparent);color:var(--dsw-alias-state-success-primary)}
393
+ .moa-diff-line-del{background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 14%, transparent);color:var(--dsw-alias-state-error-primary)}
394
+ .moa-diff-prefix{width:20px;user-select:none;flex-shrink:0;font-weight:bold}
395
+ .moa-diff-content{flex:1}
396
+ .moa-diff-stat-add{color:var(--dsw-alias-state-success-primary);font-weight:600}
397
+ .moa-diff-stat-del{color:var(--dsw-alias-state-error-primary);font-weight:600}
398
+
363
399
  `
364
400
 
365
401
  let stylesInjected = false
@@ -581,6 +617,197 @@ window.__ModuleLoader__.load({
581
617
  )
582
618
  }
583
619
 
620
+ function CandidateDiffSection({ t, currentRefs = [] }) {
621
+ const [fromCandidate, setFromCandidate] = React.useState('1')
622
+ const [toCandidate, setToCandidate] = React.useState('curator-synthesis')
623
+ const [selectedFile, setSelectedFile] = React.useState('')
624
+ const [files, setFiles] = React.useState([])
625
+ const [diff, setDiff] = React.useState([])
626
+ const [fromSize, setFromSize] = React.useState(0)
627
+ const [toSize, setToSize] = React.useState(0)
628
+ const [loading, setLoading] = React.useState(false)
629
+ const [error, setError] = React.useState(null)
630
+ const [hasFetched, setHasFetched] = React.useState(false)
631
+
632
+ const candidateCount = Math.max(currentRefs.length || 0, 2)
633
+ const candidateOptions = []
634
+ for (let i = 1; i <= candidateCount; i++) {
635
+ candidateOptions.push({
636
+ value: String(i),
637
+ label: (t('diff.candidate') || 'Candidate {num}').replace('{num}', String(i)),
638
+ })
639
+ }
640
+
641
+ const loadDiff = React.useCallback((targetFile = selectedFile) => {
642
+ setLoading(true)
643
+ setError(null)
644
+ const params = new URLSearchParams()
645
+ if (fromCandidate) params.set('from', fromCandidate)
646
+ if (toCandidate) params.set('to', toCandidate)
647
+ if (targetFile) params.set('file', targetFile)
648
+
649
+ fetch('/dsh-moa/diff?' + params.toString(), { cache: 'no-store' })
650
+ .then((r) => {
651
+ if (!r.ok) throw new Error('HTTP ' + r.status)
652
+ return r.json()
653
+ })
654
+ .then((data) => {
655
+ setLoading(false)
656
+ setHasFetched(true)
657
+ if (data && data.ok) {
658
+ const fileList = Array.isArray(data.files) ? data.files : []
659
+ setFiles(fileList)
660
+ setDiff(Array.isArray(data.diff) ? data.diff : [])
661
+ setFromSize(data.fromSize || 0)
662
+ setToSize(data.toSize || 0)
663
+ if (data.selectedFile && data.selectedFile !== targetFile) {
664
+ setSelectedFile(data.selectedFile)
665
+ }
666
+ } else {
667
+ setError(data?.error || 'Failed to load diff')
668
+ }
669
+ })
670
+ .catch((err) => {
671
+ setLoading(false)
672
+ setHasFetched(true)
673
+ setError(err.message || String(err))
674
+ })
675
+ }, [fromCandidate, toCandidate, selectedFile])
676
+
677
+ React.useEffect(() => {
678
+ loadDiff()
679
+ }, [fromCandidate, toCandidate])
680
+
681
+ const handleFileChange = (e) => {
682
+ const nextFile = e.target.value
683
+ setSelectedFile(nextFile)
684
+ loadDiff(nextFile)
685
+ }
686
+
687
+ const adds = diff.filter((d) => d.type === 'added').length
688
+ const dels = diff.filter((d) => d.type === 'removed').length
689
+
690
+ return React.createElement(
691
+ 'div',
692
+ { className: 'moa-section-card' },
693
+ React.createElement('div', { className: 'moa-section-title' }, t('diff.title') || '🔍 Candidate Diff Viewer'),
694
+ React.createElement('div', { className: 'moa-section-desc' }, t('diff.desc') || 'Visually inspect differences between candidate solutions and judge synthesis in the project workspace.'),
695
+
696
+ React.createElement(
697
+ 'div',
698
+ { className: 'moa-diff-container' },
699
+ /* Controls bar */
700
+ React.createElement(
701
+ 'div',
702
+ { className: 'moa-diff-controls' },
703
+ React.createElement(
704
+ 'label',
705
+ { style: { display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: 'var(--dsw-alias-label-secondary)' } },
706
+ t('diff.select_from') || 'Base:',
707
+ React.createElement(
708
+ 'select',
709
+ {
710
+ className: 'moa-diff-select',
711
+ value: fromCandidate,
712
+ onChange: (e) => setFromCandidate(e.target.value),
713
+ },
714
+ candidateOptions.map((opt) => React.createElement('option', { key: opt.value, value: opt.value }, opt.label)),
715
+ React.createElement('option', { value: 'curator-synthesis' }, t('diff.curator') || 'Curator Synthesis')
716
+ )
717
+ ),
718
+ React.createElement(
719
+ 'label',
720
+ { style: { display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: 'var(--dsw-alias-label-secondary)' } },
721
+ t('diff.select_to') || 'Compare with:',
722
+ React.createElement(
723
+ 'select',
724
+ {
725
+ className: 'moa-diff-select',
726
+ value: toCandidate,
727
+ onChange: (e) => setToCandidate(e.target.value),
728
+ },
729
+ React.createElement('option', { value: 'curator-synthesis' }, t('diff.curator') || 'Curator Synthesis'),
730
+ candidateOptions.map((opt) => React.createElement('option', { key: opt.value, value: opt.value }, opt.label))
731
+ )
732
+ ),
733
+ files.length > 0 && React.createElement(
734
+ 'label',
735
+ { style: { display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: 'var(--dsw-alias-label-secondary)' } },
736
+ t('diff.select_file') || 'File:',
737
+ React.createElement(
738
+ 'select',
739
+ {
740
+ className: 'moa-diff-select',
741
+ value: selectedFile || files[0] || '',
742
+ onChange: handleFileChange,
743
+ },
744
+ files.map((f) => React.createElement('option', { key: f, value: f }, f))
745
+ )
746
+ ),
747
+ React.createElement(
748
+ 'button',
749
+ {
750
+ type: 'button',
751
+ className: 'moa-btn moa-btn-sub',
752
+ onClick: () => loadDiff(),
753
+ disabled: loading,
754
+ },
755
+ loading ? (t('diff.loading') || 'Loading...') : (t('diff.refresh') || 'Refresh Diff')
756
+ ),
757
+ diff.length > 0 && React.createElement(
758
+ 'div',
759
+ { style: { fontSize: 12, display: 'flex', gap: 8, alignItems: 'center', marginLeft: 'auto' } },
760
+ adds > 0 && React.createElement('span', { className: 'moa-diff-stat-add' }, `+${adds}`),
761
+ dels > 0 && React.createElement('span', { className: 'moa-diff-stat-del' }, `-${dels}`),
762
+ fromSize > 0 && toSize > 0 && React.createElement(
763
+ 'span',
764
+ { style: { color: 'var(--dsw-alias-label-secondary)', fontSize: 11 } },
765
+ `(${fromSize}B → ${toSize}B)`
766
+ )
767
+ )
768
+ ),
769
+
770
+ /* Error state */
771
+ error && React.createElement(
772
+ 'div',
773
+ { className: 'moa-alert-err', style: { fontSize: 12, padding: '8px 12px' } },
774
+ error
775
+ ),
776
+
777
+ /* Empty files state */
778
+ !error && !loading && hasFetched && files.length === 0 && React.createElement(
779
+ 'div',
780
+ { style: { padding: '16px 12px', textAlign: 'center', color: 'var(--dsw-alias-label-secondary)', fontSize: 13, background: 'var(--dsw-alias-bg-layer-2)', borderRadius: 8 } },
781
+ t('diff.no_files') || 'No files found in candidate workspaces (.moa). Run a task with /moa to generate candidate code.'
782
+ ),
783
+
784
+ /* Identical content state */
785
+ !error && !loading && files.length > 0 && diff.length > 0 && adds === 0 && dels === 0 && React.createElement(
786
+ 'div',
787
+ { style: { padding: '12px', textAlign: 'center', color: 'var(--dsw-alias-state-success-primary)', fontSize: 12, background: 'var(--dsw-alias-bg-layer-2)', borderRadius: 8 } },
788
+ t('diff.empty') || 'Selected files are identical (zero diff).'
789
+ ),
790
+
791
+ /* Diff lines display */
792
+ !error && files.length > 0 && diff.length > 0 && (adds > 0 || dels > 0) && React.createElement(
793
+ 'div',
794
+ { className: 'moa-diff-box' },
795
+ diff.map((item, idx) => {
796
+ const isAdd = item.type === 'added'
797
+ const isDel = item.type === 'removed'
798
+ const lineClass = isAdd ? 'moa-diff-line moa-diff-line-add' : isDel ? 'moa-diff-line moa-diff-line-del' : 'moa-diff-line moa-diff-line-same'
799
+ const prefix = isAdd ? '+' : isDel ? '-' : ' '
800
+ return React.createElement(
801
+ 'div',
802
+ { key: idx, className: lineClass },
803
+ React.createElement('span', { className: 'moa-diff-prefix' }, prefix),
804
+ React.createElement('span', { className: 'moa-diff-content' }, item.line)
805
+ )
806
+ })
807
+ )
808
+ )
809
+ )
810
+ }
584
811
  function MoAEditor({
585
812
  t,
586
813
  presets,
@@ -692,7 +919,7 @@ window.__ModuleLoader__.load({
692
919
  provider: 'opencode-go',
693
920
  model: 'deepseek-v4-flash',
694
921
  }
695
- refs.push({ provider: fallback.provider, model: fallback.model })
922
+ refs.push({ provider: fallback.provider, model: fallback.model, role_persona: 'general' })
696
923
  return { ...p, reference_models: refs }
697
924
  })
698
925
  }
@@ -711,8 +938,8 @@ window.__ModuleLoader__.load({
711
938
  enabled: true,
712
939
  ask_clarifying_questions: true,
713
940
  reference_models: currentPreset?.reference_models?.length ? structuredClone(currentPreset.reference_models) : [
714
- { provider: 'opencode-go', model: 'deepseek-v4-flash' },
715
- { provider: 'codex', model: 'gpt-5.6-sol' },
941
+ { provider: 'opencode-go', model: 'deepseek-v4-flash', role_persona: 'general' },
942
+ { provider: 'codex', model: 'gpt-5.6-sol', role_persona: 'general' },
716
943
  ],
717
944
  aggregator: currentPreset?.aggregator?.provider ? structuredClone(currentPreset.aggregator) : {
718
945
  provider: 'codex',
@@ -1410,6 +1637,11 @@ window.__ModuleLoader__.load({
1410
1637
  )
1411
1638
  ),
1412
1639
 
1640
+ /* ── SECTION 4b: CANDIDATE DIFF VIEWER ── */
1641
+ React.createElement(CandidateDiffSection, {
1642
+ t,
1643
+ currentRefs,
1644
+ }),
1413
1645
  /* ── PLUGIN UPDATES ── */
1414
1646
  React.createElement(
1415
1647
  'div',
@@ -1937,12 +2169,36 @@ window.__ModuleLoader__.load({
1937
2169
  }
1938
2170
  }
1939
2171
 
2172
+ // Plugin-list seat (plugins.item): the seat the current core (0.1.6-alpha.2)
2173
+ // renders as the plugin's own page with its configuration. It needs an id and a
2174
+ // static label, so it is registered on its own rather than through the keyed
2175
+ // helper above. The label must not read the locale: it is resolved while the
2176
+ // page renders, and a lookup there would take the whole client batch down.
2177
+ const registerItemSeat = () => {
2178
+ try {
2179
+ ctx.slots.register(
2180
+ {
2181
+ name: 'plugins.item',
2182
+ id: ROW_ID,
2183
+ order: 35,
2184
+ label: () => 'Mixture of Agents (MoA)',
2185
+ locale: NS,
2186
+ inject: () => ({ ctx }),
2187
+ },
2188
+ (props) => React.createElement(MoAErrorBoundary, null, React.createElement(MoACard, props))
2189
+ )
2190
+ } catch (err) {
2191
+ console.warn('[dsh-moa] Failed to register plugins.item:', err)
2192
+ }
2193
+ }
2194
+
1940
2195
  if (typeof ctx.slots.inject === 'function') {
1941
- // Row seat first (the seat the current core renders), legacy seat kept as a
1942
- // fallback for older cores.
2196
+ // Plugin-list seat first, then the row seat and the legacy seat as fallbacks.
2197
+ ctx.slots.inject('plugins.item', registerItemSeat)
1943
2198
  ctx.slots.inject('plugins.row.config', registerCard('plugins.row.config', ROW_CONFIG_KEY))
1944
2199
  ctx.slots.inject('settings.plugin.item', registerCard('settings.plugin.item', NS))
1945
2200
  } else {
2201
+ registerItemSeat()
1946
2202
  registerCard('plugins.row.config', ROW_CONFIG_KEY)()
1947
2203
  registerCard('settings.plugin.item', NS)()
1948
2204
  }
@@ -225,7 +225,9 @@ export function isRefinementTask(userPrompt = '', projectFiles = null) {
225
225
  export async function writeCandidateWorkspace(baseDir, candidateIndex, files = []) {
226
226
  if (!baseDir || !Array.isArray(files) || files.length === 0) return []
227
227
 
228
- const candidateDir = path.join(baseDir, '.moa', `candidate-${candidateIndex}`)
228
+ const targetStr = String(candidateIndex).trim()
229
+ const sub = /^\d+$/.test(targetStr) ? `candidate-${targetStr}` : targetStr
230
+ const candidateDir = path.join(baseDir, '.moa', sub)
229
231
  await fs.mkdir(candidateDir, { recursive: true })
230
232
 
231
233
  const written = []
@@ -247,10 +249,49 @@ export async function writeCandidateWorkspace(baseDir, candidateIndex, files = [
247
249
  * Promotes winning candidate files to baseDir root,
248
250
  * then cleans up the temporary .moa folder.
249
251
  */
252
+ /**
253
+ * Best-effort integration with dsh-time-machine to create a shadow Git checkpoint
254
+ * before candidate promotion overwrites files in the project workspace.
255
+ */
256
+ export async function createPrePromotionCheckpoint(baseDir, candidateTarget, options = {}) {
257
+ if (!baseDir || options.checkpoint === false) return null
258
+ if (typeof options.checkpointFn === 'function') {
259
+ const res = await bestEffort('custom checkpointFn', () => options.checkpointFn(baseDir, candidateTarget))
260
+ return res || null
261
+ }
262
+
263
+ const label = options.checkpointLabel || `moa-pre-promotion: candidate-${candidateTarget}`
264
+ const port = options.port || process.env.DSH_PORT || process.env.PORT || 3000
265
+ const endpoint = options.timeMachineUrl || `http://127.0.0.1:${port}/dsh-time-machine/create`
266
+
267
+ const snap = await bestEffort('dsh-time-machine pre-promotion checkpoint', async () => {
268
+ const res = await fetch(endpoint, {
269
+ method: 'POST',
270
+ headers: {
271
+ 'Content-Type': 'application/json',
272
+ 'x-dsh-trusted': '1',
273
+ },
274
+ body: JSON.stringify({
275
+ label,
276
+ cwd: baseDir,
277
+ }),
278
+ signal: AbortSignal.timeout(options.timeoutMs ?? 1500),
279
+ })
280
+ if (res.ok) {
281
+ const data = await res.json()
282
+ return data?.snapshot || data || null
283
+ }
284
+ return null
285
+ })
286
+ return snap || null
287
+ }
288
+
250
289
  export async function promoteCandidateWorkspace(baseDir, winningIndex, options = { keepMoa: false }) {
251
290
  if (!baseDir || winningIndex === null || winningIndex === undefined) return []
252
291
 
253
- const winningDir = path.join(baseDir, '.moa', `candidate-${winningIndex}`)
292
+ const targetStr = String(winningIndex).trim()
293
+ const sub = /^\d+$/.test(targetStr) ? `candidate-${targetStr}` : targetStr
294
+ const winningDir = path.join(baseDir, '.moa', sub)
254
295
  const moaRoot = path.join(baseDir, '.moa')
255
296
 
256
297
  try {
@@ -262,6 +303,9 @@ export async function promoteCandidateWorkspace(baseDir, winningIndex, options =
262
303
  return []
263
304
  }
264
305
 
306
+ // Best-effort Time Machine checkpoint before overwriting files in baseDir
307
+ const checkpoint = await createPrePromotionCheckpoint(baseDir, winningIndex, options)
308
+
265
309
  const promoted = []
266
310
  for (const rel of entries) {
267
311
  const srcPath = path.join(winningDir, rel)
@@ -272,6 +316,10 @@ export async function promoteCandidateWorkspace(baseDir, winningIndex, options =
272
316
  promoted.push(rel)
273
317
  }
274
318
 
319
+ if (checkpoint) {
320
+ promoted.checkpoint = checkpoint
321
+ }
322
+
275
323
  if (!options.keepMoa) {
276
324
  // Clean up temporary .moa folder
277
325
  await fs.rm(moaRoot, { recursive: true, force: true })
@@ -369,3 +417,87 @@ export function verifyFileSyntax(files = []) {
369
417
 
370
418
  return warnings
371
419
  }
420
+
421
+ /**
422
+ * Reads all files from a candidate workspace (.moa/candidate-N or .moa/curator-synthesis)
423
+ */
424
+ export async function readCandidateFiles(baseDir, candidateTarget) {
425
+ if (!baseDir || candidateTarget === undefined || candidateTarget === null) return []
426
+ const targetStr = String(candidateTarget).trim()
427
+ const sub = /^\d+$/.test(targetStr) ? `candidate-${targetStr}` : targetStr
428
+ const targetDir = path.join(baseDir, '.moa', sub)
429
+
430
+ try {
431
+ const relFiles = await getFilesRecursively(targetDir)
432
+ const result = []
433
+ for (const rel of relFiles) {
434
+ try {
435
+ const fullPath = path.join(targetDir, rel)
436
+ const content = await fs.readFile(fullPath, 'utf8')
437
+ result.push({
438
+ relativePath: rel,
439
+ content,
440
+ size: Buffer.byteLength(content, 'utf8'),
441
+ })
442
+ } catch (readErr) {
443
+ // file read error best-effort
444
+ }
445
+ }
446
+ return result
447
+ } catch (err) {
448
+ return []
449
+ }
450
+ }
451
+
452
+ /**
453
+ * Computes a standard line-by-line diff between two text strings using LCS.
454
+ * Returns an array of diff items: { type: 'same' | 'added' | 'removed', line: string, oldNum?: number, newNum?: number }
455
+ */
456
+ export function computeLineDiff(oldText = '', newText = '') {
457
+ const oldLines = typeof oldText === 'string' ? oldText.split(/\r?\n/) : []
458
+ const newLines = typeof newText === 'string' ? newText.split(/\r?\n/) : []
459
+
460
+ const n = oldLines.length
461
+ const m = newLines.length
462
+
463
+ // Safety bound to avoid polynomial blowup on enormous files
464
+ if (n > 2000 || m > 2000) {
465
+ const res = []
466
+ for (let i = 0; i < n; i++) res.push({ type: 'removed', line: oldLines[i], oldNum: i + 1 })
467
+ for (let j = 0; j < m; j++) res.push({ type: 'added', line: newLines[j], newNum: j + 1 })
468
+ return res
469
+ }
470
+
471
+ // DP table for LCS
472
+ const dp = Array.from({ length: n + 1 }, () => new Uint16Array(m + 1))
473
+ for (let i = 0; i < n; i++) {
474
+ for (let j = 0; j < m; j++) {
475
+ if (oldLines[i] === newLines[j]) {
476
+ dp[i + 1][j + 1] = dp[i][j] + 1
477
+ } else {
478
+ dp[i + 1][j + 1] = Math.max(dp[i + 1][j], dp[i][j + 1])
479
+ }
480
+ }
481
+ }
482
+
483
+ // Backtrack
484
+ let i = n
485
+ let j = m
486
+ const stack = []
487
+
488
+ while (i > 0 || j > 0) {
489
+ if (i > 0 && j > 0 && oldLines[i - 1] === newLines[j - 1]) {
490
+ stack.push({ type: 'same', line: oldLines[i - 1], oldNum: i, newNum: j })
491
+ i--
492
+ j--
493
+ } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
494
+ stack.push({ type: 'added', line: newLines[j - 1], newNum: j })
495
+ j--
496
+ } else if (i > 0 && (j === 0 || dp[i][j - 1] < dp[i - 1][j])) {
497
+ stack.push({ type: 'removed', line: oldLines[i - 1], oldNum: i })
498
+ i--
499
+ }
500
+ }
501
+
502
+ return stack.reverse()
503
+ }
package/lib/history.js CHANGED
@@ -303,6 +303,7 @@ export function candidatesForHistory(referenceOutputs) {
303
303
  return (referenceOutputs || []).map((r) => ({
304
304
  provider: r.slot?.provider || "",
305
305
  model: r.slot?.model || "",
306
+ role: r.role_persona || r.slot?.role_persona || r.slot?.role || "general",
306
307
  files: (r.files || []).map((f) => f.relativePath),
307
308
  usage: r.usage || { inputTokens: 0, outputTokens: 0 },
308
309
  costUsd: r.costUsd || 0,
@@ -81,8 +81,9 @@ export async function runReferencesParallel(references, messages, options = {},
81
81
 
82
82
  references.forEach((slot, i) => {
83
83
  const label = slotLabel(slot)
84
- const persona = slot?.role_persona && ROLE_PERSONA_PROMPTS[slot.role_persona]
85
- ? `\n\n${ROLE_PERSONA_PROMPTS[slot.role_persona]}`
84
+ const role = slot?.role_persona || slot?.role || 'general'
85
+ const persona = (role && ROLE_PERSONA_PROMPTS[role])
86
+ ? `\n\n${ROLE_PERSONA_PROMPTS[role]}`
86
87
  : ''
87
88
  const slotMessages = [{ role: 'system', content: systemPrompt + persona }, ...advisoryMessages]
88
89
 
@@ -127,6 +128,7 @@ export async function runReferencesParallel(references, messages, options = {},
127
128
  index: i + 1,
128
129
  slot,
129
130
  label,
131
+ role_persona: role,
130
132
  text,
131
133
  usage: costInfo,
132
134
  costUsd: costInfo.costUsd,
@@ -143,6 +145,7 @@ export async function runReferencesParallel(references, messages, options = {},
143
145
  index: i + 1,
144
146
  slot,
145
147
  label,
148
+ role_persona: role,
146
149
  text: `[Model ${label} error: ${errMsg}]`,
147
150
  usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0, costUsd: 0 },
148
151
  costUsd: 0,
@@ -190,7 +190,9 @@ At the end, add a note that the user can answer briefly (e.g.: "1, 2, dark theme
190
190
  export function buildPeerCritiquePrompt(userPrompt, myProposal, otherProposals = [], isBlind = true) {
191
191
  const othersText = otherProposals
192
192
  .map((p, idx) => {
193
- const label = isBlind ? `Candidate ${idx + 1}` : (p.label || `Candidate ${idx + 1}`)
193
+ const role = p.role_persona || p.slot?.role_persona || p.role || p.slot?.role
194
+ const roleNote = role && role !== 'general' ? ` [Focus: ${role}]` : ''
195
+ const label = isBlind ? `Candidate ${idx + 1}${roleNote}` : `${p.label || `Candidate ${idx + 1}`}${roleNote}`
194
196
  let text = p.text || ''
195
197
  if (text.length > 3000) {
196
198
  text = stripOrSummarizeCode(text)
@@ -230,10 +232,12 @@ export function buildCuratorSynthesisPrompt(userPrompt, referenceOutputs = [], j
230
232
  if (textContent.length > 3000) {
231
233
  textContent = stripOrSummarizeCode(textContent)
232
234
  }
235
+ const role = r.role_persona || r.slot?.role_persona || r.role || r.slot?.role
236
+ const roleNote = role && role !== 'general' ? ` [Focus: ${role}]` : ''
233
237
  const syntaxNote = r.syntaxWarning ? ` [⚠️ Syntax Warning: ${r.syntaxWarning}]` : ''
234
238
  const header = isBlind
235
- ? `Candidate ${i + 1}:${syntaxNote}${fileSummary}`
236
- : `Candidate ${i + 1} — ${r.label}:${syntaxNote}${fileSummary}`
239
+ ? `Candidate ${i + 1}${roleNote}:${syntaxNote}${fileSummary}`
240
+ : `Candidate ${i + 1} — ${r.label}${roleNote}:${syntaxNote}${fileSummary}`
237
241
  return `${header}\n${textContent}`
238
242
  })
239
243
  .join('\n\n')
@@ -297,10 +301,12 @@ export function buildSynthesisPrompt(userPrompt, referenceOutputs = [], judgeCri
297
301
  if (textContent.length > 3000) {
298
302
  textContent = stripOrSummarizeCode(textContent)
299
303
  }
304
+ const role = r.role_persona || r.slot?.role_persona || r.role || r.slot?.role
305
+ const roleNote = role && role !== 'general' ? ` [Focus: ${role}]` : ''
300
306
  const syntaxNote = r.syntaxWarning ? ` [⚠️ Syntax Warning: ${r.syntaxWarning}]` : ''
301
307
  const header = isBlind
302
- ? `Reference ${i + 1}:${syntaxNote}${fileSummary}`
303
- : `Reference ${i + 1} — ${r.label}:${syntaxNote}${fileSummary}`
308
+ ? `Reference ${i + 1}${roleNote}:${syntaxNote}${fileSummary}`
309
+ : `Reference ${i + 1} — ${r.label}${roleNote}:${syntaxNote}${fileSummary}`
304
310
  return `${header}\n${textContent}`
305
311
  })
306
312
  .join('\n\n')
package/lib/moa-runner.js CHANGED
@@ -307,7 +307,11 @@ export async function runMoAPipeline({
307
307
  const r2Promises = successfulRefs.map(async (cand) => {
308
308
  const opponents = successfulRefs
309
309
  .filter((c) => c.index !== cand.index)
310
- .map((c) => ({ label: isBlindEvaluation ? `Candidate ${c.index}` : c.label, text: c.text }))
310
+ .map((c) => ({
311
+ label: isBlindEvaluation ? `Candidate ${c.index}` : c.label,
312
+ role_persona: c.role_persona || c.slot?.role_persona,
313
+ text: c.text,
314
+ }))
311
315
  const r2Prompt = buildPeerCritiquePrompt(userPrompt, cand.text, opponents, isBlindEvaluation)
312
316
  try {
313
317
  const slot = referenceModels[cand.index - 1] || { provider: cand.provider, model: cand.model }
package/lib/routes.js CHANGED
@@ -8,7 +8,7 @@ import {
8
8
  getMoaRunById,
9
9
  exportMoaHistory,
10
10
  } from './history.js'
11
- import { promoteCandidateWorkspace } from './file-workspace.js'
11
+ import { promoteCandidateWorkspace, readCandidateFiles, computeLineDiff } from './file-workspace.js'
12
12
  import { isSafeWriteRequest } from './updater.js'
13
13
  import { runMoAPipeline } from './moa-runner.js'
14
14
 
@@ -288,7 +288,7 @@ ctx.effect(() => {
288
288
  }
289
289
  const cwd = body?.cwd || process.cwd()
290
290
  const promotedFiles = await promoteCandidateWorkspace(cwd, candidateIndex, { keepMoa: true })
291
- writeJson(res, 200, { ok: true, candidateIndex, promotedFiles })
291
+ writeJson(res, 200, { ok: true, candidateIndex, promotedFiles, checkpoint: promotedFiles?.checkpoint || null })
292
292
  } catch (err) {
293
293
  writeJson(res, 500, { ok: false, error: err?.message || String(err) })
294
294
  }
@@ -296,6 +296,58 @@ ctx.effect(() => {
296
296
  })
297
297
  }, 'dsh-moa: promote candidate route')
298
298
 
299
+ // Route: /dsh-moa/diff (Candidate Diff Viewer)
300
+ ctx.effect(() => {
301
+ return ctx.webServer.register({
302
+ kind: 'exact',
303
+ path: '/dsh-moa/diff',
304
+ handler: async (req, res) => {
305
+ if (req.method !== 'GET') {
306
+ writeJson(res, 405, { ok: false, error: 'GET required' })
307
+ return
308
+ }
309
+ try {
310
+ const u = new URL(req.url, 'http://localhost')
311
+ const cwd = u.searchParams.get('cwd') || process.cwd()
312
+ const from = u.searchParams.get('from') || '1'
313
+ const to = u.searchParams.get('to') || 'curator-synthesis'
314
+ const file = u.searchParams.get('file') || ''
315
+
316
+ const fromFiles = await readCandidateFiles(cwd, from)
317
+ const toFiles = await readCandidateFiles(cwd, to)
318
+
319
+ // Collect all unique file paths
320
+ const allPaths = Array.from(new Set([
321
+ ...fromFiles.map((f) => f.relativePath),
322
+ ...toFiles.map((f) => f.relativePath),
323
+ ])).sort()
324
+
325
+ const targetPath = file || allPaths[0] || ''
326
+ const fromItem = fromFiles.find((f) => f.relativePath === targetPath)
327
+ const toItem = toFiles.find((f) => f.relativePath === targetPath)
328
+
329
+ const diff = targetPath
330
+ ? computeLineDiff(fromItem?.content || '', toItem?.content || '')
331
+ : []
332
+
333
+ writeJson(res, 200, {
334
+ ok: true,
335
+ from,
336
+ to,
337
+ files: allPaths,
338
+ selectedFile: targetPath,
339
+ fromSize: fromItem?.size || 0,
340
+ toSize: toItem?.size || 0,
341
+ diff,
342
+ })
343
+ } catch (err) {
344
+ writeJson(res, 500, { ok: false, error: err?.message || String(err) })
345
+ }
346
+ },
347
+ })
348
+ }, 'dsh-moa: candidate diff route')
349
+
350
+
299
351
  // Route: /dsh-moa/history/export
300
352
  ctx.effect(() => {
301
353
  return ctx.webServer.register({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-moa",
3
- "version": "0.2.18",
3
+ "version": "0.2.20",
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",
@@ -14,10 +14,13 @@
14
14
  "lib/",
15
15
  "cordis.patch.yml",
16
16
  "README.md",
17
+ "README.ru.md",
18
+ "README.zh.md",
17
19
  "LICENSE"
18
20
  ],
19
21
  "scripts": {
20
- "test": "node --test test/*.test.mjs"
22
+ "test": "node --test test/*.test.mjs",
23
+ "build:client": "node scripts/build-client.mjs"
21
24
  },
22
25
  "keywords": [
23
26
  "dsh",