dsh-agent-board 1.7.8 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -207,7 +207,7 @@ function apply(ctx) {
207
207
  ['circle', { cx: 12, cy: 12, r: 10 }],
208
208
  ['rect', { width: 6, height: 6, x: 9, y: 9 }]
209
209
  ],
210
- // 取消入口图标(详情页「取消」按钮;语义=作废不派发,与删除 trash-2 区分)
210
+ // 取消入口图标(详情页「取消任务」按钮;语义=作废不派发,与删除 trash-2 区分)
211
211
  'ban': [
212
212
  ['circle', { cx: 12, cy: 12, r: 10 }],
213
213
  ['path', { d: 'm4.9 4.9 14.2 14.2' }]
@@ -247,7 +247,15 @@ function apply(ctx) {
247
247
  blocked: { title: '阻塞:卡住待处理(等依赖/等主窗口裁决)', empty: '暂无阻塞——任务卡住待裁决时会出现在这里' }
248
248
  }
249
249
  var reqEpoch = 0 // 会话切换纪元:切会话时自增,旧会话在途响应按纪元丢弃
250
- var state = { sessionId: null, tasks: [], boardMode: 'auto', teamMode: false, workMode: 'auto', minWorkers: 1, maxWorkers: 3, minVerifiers: 0, maxVerifiers: 2, workerModel: '', verifierModel: '', softTimeoutMin: 30, hardTimeoutMin: 120, feedbackEnabled: true, notifyDispatch: true, notifyDone: true, epicSplit: true, verifyUserGuide: true, lessonPushed: {}, isRoot: true, isRootEverTrue: false, isRootFalseN: 0, isRootStable: true, open: false, detailId: null, children: [], dragOver: null, dragTask: null, dispatchInfo: '', view: 'board', layoutLeft: 280, layoutRight: 0, poolStatus: null, escalatedIds: [], filterQ: '', filterPrio: [], filterTag: '', filterReview: false, selectMode: false, selected: {}, undoSnapshot: null, cardHover: '', archSort: 'time-desc', dateRange: { from: '', to: '' }, rfOpen: false, gboOpen: false, activity: {}, archived: [], archQ: '', globalBoards: [], createOpen: false, createFlash: '', mpRole: '', mpSize: '', usageSummary: null, childStats: {}, tasksErr: '', tasksHash: '' }
250
+ // ===== 界面缩放档位(task-mv58e01j,用户 2026-10-12「字体整体偏小」)=====
251
+ // 面板内 ~317 处内联 fontSize px 写死(9/10/11 为主),逐处改不值 → 面板根容器 CSS zoom
252
+ // 等比缩放统一接管(Chromium 支持,字体/图标/间距一并放大;zoom 只作用本面板子树,不碰宿主页面)。
253
+ // 纯客户端偏好(不走 host set-board-config):localStorage 键 dsh-agent-board-ui-zoom 持久化,缺省 1;
254
+ // 脏值/档位外值/读取异常一律回退 1(白名单校验,不写回——下次选中档位时覆盖)。
255
+ var UI_ZOOM_KEY = 'dsh-agent-board-ui-zoom'
256
+ var UI_ZOOM_LEVELS = [0.9, 1, 1.1, 1.25, 1.5] // 90% / 100%(默认)/ 110% / 125% / 150%
257
+ function readUiZoom() { try { var v = parseFloat(localStorage.getItem(UI_ZOOM_KEY)); return UI_ZOOM_LEVELS.indexOf(v) >= 0 ? v : 1 } catch (_) { return 1 } }
258
+ var state = { sessionId: null, tasks: [], boardMode: 'auto', teamMode: false, workMode: 'auto', minWorkers: 1, maxWorkers: 3, minVerifiers: 0, maxVerifiers: 2, workerModel: '', verifierModel: '', softTimeoutMin: 30, hardTimeoutMin: 120, cleanupRetentionDays: 7, feedbackEnabled: true, notifyDispatch: true, notifyDone: true, epicSplit: true, verifyUserGuide: true, uiZoom: readUiZoom(), lessonPushed: {}, isRoot: true, isRootEverTrue: false, isRootFalseN: 0, isRootStable: true, open: false, detailId: null, children: [], dragOver: null, dragTask: null, dispatchInfo: '', view: 'board', layoutLeft: 280, layoutRight: 0, poolStatus: null, escalatedIds: [], filterQ: '', filterPrio: [], filterTag: '', filterReview: false, selectMode: false, selected: {}, undoSnapshot: null, cardHover: '', archSort: 'time-desc', dateRange: { from: '', to: '' }, rfOpen: false, gboOpen: false, activity: {}, archived: [], archQ: '', globalBoards: [], createOpen: false, createFlash: '', mpRole: '', mpSize: '', usageSummary: null, childStats: {}, tasksErr: '', tasksHash: '' }
251
259
 
252
260
  // #16 快捷键:Esc 逐级关闭(详情→看板→面板);输入框聚焦时不劫持
253
261
  try {
@@ -328,7 +336,12 @@ function apply(ctx) {
328
336
  // 空范围(两端皆空)不传 field:host 收到 undefined 即全量聚合,与老 host 的调用形态完全一致;
329
337
  // 绝不用空字符串占位(host 侧 `from`/`to` 空串虽也判为无范围,但少传一个字段就少一处口径分叉)。
330
338
  var rgSend = activeRange()
331
- var rpcArgs = (rgSend.from || rgSend.to) ? { range: rgSend } : undefined
339
+ var rpcArgs = (rgSend.from || rgSend.to) ? { range: rgSend } : {}
340
+ // 条件请求瘦身(task-mv492for):带上轮 tasksHash 作 ifHash,host 比对一致则返回 unchanged:true
341
+ // 轻量响应(不含 tasks 全量,轮询载荷 1.34MB→KB 级);首轮/会话切换后 state.tasksHash 为空不带
342
+ // ifHash → 全量返回(与不带 ifHash 的老客户端同路径)。空范围时 rpcArgs 由 undefined 改 {} 是同义
343
+ // 形态(rpc 助手 args||{} 归一;host 只读 range/sessionId/ifHash,多传空对象无害)。
344
+ if (state.tasksHash) rpcArgs.ifHash = state.tasksHash
332
345
  // 范围守卫:范围已在本轮响应回来前被改掉 → 这份响应属于旧范围,丢弃 usageSummary 赋值
333
346
  // (否则旧范围的旧聚合会覆盖新范围的新聚合,用户看到「切了范围数字没变」直到下一次轮询)。
334
347
  // 注意 tasksHash 短路只管 tasks:usageSummary 照常赋值(现状已对,保持)。
@@ -343,7 +356,11 @@ function apply(ctx) {
343
356
  // (hash 只管任务列表渲染;这些字段随下次任意 notify 生效,不为它们单独渲染)。
344
357
  // 老 host 不返回 tasksHash → 恒视为变化,行为与旧版完全一致。
345
358
  var newHash = (d && d.tasksHash) || ''
346
- var tasksChanged = !newHash || newHash !== state.tasksHash
359
+ // 条件请求瘦身(task-mv492for):host 判 ifHash 一致时返回 unchanged:true(响应不含 tasks 全量)。
360
+ // state.tasks 必须原样保留——unchanged 优先于 hash 比对:缺 tasks 的响应若误入变化分支,
361
+ // `(d && d.tasks) || []` 会把列表清空。轻量字段不受影响,照常走下方既有赋值链。
362
+ var unchanged = !!(d && d.unchanged)
363
+ var tasksChanged = !unchanged && (!newHash || newHash !== state.tasksHash)
347
364
  // 设置开关的权威纠偏(task-muw5uudk):开关不走 tasksHash,hash 不变分支里照常赋值也没有 notify
348
365
  // → 勾选框必须等下一次任意 notify 才翻面(安静板卡数秒)。这里在赋值前快照、赋值后比对,
349
366
  // 有变化就补一次 notify:乐观更新(PoolCfgPopover)已让点击瞬时翻面,本兜底是服务端权威值纠偏
@@ -398,6 +415,9 @@ function apply(ctx) {
398
415
  state.epicSplit = cfgKnobOf(d, 'epicSplit')
399
416
  // 自测指南开关(设置区「验收」):同上——缺字段=开(详情块/报告清单段照常),只有显式 false 才关
400
417
  state.verifyUserGuide = cfgKnobOf(d, 'verifyUserGuide')
418
+ // 清理保留天数(设置区「存储清理」,卡龄主闸):数值键,host 已 cfg 归一(缺字段/脏值=7、钳 1-90),
419
+ // 客户端读服务端权威值(与 softTimeoutMin/hardTimeoutMin 同款直接赋值,不做布尔口径纠偏)。
420
+ state.cleanupRetentionDays = (d && d.cleanupRetentionDays) || 7
401
421
  // 开关有变 / Token 区聚合有变 → 补一次 notify(tasksChanged 分支已在上面 notify 过,
402
422
  // 这里只管 hash 不变时被跳过的那两次:开关乐观更新纠偏 + 统计范围切换后的新聚合)
403
423
  var cfgDelta = !tasksChanged && cfgKnobsChanged(cfgKnobs, state)
@@ -615,8 +635,10 @@ function apply(ctx) {
615
635
  var _ndo = useState(state.notifyDispatch), ndOn = _ndo[0], setNdOn = _ndo[1]; var _nno = useState(state.notifyDone), nnOn = _nno[0], setNnOn = _nno[1]
616
636
  var _eso = useState(state.epicSplit), esOn = _eso[0], setEsOn = _eso[1] // 史诗拆分总开关勾选态(设置区「功能」)
617
637
  var _vug = useState(state.verifyUserGuide), vugOn = _vug[0], setVugOn = _vug[1] // 自测指南开关勾选态(设置区「验收」)
638
+ var _crd = useState(state.cleanupRetentionDays), crd = _crd[0], setCrd = _crd[1] // 清理保留天数(设置区「存储清理」)
639
+ var _uzm = useState(state.uiZoom), uiZoom = _uzm[0], setUiZoom = _uzm[1] // 界面缩放档位(设置区「界面」,zoom 挂面板根容器)
618
640
  var _usg = useState(state.usageSummary), setUsage = _usg[1] // Token 区聚合订阅(task-mv10lvud 根修):usageSummary 换新对象即触发渲染
619
- useEffect(function () { function update() { setOpen(state.open); setTasksState(state.tasks); setModeState(state.boardMode); setDetailId(state.detailId); setDragOver(state.dragOver); setDispatchInfo(state.dispatchInfo); setViewState(state.view); setLayL(state.layoutLeft); setLayR(state.layoutRight); setMinW(state.minWorkers); setMaxW(state.maxWorkers); setMinV(state.minVerifiers); setMaxV(state.maxVerifiers); setWorkerModel(state.workerModel); setVerifierModel(state.verifierModel); setTeamMode(state.teamMode); setWorkModeState(state.workMode); setDR(state.dateRange); setSoftT(state.softTimeoutMin); setHardT(state.hardTimeoutMin); setFbEnabled(state.feedbackEnabled); setNdOn(state.notifyDispatch); setNnOn(state.notifyDone); setEsOn(state.epicSplit); setVugOn(state.verifyUserGuide); setCreateOpen(state.createOpen); setCreateFlash(state.createFlash); setTasksErr(state.tasksErr); setUsage(state.usageSummary) }; listeners.push(update); update(); return function () { var i = listeners.indexOf(update); if (i >= 0) listeners.splice(i, 1) } }, [])
641
+ useEffect(function () { function update() { setOpen(state.open); setTasksState(state.tasks); setModeState(state.boardMode); setDetailId(state.detailId); setDragOver(state.dragOver); setDispatchInfo(state.dispatchInfo); setViewState(state.view); setLayL(state.layoutLeft); setLayR(state.layoutRight); setMinW(state.minWorkers); setMaxW(state.maxWorkers); setMinV(state.minVerifiers); setMaxV(state.maxVerifiers); setWorkerModel(state.workerModel); setVerifierModel(state.verifierModel); setTeamMode(state.teamMode); setWorkModeState(state.workMode); setDR(state.dateRange); setSoftT(state.softTimeoutMin); setHardT(state.hardTimeoutMin); setFbEnabled(state.feedbackEnabled); setNdOn(state.notifyDispatch); setNnOn(state.notifyDone); setEsOn(state.epicSplit); setVugOn(state.verifyUserGuide); setCrd(state.cleanupRetentionDays); setUiZoom(state.uiZoom); setCreateOpen(state.createOpen); setCreateFlash(state.createFlash); setTasksErr(state.tasksErr); setUsage(state.usageSummary) }; listeners.push(update); update(); return function () { var i = listeners.indexOf(update); if (i >= 0) listeners.splice(i, 1) } }, [])
620
642
  if (!open) return null
621
643
  if (!state.isRootStable) return null // 子代理会话不渲染看板面板(读 isRootStable:瞬态 false 不闪,见 isRoot 蝶变防抖)
622
644
  var active = tasks.filter(function (t) { return t.status !== 'archived' }); var archived = tasks.filter(function (t) { return t.status === 'archived' })
@@ -638,11 +660,14 @@ function apply(ctx) {
638
660
  })
639
661
  content = React.createElement('div', null, React.createElement(FilterBar, null), hasFilter && active.length === 0 && archived.length === 0 ? React.createElement('div', { style: { textAlign: 'center', padding: 16, color: C.text2, fontSize: 11 } }, '无匹配任务') : null, React.createElement('div', { style: { fontSize: 10, color: C.text2, marginBottom: 6 } }, state.selectMode ? '多选模式:点击卡片勾选,底部批量操作' : '提示:拖拽卡片到目标列即可流转状态'), tasks.length === 0 ? React.createElement('div', { style: { textAlign: 'center', padding: '16px 8px 12px', color: C.text2, fontSize: 12 } }, '点击右上角', React.createElement('span', { style: { color: C.brand, fontWeight: 600 } }, '「+ 新建任务」'), '创建第一张卡,或直接在对话里说:帮我建一张任务卡') : null, React.createElement('div', { style: { display: 'flex', gap: 8, overflowX: 'auto', paddingBottom: 4 } }, cols), React.createElement(BatchBar, null))
640
662
  }
641
- return React.createElement('div', { style: { position: 'fixed', top: 44, left: (layL + 8) + 'px', right: (layR + 8) + 'px', maxHeight: '60vh', zIndex: 900, background: C.bg, border: '1px solid ' + C.border, borderRadius: '0 0 10px 10px', boxShadow: '0 8px 24px rgba(0,0,0,0.12)', display: 'flex', flexDirection: 'column', overflow: 'hidden' } },
663
+ // 面板根容器(顶层 div):zoom 挂这里——等比缩放整个面板子树(字体/图标/间距),只影响本面板
664
+ // 不影响宿主页面;uiZoom 为订阅值(设置弹层选档 → state.uiZoom + notify → 本组件重渲染即时生效)。
665
+ // React 的 zoom 属无单位数值型 style(isUnitlessNumber 含 zoom),直接传数值不会拼 px。
666
+ return React.createElement('div', { style: { position: 'fixed', top: 44, left: (layL + 8) + 'px', right: (layR + 8) + 'px', maxHeight: '60vh', zIndex: 900, zoom: uiZoom, background: C.bg, border: '1px solid ' + C.border, borderRadius: '0 0 10px 10px', boxShadow: '0 8px 24px rgba(0,0,0,0.12)', display: 'flex', flexDirection: 'column', overflow: 'hidden' } },
642
667
  React.createElement('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '6px 12px', borderBottom: '1px solid ' + C.border, flexShrink: 0, flexWrap: 'wrap', gap: 4 } },
643
668
  React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 6 } }, React.createElement('span', { style: { fontWeight: 600, fontSize: 13, color: C.text, display: 'inline-flex', alignItems: 'center', gap: 5 } }, ic('clipboard-list', 15), '智能看板'), React.createElement(ViewTab, null), React.createElement(PoolStatus, null)),
644
669
  React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' } },
645
- React.createElement(PoolCfgPopover, { minW: minW, maxW: maxW, minV: minV, maxV: maxV, workerModel: workerModel, verifierModel: verifierModel, softT: softT, hardT: hardT, feedbackEnabled: fbEnabled, notifyDispatch: ndOn, notifyDone: nnOn, epicSplit: esOn, verifyUserGuide: vugOn }),
670
+ React.createElement(PoolCfgPopover, { minW: minW, maxW: maxW, minV: minV, maxV: maxV, workerModel: workerModel, verifierModel: verifierModel, softT: softT, hardT: hardT, feedbackEnabled: fbEnabled, notifyDispatch: ndOn, notifyDone: nnOn, epicSplit: esOn, verifyUserGuide: vugOn, retentionDays: crd }),
646
671
  dispatchInfo ? React.createElement('span', { style: { fontSize: 9, color: C.brand, maxWidth: 120, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, title: dispatchInfo }, dispatchInfo) : null,
647
672
  React.createElement('button', { onClick: function () { state.createOpen = true; notify() }, title: '新建任务(可存为草稿)', style: { fontSize: 11, padding: '3px 8px', border: '1px solid ' + C.border, borderRadius: 6, cursor: 'pointer', background: 'transparent', color: C.text2, display: 'inline-flex', alignItems: 'center', gap: 3 } }, ic('plus', 11), '新建任务'),
648
673
  createFlash ? React.createElement('span', { style: { fontSize: 10, color: C.ok } }, createFlash) : null,
@@ -1345,7 +1370,10 @@ function apply(ctx) {
1345
1370
  p.then(function (r) { if (r && r.ok === false) setActionMsg('⚠️ 流转失败:' + (r.error || '未知错误')); else setActionMsg('✅ 已流转到「' + (statusLabels[to] || to) + '」'); fetchTasks() }).catch(function (e) { setActionMsg('⚠️ ' + String(e)) })
1346
1371
  }
1347
1372
  return React.createElement('div', { style: { padding: '4px 2px' } },
1348
- React.createElement('div', { onClick: function () { state.detailId = null; notify() }, style: { fontSize: 11, color: C.brand, cursor: 'pointer', marginBottom: 8 } }, '← 返回看板'),
1373
+ // 头部行(task-mv493xjs 巡检次生观察②):左「← 返回看板」+ 右上显性 ✕ 关闭钮(原来只有文字链不够显性)
1374
+ React.createElement('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 } },
1375
+ React.createElement('div', { onClick: function () { state.detailId = null; notify() }, style: { fontSize: 11, color: C.brand, cursor: 'pointer' } }, '← 返回看板'),
1376
+ React.createElement('button', { onClick: function () { state.detailId = null; notify() }, title: '关闭详情面板(返回看板)', style: { fontSize: 11, padding: '1px 6px', border: '1px solid ' + C.border, borderRadius: 3, background: 'transparent', color: C.text2, cursor: 'pointer', display: 'inline-flex', alignItems: 'center', lineHeight: 1.4 } }, ic('x', 11))),
1349
1377
  React.createElement(ReviewSummary, { task: task }),
1350
1378
  React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 6, marginBottom: 8 } }, React.createElement('select', { value: task.priority || 'medium', onChange: function (e) { rpc('update-task', { taskId: task.id, priority: e.target.value }).then(fetchTasks).catch(function () {}) }, title: '优先级(四档:紧急/高/中/低)', style: { fontSize: 10, padding: '1px 4px', border: '1px solid ' + C.border, borderRadius: 3, background: C.card, color: (prioColor[task.priority] || prioColor.low), fontWeight: 600 } }, ['critical', 'high', 'medium', 'low'].map(function (p) { return React.createElement('option', { key: p, value: p }, prioLabel[p] + '(' + p + ')') })), React.createElement('span', { style: { fontSize: 11, padding: '1px 8px', borderRadius: 3, background: C.nested, color: C.text } }, statusLabels[task.status] || task.status), React.createElement('select', { value: task.pipeline || 'full', onChange: function (e) { rpc('update-task', { taskId: task.id, pipeline: e.target.value }).then(fetchTasks).catch(function () {}) }, title: '管线档位', style: { fontSize: 10, padding: '1px 4px', border: '1px solid ' + C.border, borderRadius: 3, background: C.card, color: C.text2 } }, React.createElement('option', { value: 'full' }, '全流程(执行+验证)'), React.createElement('option', { value: 'work' }, '免验证(只做不验)'), React.createElement('option', { value: 'direct' }, '主窗口处理')), task.pipelineAuto ? React.createElement('span', { style: { fontSize: 9, color: C.text2 }, title: '由规则自动分类,可手动覆盖' }, 'auto') : null, isManual ? React.createElement('span', { style: { fontSize: 10, color: C.text2, display: 'inline-flex', alignItems: 'center', gap: 2 } }, ic('user', 10), '手动派发') : null),
1351
1379
  React.createElement(TagsEditor, { key: task.id, task: task }),
@@ -1466,12 +1494,15 @@ function apply(ctx) {
1466
1494
  task.status === 'verifying' ? React.createElement('button', { onClick: function () { doAction(function () { return rpc('verify-task', { taskId: task.id, verdict: 'approved' }) }) }, style: { fontSize: 10, padding: '3px 8px', border: 'none', borderRadius: 3, background: C.ok, color: C_INV, cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: 3 } }, ic('check-circle', 10), '通过') : null,
1467
1495
  task.status === 'verifying' ? React.createElement('button', { onClick: function () { var r = window.prompt('驳回原因:'); doAction(function () { return rpc('verify-task', { taskId: task.id, verdict: 'rejected', comment: r || '' }) }) }, style: { fontSize: 10, padding: '3px 8px', border: 'none', borderRadius: 3, background: C.err, color: C_INV, cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: 3 } }, ic('x-circle', 10), '驳回') : null,
1468
1496
  task.status === 'resolved' ? React.createElement('button', { onClick: function () { doAction(function () { return rpc('archive-task', { taskId: task.id }) }) }, style: { fontSize: 10, padding: '3px 8px', border: 'none', borderRadius: 3, background: C.text2, color: C_INV, cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: 3 } }, ic('archive', 10), '归档') : null),
1469
- // 取消(仅草稿/待办/阻塞):confirm 预警「取消后不再派发,可在归档恢复」;与删除并列但语义=作废留档(可恢复)
1470
- canCancel(task) ? React.createElement('button', { onClick: function () { cancelTask(task.id, task.title, setActionMsg) }, title: '取消任务(取消后不再派发,可在归档恢复待办)', style: { fontSize: 10, padding: '3px 8px', border: '1px solid ' + C.warn, borderRadius: 3, background: 'transparent', color: C.warn, cursor: 'pointer', fontWeight: 600, display: 'inline-flex', alignItems: 'center', gap: 3 } }, ic('ban', 10), '取消') : null,
1471
- // 删除(仅草稿/待办/阻塞):confirm 防误删;被 host 门禁拒绝时把原因回显到详情页 actionMsg
1472
- // 容器 div 的收尾括号已挂在「归档」行;删除按钮作为**额外一行元素**排在它后面
1473
- // (多一个实参给同一个 createElement,属于合法调用),因此本行自身必须配平。
1474
- canDelete(task) ? React.createElement('button', { onClick: function () { deleteTask(task.id, task.title, setActionMsg) }, title: '删除任务(不可恢复;执行中请先终止,已落定请用归档)', style: { fontSize: 10, padding: '3px 8px', border: '1px solid ' + C.err, borderRadius: 3, background: 'transparent', color: C.err, cursor: 'pointer', fontWeight: 600, display: 'inline-flex', alignItems: 'center', gap: 3 } }, ic('trash-2', 10), '删除') : null,
1497
+ // ===== 破坏性操作区(task-mv493xjs 消歧,巡检实证 n-mv2turadjv63 误触)=====
1498
+ // 视觉分层:与上方表单按钮组(保存/发布/领取/派发/通过/驳回/归档)拉开——独立一行 +
1499
+ // 顶部虚线分隔 + 「危险操作」前缀标签。「取消」改名「取消任务」:按表单惯例会被误读为
1500
+ // 「关面板」,实际是 task_cancel 破坏性流转(confirm 确认在 kernel.cancelTask 已存在)。
1501
+ // 取消(仅草稿/待办/阻塞)语义=作废留档(可恢复);删除=真删不可恢复,被 host 门禁拒绝时原因回显 actionMsg。
1502
+ (canCancel(task) || canDelete(task)) ? React.createElement('div', { role: 'group', 'aria-label': '危险操作', style: { display: 'flex', gap: 4, flexWrap: 'wrap', alignItems: 'center', marginTop: 8, paddingTop: 6, borderTop: '1px dashed ' + C.err } },
1503
+ React.createElement('span', { style: { fontSize: 9, color: C.err, fontWeight: 700, marginRight: 2, letterSpacing: 1 } }, '危险操作'),
1504
+ canCancel(task) ? React.createElement('button', { onClick: function () { cancelTask(task.id, task.title, setActionMsg) }, title: '取消任务(取消后不再派发,可在归档恢复待办)', style: { fontSize: 10, padding: '3px 8px', border: '1px solid ' + C.warn, borderRadius: 3, background: 'transparent', color: C.warn, cursor: 'pointer', fontWeight: 600, display: 'inline-flex', alignItems: 'center', gap: 3 } }, ic('ban', 10), '取消任务') : null,
1505
+ canDelete(task) ? React.createElement('button', { onClick: function () { deleteTask(task.id, task.title, setActionMsg) }, title: '删除任务(不可恢复;执行中请先终止,已落定请用归档)', style: { fontSize: 10, padding: '3px 8px', border: '1px solid ' + C.err, borderRadius: 3, background: 'transparent', color: C.err, cursor: 'pointer', fontWeight: 600, display: 'inline-flex', alignItems: 'center', gap: 3 } }, ic('trash-2', 10), '删除') : null) : null,
1475
1506
  (function () {
1476
1507
  // 历史会话:无论任务处于哪个阶段都完整列出(Worker 各次 + Verifier 各次)
1477
1508
  var runs = historyRuns(task)
@@ -1977,21 +2008,21 @@ function apply(ctx) {
1977
2008
  // 否则「选了范围数字没变」看起来像 bug。模型分布/Top8/累计才是范围生效的三处。
1978
2009
  React.createElement('div', { style: { fontSize: 9, color: C.text2, marginTop: 6, lineHeight: 1.5 } }, '口径:累计与 Top 8 仅看板派发的 Worker/Verifier run(= 一次执行)消耗;主窗口行=本会话对话消耗,与看板派发口径并列不混入(单列一行 + 模型分布尾部一条,不进累计 / Top 8);主数字(今日 / 近 7 天 / 模型分布 / Top 8 / 主窗口行)均为总量口径(含缓存读);有效消耗(input+output+cacheWrite,不含缓存读)见悬浮 title'),
1979
2010
  React.createElement('div', { style: { fontSize: 9, color: C.text2, marginTop: 2, lineHeight: 1.5 } }, '统计范围作用于按模型分布 / 任务消耗 Top 8 / 累计三分量(按 run 的本地日落点过滤);今日与「近 7 天」为固定口径,不随范围变化。'),
1980
- // 「→ 模型表现」滚动锚链接(task-muxhtgi2 卡3):一键滚到下方模型×场景七指标表
2011
+ // 「→ 模型表现」滚动锚链接(task-muxhtgi2 卡3):一键滚到下方模型×场景八指标表
1981
2012
  // (与详情页 goArbitration 同一 scrollIntoView 原语;目标区空态也挂锚 id,链接始终可达)
1982
2013
  React.createElement('div', { style: { fontSize: 9, marginTop: 4 } },
1983
- React.createElement('span', { onClick: function () { var el = document.getElementById('tskb-model-perf'); if (el && el.scrollIntoView) el.scrollIntoView({ block: 'start' }) }, style: { color: C.brand, cursor: 'pointer' }, title: '滚动到下方「模型表现」区:模型 × 场景(角色 × 规模段)七指标对比表' }, '→ 模型表现')))
2014
+ React.createElement('span', { onClick: function () { var el = document.getElementById('tskb-model-perf'); if (el && el.scrollIntoView) el.scrollIntoView({ block: 'start' }) }, style: { color: C.brand, cursor: 'pointer' }, title: '滚动到下方「模型表现」区:模型 × 场景(角色 × 规模段)八指标对比表' }, '→ 模型表现')))
1984
2015
  }
1985
2016
 
1986
- // ===== 模型表现区(记分卡卡3,task-muxhtgi2):七指标表格 × 场景筛选器(本地过滤不重拉)=====
2017
+ // ===== 模型表现区(记分卡卡3,task-muxhtgi2):八指标表格 × 场景筛选器(本地过滤不重拉)=====
1987
2018
  // 数据源:state.usageSummary.scoreboard——卡2 host 聚合底座(buildScoreboard)挂进 get-tasks 响应,
1988
2019
  // 与 Token 区同通道透传、同一 range 口径(范围切换重拉后随 notify 同步刷新,本区零自持请求)。
1989
2020
  // 每桶 = 模型 × 角色(worker/verifier)× 规模段(small/medium/large,按任务有效 token 分桶)。
1990
2021
  // 本区三件事全部本地现算:
1991
2022
  // ① 场景筛选器:角色(全部/worker/verifier)+ 规模段(全部/小/中/大)——scoreboard 已在响应里,
1992
2023
  // 筛选只是 buckets 数组 pass,绝不重拉(组件内无 rpc/fetchTasks);
1993
- // ② 七指标表格:每 模型×场景桶 一行,七列——有效均值/耗时中位/驳回率/一次通过率/超时率/续跑率/
1994
- // 缓存命中率;表头列名带 title 口径说明(与卡2 host 注释同源);
2024
+ // ② 八指标表格:每 模型×场景桶 一行,八列——有效均值/耗时中位/驳回率/一次通过率/超时率/失败率/
2025
+ // 续跑率/缓存命中率;表头列名带 title 口径说明(与卡2 host 注释同源);
1995
2026
  // ③ 样本护栏:insufficient(runs<5)行整行灰显 + title 前缀「样本 <5,仅供参考」——
1996
2027
  // 仅供参考,不参与任何「最优」视觉强调(本区 v1 不做任何高亮排名,从源头规避)。
1997
2028
  // 空态:scoreboard 缺字段 / buckets 空 → 「暂无足够 run 数据」(锚 id 照挂,Token 区链接始终可达);
@@ -2001,13 +2032,16 @@ function apply(ctx) {
2001
2032
  // 率类/耗时格式化:null = 无样本(不假装 0,与 host 同哲学)
2002
2033
  function mpPct(r) { return (r === null || r === undefined || !isFinite(r)) ? '—' : Math.round(r * 100) + '%' }
2003
2034
  function mpDur(ms) { if (ms === null || ms === undefined || !isFinite(ms)) return '—'; var m = Math.round(ms / 60000); if (m < 1) return '<1m'; if (m < 60) return m + 'm'; return (m / 60).toFixed(1) + 'h' }
2004
- // 七列定义:label + title 口径说明 + 取值格式化(口径一句话版与 README 仪表盘段同步维护)
2035
+ // 八列定义:label + title 口径说明 + 取值格式化(口径一句话版与 README 仪表盘段同步维护)
2005
2036
  var MP_COLS = [
2006
2037
  { k: 'effAvg', label: '有效均值', title: '有效 token 均值 = 桶内 run 有效消耗(输入+输出+缓存写,不含缓存读)的均值;无 usage 留账的 run 不进均值(不伪造 0)', fmt: function (b) { return b.effAvg === null ? '—' : fmtTokens(b.effAvg) } },
2007
2038
  { k: 'durMedianMs', label: '耗时中位', title: 'run 耗时(endedAt - at)中位数;P90 见行 title;无合法时刻的 run 不进分布', fmt: function (b) { return mpDur(b.durMedianMs) } },
2008
2039
  { k: 'rejectRate', label: '驳回率', title: '驳回率 = 归因到本桶 run 的验收驳回次数 / 桶内 runs(归因 = 驳回前最近 worker run)', fmt: function (b) { return mpPct(b.rejectRate) } },
2009
2040
  { k: 'firstPassRate', label: '一次通过率', title: '一次通过率 = 桶内 resolved 任务中 rejectCount=0(零驳回)的占比;桶内无 resolved 任务 → —', fmt: function (b) { return mpPct(b.firstPassRate) } },
2010
2041
  { k: 'timeoutRate', label: '超时率', title: '超时率 = outcome 超时落定(timeout/error)的 run / 桶内 runs', fmt: function (b) { return mpPct(b.timeoutRate) } },
2042
+ // 失败率列(task-mv73ym6i):跑崩的 run 不再隐形——incomplete(猝死/未产出结算)/error/failed
2043
+ // 计入;terminated 人工终止不计入(人工动作非模型表现);超时落定走「超时率」列不重复计。
2044
+ { k: 'failRate', label: '失败率', title: '失败率 = 跑崩落定(outcome 为 incomplete/error/failed)的 run / 桶内 runs;terminated 人工终止不计入(人工动作非模型表现);超时落定走「超时率」列,不重复计', fmt: function (b) { return mpPct(b.failRate) } },
2011
2045
  { k: 'resumeRate', label: '续跑率', title: '续跑率 = resume:true 续跑记录 / 桶内 runs', fmt: function (b) { return mpPct(b.resumeRate) } },
2012
2046
  { k: 'cacheHitRate', label: '缓存命中率', title: '缓存命中率 = cacheRead / (input + cacheRead);⚠️ 跨 provider 口径可能不一(input 是否含缓存读各家不同),对比前先核口径;无 usage 样本 → —', fmt: function (b) { return mpPct(b.cacheHitRate) } }
2013
2047
  ]
@@ -2057,14 +2091,14 @@ function apply(ctx) {
2057
2091
  MP_COLS.map(function (c) { return React.createElement('th', { key: c.k, style: thR, title: c.title }, c.label) }))),
2058
2092
  React.createElement('tbody', null, shown.map(function (b) {
2059
2093
  // 样本护栏:insufficient 行整行灰显 + title 前缀「样本 <5,仅供参考」,不进任何最优强调
2060
- var tip = (b.insufficient ? '样本 <5,仅供参考——' : '') + b.model + ' × ' + b.role + ' × ' + (MP_SIZE_LABEL[b.size] || b.size) + ':runs=' + b.runs + ';有效总额 ' + fmtTokens(b.effSum) + ' tok;耗时 P90 ' + mpDur(b.durP90Ms) + '(七指标口径见表头 title)'
2094
+ var tip = (b.insufficient ? '样本 <5,仅供参考——' : '') + b.model + ' × ' + b.role + ' × ' + (MP_SIZE_LABEL[b.size] || b.size) + ':runs=' + b.runs + ';有效总额 ' + fmtTokens(b.effSum) + ' tok;耗时 P90 ' + mpDur(b.durP90Ms) + '(八指标口径见表头 title)'
2061
2095
  return React.createElement('tr', { key: b.model + '|' + b.role + '|' + b.size, title: tip, style: { opacity: b.insufficient ? 0.45 : 1 } },
2062
2096
  React.createElement('td', { style: tdL }, b.model),
2063
2097
  React.createElement('td', { style: tdL }, b.role + '·' + (MP_SIZE_LABEL[b.size] || b.size)),
2064
2098
  React.createElement('td', { style: tdR }, String(b.runs)),
2065
2099
  MP_COLS.map(function (c) { return React.createElement('td', { key: c.k, style: tdR }, c.fmt(b)) }))
2066
2100
  })))),
2067
- React.createElement('div', { style: { fontSize: 9, color: C.text2, marginTop: 6, lineHeight: 1.5 } }, '口径:每行 = 模型 × 角色 × 规模段桶(规模按任务有效 token:小 <1M / 中 1~10M / 大 >10M);率类分母为桶内 runs,一次通过率分母为桶内 resolved 任务;runs<5 行灰显 = 样本不足仅供参考;随统计范围裁剪(与 Token 区同一 range)'))
2101
+ React.createElement('div', { style: { fontSize: 9, color: C.text2, marginTop: 6, lineHeight: 1.5 } }, '口径:每行 = 模型 × 角色 × 规模段桶(规模按任务有效 token:小 <1M / 中 1~10M / 大 >10M);率类分母为桶内 runs,一次通过率分母为桶内 resolved 任务;失败率 = 跑崩落定(outcome 为 incomplete/error/failed)的 run / 桶内 runs,terminated 人工终止不计入;runs<5 行灰显 = 样本不足仅供参考;随统计范围裁剪(与 Token 区同一 range)'))
2068
2102
  }
2069
2103
 
2070
2104
  // ===== 质量趋势区(记分卡卡4,task-muxhtgil):一次通过率趋势 / 卡时长分布 / 超时率走势 / 续跑成功率 =====
@@ -2261,6 +2295,73 @@ function apply(ctx) {
2261
2295
  }))
2262
2296
  }
2263
2297
 
2298
+ // ===== 存储清理区(已结算会话清理,task-mv2131cz):数据源 host 端 cleanup-preview / cleanup-run =====
2299
+ // preview 纯读盘零副作用;run 需 confirm:true 二次确认(删除不可恢复,token 账已入卡不受影响)。
2300
+ // 可删判定口径在 host lib/cleanup.mjs:卡龄主闸(卡归档满 cleanupRetentionDays 天)+ 三底闸(落定/不活跃/路径安全)。
2301
+ function fmtBytes(n) {
2302
+ var v = Number(n) || 0
2303
+ if (v >= 1073741824) return (v / 1073741824).toFixed(1) + ' GB'
2304
+ if (v >= 1048576) return (v / 1048576).toFixed(v >= 10485760 ? 0 : 1) + ' MB'
2305
+ if (v >= 1024) return (v / 1024).toFixed(v >= 10240 ? 0 : 1) + ' KB'
2306
+ return String(v) + ' B'
2307
+ }
2308
+ function StorageCleanup() {
2309
+ var _R = React; var useState = _R.useState
2310
+ var _p = useState(null), prev = _p[0], setPrev = _p[1]
2311
+ var _o = useState(false), open = _o[0], setOpen = _o[1]
2312
+ var _r = useState(null), report = _r[0], setReport = _r[1]
2313
+ var _m = useState(''), msg = _m[0], setMsg = _m[1]
2314
+ var _b = useState(false), busy = _b[0], setBusy = _b[1]
2315
+ var _c = useState(false), confirmOn = _c[0], setConfirmOn = _c[1]
2316
+ function preview() {
2317
+ setBusy(true); setMsg('')
2318
+ rpc('cleanup-preview').then(function (r) {
2319
+ if (r && r.ok) { setPrev(r); setReport(null); setConfirmOn(false) } else { setMsg((r && r.error) || '预览失败') }
2320
+ setBusy(false)
2321
+ }).catch(function (e) { setMsg('预览失败: ' + (e && e.message || e)); setBusy(false) })
2322
+ }
2323
+ function runClean() {
2324
+ setBusy(true); setMsg('')
2325
+ rpc('cleanup-run', { confirm: true }).then(function (r) {
2326
+ setReport(r); setPrev(null); setConfirmOn(false); setBusy(false)
2327
+ if (r && r.ok) { if (typeof fetchTasks === 'function') fetchTasks() } else { setMsg((r && r.error) || '清理失败') }
2328
+ }).catch(function (e) { setMsg('清理失败: ' + (e && e.message || e)); setBusy(false) })
2329
+ }
2330
+ var box = { padding: '8px 10px', background: C.card, border: '1px solid ' + C.border, borderRadius: 6, marginBottom: 12 }
2331
+ var head = React.createElement('div', { style: { fontSize: 11, fontWeight: 600, color: C.text2, marginBottom: 6, display: 'flex', alignItems: 'center', gap: 4 } }, ic('trash-2', 11), '存储清理')
2332
+ var btn = { fontSize: 10, padding: '2px 10px', border: '1px solid ' + C.border, borderRadius: 5, background: C.card, color: C.text, cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: 4 }
2333
+ var dangerBtn = { fontSize: 10, padding: '2px 10px', border: '1px solid ' + C.err, borderRadius: 5, background: C.card, color: C.err, cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: 4 }
2334
+ function skippedLine(s, i) {
2335
+ return React.createElement('div', { key: 'sk' + i, style: { fontSize: 9, color: C.text2, marginBottom: 2 } },
2336
+ React.createElement('span', { style: { color: C.warn } }, '⏭ '), String(s.id || '').slice(0, 14) + '…:' + (s.text || s.reason || ''))
2337
+ }
2338
+ return React.createElement('div', { style: box },
2339
+ head,
2340
+ React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' } },
2341
+ React.createElement('button', { onClick: preview, disabled: busy, style: btn, title: '扫描本看板归档超过保留天数的卡片的会话,估算可释放空间(只读不删)' }, ic('refresh-cw', 11), busy ? '扫描中…' : '预览可清理会话'),
2342
+ prev ? React.createElement('span', { style: { fontSize: 10, color: C.text } }, prev.total > 0 ? ('可删 ' + prev.total + ' 个会话 · 约 ' + fmtBytes(prev.totalBytes)) : '无可删会话') : null,
2343
+ prev && prev.total > 0 ? React.createElement('button', { onClick: function () { setOpen(!open); setConfirmOn(false) }, style: btn }, ic(open ? 'chevron-up' : 'chevron-down', 10), open ? '收起明细' : '展开明细') : null,
2344
+ prev && prev.total > 0 ? (confirmOn ? React.createElement('span', { style: { display: 'inline-flex', alignItems: 'center', gap: 4 } },
2345
+ React.createElement('button', { onClick: runClean, disabled: busy, style: dangerBtn, title: '确认删除不可恢复;token 账已入卡不受影响' }, ic('check', 11), '确认清理(不可恢复)'),
2346
+ React.createElement('button', { onClick: function () { setConfirmOn(false) }, style: btn }, '取消'))
2347
+ : React.createElement('button', { onClick: function () { setConfirmOn(true) }, disabled: busy, style: btn, title: '删除不可恢复;token 账已入卡不受影响' }, ic('trash-2', 11), '清理')) : null),
2348
+ msg ? React.createElement('div', { style: { fontSize: 9, color: C.err, marginTop: 4 } }, msg) : null,
2349
+ report && report.ok ? React.createElement('div', { style: { fontSize: 10, color: C.text, marginTop: 5 } },
2350
+ '✅ 已释放约 ' + fmtBytes(report.freedBytes) + '(删除 ' + report.deleted + ' 个会话' + ((report.skipped && report.skipped.length) ? ' · 跳过 ' + report.skipped.length + ' 个' : '') + ')') : null,
2351
+ report && report.ok && report.skipped && report.skipped.length ? React.createElement('div', { style: { marginTop: 4 } }, report.skipped.map(skippedLine)) : null,
2352
+ open && prev && prev.total > 0 ? React.createElement('div', { style: { marginTop: 6, borderTop: '1px solid ' + C.nested, paddingTop: 5 } },
2353
+ React.createElement('div', { style: { fontSize: 9, color: C.text2, marginBottom: 4 } }, '可删会话(会话目录 + projcache 缓存一并删除)'),
2354
+ (prev.byTask || []).map(function (b) {
2355
+ return React.createElement('div', { key: b.taskId, style: { marginBottom: 3, fontSize: 9, color: C.text2 } },
2356
+ React.createElement('span', { style: { color: C.brand } }, shortId(b.title)), ':' + b.count + ' 个 · 约 ' + fmtBytes(b.bytes))
2357
+ }),
2358
+ React.createElement('div', { style: { fontSize: 9, color: C.text2, marginTop: 2 } }, '数据来源:本看板 runs[] 里所属卡已归档超过 ' + (state.cleanupRetentionDays || 7) + ' 天、且 outcome 已落定并已不活跃的 Worker/Verifier 会话;token 账早已记入卡片,删除不影响任何统计。')
2359
+ ) : null,
2360
+ prev && prev.total === 0 && prev.skipped && prev.skipped.length ? React.createElement('div', { style: { marginTop: 5 } },
2361
+ React.createElement('div', { style: { fontSize: 9, color: C.text2, marginBottom: 3 } }, '无符合条件可删的会话(跳过项)'),
2362
+ prev.skipped.slice(0, 8).map(skippedLine)) : null)
2363
+ }
2364
+
2264
2365
  function Dashboard() {
2265
2366
  var stats = computeStats(state.tasks); var statusOrder = ['pending', 'in-progress', 'verifying', 'resolved', 'blocked', 'archived']; var prioOrder = ['critical', 'high', 'medium', 'low']
2266
2367
  return React.createElement('div', null,
@@ -2268,6 +2369,7 @@ function apply(ctx) {
2268
2369
  React.createElement(GlobalBoards),
2269
2370
  React.createElement('div', { style: { display: 'flex', gap: 6, marginBottom: 12, flexWrap: 'wrap' } }, React.createElement(StatCard, { label: '总任务', value: stats.total, color: C.text }), React.createElement(StatCard, { label: '待办', value: stats.byStatus['pending'] || 0, color: C.text2 }), React.createElement(StatCard, { label: '进行中', value: stats.byStatus['in-progress'] || 0, color: C.brand }), React.createElement(StatCard, { label: '验证中', value: stats.byStatus['verifying'] || 0, color: C.warn }), React.createElement(StatCard, { label: '已完成', value: stats.byStatus['resolved'] || 0, color: C.ok }), React.createElement(StatCard, { label: '已归档', value: stats.byStatus['archived'] || 0, color: C.text2 })),
2270
2371
  React.createElement(HealthHints),
2372
+ React.createElement(StorageCleanup),
2271
2373
  React.createElement(TokenUsage, { usage: state.usageSummary }),
2272
2374
  // 模型表现区(卡3):Token 区正下方;scoreboard 随 usageSummary 同通道透传(老 host 缺字段 → null → 空态)
2273
2375
  React.createElement(ModelPerf, { sb: (state.usageSummary && state.usageSummary.scoreboard) || null }),
@@ -2331,6 +2433,30 @@ function apply(ctx) {
2331
2433
  React.createElement('span', { style: { fontSize: 9, color: C.text2 } }, '分'),
2332
2434
  dirty ? React.createElement('button', { onClick: function () { commit(val) }, title: '应用', style: { fontSize: 9, padding: '0px 5px', border: 'none', borderRadius: 2, background: C.brand, color: C_INV, cursor: 'pointer', lineHeight: '14px', fontWeight: 600, display: 'inline-flex', alignItems: 'center' } }, ic('check', 9)) : null)
2333
2435
  }
2436
+ // 清理保留天数输入(卡龄主闸设置项 cleanupRetentionDays,1~90 天):与 MinCfg 同款受控输入 + 失焦/回车提交,
2437
+ // 越界值直接回弹到服务端权威值(不留脏态),成功走 set-board-config → fetchTasks 由服务端权威值纠偏。
2438
+ function RetentionCfg(props) {
2439
+ var _R = React; var useState = _R.useState, useEffect = _R.useEffect
2440
+ var _a = useState(String(props.value)), val = _a[0], setVal = _a[1]
2441
+ var _b = useState(false), dirty = _b[0], setDirty = _b[1]
2442
+ useEffect(function () { setVal(String(props.value)); setDirty(false) }, [props.value])
2443
+ function commit(v) {
2444
+ var n = parseInt(v, 10)
2445
+ if (isNaN(n) || n < 1 || n > 90) { setVal(String(props.value)); setDirty(false); return }
2446
+ setVal(String(n)); setDirty(false); rpc('set-board-config', { key: 'cleanupRetentionDays', value: n }).then(fetchTasks).catch(function () {})
2447
+ }
2448
+ return React.createElement('span', { style: { display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 9, color: C.text2 } },
2449
+ props.label,
2450
+ React.createElement('input', {
2451
+ value: val, title: props.tip || ('1 ~ 90 天,默认 7'),
2452
+ onChange: function (e) { setVal(e.target.value); setDirty(e.target.value !== String(props.value)) },
2453
+ onBlur: function () { if (dirty) commit(val) },
2454
+ onKeyDown: function (e) { if (e.key === 'Enter') commit(val) },
2455
+ style: { width: 38, padding: '0px 3px', fontSize: 9, textAlign: 'center', border: '1px solid ' + (dirty ? C.brand : C.border2), borderRadius: 2, background: C.card, color: C.text }
2456
+ }),
2457
+ React.createElement('span', { style: { fontSize: 9, color: C.text2 } }, '天'),
2458
+ dirty ? React.createElement('button', { onClick: function () { commit(val) }, title: '应用', style: { fontSize: 9, padding: '0px 5px', border: 'none', borderRadius: 2, background: C.brand, color: C_INV, cursor: 'pointer', lineHeight: '14px', fontWeight: 600, display: 'inline-flex', alignItems: 'center' } }, ic('check', 9)) : null)
2459
+ }
2334
2460
 
2335
2461
  function PoolStatus() {
2336
2462
  var ps = state.poolStatus
@@ -2374,6 +2500,23 @@ function apply(ctx) {
2374
2500
  dirty ? React.createElement('button', { onClick: save, title: '应用', style: { fontSize: 9, padding: '0px 5px', border: 'none', borderRadius: 2, background: C.brand, color: C_INV, cursor: 'pointer', lineHeight: '14px', fontWeight: 600, display: 'inline-flex', alignItems: 'center' } }, ic('check', 9)) : null)
2375
2501
  }
2376
2502
 
2503
+ // 界面缩放档位选择(task-mv58e01j,用户反馈「字体整体偏小」):90/100/110/125/150% 五档下拉。
2504
+ // 纯客户端偏好——不走 set-board-config(host 不管 UI 偏好):选中即 localStorage.setItem 持久化
2505
+ // + state.uiZoom + notify,TopPanel 根容器 zoom 订阅即时生效;非法档位(脏值)不采纳。
2506
+ function ZoomCfg() {
2507
+ var cur = state.uiZoom || 1
2508
+ function pick(v) {
2509
+ var n = parseFloat(v)
2510
+ if (UI_ZOOM_LEVELS.indexOf(n) < 0) return
2511
+ state.uiZoom = n
2512
+ try { localStorage.setItem(UI_ZOOM_KEY, String(n)) } catch (_) {}
2513
+ notify()
2514
+ }
2515
+ return React.createElement('span', { style: { display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 9, color: C.text2 } }, '界面缩放',
2516
+ React.createElement('select', { value: String(cur), onChange: function (e) { pick(e.target.value) }, title: '界面缩放:等比放大/缩小面板内字体、图标与间距(选中即时生效,本地记忆)', style: { padding: '0px 4px', fontSize: 9, border: '1px solid ' + C.border, borderRadius: 2, background: C.card, color: C.text } },
2517
+ UI_ZOOM_LEVELS.map(function (z) { return React.createElement('option', { key: String(z), value: String(z) }, Math.round(z * 100) + '%' + (z === 1 ? '(默认)' : '')) })))
2518
+ }
2519
+
2377
2520
  function PoolCfgPopover(props) {
2378
2521
  var _R = React; var useState = _R.useState, useEffect = _R.useEffect, useRef = _R.useRef
2379
2522
  var _a = useState(false), open = _a[0], setOpen = _a[1]
@@ -2397,12 +2540,16 @@ function apply(ctx) {
2397
2540
  // position:fixed + 视口坐标:面板容器是 overflow:hidden + maxHeight:60vh,
2398
2541
  // absolute 弹窗在面板内容短时会被裁掉下半截(超时配置行曾被整个裁掉)。
2399
2542
  // fixed 脱离任何祖先裁剪(面板无 transform/filter,不会形成包含块)。
2400
- if (!open && btnRef.current) { var r = btnRef.current.getBoundingClientRect(); setPos({ top: r.bottom + 4, right: Math.max(8, window.innerWidth - r.right) }) }
2543
+ // 界面缩放补偿(task-mv58e01j):根容器挂了 CSS zoom 后,getBoundingClientRect 返回缩放后的
2544
+ // 可视坐标,而 fixed 弹层在 zoom 子树内 top/right/maxHeight 会再被缩一次 → 各项除以档位还原成
2545
+ // CSS px,弹层仍贴着按钮且不溢出视口底(zoom=1 时与旧行为逐字一致)。
2546
+ var z = state.uiZoom || 1
2547
+ if (!open && btnRef.current) { var r = btnRef.current.getBoundingClientRect(); setPos({ top: (r.bottom + 4) / z, right: Math.max(8, window.innerWidth - r.right) / z, maxH: Math.max(140, (window.innerHeight - r.bottom - 16) / z) }) }
2401
2548
  setOpen(!open)
2402
2549
  }
2403
2550
  return React.createElement('span', { ref: ref, style: { position: 'relative', display: 'inline-flex' } },
2404
2551
  React.createElement('button', { ref: btnRef, onClick: toggle, title: '池配置', style: { fontSize: 12, padding: '3px 8px', border: '1px solid ' + (open ? C.brand : C.border), borderRadius: 6, cursor: 'pointer', background: open ? C.nested : 'transparent', color: C.text2, display: 'inline-flex', alignItems: 'center' } }, ic('settings', 13)),
2405
- open && pos ? React.createElement('div', { style: { position: 'fixed', top: pos.top + 'px', right: pos.right + 'px', padding: '8px 10px', background: C.card, border: '1px solid ' + C.border, borderRadius: 8, boxShadow: '0 4px 16px rgba(0,0,0,0.15)', zIndex: 1000, whiteSpace: 'nowrap', maxHeight: 'calc(100vh - ' + (pos.top + 12) + 'px)', overflowY: 'auto' } },
2552
+ open && pos ? React.createElement('div', { style: { position: 'fixed', top: pos.top + 'px', right: pos.right + 'px', padding: '8px 10px', background: C.card, border: '1px solid ' + C.border, borderRadius: 8, boxShadow: '0 4px 16px rgba(0,0,0,0.15)', zIndex: 1000, whiteSpace: 'nowrap', maxHeight: (pos.maxH || 300) + 'px', overflowY: 'auto' } },
2406
2553
  React.createElement('div', { style: { fontSize: 10, fontWeight: 600, color: C.text2, marginBottom: 5 } }, '并发上限(一次性派发,用完即销毁)'),
2407
2554
  React.createElement('div', { style: { display: 'flex', gap: 8, alignItems: 'center', marginBottom: 5 } },
2408
2555
  React.createElement(PoolCfg, { label: 'Worker 并发', cfgKey: 'maxWorkers', value: props.maxW }),
@@ -2446,7 +2593,19 @@ function apply(ctx) {
2446
2593
  React.createElement('label', { style: { display: 'flex', alignItems: 'center', gap: 5, fontSize: 10, color: C.text, cursor: 'pointer', whiteSpace: 'normal', maxWidth: 260 } },
2447
2594
  React.createElement('input', { type: 'checkbox', checked: props.epicSplit !== false, onChange: function (e) { setCfg('epicSplit', e.target.checked) } }),
2448
2595
  React.createElement('span', null, '🧩 史诗拆分:大任务引导拆为 epic + 子任务')),
2449
- React.createElement('div', { style: { fontSize: 9, color: C.text2, marginTop: 3, whiteSpace: 'normal', maxWidth: 260 } }, '关掉只停引导:显式 parentId 建子卡与史诗自动收口照常工作')) : null)
2596
+ React.createElement('div', { style: { fontSize: 9, color: C.text2, marginTop: 3, whiteSpace: 'normal', maxWidth: 260 } }, '关掉只停引导:显式 parentId 建子卡与史诗自动收口照常工作'),
2597
+ // 清理保留天数(卡龄主闸 cleanupRetentionDays,默认 7、可配 1-90):回收与归档生命周期对齐——
2598
+ // 卡片归档满该天数后,其会话才进入可清理池。仪表盘「存储清理」区文案读同一服务端权威值。
2599
+ React.createElement('div', { style: { fontSize: 10, fontWeight: 600, color: C.text2, margin: '7px 0 5px' } }, '存储清理'),
2600
+ React.createElement('div', { style: { display: 'flex', gap: 8, alignItems: 'center' } },
2601
+ React.createElement(RetentionCfg, { label: '归档保留', value: props.retentionDays, tip: '卡片归档后多少天可被完全回收(1~90 天,默认 7)' }),
2602
+ React.createElement('span', { style: { fontSize: 9, color: C.text2 } }, '(归档满该天数后其会话可被清理回收)')),
2603
+ // 界面缩放档位(task-mv58e01j):设置弹层「界面」小节——五档下拉选中即 localStorage
2604
+ // 持久化 + state.uiZoom + notify 即时生效(zoom 挂在 TopPanel 根容器,只影响本面板)。
2605
+ React.createElement('div', { style: { fontSize: 10, fontWeight: 600, color: C.text2, margin: '7px 0 5px' } }, '界面'),
2606
+ React.createElement('div', { style: { display: 'flex', gap: 8, alignItems: 'center' } },
2607
+ React.createElement(ZoomCfg, null),
2608
+ React.createElement('span', { style: { fontSize: 9, color: C.text2 } }, '(等比缩放面板字体/图标/间距,仅本面板生效并本地记忆)'))) : null)
2450
2609
  }
2451
2610
 
2452
2611
  function TeamView() {
package/lib/core.mjs CHANGED
@@ -139,7 +139,11 @@ export function classifyPipeline(t) {
139
139
  export function cfg(d) {
140
140
  var soft = Math.max(1, Math.min(480, d.softTimeoutMin || 30))
141
141
  var hard = Math.max(soft, Math.min(1440, d.hardTimeoutMin || 120))
142
- return { minWorkers: Math.max(0, Math.min(10, d.minWorkers || 1)), maxWorkers: Math.max(1, Math.min(10, d.maxWorkers || 3)), minVerifiers: Math.max(0, Math.min(5, d.minVerifiers || 0)), maxVerifiers: Math.max(0, Math.min(5, d.maxVerifiers || 2)), softTimeoutMin: soft, hardTimeoutMin: hard, feedbackEnabled: d.feedbackEnabled !== false, notifyDispatch: d.notifyDispatch !== false, notifyDone: d.notifyDone !== false, epicSplit: d.epicSplit !== false, workerContinuable: d.workerContinuable !== false, verifyUserGuide: d.verifyUserGuide !== false }
142
+ // 清理保留天数(卡龄主闸,默认 7 天):读路径归一,缺字段/脏值=7,钳 1-90。
143
+ var crd = d.cleanupRetentionDays
144
+ if (typeof crd !== 'number' || !(crd >= 1)) crd = 7
145
+ crd = Math.max(1, Math.min(90, Math.round(crd)))
146
+ return { minWorkers: Math.max(0, Math.min(10, d.minWorkers || 1)), maxWorkers: Math.max(1, Math.min(10, d.maxWorkers || 3)), minVerifiers: Math.max(0, Math.min(5, d.minVerifiers || 0)), maxVerifiers: Math.max(0, Math.min(5, d.maxVerifiers || 2)), softTimeoutMin: soft, hardTimeoutMin: hard, cleanupRetentionDays: crd, feedbackEnabled: d.feedbackEnabled !== false, notifyDispatch: d.notifyDispatch !== false, notifyDone: d.notifyDone !== false, epicSplit: d.epicSplit !== false, workerContinuable: d.workerContinuable !== false, verifyUserGuide: d.verifyUserGuide !== false }
143
147
  }
144
148
 
145
149
  // ===== 看板数据目录(跨重启继承用)=====
@@ -156,7 +160,7 @@ export function boardHome() { return path.join(boardDirName(), '.dsh') }
156
160
  // ownerCwd(跨重启继承):创建该看板的会话工作区路径,继承判定全靠它——取不到就省略字段
157
161
  // (绝不落空串,否则「路径读不到的多个会话」会被误判成同一工作区)。
158
162
  export function seed(sid, ownerCwd) {
159
- var d = { version: 12, ownerSession: sid, boardMode: 'auto', teamMode: false, feedbackEnabled: true, notifyDispatch: true, notifyDone: true, epicSplit: true, workerContinuable: true, verifyUserGuide: true, minWorkers: 1, maxWorkers: 3, minVerifiers: 0, maxVerifiers: 2, workerModel: '', verifierModel: '', softTimeoutMin: 30, hardTimeoutMin: 120, poolStatus: { workers: [], verifiers: [] }, tasks: [] }
163
+ var d = { version: 12, ownerSession: sid, boardMode: 'auto', teamMode: false, feedbackEnabled: true, notifyDispatch: true, notifyDone: true, epicSplit: true, workerContinuable: true, verifyUserGuide: true, minWorkers: 1, maxWorkers: 3, minVerifiers: 0, maxVerifiers: 2, workerModel: '', verifierModel: '', softTimeoutMin: 30, hardTimeoutMin: 120, cleanupRetentionDays: 7, poolStatus: { workers: [], verifiers: [] }, tasks: [] }
160
164
  if (typeof ownerCwd === 'string' && ownerCwd) d.ownerCwd = ownerCwd
161
165
  return d
162
166
  }
@@ -178,6 +182,9 @@ export function normalizeBoard(d) {
178
182
  if (typeof d.epicSplit !== 'boolean') d.epicSplit = true
179
183
  if (typeof d.workerContinuable !== 'boolean') d.workerContinuable = true
180
184
  if (typeof d.verifyUserGuide !== 'boolean') d.verifyUserGuide = true
185
+ // 清理保留天数(卡龄主闸):缺字段/脏值(非数或 <1)→ 7;合法值四舍五入并钳 1-90。
186
+ if (typeof d.cleanupRetentionDays !== 'number' || !(d.cleanupRetentionDays >= 1)) d.cleanupRetentionDays = 7
187
+ else d.cleanupRetentionDays = Math.max(1, Math.min(90, Math.round(d.cleanupRetentionDays)))
181
188
  if (Array.isArray(d.tasks)) {
182
189
  for (var i = 0; i < d.tasks.length; i++) {
183
190
  var t = d.tasks[i]
@@ -760,7 +767,15 @@ export function buildWorkerPrompt(t, pack, feedbackEnabled) {
760
767
  if (feedbackEnabled !== false) p += '\n\n' + LESSON_RECALL_HINT
761
768
  // git 纪律红线(n-muyaaoo7cqnd 实证事故:并行 Worker 跑 git checkout HEAD -- package.json
762
769
  // 把另一 Worker 的在途编辑冲掉):禁止还原命令,只改自己写的文件,要干净基线走 escalate 上报。
763
- p += '\n\ngit 纪律红线:禁止 git checkout / git restore / git reset --hard / git clean 等还原命令(会冲掉并行 Worker 与你自己的未提交在途编辑);你只需管自己写的文件,别人改了别的文件与你无关;确实需要干净基线时用 board_report(kind=escalate)上报,由主窗口裁决。'
770
+ // 精确路径 add 条款(n-mv6xntg1p6i5 实证:并行 Worker 共享 worktree+暂存区,宽泛 git add
771
+ // 三起吞并他卡在途编辑——p4-rdf 带走 p4-apply-ddl-core 的 4 处在途编辑混入自己 commit):
772
+ // 提交只允许精确路径 add(本卡 touches 或逐一列明改过的文件),禁 git add -A / . / 目录。
773
+ p += '\n\ngit 纪律红线:禁止 git checkout / git restore / git reset --hard / git clean 等还原命令(会冲掉并行 Worker 与你自己的未提交在途编辑);你只需管自己写的文件,别人改了别的文件与你无关;确实需要干净基线时用 board_report(kind=escalate)上报,由主窗口裁决。提交时只允许精确路径 git add <本卡 touches 声明的文件>(或明确你改过的文件路径逐一列出);禁止 git add -A / git add . / git add 目录——并行 Worker 共享暂存区,宽泛 add 会把别卡在途编辑卷进你的 commit。'
774
+ // 大文件交付纪律(实证 n-mv65bhk0mg5w:gs-gov-policy task-mv63fo9y 池 Worker 连续 3 个 run 单次输出
775
+ // 64k tok 撞上限、烧 330k token 零文件产出——都在试图一次性 write 整个 22.5KB HTML;主窗口改用
776
+ // write×1 + edit×2 分块落地同一 spec 一次过):固定段写死「预估 >8KB 禁止单次 write 全量」。
777
+ // 纯 prompt 层指引段,零逻辑风险;只进 Worker prompt,不进 verifier/hook(与 git 纪律段同例)。
778
+ p += '\n\n大文件交付纪律:单文件交付预估 >8KB(或 spec 含全量文案/长代码)时,禁止单次 write 写入全量——单次超长输出会撞模型输出上限、上下文耗尽零产出。先 write 落主体骨架,再用 edit 追加续块(每块 ≤4KB)逐块落地,直至写完。'
764
779
  p += '\n\n完成契约(双模,工具优先):\n1. 完成时:优先调用 board_report 工具(kind=complete, taskId=' + t.id + ',summary=开发描述/changes=改动清单/selfTest=自测情况/diffStat=变更概要);工具不可用则按分段格式输出(## 开发描述 / ## 改动清单 / ## 自测情况 / ## diff 概要)。\n diffStat 要求:若本次改动发生在 git 仓库内,运行 git diff --stat(含 git status --short),把输出贴进 diffStat(≤1500 字符);关键逻辑变更可附 ≤20 行核心片段。非代码任务/无 git 仓库可省略。\n **board_report 调用成功即任务终点:立即结束输出,不要再修改/验证任何文件**。上报后任务即刻进入验收,你继续改动会让代码在验收口径之外漂移、且阻塞 Verifier 派发(实测有 Worker 上报后又自测 16 分钟);上报后发现新问题的,写进 selfTest 备注交由 Verifier/主窗口裁决。\n2. 歧义/信息不足/需用户决策时:优先调用 board_report(kind=escalate, taskId=' + t.id + ', question=疑问);工具不可用则输出以 [ESCALATE] 开头的说明。不要猜测。上报歧义后直接结束本轮——裁决后会有新 Worker 带着裁决答案接手。\n3. 进展汇报(较大任务):按里程碑推进,每完成一个可验证的里程碑调用一次 board_report(kind="progress", taskId=' + t.id + ', question=一行进展摘要,≤200 字符)。只在有实际产物/结论时报;禁止定时汇报或表演式汇报。'
765
780
  return p
766
781
  }
@@ -904,6 +919,11 @@ export function touchesConflict(t, holds) {
904
919
  // pre hook 串行闸门(hooks=agent run 接线①):候选所属 epic 的 hooks.pre 未 done 时整批不派
905
920
  // (见 preHookGate),由 poolCycle 改 spawn hook-pre run——同一 epic 的子任务在准备完成前
906
921
  // 一张都不派,这是「pre 闸门」的串行语义;未声明 hooks 的 epic 逐字不受影响。
922
+ // 容器卡结构性排除(epic 占 worker 槽根修):凡有子任务的卡(epic)永不进派发候选——
923
+ // 与 pipeline 字段无关,不再只靠「pipeline=direct 口头约定」(task_create 缺省 full,
924
+ // 手滑忘传 direct 的 epic 进池被派出占槽、已就绪任务空等,用户实证)。verifs 分支不动:
925
+ // 子任务全了结后 epic 转 verifying 派 Verifier 验收是既有正确行为,不受此排除影响。
926
+ // 子任务口径与 childStats 同源:含已归档(parentId 仍指向即算有子任务)。
907
927
  export function pickDispatch(d, capW, capV, busyTaskIds) {
908
928
  var blockedTouches = []
909
929
  var pendings = []
@@ -911,7 +931,7 @@ export function pickDispatch(d, capW, capV, busyTaskIds) {
911
931
  // 防止同一轮 cycle 派出的两个任务声明重叠 touches。
912
932
  var holds = holdsFiles(d)
913
933
  if (capW > 0) {
914
- var cands = d.tasks.filter(function (t) { return t.status === 'pending' && !t.claimedBy && !t.frozen && t.assignMode !== 'manual' && t.pipeline !== 'direct' && depsSatisfied(d, t) && !t.escalation && preHookGate(gpt(t, d.tasks)).pass })
934
+ var cands = d.tasks.filter(function (t) { return t.status === 'pending' && !t.claimedBy && !t.frozen && t.assignMode !== 'manual' && t.pipeline !== 'direct' && !d.tasks.some(function (x) { return x.parentId === t.id }) && depsSatisfied(d, t) && !t.escalation && preHookGate(gpt(t, d.tasks)).pass })
915
935
  .sort(function (a, b) { var p = (PRIO_RANK[b.priority] || 2) - (PRIO_RANK[a.priority] || 2); return p !== 0 ? p : (a.createdAt || '').localeCompare(b.createdAt || '') })
916
936
  for (var i = 0; i < cands.length && pendings.length < capW; i++) {
917
937
  var conflicts = touchesConflict(cands[i], holds)