dsh-agent-board 1.7.6 → 1.7.7

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
@@ -242,7 +242,7 @@ function apply(ctx) {
242
242
  blocked: { title: '阻塞:卡住待处理(等依赖/等主窗口裁决)', empty: '暂无阻塞——任务卡住待裁决时会出现在这里' }
243
243
  }
244
244
  var reqEpoch = 0 // 会话切换纪元:切会话时自增,旧会话在途响应按纪元丢弃
245
- 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: '', 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: '' }
245
+ 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: '' }
246
246
 
247
247
  // #16 快捷键:Esc 逐级关闭(详情→看板→面板);输入框聚焦时不劫持
248
248
  try {
@@ -517,12 +517,17 @@ function apply(ctx) {
517
517
  function onColDragLeave(st) { if (state.dragOver === st) { state.dragOver = null; notify() } }
518
518
  function onColDrop(e, st) { e.preventDefault(); try { var data = JSON.parse(e.dataTransfer.getData('text/plain')); state.dragOver = null; state.dragTask = null; transition(data.id, data.status, st); notify() } catch (_) {} }
519
519
 
520
- // #13 筛选:文本(标题/描述/ID)+ 优先级多选 + 标签
520
+ // ===== 风险队列「待你过目」客户端单一事实源(异常驱动审视①)=====
521
+ // 命中=host 现算的 reviewHint.score>0;待过目=命中 + 未阅(reviewedAt 空)+ 终态(resolved/archived)。
522
+ // 徽章 / chip 过滤 / digest 计数三处同用这一个谓词,避免口径分叉。
523
+ function reviewPending(t) { return !!(t && t.reviewHint && t.reviewHint.score > 0 && !t.reviewedAt && (t.status === 'resolved' || t.status === 'archived')) }
524
+ // #13 筛选:文本(标题/描述/ID)+ 优先级多选 + 标签 + 待过目队列
521
525
  function passFilter(t) {
522
526
  var q = state.filterQ.trim().toLowerCase()
523
527
  if (q && (t.title + ' ' + (t.description || '') + ' ' + t.id).toLowerCase().indexOf(q) < 0) return false
524
528
  if (state.filterPrio.length > 0 && state.filterPrio.indexOf(t.priority || 'medium') < 0) return false
525
529
  if (state.filterTag && (t.tags || []).indexOf(state.filterTag) < 0) return false
530
+ if (state.filterReview && !reviewPending(t)) return false
526
531
  return true
527
532
  }
528
533
  function allTags() { var s = {}; state.tasks.forEach(function (t) { (t.tags || []).forEach(function (g) { s[g] = 1 }) }); return Object.keys(s) }
@@ -588,11 +593,11 @@ function apply(ctx) {
588
593
  if (!open) return null
589
594
  if (!state.isRootStable) return null // 子代理会话不渲染看板面板(读 isRootStable:瞬态 false 不闪,见 isRoot 蝶变防抖)
590
595
  var active = tasks.filter(function (t) { return t.status !== 'archived' }); var archived = tasks.filter(function (t) { return t.status === 'archived' })
591
- var hasFilter = state.filterQ.trim() || state.filterPrio.length > 0 || state.filterTag
596
+ var hasFilter = state.filterQ.trim() || state.filterPrio.length > 0 || state.filterTag || state.filterReview
592
597
  if (hasFilter) { active = active.filter(passFilter); archived = archived.filter(passFilter) }
593
598
  var content
594
599
  if (view === 'dashboard') { content = React.createElement(Dashboard) }
595
- else if (view === 'archive') { content = React.createElement(ArchiveView) }
600
+ else if (view === 'archive') { content = detailId ? React.createElement(DetailView) : React.createElement(ArchiveView) }
596
601
  else if (view === 'team') { content = React.createElement(TeamView) }
597
602
  else if (detailId) { content = React.createElement(DetailView) }
598
603
  else {
@@ -687,12 +692,14 @@ function apply(ctx) {
687
692
  useEffect(function () { function update() { setQ(state.filterQ); setFt(state.filterTag) }; listeners.push(update); update(); return function () { var i = listeners.indexOf(update); if (i >= 0) listeners.splice(i, 1) } }, [])
688
693
  function setQ2(v) { state.filterQ = v; notify() }
689
694
  function togglePrio(p) { var i = state.filterPrio.indexOf(p); if (i >= 0) state.filterPrio.splice(i, 1); else state.filterPrio.push(p); notify() }
690
- function clearAll() { state.filterQ = ''; state.filterPrio = []; state.filterTag = ''; notify() }
691
- var hasFilter = q.trim() || state.filterPrio.length > 0 || ft
695
+ function toggleReview() { state.filterReview = !state.filterReview; notify() }
696
+ function clearAll() { state.filterQ = ''; state.filterPrio = []; state.filterTag = ''; state.filterReview = false; notify() }
697
+ var hasFilter = q.trim() || state.filterPrio.length > 0 || ft || state.filterReview
692
698
  var chipBase = { fontSize: 10, padding: '2px 8px', borderRadius: 10, border: '1px solid ' + C.border, cursor: 'pointer', background: 'transparent', color: C.text2, transition: 'all .15s' }
693
699
  return React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 6, marginBottom: 8, flexWrap: 'wrap' } },
694
700
  React.createElement('input', { value: q, onChange: function (e) { setQ2(e.target.value) }, placeholder: '🔍 搜索标题/描述/ID…', style: { flex: '0 1 200px', fontSize: 11, padding: '3px 8px', border: '1px solid ' + C.border2, borderRadius: 4, background: C.card, color: C.text } }),
695
701
  ['critical', 'high', 'medium', 'low'].map(function (p) { var on = state.filterPrio.indexOf(p) >= 0; return React.createElement('button', { key: p, onClick: function () { togglePrio(p) }, style: Object.assign({}, chipBase, on ? { background: prioColor[p], color: C_INV, borderColor: prioColor[p] } : {}) }, prioLabel[p]) }),
702
+ React.createElement('button', { key: 'review', onClick: toggleReview, title: '只看命中风险信号且未阅的已完成/归档卡(已阅卡不计)', style: Object.assign({}, chipBase, state.filterReview ? { background: C.warn, color: C_INV, borderColor: C.warn } : {}) }, '👁 待你过目'),
696
703
  allTags().length > 0 ? React.createElement('select', { value: ft, onChange: function (e) { state.filterTag = e.target.value; notify() }, style: { fontSize: 10, padding: '2px 4px', border: '1px solid ' + C.border, borderRadius: 4, background: C.card, color: C.text } }, React.createElement('option', { value: '' }, '🏷 全部标签'), allTags().map(function (g) { return React.createElement('option', { key: g, value: g }, g) })) : null,
697
704
  hasFilter ? React.createElement('button', { onClick: clearAll, style: { fontSize: 10, padding: '2px 8px', borderRadius: 10, border: 'none', background: C.nested, color: C.text2, cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: 2 } }, ic('x', 9), '清除') : null)
698
705
  }
@@ -789,7 +796,7 @@ function apply(ctx) {
789
796
  // Enter/Space 触发与点击相同的激活行为(多选=切换选中,否则开详情);
790
797
  // focus 态用主题色 C.brand 2px outline(不用浏览器默认蓝框,保持主题一致)
791
798
  function activate() { if (state.selectMode) { if (state.selected[t.id]) delete state.selected[t.id]; else state.selected[t.id] = true; notify() } else { state.detailId = t.id; notify() } }
792
- return React.createElement('div', { role: 'button', tabIndex: 0, 'aria-label': t.title + '(' + (statusLabels[t.status] || t.status) + ')', draggable: !state.selectMode, onDragStart: function (e) { onDragStart(e, t) }, onDragEnd: onDragEnd, onMouseEnter: function () { if (delOk) setHover(t.id, 'del') }, onMouseLeave: function () { if (delOk) setHover('', '') }, onFocus: function () { setCardFocus(t.id) }, onBlur: function () { setCardFocus('') }, onKeyDown: function (e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); activate() } }, onClick: activate, style: { outline: isCardFocus(t.id) ? '2px solid ' + C.brand : 'none', outlineOffset: 2, border: '1px solid ' + (sel ? C.brand : (t.escalation ? C.err : (critGlow ? C.err : C.border))), borderRadius: 6, padding: '6px 8px', marginBottom: 6, background: sel ? C.nested : C.card, borderLeft: '3px solid ' + (t.escalation ? C.err : pc), cursor: state.selectMode ? 'pointer' : 'grab', fontSize: 12, opacity: dragging ? 0.4 : (depBlock ? 0.65 : 1), transition: 'opacity .15s', animation: critGlow ? 'tskb-crit 2s ease-in-out infinite' : 'none' } }, React.createElement('div', { style: { display: 'flex', alignItems: 'flex-start', gap: 4 } }, state.selectMode ? React.createElement('span', { style: { color: sel ? C.brand : C.text2, flexShrink: 0, marginTop: 1, display: 'inline-flex' } }, ic(sel ? 'square-check-big' : 'square', 12)) : null, React.createElement('div', { style: { fontWeight: 600, color: C.text, marginBottom: 2, wordBreak: 'break-word', flex: 1 } }, t.title), React.createElement('span', { style: { flexShrink: 0, marginTop: 1, display: 'inline-flex', color: C.text2 }, title: pm.label }, ic(pm.icon, 10)), React.createElement('span', { style: { fontSize: 9, padding: '1px 5px', borderRadius: 3, background: 'color-mix(in srgb, ' + pc + ' 20%, transparent)', color: pc, flexShrink: 0, marginTop: 1 } }, prioLabel[t.priority] || '中'), noResearch(t) ? React.createElement('span', { title: '本任务未附调研上下文,Worker 需自行定位——建议补 contextFiles/contextNotes', style: { fontSize: 9, padding: '1px 5px', borderRadius: 3, background: 'color-mix(in srgb, ' + C.warn + ' 18%, transparent)', color: C.warn, fontWeight: 600, flexShrink: 0, marginTop: 1, whiteSpace: 'nowrap' } }, '⚠️ 无调研') : null, (t.usage && t.usage.total) ?React.createElement('span', { style: { fontSize: 9, color: C.text2, flexShrink: 0, marginTop: 1 }, title: '本任务累计 token:' + String(t.usage.total) + '(' + (t.usage.runs || 0) + ' 次 run)' }, '⛁ ' + fmtTokens(t.usage.total)) : null), t.parentId ? React.createElement('div', { style: { fontSize: 10, color: C.text2, marginBottom: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, title: parentT ? '父任务:' + parentT.title + ' (' + t.parentId + ')' : t.parentId }, '↳ ' + (parentT && parentT.title ? parentT.title : shortId(t.parentId))) : null, t.escalation ? React.createElement('div', { style: { fontSize: 10, color: C.err, fontWeight: 600, marginBottom: 2, display: 'flex', alignItems: 'center', gap: 3 } }, ic('alert-triangle', 10), '待裁决 — 点击查看疑问') : null, t.stuckSince ? React.createElement('div', { style: { fontSize: 10, color: C.warn, fontWeight: 600, marginBottom: 2, animation: 'tskb-pulse 1.5s ease-in-out infinite' } }, '⏱ 疑似卡死 · ' + ago(t.stuckSince) + ' — 点击处理') : null, React.createElement('div', { style: { fontSize: 10, color: C.text2, display: 'flex', alignItems: 'center', gap: 5, flexWrap: 'wrap' } },
799
+ return React.createElement('div', { role: 'button', tabIndex: 0, 'aria-label': t.title + '(' + (statusLabels[t.status] || t.status) + ')', draggable: !state.selectMode, onDragStart: function (e) { onDragStart(e, t) }, onDragEnd: onDragEnd, onMouseEnter: function () { if (delOk) setHover(t.id, 'del') }, onMouseLeave: function () { if (delOk) setHover('', '') }, onFocus: function () { setCardFocus(t.id) }, onBlur: function () { setCardFocus('') }, onKeyDown: function (e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); activate() } }, onClick: activate, style: { outline: isCardFocus(t.id) ? '2px solid ' + C.brand : 'none', outlineOffset: 2, border: '1px solid ' + (sel ? C.brand : (t.escalation ? C.err : (critGlow ? C.err : C.border))), borderRadius: 6, padding: '6px 8px', marginBottom: 6, background: sel ? C.nested : C.card, borderLeft: '3px solid ' + (t.escalation ? C.err : pc), cursor: state.selectMode ? 'pointer' : 'grab', fontSize: 12, opacity: dragging ? 0.4 : (depBlock ? 0.65 : 1), transition: 'opacity .15s', animation: critGlow ? 'tskb-crit 2s ease-in-out infinite' : 'none' } }, React.createElement('div', { style: { display: 'flex', alignItems: 'flex-start', gap: 4 } }, state.selectMode ? React.createElement('span', { style: { color: sel ? C.brand : C.text2, flexShrink: 0, marginTop: 1, display: 'inline-flex' } }, ic(sel ? 'square-check-big' : 'square', 12)) : null, React.createElement('div', { style: { fontWeight: 600, color: C.text, marginBottom: 2, wordBreak: 'break-word', flex: 1 } }, t.title), React.createElement('span', { style: { flexShrink: 0, marginTop: 1, display: 'inline-flex', color: C.text2 }, title: pm.label }, ic(pm.icon, 10)), React.createElement('span', { style: { fontSize: 9, padding: '1px 5px', borderRadius: 3, background: 'color-mix(in srgb, ' + pc + ' 20%, transparent)', color: pc, flexShrink: 0, marginTop: 1 } }, prioLabel[t.priority] || '中'), reviewPending(t) ? React.createElement('span', { title: '👁 建议过目(命中风险信号):\n' + ((t.reviewHint && t.reviewHint.reasons) || []).join('\n'), style: { fontSize: 9, padding: '0 4px', borderRadius: 2, background: 'color-mix(in srgb, ' + C.warn + ' 22%, transparent)', color: C.warn, fontWeight: 600, whiteSpace: 'nowrap', flexShrink: 0, marginTop: 1 } }, '👁 建议过目') : null, noResearch(t) ? React.createElement('span', { title: '本任务未附调研上下文,Worker 需自行定位——建议补 contextFiles/contextNotes', style: { fontSize: 9, padding: '1px 5px', borderRadius: 3, background: 'color-mix(in srgb, ' + C.warn + ' 18%, transparent)', color: C.warn, fontWeight: 600, flexShrink: 0, marginTop: 1, whiteSpace: 'nowrap' } }, '⚠️ 无调研') : null, (t.usage && t.usage.total) ?React.createElement('span', { style: { fontSize: 9, color: C.text2, flexShrink: 0, marginTop: 1 }, title: '本任务累计 token:' + String(t.usage.total) + '(' + (t.usage.runs || 0) + ' 次 run)' }, '⛁ ' + fmtTokens(t.usage.total)) : null), t.parentId ? React.createElement('div', { style: { fontSize: 10, color: C.text2, marginBottom: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, title: parentT ? '父任务:' + parentT.title + ' (' + t.parentId + ')' : t.parentId }, '↳ ' + (parentT && parentT.title ? parentT.title : shortId(t.parentId))) : null, t.escalation ? React.createElement('div', { style: { fontSize: 10, color: C.err, fontWeight: 600, marginBottom: 2, display: 'flex', alignItems: 'center', gap: 3 } }, ic('alert-triangle', 10), '待裁决 — 点击查看疑问') : null, t.stuckSince ? React.createElement('div', { style: { fontSize: 10, color: C.warn, fontWeight: 600, marginBottom: 2, animation: 'tskb-pulse 1.5s ease-in-out infinite' } }, '⏱ 疑似卡死 · ' + ago(t.stuckSince) + ' — 点击处理') : null, React.createElement('div', { style: { fontSize: 10, color: C.text2, display: 'flex', alignItems: 'center', gap: 5, flexWrap: 'wrap' } },
793
800
  epic ? React.createElement('span', { title: '史诗父卡:' + csDone + '/' + cs.total + ' 个子任务已了结(完成/取消/归档)', style: { fontSize: 9, padding: '0 4px', borderRadius: 2, background: 'color-mix(in srgb, ' + C.brand + ' 14%, transparent)', color: C.brand, fontWeight: 600 } }, '📦 史诗 · ' + csDone + '/' + cs.total) : null,
794
801
  // hook 相位徽章:与 📦 徽章**并存**(相位是 epic 的附加过程态,不是替代进度)
795
802
  epic ? hookPhaseBadge(t) : null,
@@ -833,21 +840,31 @@ function apply(ctx) {
833
840
  function archTs(t) { return t.archivedAt || t.resolvedAt || t.createdAt || '' }
834
841
  return (archTs(b) || '').localeCompare(archTs(a) || '')
835
842
  })
836
- function restore(id) { rpc('update-task', { taskId: id, resetToPending: true }).then(function () { fetchTasks() }).catch(function () {}) }
843
+ // 工作模式(与 fetchTasks 同口径):list=清单模式(恢复不重派)不弹;auto/team 恢复即重派 → confirm 门禁
844
+ var wm = state.workMode || (state.teamMode ? 'team' : (state.boardMode === 'auto' ? 'auto' : 'list'))
845
+ function restore(id) {
846
+ if (wm !== 'list') {
847
+ if (!window.confirm('恢复待办后将按当前工作模式立即重新派发,消耗一轮 Worker+Verifier token。继续?')) return
848
+ }
849
+ rpc('update-task', { taskId: id, resetToPending: true }).then(function () { fetchTasks() }).catch(function () {})
850
+ }
837
851
  return React.createElement('div', null,
838
852
  React.createElement('input', { value: q, onChange: function (e) { setQ(e.target.value); state.archQ = e.target.value }, placeholder: '检索标题 / ID / 标签…', style: { width: '100%', padding: '5px 8px', fontSize: 11, border: '1px solid ' + C.border2, borderRadius: 5, background: C.card, color: C.text, marginBottom: 8 } }),
839
853
  React.createElement('div', { style: { fontSize: 10, color: C.text2, marginBottom: 6 } }, '共 ' + archived.length + ' 条归档(全量,与仪表盘「已归档」同口径)'),
840
854
  archived.length === 0 ? React.createElement('div', { style: { fontSize: 11, color: C.text2, padding: 12, textAlign: 'center' } }, '暂无归档任务') :
841
855
  list.length === 0 ? React.createElement('div', { style: { fontSize: 11, color: C.text2, padding: 12, textAlign: 'center' } }, '无匹配结果') :
842
856
  list.map(function (t) {
843
- return React.createElement('div', { key: t.id, style: { padding: '6px 8px', marginBottom: 5, background: C.card, border: '1px solid ' + C.border, borderRadius: 6 } },
857
+ // 归档行点击进详情(用户 2026-10-09 指令):复用 state.detailId 机制,与看板卡同路径;
858
+ // 详情页对 archived 的操作区既有门禁不回归(恢复待办=保存并重置、删除仅 draft/pending/blocked)。
859
+ return React.createElement('div', { key: t.id, onClick: function () { state.detailId = t.id; notify() }, title: '点击查看详情', style: { padding: '6px 8px', marginBottom: 5, background: C.card, border: '1px solid ' + C.border, borderRadius: 6, cursor: 'pointer' } },
844
860
  React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 6 } },
845
861
  React.createElement('span', { style: { fontSize: 11, fontWeight: 600, color: C.text, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, title: t.title }, t.title),
862
+ reviewPending(t) ? React.createElement('span', { title: '👁 建议过目(命中风险信号):\n' + ((t.reviewHint && t.reviewHint.reasons) || []).join('\n'), style: { fontSize: 9, padding: '0 4px', borderRadius: 2, background: 'color-mix(in srgb, ' + C.warn + ' 22%, transparent)', color: C.warn, fontWeight: 600, whiteSpace: 'nowrap', flexShrink: 0 } }, '👁 建议过目') : null,
846
863
  t.verification ? React.createElement('span', { title: '验收结论: ' + (t.verification.summary || '').slice(0, 100), style: { fontSize: 9, padding: '1px 5px', borderRadius: 3, background: t.verification.verdict === 'approved' ? C.ok : C.err, color: C_INV } }, t.verification.verdict === 'approved' ? '过' : '驳') : null,
847
864
  React.createElement('span', { style: { fontSize: 9, color: C.text2 } }, t.archivedAt ? ago(t.archivedAt) : '')),
848
865
  t.verification && t.verification.summary ? React.createElement('div', { style: { fontSize: 10, color: C.text2, marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, '📝 ' + t.verification.summary) : null,
849
866
  React.createElement('div', { style: { display: 'flex', gap: 5, marginTop: 4, flexWrap: 'wrap' } },
850
- React.createElement('button', { onClick: function () { restore(t.id) }, title: '恢复为待办', style: { fontSize: 9, padding: '2px 7px', border: '1px solid ' + C.brand, borderRadius: 3, background: 'transparent', color: C.brand, cursor: 'pointer' } }, '↩ 恢复待办'),
867
+ React.createElement('button', { onClick: function (e) { e.stopPropagation(); restore(t.id) }, title: wm === 'list' ? '恢复为待办' : '恢复为待办(将按当前工作模式立即重新派发,消耗一轮 Worker+Verifier token)', style: { fontSize: 9, padding: '2px 7px', border: '1px solid ' + C.brand, borderRadius: 3, background: 'transparent', color: C.brand, cursor: 'pointer' } }, '↩ 恢复待办'),
851
868
  (function () {
852
869
  var runs = historyRuns(t)
853
870
  if (!runs.length) return React.createElement('span', { style: { fontSize: 9, color: C.text2, opacity: 0.6, padding: '2px 0' } }, '无历史会话')
@@ -856,7 +873,7 @@ function apply(ctx) {
856
873
  var seq = r.role === 'verifier' ? (++vN) : (++wN)
857
874
  var isV = r.role === 'verifier'
858
875
  return React.createElement('button', {
859
- key: i, onClick: function () { if (uiWorkspaceSvc) uiWorkspaceSvc.openSession(r.id) },
876
+ key: i, onClick: function (e) { e.stopPropagation(); if (uiWorkspaceSvc) uiWorkspaceSvc.openSession(r.id) },
860
877
  title: (isV ? 'Verifier' : 'Worker') + ' 第 ' + seq + ' 次' + (r.at ? ' · ' + ago(r.at) : '') + (r.model ? ' · ' + r.model : '') + '(' + r.id + ')',
861
878
  style: { fontSize: 9, padding: '2px 7px', border: '1px solid ' + (isV ? C.warn : C.brand), borderRadius: 3, background: 'transparent', color: isV ? C.warn : C.brand, cursor: 'pointer' }
862
879
  }, '→ ' + (isV ? 'V' : 'W') + '#' + seq)
@@ -877,6 +894,7 @@ function apply(ctx) {
877
894
  var _tc = useState(''), touchesRaw = _tc[0], setTouchesRaw = _tc[1]
878
895
  var _dp = useState({}), depSel = _dp[0], setDepSel = _dp[1]
879
896
  var _ac = useState(''), acc = _ac[0], setAcc = _ac[1]
897
+ var _tg = useState(''), tagsRaw = _tg[0], setTagsRaw = _tg[1]
880
898
  var _dr = useState(wm === 'team'), asDraft = _dr[0], setAsDraft = _dr[1]
881
899
  var _bs = useState(false), busy = _bs[0], setBusy = _bs[1]
882
900
  var _er = useState(''), err = _er[0], setErr = _er[1]
@@ -891,9 +909,10 @@ function apply(ctx) {
891
909
  var t = title.trim()
892
910
  if (!t) { setErr('⚠️ 请先填写标题'); return }
893
911
  var touches = touchesRaw.split(/[\n,,;;]/).map(function (s) { return s.trim() }).filter(function (s) { return s.length > 0 })
912
+ var tags = tagsRaw.split(/[,,]/).map(function (s) { return s.trim() }).filter(function (s) { return s.length > 0 })
894
913
  var deps = cands.filter(function (x) { return depSel[x.id] }).map(function (x) { return x.id })
895
914
  setBusy(true); setErr(''); setOkMsg(''); setWarn('')
896
- rpc('create-task', { title: t, description: desc, priority: prio, pipeline: pipe, touches: touches, dependsOn: deps, acceptance: acc.trim(), draft: asDraft }).then(function (r) {
915
+ rpc('create-task', { title: t, description: desc, priority: prio, pipeline: pipe, touches: touches, tags: tags, dependsOn: deps, acceptance: acc.trim(), draft: asDraft }).then(function (r) {
897
916
  setBusy(false)
898
917
  if (r && r.ok === false) { setErr('⚠️ ' + (r.error || '创建失败')); return }
899
918
  fetchTasks()
@@ -929,6 +948,7 @@ function apply(ctx) {
929
948
  React.createElement('option', { value: 'full' }, '全流程(执行+验证)'),
930
949
  React.createElement('option', { value: 'work' }, '免验证(只做不验)'),
931
950
  React.createElement('option', { value: 'direct' }, '主窗口处理')), '管线 = 任务走哪种流程:全流程 = Worker 做 + Verifier 验;免验证 = 只做不验;主窗口处理 = 你在对话里直接办'))),
951
+ field('标签(逗号分隔,可空)', React.createElement('input', { value: tagsRaw, onChange: function (e) { setTagsRaw(e.target.value) }, placeholder: '如 bug, epic, 待验收', style: inp }), '标签 = 用于筛选的逗号分隔关键字(自动 trim + 去空)'),
932
952
  field('touches(文件/glob,逗号或换行分隔,可空)', React.createElement('input', { value: touchesRaw, onChange: function (e) { setTouchesRaw(e.target.value) }, placeholder: 'src/a.mjs, src/**', style: inp }), 'touches = 本卡要改动的文件(glob 匹配);声明后并行任务间防互踩(文件锁)'),
933
953
  field('依赖(全部完成后才派发,可空)', cands.length === 0
934
954
  ? React.createElement('div', { style: { fontSize: 10, color: C.text2 } }, '当前无草稿/待办任务可选')
@@ -1172,11 +1192,79 @@ function apply(ctx) {
1172
1192
  React.createElement('div', { style: { fontSize: 9, color: C.text2, marginTop: 4, lineHeight: 1.6 } }, '每个点位是一次真实 agent 运行(不是声明式命令):prompt 只给薄框架契约与上下文,动作由 hook agent 自行决策,吃不准会歧义上报;失败即转阻塞等人裁决(重试/跳过/放弃),不会自动重跑。清空 prompt 保存 = 撤掉该点位。hooks 仅主窗口可设(host 门禁,失败原因原样回显)。'))
1173
1193
  }
1174
1194
 
1195
+ // ===== 审视摘要区(异常驱动审视①④):详情顶部新块,仅 reviewHint 命中时显示 =====
1196
+ // 四问动线 + 来源徽标,素材全用现有字段纯展示拼装(reviewHint 由 host 现算、get-tasks 透出):
1197
+ // ①为什么在这 → reviewHint.reasons(host 现算中文理由行)
1198
+ // ②改了什么 → deliverable.summary + deliverable.diff(git diff --stat 原文),标「Worker 汇报」来源徽标
1199
+ // ③机器怎么验的 → verification 结论(标「Verifier 实证」)+ 验收脚本 + 驳回/仲裁史(标「系统记录」)
1200
+ // ④怎么亲自确认 → verification.userTest 自测指南复用渲染,缺省显示 tier=internal 提示
1201
+ // 已阅(reviewedAt 非空)仍显示:已阅只收敛列表徽章/chip,详情块不消失——用户要真正过目,不能打开即闪退。
1202
+ function ReviewSummary(props) {
1203
+ var task = props.task
1204
+ var rh = task.reviewHint
1205
+ if (!rh || !(rh.score > 0)) return null
1206
+ function srcBadge(text, color) { return React.createElement('span', { style: { fontSize: 9, padding: '0 5px', borderRadius: 3, background: 'color-mix(in srgb, ' + color + ' 16%, transparent)', color: color, fontWeight: 600, flexShrink: 0 } }, text) }
1207
+ // ③ 驳回/仲裁史:history(歧义/裁决/驳回/干预/rejected)+ messages(rejection/arbitration)
1208
+ var histLines = []
1209
+ if (Array.isArray(task.history)) task.history.forEach(function (h) { if (h && h.note && /歧义|裁决|驳回|干预|rejected/i.test(h.note)) histLines.push('[' + (h.timestamp || '') + '] ' + String(h.note).slice(0, 160)) })
1210
+ if (Array.isArray(task.messages)) task.messages.forEach(function (m) { if (m && (m.kind === 'rejection' || m.kind === 'arbitration')) histLines.push('[' + (m.kind === 'rejection' ? '驳回' : '裁决') + '] ' + String(m.text || '').slice(0, 160)) })
1211
+ histLines = histLines.slice(0, 5)
1212
+ var q3rows = []
1213
+ if (task.verification) q3rows.push(React.createElement('div', { key: 'v', style: { display: 'flex', alignItems: 'flex-start', gap: 4, marginBottom: 2 } }, srcBadge('Verifier 实证', C.ok), React.createElement('span', { style: { fontSize: 11, color: C.text } }, (task.verification.verdict === 'approved' ? '验收通过' : '验收驳回') + (task.verification.summary ? ':' + task.verification.summary : ''))))
1214
+ if (task.acceptance) q3rows.push(React.createElement('div', { key: 'a', style: { display: 'flex', alignItems: 'flex-start', gap: 4, marginBottom: 2 } }, srcBadge('系统记录', C.text2), React.createElement('span', { style: { fontSize: 10, color: C.text2, fontFamily: 'monospace' } }, '验收脚本:' + task.acceptance)))
1215
+ if (histLines.length) q3rows.push(React.createElement('div', { key: 'h', style: { display: 'flex', alignItems: 'flex-start', gap: 4 } }, srcBadge('系统记录', C.text2), React.createElement('span', { style: { fontSize: 10, color: C.text2, whiteSpace: 'pre-wrap', lineHeight: 1.5 } }, '驳回/仲裁史:\n' + histLines.join('\n'))))
1216
+ // ④ 自测指南:复用现有 userTest 渲染口径,缺省 tier=internal 提示
1217
+ var q4
1218
+ var ut = task.verification && task.verification.userTest
1219
+ if (ut) {
1220
+ var tierMeta = ut.tier === 'ui' ? { c: C.ok, label: 'UI 可操作' } : (ut.tier === 'metric' ? { c: C.brand, label: '看指标' } : { c: C.text2, label: '纯内部' })
1221
+ q4 = React.createElement('div', { style: { marginBottom: 6 } },
1222
+ React.createElement('div', { style: { fontSize: 10, fontWeight: 600, color: C.text2, marginBottom: 2, display: 'flex', alignItems: 'center', gap: 4 } }, '④ 怎么亲自确认', srcBadge('自测指南 · ' + tierMeta.label, tierMeta.c)),
1223
+ ut.gist ? React.createElement('div', { style: { fontSize: 11, color: C.text, marginBottom: 2 } }, ut.gist) : null,
1224
+ ut.steps && ut.steps.length ? React.createElement('div', { style: { fontSize: 10, color: C.text } }, ut.steps.map(function (s, i) { return React.createElement('div', { key: i, style: { marginBottom: 1 } }, (i + 1) + '. ' + s) })) : null,
1225
+ ut.expect ? React.createElement('div', { style: { fontSize: 10, color: C.text2 } }, '预期:' + ut.expect) : null)
1226
+ } else {
1227
+ q4 = React.createElement('div', { style: { fontSize: 10, color: C.text2, lineHeight: 1.5 } }, '④ 怎么亲自确认:本卡无用户自测指南(tier=internal / 未附 userTest)——纯内部改动,验证靠测试套件')
1228
+ }
1229
+ return React.createElement('div', { id: 'tskb-review-summary', style: { marginBottom: 8, padding: '8px 10px', border: '1px solid ' + C.warn, borderRadius: 6, background: 'color-mix(in srgb, ' + C.warn + ' 7%, transparent)' } },
1230
+ React.createElement('div', { style: { fontSize: 12, fontWeight: 700, color: C.warn, marginBottom: 6, display: 'flex', alignItems: 'center', gap: 4 } }, ic('alert-triangle', 13), '待你过目 · 命中 ' + rh.score + ' 条风险信号'),
1231
+ React.createElement('div', { style: { marginBottom: 6 } },
1232
+ React.createElement('div', { style: { fontSize: 10, fontWeight: 600, color: C.text2, marginBottom: 2 } }, '① 为什么在这'),
1233
+ (rh.reasons || []).map(function (r, i) { return React.createElement('div', { key: i, style: { fontSize: 11, color: C.text, lineHeight: 1.5 } }, '· ' + r) })),
1234
+ React.createElement('div', { style: { marginBottom: 6 } },
1235
+ React.createElement('div', { style: { fontSize: 10, fontWeight: 600, color: C.text2, marginBottom: 2, display: 'flex', alignItems: 'center', gap: 4 } }, '② 改了什么', srcBadge('Worker 汇报', C.brand)),
1236
+ (task.deliverable && task.deliverable.summary) ? React.createElement('div', { style: { fontSize: 11, color: C.text, whiteSpace: 'pre-wrap', marginBottom: 2 } }, task.deliverable.summary) : React.createElement('div', { style: { fontSize: 10, color: C.text2, marginBottom: 2 } }, '(无开发描述)'),
1237
+ (task.deliverable && task.deliverable.diff) ? React.createElement('div', { style: { fontSize: 10, color: C.text2, whiteSpace: 'pre-wrap', maxHeight: 80, overflowY: 'auto', fontFamily: 'monospace' } }, task.deliverable.diff) : null),
1238
+ q3rows.length ? React.createElement('div', { style: { marginBottom: 6 } },
1239
+ React.createElement('div', { style: { fontSize: 10, fontWeight: 600, color: C.text2, marginBottom: 2 } }, '③ 机器怎么验的'),
1240
+ q3rows) : null,
1241
+ q4)
1242
+ }
1243
+
1244
+ // 标签编辑(巡检三连①):逗号分隔 input,提交走 update-task 的 tags 通道(trim + 去空)
1245
+ function TagsEditor(props) {
1246
+ var task = props.task
1247
+ var _R = React; var useState = _R.useState
1248
+ var _s = useState((task.tags || []).join(', ')), draft = _s[0], setDraft = _s[1]
1249
+ function commit() {
1250
+ var tags = draft.split(/[,,]/).map(function (s) { return s.trim() }).filter(function (s) { return s.length > 0 })
1251
+ rpc('update-task', { taskId: task.id, tags: tags }).then(function () { fetchTasks() }).catch(function () {})
1252
+ }
1253
+ return React.createElement('div', { style: { marginBottom: 8, padding: '6px 8px', border: '1px solid ' + C.border, borderRadius: 6, background: C.card } },
1254
+ React.createElement('div', { style: { fontSize: 11, fontWeight: 600, color: C.text2, marginBottom: 4 } }, '🏷 标签(逗号分隔)'),
1255
+ React.createElement('div', { style: { display: 'flex', gap: 4 } },
1256
+ React.createElement('input', { value: draft, onChange: function (e) { setDraft(e.target.value) }, onKeyDown: function (e) { if (e.key === 'Enter') commit() }, placeholder: '如 bug, epic, 待验收', style: { flex: 1, minWidth: 0, fontSize: 11, padding: '4px 8px', border: '1px solid ' + C.border2, borderRadius: 4, background: C.card, color: C.text, fontFamily: 'inherit', boxSizing: 'border-box' } }),
1257
+ React.createElement('button', { onClick: commit, title: '保存标签(逗号分隔,自动 trim + 去空)', style: { fontSize: 10, padding: '3px 10px', border: 'none', borderRadius: 4, background: C.brand, color: C_INV, cursor: 'pointer', flexShrink: 0 } }, '保存')))
1258
+ }
1259
+
1175
1260
  function DetailView() {
1176
1261
  var _R = React; var useState = _R.useState, useEffect = _R.useEffect; var task = getTask(state.detailId)
1177
1262
  var _a = useState(task ? task.title : ''), editTitle = _a[0], setEditTitle = _a[1]; var _b = useState(task ? task.description || '' : ''), editDesc = _b[0], setEditDesc = _b[1]; var _c = useState(false), saving = _c[0], setSaving = _c[1]; var _d = useState(state.boardMode), mode = _d[0], setMode = _d[1]
1178
1263
  var _e2 = useState(''), arbAnswer = _e2[0], setArbAnswer = _e2[1]; var _f2 = useState(''), interveneMsg = _f2[0], setInterveneMsg = _f2[1]; var _g2 = useState(''), actionMsg = _g2[0], setActionMsg = _g2[1]; var _h2 = useState('resume'), arbAction = _h2[0], setArbAction = _h2[1]
1179
1264
  useEffect(function () { function update() { setMode(state.boardMode) }; listeners.push(update); update(); return function () { var i = listeners.indexOf(update); if (i >= 0) listeners.splice(i, 1) } }, [])
1265
+ // 已阅落账(异常驱动审视①③):打开命中且未阅的详情页即幂等 mark-reviewed——
1266
+ // host 幂等只写一次;本轮渲染仍按「未阅」展示审视摘要区(fetchTasks 回来前不闪退)。
1267
+ useEffect(function () { var t = getTask(state.detailId); if (t && t.reviewHint && t.reviewHint.score > 0 && !t.reviewedAt) { rpc('mark-reviewed', { taskId: t.id }).then(fetchTasks).catch(function () {}) } }, [state.detailId])
1180
1268
  if (!task) { state.detailId = null; return React.createElement('div', { style: { padding: 20, color: C.text2 } }, '任务不存在') }
1181
1269
  function doAction(fn) { fn().then(fetchTasks).catch(function () {}) }
1182
1270
  function saveEdit() { setSaving(true); rpc('update-task', { taskId: task.id, title: editTitle, description: editDesc, resetToPending: true }).then(function () { setSaving(false); fetchTasks() }).catch(function () { setSaving(false) }) }
@@ -1227,7 +1315,9 @@ function apply(ctx) {
1227
1315
  }
1228
1316
  return React.createElement('div', { style: { padding: '4px 2px' } },
1229
1317
  React.createElement('div', { onClick: function () { state.detailId = null; notify() }, style: { fontSize: 11, color: C.brand, cursor: 'pointer', marginBottom: 8 } }, '← 返回看板'),
1230
- React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 6, marginBottom: 8 } }, React.createElement('span', { style: { fontSize: 10, padding: '1px 6px', borderRadius: 3, background: 'color-mix(in srgb, ' + (prioColor[task.priority] || prioColor.low) + ' 20%, transparent)', color: (prioColor[task.priority] || prioColor.low) } }, prioLabel[task.priority] || '中'), 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),
1318
+ React.createElement(ReviewSummary, { task: task }),
1319
+ 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),
1320
+ React.createElement(TagsEditor, { key: task.id, task: task }),
1231
1321
  // 流转到按钮组(键盘可达的状态迁移入口,替代拖拽;无合法迁移的状态(draft/resolved 等)整块不渲染)
1232
1322
  flowBtns.length > 0 ? React.createElement('div', { role: 'group', 'aria-label': '状态流转', style: { display: 'flex', alignItems: 'center', gap: 5, flexWrap: 'wrap', marginBottom: 8, padding: '5px 8px', border: '1px solid ' + C.border, borderRadius: 6, background: C.card } },
1233
1323
  React.createElement('span', { style: { fontSize: 10, fontWeight: 600, color: C.text2 } }, '⇄ 流转到:'),
@@ -1325,6 +1415,14 @@ function apply(ctx) {
1325
1415
  ut.steps && ut.steps.length ? React.createElement('div', { style: { fontSize: 10, color: C.text, marginBottom: 3 } }, ut.steps.map(function (s, i) { return React.createElement('div', { key: i, style: { marginBottom: 1 } }, (i + 1) + '. ' + s) })) : null,
1326
1416
  ut.expect ? React.createElement('div', { style: { fontSize: 10, color: C.text2 } }, '预期:' + ut.expect) : null)
1327
1417
  })() : null) : null,
1418
+ // ===== 迟到 Verifier 结论块(验收时序三洞 ③):verifier 结论落到已落定卡后的留痕展示 =====
1419
+ // 主窗口抢批/归档早于 Verifier 结算时,其独立结论落 t.lateVerdict 而非静默丢弃——此处整块展示
1420
+ // (verdict/summary/checks + note 说明 + 时间来源),与上方正常 verification 块并列可对照。
1421
+ task.lateVerdict ? React.createElement('div', { style: { marginBottom: 8, padding: '6px 8px', border: '1px solid ' + (task.lateVerdict.verdict === 'approved' ? C.ok : C.err), borderRadius: 6, background: 'color-mix(in srgb, ' + (task.lateVerdict.verdict === 'approved' ? C.ok : C.err) + ' 8%, transparent)' } },
1422
+ React.createElement('div', { style: { fontSize: 11, fontWeight: 700, color: task.lateVerdict.verdict === 'approved' ? C.ok : C.err, marginBottom: 4, display: 'flex', alignItems: 'center', gap: 4 } }, ic('alert-triangle', 12), '迟到 Verifier 结论 · ' + (task.lateVerdict.by || '') + ' · ' + ago(task.lateVerdict.at)),
1423
+ React.createElement('div', { style: { fontSize: 10, color: C.text2, marginBottom: 4 } }, String(task.lateVerdict.note || '')),
1424
+ React.createElement('div', { style: { fontSize: 11, color: C.text, marginBottom: 4, whiteSpace: 'pre-wrap' } }, (task.lateVerdict.verdict === 'approved' ? '验收通过' : '验收驳回') + (task.lateVerdict.summary ? ':' + task.lateVerdict.summary : '')),
1425
+ task.lateVerdict.checks ? React.createElement('div', { style: { marginTop: 4 } }, React.createElement('div', { style: { fontSize: 10, fontWeight: 600, color: C.text2 } }, '核对项'), React.createElement('div', { style: { fontSize: 10, color: C.text2, whiteSpace: 'pre-wrap', maxHeight: 120, overflowY: 'auto' } }, task.lateVerdict.checks)) : null) : null,
1328
1426
  React.createElement(MsgThread, { messages: task.messages, taskId: task.id }),
1329
1427
  Array.isArray(task.history) && task.history.length > 0 ? React.createElement('div', { style: { marginBottom: 8 } }, React.createElement('div', { style: { fontSize: 11, fontWeight: 600, color: C.text2, marginBottom: 3 } }, '流转轨迹'), React.createElement('div', { style: { fontSize: 10, color: C.text2, padding: '4px 6px', background: C.nested, borderRadius: 4 } }, task.history.map(function (h, i) { return React.createElement('div', { key: i, style: { marginBottom: 2 } }, React.createElement('span', { style: { color: C.brand } }, statusLabels[h.to] || h.to), ' · ' + ago(h.timestamp) + ' · ', React.createElement(ActorLink, { id: h.actor }), h.note ? ' · ' + h.note : '') }))) : null,
1330
1428
  React.createElement('div', { style: { display: 'flex', gap: 4, flexWrap: 'wrap', marginTop: 6 } },
@@ -1585,7 +1683,7 @@ function apply(ctx) {
1585
1683
  function batchDigest() {
1586
1684
  var now = Date.now(), since = now - 86400000
1587
1685
  var all = (Array.isArray(state.tasks) ? state.tasks : []).concat(Array.isArray(state.archived) ? state.archived : [])
1588
- var resolved = 0, rejected = 0, tokTot = 0, tokEff = 0, timeouts = 0, trendResolved = 0, trendFirstPass = 0
1686
+ var resolved = 0, rejected = 0, tokTot = 0, tokEff = 0, timeouts = 0, trendResolved = 0, trendFirstPass = 0, reviewPending = 0
1589
1687
  function in24(iso) { var d = Date.parse(iso); return isFinite(d) && d >= since && d <= now }
1590
1688
  function pos(v) { var n = Number(v); return isFinite(n) && n > 0 ? n : 0 }
1591
1689
  all.forEach(function (t) {
@@ -1604,13 +1702,15 @@ function apply(ctx) {
1604
1702
  tokTot += tot; tokEff += inp + outp + cw
1605
1703
  if (r.outcome === 'timeout/error' || r.outcome === 'timeout') timeouts++
1606
1704
  })
1705
+ // ===== 未过目计数(异常驱动审视①⑤):命中风险信号且未阅的已完成/归档卡 =====
1706
+ if (reviewPending(t)) reviewPending++
1607
1707
  })
1608
1708
  var ghosts = 0
1609
1709
  var hints = Array.isArray(state.healthHints) ? state.healthHints : []
1610
1710
  hints.forEach(function (h) { var m = h && h.text ? String(h.text).match(/本轮回收 (\d+) 个幽灵活跃表项/) : null; if (m) ghosts += pos(m[1]) })
1611
1711
  var trend = trendResolved > 0 ? ('一次通过率 ' + Math.round(trendFirstPass / trendResolved * 100) + '%(近 24h 完成 ' + trendResolved + ' 张中 ' + trendFirstPass + ' 张零驳回)') : '近 24h 无完成记录,暂无质量趋势'
1612
1712
  var has = resolved > 0 || rejected > 0 || tokTot > 0 || timeouts > 0 || ghosts > 0 || trendResolved > 0
1613
- return { has: has, resolved: resolved, rejected: rejected, tokTot: tokTot, tokEff: tokEff, timeouts: timeouts, ghosts: ghosts, trend: trend }
1713
+ return { has: has, resolved: resolved, rejected: rejected, tokTot: tokTot, tokEff: tokEff, timeouts: timeouts, ghosts: ghosts, trend: trend, reviewPending: reviewPending }
1614
1714
  }
1615
1715
 
1616
1716
  function buildReport() {
@@ -1624,10 +1724,12 @@ function apply(ctx) {
1624
1724
  lines.push('- Token:约 ' + fmtTokens(dg.tokTot) + (dg.tokEff > 0 ? '(其中有效 ' + fmtTokens(dg.tokEff) + ')' : ''))
1625
1725
  lines.push('- 异常事件:超时 ' + dg.timeouts + ' 次 · 幽灵回收 ' + dg.ghosts + ' 次')
1626
1726
  lines.push('- 质量趋势:' + dg.trend)
1727
+ lines.push('- 未过目:' + dg.reviewPending + ' 张')
1627
1728
  lines.push('')
1628
1729
  } else {
1629
1730
  lines.push('## 批次摘要(近 24 小时)')
1630
1731
  lines.push('近 24 小时无活动记录(无完成 / 驳回 / 消耗 / 异常事件)')
1732
+ lines.push('- 未过目:' + dg.reviewPending + ' 张')
1631
1733
  lines.push('')
1632
1734
  }
1633
1735
  var groups = { inProgress: [], verifying: [], pending: [], blocked: [], resolved: [] }
@@ -2109,7 +2211,7 @@ function apply(ctx) {
2109
2211
  function HealthHints() {
2110
2212
  var hints = (Array.isArray(state.healthHints) ? state.healthHints : []).filter(function (h) { return h && h.text })
2111
2213
  if (!hints.length) return null
2112
- // 两级配色:warn=⚠️ 琥珀(C.warn);info=ℹ️ 蓝灰(DSH 现有 business 信息色——色板无独立 info 档)
2214
+ // 三级配色:err=🚨 红(C.err,数据安全红线);warn=⚠️ 琥珀(C.warn);info=ℹ️ 蓝灰(DSH 现有 business 信息色)
2113
2215
  var infoColor = 'var(--dsw-alias-state-business-primary)'
2114
2216
  return React.createElement('div', { style: { padding: '8px 10px', background: C.card, border: '1px solid ' + C.border, borderRadius: 6, marginBottom: 12 } },
2115
2217
  React.createElement('div', { style: { fontSize: 11, fontWeight: 600, color: C.text2, marginBottom: 6, display: 'flex', alignItems: 'center', gap: 4 } }, ic('activity', 11), '架构健康'),
@@ -2117,9 +2219,10 @@ function apply(ctx) {
2117
2219
  React.createElement('div', { style: { fontSize: 9, color: C.text2, marginBottom: 5, lineHeight: 1.5 } }, '运行时自检告警,多为自动恢复的瞬时异常。术语:settle = 任务结算通道;touches = 任务声明的改动文件(文件锁);p90 = 执行时长第 90 百分位'),
2118
2220
  hints.map(function (h, i) {
2119
2221
  var warn = h.level === 'warn'
2120
- var col = warn ? C.warn : infoColor
2222
+ var err = h.level === 'err'
2223
+ var col = err ? C.err : (warn ? C.warn : infoColor)
2121
2224
  return React.createElement('div', { key: i, style: { display: 'flex', alignItems: 'baseline', gap: 5, fontSize: 10, lineHeight: 1.5, color: col, marginBottom: 3 } },
2122
- React.createElement('span', { style: { flexShrink: 0 } }, warn ? '⚠️' : 'ℹ️'),
2225
+ React.createElement('span', { style: { flexShrink: 0 } }, err ? '🚨' : (warn ? '⚠️' : 'ℹ️')),
2123
2226
  React.createElement('span', null, String(h.text)))
2124
2227
  }))
2125
2228
  }
package/lib/core.mjs CHANGED
@@ -571,6 +571,28 @@ export function pushRejection(t, summary, checks, at, by) {
571
571
  return true
572
572
  }
573
573
 
574
+ // ===== 迟到 verdict 落账(验收时序三洞 ③,用户 n-mv0ebretgv1o)=====
575
+ // 事故:主窗口在 Verifier 活跃期 task_verify 抢批(早 0.4s)+ 归档,Verifier 的独立结论与自测
576
+ // 指南被静默吞掉、无回执。修法:verifier 结论(工具通道 board_verdict / 文本通道 settleVerifier)
577
+ // 落到已 resolved/archived/cancelled 卡时不再静默丢弃——落 t.lateVerdict 留痕。
578
+ // 纯函数(零 IO):写 t.lateVerdict;userTest 若开关开且解析得出 → 挂 t.lateVerdict.userTest,
579
+ // 并补挂 t.verification.userTest(原 verification 缺 userTest 时;主窗口 task_verify approved 不写
580
+ // t.verification,此时用迟到结论的 verdict/summary/checks 兜底建 verification,避免详情页渲染空验收块)。
581
+ // 返回 { lateRejected, wasApproved }:调用方据此决定要不要发 err 级通知 + healthHint
582
+ // (I/O 留在调用方,core 保持零依赖)。wasApproved 必须先算——下方可能覆写 t.verification。
583
+ export function lateVerdictApply(t, verdict, summary, checks, userTest, at, by, verifyUserGuideOn) {
584
+ var approved = verdict === 'approved'
585
+ var wasApproved = t.status === 'resolved' || (t.status === 'archived' && !!(t.verifiedAt || t.verifiedBy || (t.verification && t.verification.verdict === 'approved')))
586
+ t.lateVerdict = { verdict: verdict, summary: summary || '', checks: checks || '', at: at || new Date().toISOString(), by: by || 'system', note: '迟到结论:卡在落定后收到' }
587
+ var ut = verifyUserGuideOn !== false ? normalizeUserTest(userTest) : null
588
+ if (ut) {
589
+ t.lateVerdict.userTest = ut
590
+ if (!t.verification) t.verification = { verdict: verdict, summary: summary || '', checks: checks || '', at: at || new Date().toISOString(), by: by || 'system' }
591
+ if (!t.verification.userTest) t.verification.userTest = ut
592
+ }
593
+ return { lateRejected: !approved, wasApproved: wasApproved }
594
+ }
595
+
574
596
  // ===== 一次性子代理 prompt 构建(上下文由主窗口 agent 写入 description/instructions,系统只追加生命周期记录)=====
575
597
  // 学习飞轮 v1 软召回引导(feedbackEnabled 开时才拼进 prompt):环境里若有笔记/记忆类工具,先查历史教训再动手。
576
598
  // 只是"提示先搜"——看板不代查、不调用任何记忆工具、也无从知道有没有这类工具(零耦合)。
@@ -1104,7 +1126,10 @@ export function tasksHash(tasks) {
1104
1126
  Array.isArray(t.tags) ? t.tags.join(',') : '',
1105
1127
  t.claimedBy, t.claimedAt, t.resolvedAt, t.verifiedAt, t.archivedAt,
1106
1128
  t.lastError, t.lastProgress && t.lastProgress.text,
1107
- t.frozen ? '1' : '', t.stuckSince, t.escalation && t.escalation.question
1129
+ t.frozen ? '1' : '', t.stuckSince, t.escalation && t.escalation.question,
1130
+ t.reviewedAt,
1131
+ t.reviewHint ? String(t.reviewHint.score) + ':' + (Array.isArray(t.reviewHint.reasons) ? t.reviewHint.reasons.join('|') : '') : '',
1132
+ t.lateVerdict ? (t.lateVerdict.verdict + ':' + t.lateVerdict.at) : ''
1108
1133
  ].join('\u0001'))
1109
1134
  }
1110
1135
  var s = parts.join('\u0002')
@@ -1112,3 +1137,80 @@ export function tasksHash(tasks) {
1112
1137
  for (var j = 0; j < s.length; j++) h = (((h << 5) + h) ^ s.charCodeAt(j)) >>> 0
1113
1138
  return h.toString(36)
1114
1139
  }
1140
+
1141
+ // ===== 风险队列「待你过目」:reviewHint 现算(异常驱动审视①,task-muzikj1y)=====
1142
+ // 纯函数零 IO 零落库:唯一会落库的新字段是 reviewedAt(rpc.mjs mark-reviewed 落),
1143
+ // reviewHint 每轮 get-tasks 现算(四硬信号恒计入 + 两软信号仅质量异动告警激活期间计入)。
1144
+ // 信号口径:
1145
+ // 硬① 被驳回过:rejectCount>0 或 verification.verdict='rejected' 或 messages 有 rejection 包 或 runs 含 rejected 结局
1146
+ // 硬② 上报过歧义:messages 有 arbitration 裁决包
1147
+ // 硬③ touches 碰核心文件:任一条 touches 的 basename ∈ {dispatch.mjs, rpc.mjs, core.mjs}
1148
+ // 硬④ full 无 acceptance:pipeline=full 且无硬性验收脚本
1149
+ // 软⑤ diff>300 行:deliverable.diff(git diff --stat)解析出的总增删行数 >300(仅告警态)
1150
+ // 软⑥ 续跑≥2 次:runs 中 resume:true 条目数 ≥2(仅告警态)
1151
+ export var REVIEW_CORE_FILES = ['dispatch.mjs', 'rpc.mjs', 'core.mjs']
1152
+ export var REVIEW_DIFF_LINES = 300
1153
+ export var REVIEW_RESUME_MIN = 2
1154
+ function reviewBaseName(p) { var s = String(p == null ? '' : p); var i = Math.max(s.lastIndexOf('/'), s.lastIndexOf('\\')); return i >= 0 ? s.slice(i + 1) : s }
1155
+ function reviewTouchesCore(touches) {
1156
+ var list = Array.isArray(touches) ? touches : []
1157
+ for (var i = 0; i < list.length; i++) { if (REVIEW_CORE_FILES.indexOf(reviewBaseName(list[i])) >= 0) return true }
1158
+ return false
1159
+ }
1160
+ function reviewWasRejected(t) {
1161
+ if (!t) return false
1162
+ if ((t.rejectCount || 0) > 0) return true
1163
+ if (t.verification && t.verification.verdict === 'rejected') return true
1164
+ if (Array.isArray(t.messages)) { for (var i = 0; i < t.messages.length; i++) { var m = t.messages[i]; if (m && m.kind === 'rejection') return true } }
1165
+ if (Array.isArray(t.runs)) { for (var j = 0; j < t.runs.length; j++) { var r = t.runs[j]; if (r && r.outcome === 'rejected') return true } }
1166
+ return false
1167
+ }
1168
+ function reviewHasArbitration(t) {
1169
+ if (!t || !Array.isArray(t.messages)) return false
1170
+ for (var i = 0; i < t.messages.length; i++) { var m = t.messages[i]; if (m && m.kind === 'arbitration') return true }
1171
+ return false
1172
+ }
1173
+ // diffStat 解析:优先读汇总行「N files changed, X insertions(+), Y deletions(-)」的增删合计;
1174
+ // 无汇总行回退按每文件「 | N 」改动数累加(git diff --stat 每行路径 + 改动数)。解析不出返回 0(不计软信号)。
1175
+ function reviewDiffLines(t) {
1176
+ var text = t && t.deliverable && t.deliverable.diff ? String(t.deliverable.diff) : ''
1177
+ if (!text) return 0
1178
+ var mIns = text.match(/(\d+)\s+insertions?\(\+\)/)
1179
+ var mDel = text.match(/(\d+)\s+deletions?\(-\)/)
1180
+ var ins = mIns ? (parseInt(mIns[1], 10) || 0) : 0
1181
+ var del = mDel ? (parseInt(mDel[1], 10) || 0) : 0
1182
+ if (ins || del) return ins + del
1183
+ var total = 0
1184
+ var re = /\|\s*(\d+)\s/g
1185
+ var mm
1186
+ while ((mm = re.exec(text)) !== null) total += parseInt(mm[1], 10) || 0
1187
+ return total
1188
+ }
1189
+ function reviewResumeCount(t) {
1190
+ if (!t || !Array.isArray(t.runs)) return 0
1191
+ var n = 0
1192
+ for (var i = 0; i < t.runs.length; i++) { var r = t.runs[i]; if (r && r.resume === true) n++ }
1193
+ return n
1194
+ }
1195
+ // 返回 { [taskId]: { score, reasons[] } }(含 score=0 的空 reasons 条目,调用方按需透出)。
1196
+ // opts.qualityAlertActive = 质量异动告警黄条激活态(读 qualityChangeHints 输出非空),软信号仅此时计入。
1197
+ export function computeReviewHints(tasks, opts) {
1198
+ var list = Array.isArray(tasks) ? tasks : []
1199
+ var alertActive = !!(opts && opts.qualityAlertActive)
1200
+ var out = {}
1201
+ for (var i = 0; i < list.length; i++) {
1202
+ var t = list[i]
1203
+ if (!t || !t.id) continue
1204
+ var reasons = []
1205
+ if (reviewWasRejected(t)) reasons.push('曾被 Verifier 驳回(验收判 reject)')
1206
+ if (reviewHasArbitration(t)) reasons.push('执行中上报过歧义(待主窗口裁决)')
1207
+ if (reviewTouchesCore(t.touches)) reasons.push('touches 触碰核心文件(dispatch/rpc/core)')
1208
+ if ((t.pipeline || 'full') === 'full' && !(t.acceptance && String(t.acceptance).trim())) reasons.push('全流程任务缺硬性验收脚本(acceptance)')
1209
+ if (alertActive) {
1210
+ if (reviewDiffLines(t) > REVIEW_DIFF_LINES) reasons.push('改动规模大(diff 超 ' + REVIEW_DIFF_LINES + ' 行)')
1211
+ if (reviewResumeCount(t) >= REVIEW_RESUME_MIN) reasons.push('续跑 ≥' + REVIEW_RESUME_MIN + ' 次(冷复活重试偏多)')
1212
+ }
1213
+ out[t.id] = { score: reasons.length, reasons: reasons }
1214
+ }
1215
+ return out
1216
+ }
package/lib/dispatch.mjs CHANGED
@@ -7,7 +7,7 @@ import { statSync } from 'node:fs'
7
7
  import * as core from './core.mjs'
8
8
  import { readRunUsage, findRunLog, readLogBytes, readLogFrames } from './usage.mjs'
9
9
  import { splitRuleOf, pushRejectLesson } from './policy.mjs'
10
- const { ah, cfg, claimApply, resolveApply, verifyApply, parseSections, outputText, pickDispatch, isOrphan, buildWorkerPrompt, buildVerifierPrompt, buildContextPackSection, parseContextFileEntry, parentKickOnDispatch, LESSON_RECALL_HINT, buildHookPrompt, applyHookSettle, hookOn, hookSetState, gsb, pushRejection, VERIFIER_PERSONA, VERIFIER_TOOL_FILTER } = core
10
+ const { ah, cfg, claimApply, resolveApply, verifyApply, lateVerdictApply, parseSections, outputText, pickDispatch, isOrphan, buildWorkerPrompt, buildVerifierPrompt, buildContextPackSection, parseContextFileEntry, parentKickOnDispatch, LESSON_RECALL_HINT, buildHookPrompt, applyHookSettle, hookOn, hookSetState, gsb, pushRejection, VERIFIER_PERSONA, VERIFIER_TOOL_FILTER } = core
11
11
 
12
12
  export function createDispatch(ctx, state, deps) {
13
13
  // 宿主 fs 句柄与「会话工作区解析根」随预研注入瘦身退役(task-muvjs392):派发侧不再读盘——
@@ -22,6 +22,7 @@ export function createDispatch(ctx, state, deps) {
22
22
  // Team 提示词组装是同步函数,只能读缓存,不能读盘。
23
23
  var epicSplitOn = deps.epicSplitOn
24
24
  var pushSysNote = deps.pushSysNote, maybeNotify = deps.maybeNotify, notifyTaskDone = deps.notifyTaskDone
25
+ var notifyLateReject = (typeof deps.notifyLateReject === 'function') ? deps.notifyLateReject : null // 迟到驳回 err 级通知(未接线 → 静默跳过)
25
26
  // 派发即回执:spawn 成功后入 45s 聚合队列的「🚀 已派发」区(老 host 未注入 → 静默跳过)
26
27
  var notifyDispatched = deps.notifyDispatched
27
28
  // 共享状态别名(本体由 index.mjs apply 统一构建并逐模块注入)
@@ -805,7 +806,22 @@ export function createDispatch(ctx, state, deps) {
805
806
  doneOn = cfg(d).notifyDone !== false
806
807
  var t = d.tasks.find(function (x) { return x.id === rec.taskId })
807
808
  if (!t) return null
808
- if (t.status !== 'verifying' || t.escalation) return { task: t, already: true } // 工具通道已处理
809
+ if (t.escalation) return { task: t, already: true }
810
+ if (t.status !== 'verifying') {
811
+ // 迟到落账(验收时序三洞 ③):verifier 结论落到已落定卡(主窗口抢批/归档早于本 run 结算)不再静默丢弃。
812
+ // 但「工具通道已处理」= board_verdict 已由本 run 自己落账(t.verification.by === rec.id)→ 保持 already。
813
+ if (t.status === 'resolved' || t.status === 'archived' || t.status === 'cancelled') {
814
+ if (t.verification && String(t.verification.by) === String(rec.id)) return { task: t, already: true }
815
+ var ltrim = (output || '').trim()
816
+ var lvm = ltrim.match(/^[ \t>*#\-\s]*(APPROVED|REJECTED)\b/im)
817
+ if (failed || !lvm) return { task: t, already: true } // 无有效结论,不落 late
818
+ var lapproved = lvm[1].toUpperCase() === 'APPROVED'
819
+ var lsecs = parseSections(ltrim)
820
+ var lr = lateVerdictApply(t, lapproved ? 'approved' : 'rejected', lsecs.verifySummary || ltrim.slice(0, 600), lsecs.checks || '', lsecs.userTest, new Date().toISOString(), String(rec.id), cfg(d).verifyUserGuide)
821
+ return { task: t, late: true, lateRejected: lr.lateRejected && lr.wasApproved }
822
+ }
823
+ return { task: t, already: true }
824
+ }
809
825
  var trimmed = (output || '').trim()
810
826
  var vm = trimmed.match(/^[ \t>*#\-\s]*(APPROVED|REJECTED)\b/im)
811
827
  if (failed || !vm) {
@@ -841,6 +857,14 @@ export function createDispatch(ctx, state, deps) {
841
857
  if (!result) return
842
858
  // already=true:工具通道(board_verdict)已推进状态并已发回执,settle 只负责 dispose,不再重复通知
843
859
  if (result.already) return
860
+ // 迟到驳回(验收时序三洞 ③):verdict=rejected 且卡已 approved → err 级通知 + healthHint
861
+ if (result.late) {
862
+ if (result.lateRejected) {
863
+ if (notifyLateReject) notifyLateReject(sid, result.task)
864
+ poolHealthFor(sid).lateRejectNote = { taskId: result.task.id, at: Date.now() }
865
+ }
866
+ return
867
+ }
844
868
  if (result.escalated) maybeNotify(sid, result.task)
845
869
  if (doneOn && result.task && result.task.status === 'resolved') notifyTaskDone(sid, result.task, 'resolved')
846
870
  if (doneOn && result.task && result.task.status === 'blocked') notifyTaskDone(sid, result.task, 'blocked')
package/lib/health.mjs CHANGED
@@ -194,6 +194,28 @@ export function computeRuntimeHealthHints(board, rt, opts) {
194
194
  var bornAt = typeof rt.bornAt === 'number' ? rt.bornAt : now
195
195
  var tasks = board.tasks
196
196
 
197
+ // --- ④ 看板数据文件腐坏/抢救可见化(task-mv0bl9vg ②③)---
198
+ // store.recoverCorrupt 隔离/抢救时写 rt.corruptNote({ at, salvage, kept, file }),这里现算透出
199
+ // err 级 hint(客户端红色档,区别于 warn/info)。数据安全红线信号:sticky 不消费、不 TTL——
200
+ // 只要板还处于腐坏态就持续亮(poolHealth 纯内存,host 重启自然清)。level='err' 是通道新档,
201
+ // 客户端按非 warn/info 渲染红色;老客户端缺省降级为 info 色,信号仍可见。
202
+ var cn = rt.corruptNote
203
+ if (cn && typeof cn === 'object') {
204
+ var cnText = cn.salvage
205
+ ? '🔴 看板数据文件腐坏,已自动抢救保留 ' + (cn.kept || 0) + ' 张卡(丢弃撕裂残片),原始文件留档 ' + (cn.file || '') + ',可联系恢复'
206
+ : '🔴 看板数据文件腐坏已隔离,历史在 ' + (cn.file || '') + ',可联系恢复'
207
+ out.hints.push({ level: 'err', text: cnText })
208
+ }
209
+
210
+ // --- ⑤ 迟到驳回可见化(验收时序三洞 ③,用户 n-mv0ebretgv1o)---
211
+ // 主窗口 force 抢批后 Verifier 迟到驳回:err 级 hint(sticky 不 TTL——host 重启才清),
212
+ // 与 corruptNote 同款「进行中的事故优先」语义。rt.lateRejectNote 由 board_verdict/settleVerifier
213
+ // 迟到落账路径写入(dispatch.mjs poolHealthFor / rpc.mjs state.poolHealth)。
214
+ var lr = rt.lateRejectNote
215
+ if (lr && typeof lr === 'object' && lr.taskId) {
216
+ out.hints.push({ level: 'err', text: '迟到驳回:' + String(lr.taskId) + ' 你批早了但 Verifier 验出红的' })
217
+ }
218
+
197
219
  if (alive) {
198
220
  // --- ① 派发循环心跳:有可派卡 + 池有空位 + 上次成功轮 >5min ---
199
221
  // poolLastOkAt 缺失时退到 bornAt(首次见到本板的时刻):host 重启后心跳全断也能在 5min 宽限后亮条,
package/lib/notify.mjs CHANGED
@@ -183,5 +183,27 @@ export function createNotify(ctx, state, deps) {
183
183
  } else deliver().catch(function (e) { console.error('[task-board] receipt flush failed:', String(e)) })
184
184
  }
185
185
 
186
- return { maybeNotify: maybeNotify, notifyTaskDone: notifyTaskDone, notifyDispatched: notifyDispatched, pushSysNote: pushSysNote }
186
+ // ===== 看板数据文件腐坏/抢救通知(task-mv0bl9vg ②):P0 数据安全,直投 owner,不走回执聚合 =====
187
+ // store.recoverCorrupt 隔离/抢救时调用(经 storeDeps.notifyBoardCorrupt 晚绑定注入)。
188
+ // 与歧义裁决通知同款:任何模式都投、不读回执开关、直接 followup——数据可能丢了必须立刻提醒,
189
+ // 不能等 45s 聚合窗口、更不能被「回执已关」吞掉(回执开关只闸派发/完成两条回执)。
190
+ function notifyBoardCorrupt(sid, text) {
191
+ var root = rootForSession(sid)
192
+ if (!root) return
193
+ try { root.followup(makeMsg('🚨 [任务看板] ' + text + '\n\n历史看板数据未丢,可从留档文件人工恢复;如需协助请告知。', 'notice', '看板数据文件腐坏')) } catch (e) { console.error('[task-board] board corrupt notify failed:', String(e)) }
194
+ }
195
+
196
+ // ===== 迟到驳回通知(验收时序三洞 ③,用户 n-mv0ebretgv1o):err 级,回执直投 owner,不走开关/聚合 =====
197
+ // 主窗口抢批(force approve)后 Verifier 迟到驳回——「你批早了但验收是红的」是 P1 信号,必须立刻直达
198
+ // owner:不进 45s 回执聚合、不被 notifyDone 开关吞掉(与 notifyBoardCorrupt 同一纪律)。
199
+ function notifyLateReject(sid, t) {
200
+ var root = rootForSession(sid)
201
+ if (!root) return
202
+ var lv = (t && t.lateVerdict) || {}
203
+ try {
204
+ root.followup(makeMsg('🚨 [任务看板] 迟到驳回:' + (t.id || '') + ' 你批早了但 Verifier 验出红的\n\n任务: ' + String(t.title || '') + ' (' + (t.id || '') + ')\n\nVerifier 结论(迟到): ' + String(lv.summary || '(无)') + '\n核对项: ' + String(lv.checks || '(无)') + '\n\n详情页已展示「迟到 Verifier 结论」块;如需据此返工,请把任务驳回重投或人工处置。', 'notice', '迟到驳回: ' + (t.id || '')))
205
+ } catch (e) { console.error('[task-board] late reject notify failed:', String(e)) }
206
+ }
207
+
208
+ return { maybeNotify: maybeNotify, notifyTaskDone: notifyTaskDone, notifyDispatched: notifyDispatched, pushSysNote: pushSysNote, notifyBoardCorrupt: notifyBoardCorrupt, notifyLateReject: notifyLateReject }
187
209
  }