dsh-agent-board 1.7.6 → 1.7.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,7 +6,25 @@
6
6
  [![license](https://img.shields.io/npm/l/dsh-agent-board)](https://github.com/PPawnsir/task-board-plugin/blob/main/LICENSE)
7
7
  ![category](https://img.shields.io/badge/awesome--dsh--plugin-workflow-blue)
8
8
 
9
- 智能看板插件 — Agent 自主任务驱动开发:看板管理 + 一次性 Worker/Verifier 派发 + 依赖调度 + Team 模式。
9
+ 智能看板插件 — agent 团队的持久台账与治理层:把「派发 → 执行 → 验收 → 度量 → 复盘」跑成全链路闭环,每一张卡都是可审计、可度量的长期资产。
10
+
11
+ ## 定位
12
+
13
+ **dsh-agent-board 是 agent 团队的持久台账与治理层——看板只是界面,内核是「派发 → 执行 → 验收 → 度量 → 复盘」的全链路闭环。** 它与「一次性组队、跑完即散」的冲锋型编排器、「人建卡、agent 认领、人逐张验收」的手工看板不是一个代际:
14
+
15
+ - **持久台账**:任务卡是长期资产。完成 / 驳回 / 歧义裁决 / 验收结论 / Token 消耗 / 自测指南,每一笔都落账留痕,看板文件即审计底稿;卸载不删数据,重启自动继承。
16
+ - **池化 Worker + 独立 Verifier**:执行与验收分离——Worker 负责动手(可续跑,续跑只认新增产出),Verifier 独立复跑验收脚本、以实证为准,不听 Worker 的一面之词。
17
+ - **独立验收**:`acceptance` 硬性脚本命令由 Worker 实跑、Verifier 独立复跑;结论分通过 / 驳回并逐条给证据。人只看该看的——风险队列「待你过目」把该亲自确认的卡推到眼前,其余交给机器闭环。
18
+ - **可运维度量栈**:记分卡(模型表现 / 质量趋势 / 驳回聚类)、运行时健康自检、异常驱动审视(风险队列 / 质量异动告警 / 批次摘要)三层,让成本与质量从「感觉」变成「数字」。
19
+
20
+ > **流水线归机器,审计台归人。** 我们不造「跑完就散」的冲锋队,也不做「人逐张盯着验收」的手工看板;我们的信念只有三条:**验收独立复跑、人只看该看的、数据沉淀成资产**。别人负责冲锋,agent-board 负责记账与验收——互补,不对抗。
21
+
22
+ ### 适合谁 / 什么时候用
23
+
24
+ - **长期多任务并行**:一个会话里几十张卡、有依赖有先后、还不断追加新需求——需要一份不随会话结束而消失的台账,而不是每次重开都从零描述。
25
+ - **关心成本与质量趋势**:想看清哪个模型在什么场景更省、驳回率在涨还是跌、哪些文件反复成为锁热点——需要记分卡与趋势,而不是拍脑袋。
26
+ - **不想逐张审卡**:接受「独立复跑 + 风险队列兜底」的治理方式——机器把「该我亲自看」的卡筛出来,其余信任验收闭环。
27
+ - **缺一层治理而非缺一个编排器**:已有冲锋型的团队 / 编排工具,缺的正是「记账、验收、度量、复盘」这一层——agent-board 补上它。
10
28
 
11
29
  ## 安装
12
30
 
@@ -85,8 +103,27 @@ dsh plugin --profile web remove dsh-agent-board
85
103
  > - **重启继承**:DSH 重启后同一会话的根 id 可能漂移,此时新 id 没有对应文件——若同工作区(`ownerCwd` 严格相等)存在**唯一**「原主已不在 `agents.roots()`」的看板,则自动继承:文件重命名为新 id、文件内 `ownerSession` 改写为新 id、`console.error` 留一行 `[task-board] 继承看板 <旧sid> → <新sid>`;**多个候选一律不自动接管**(记一行日志后按空板处理,防误合并,旧板仍可在 `list-boards` 全局视图里看到)。
86
104
  > - **写盘保护**:落盘走 临时文件+rename 原子写(EPERM/EBUSY 退避重试);瞬时读失败/坏文件隔离后返回的空板**禁止回写**(防一个"读不到"的瞬间把看板覆成空板),坏文件隔离为 `.corrupt-<时间戳>` 留档不丢数据。
87
105
 
106
+ ## 数据安全
107
+
108
+ 看板数据(`~/.dsh/tasks-<sessionId>.json`)是本插件唯一的持久化状态,按三层防线保证数据不丢、事故可见:
109
+
110
+ - **原子写盘(防撕裂写)**:落盘一律「写临时文件 → `fsync` → `rename` 覆盖目标」——`rename` 在同分区是原子的,进程在写盘中途被强杀时磁盘上最多残留一个 `.tmp` 残件,看板本体永远不会是截断的半个 JSON。Windows 上 `rename` 覆盖已存在目标原生支持(实测直接覆盖成功),仅当目标被并发读句柄/Defender/索引器瞬时占用(`EPERM`/`EBUSY`/`EACCES`/`ENOTEMPTY`)时做有限退避重试;重试耗尽仍失败则降级「先 `unlink` 目标再 `rename`」并留日志——绝不丢整板。
111
+ - **腐坏隔离可见化(不再静默)**:加载时若 JSON 解析失败(撕裂写/重启打断导致文件中部夹杂另一版本残片),坏文件改名 `.corrupt-<时间戳>` 留档隔离(防反复 poison),同时在「架构健康」区亮一条 **err 级红条**(`healthHints`)提示「看板数据文件腐坏已隔离,历史在 `.corrupt-<ts>`,可联系恢复」,并向 owner 会话推送通知——数据不丢,也不再静默从空板重启。
112
+ - **自动 salvage(截断抢救)**:隔离前先尝试抢救——从首字节起截到第一个完整 JSON 值(`JSON.parse` 报错 `position` 截断法,与人工恢复同款手法),成功则以抢救出的完整板为底自动写回继续(`healthHints` 红条记「已抢救保留 N 张卡,丢弃撕裂残片」),失败才回退空板。两种情况下原始文件都留档 `.corrupt-<ts>`,可随时人工恢复。
113
+
88
114
  ## 功能总览
89
115
 
116
+ **核心能力一页速览**(`★` 为 v1.7.5–1.7.7 新栈):
117
+
118
+ - **看板**:六列状态流、拖拽 / 多选批量 / 文本·优先级·标签筛选、归档留档、会话隔离
119
+ - **派发**:一次性 Worker / 独立 Verifier、依赖调度(DFS 环检测)、touches 文件级排他、可续跑 Worker、Team 托管
120
+ - **验收治理** `★`:硬性验收脚本独立复跑、驳回全量带回重派、Verifier 自测指南(userTest)、风险队列「待你过目」、验收时序防护(Verifier 在场时主窗口抢批需显式 `force:true`;有活跃 run 的卡禁止归档;Verifier 迟到结论落 `lateVerdict` 留痕+迟到驳回直投告警,不再静默吞掉)
121
+ - **度量** `★`:Token 消耗(总量 / 有效 / 缓存读,按模型·按任务·按日)+ 记分卡(模型表现 / 质量趋势 / 驳回聚类)
122
+ - **可运维性** `★`:运行时健康自检(派发冻结 / 结算停滞告警)、异常驱动审视(风险队列 / 质量异动告警 / 批次摘要)、起草 lint、数据安全(原子写盘 / 腐坏隔离 / 自动 salvage)
123
+ - **学习反馈**:候选教训信号 → 主窗口沉淀(零耦合,不碰任何笔记 / 记忆工具 API)
124
+
125
+ 以下各节是完整规格。
126
+
90
127
  ### 看板 UI
91
128
 
92
129
  - 会话标题栏「智能看板」按钮 → 顶部抽屉面板(看板 / 团队 / 仪表盘三视图)
@@ -116,6 +153,7 @@ dsh plugin --profile web remove dsh-agent-board
116
153
  - **「驳回聚类」区**(记分卡卡5,`task-muxhu1zv`):质量趋势区下方新增**驳回原因 Top3**——host 对验收驳回文本做规则法关键词聚类(不上 embedding:驳回文本结构模板化;首命中归类,占比分母=驳回总数,未匹配进「其他」兜底桶;按驳回消息自身本地日落点随统计范围裁剪),每行 TopN 类目 + 占比 + 代表原文(行 title 悬浮,各截 80 字)+ **「建议沉淀为约定」结构化文本**(形如「驳回 Top1『验收脚本未实跑』占 42%——建议在派发约定/Worker prompt 补一条:…」,一键复制按钮,沉淀动作人工);挂 `usageSummary.scoreboard.rejectionClusters`,老 host 缺字段整块不渲染,无驳回显空态。同一通道的**「按表现荐模型」hint**(卡5③):某模型在中小卡 worker 桶合并后 runs≥10 且一次通过率≥90% 且有效均值较其他每个合格模型严格低 >30% → 「架构健康」区亮黄条「模型 X 在中小卡表现最优(通过率 92% · 有效均值 0.8M,较其他合格模型低 35%),建议设为默认 workerModel」;无足够数据/无对比对象不亮
117
154
  - 「架构健康」区(架构自省 L1):`get-tasks` 顺带对**近 50 张卡**现算四信号(纯函数零存储:touches 声明热度 ≥8 次且占比 ≥40% / 带 touches 任务滞留中位数 >2 倍 / 任务**执行**时长 p90 >45min(claimedAt→resolvedAt 纯干活口径,不含排队)/ 同路径驳回 ≥2 次),命中才在仪表盘渲染提示条(⚠️/ℹ️ 两级,最多 3 条)——让运行数据主动提示"该优化架构了"(如某文件反复成为锁热点=该拆),信号只建议不裁判;**运行时健康自检**(`task-muxhrkbg`)复用同一 hint 通道——host 侧在 poolCycle 成功轮 / settle 结算 / 幽灵回收三处记内存心跳(`state.poolHealth`,不落盘):有可派卡+有空位却 >5min 无成功派发轮 → 亮「🔴 派发循环疑似冻结」,有在跑卡却 >30min 无任何结算 → 黄级「结算通道长时间无活动」,幽灵表项回收 >0 时留一条一次性 info 记录(90s 保鲜、读到即灭)——运行事故不再只靠 e2e 翻车与肉眼发现;**质量异动告警**(异常驱动审视②):`usageSummary.scoreboard.trends` 环比——近 7 天一次通过率较再前 7 天跌 >10 个百分点(两侧样本各 ≥5 防小样本误报)→ 黄条「质量异动:一次通过率本周 X%(上周 Y%),建议抽查近期验收」,超时率 / 驳回率突变各加一条同构规则(阈值常量置顶,恰好 10pp 不亮、10.1pp 亮)
118
155
  - 报告导出新增「## 批次摘要(近 24 小时)」段(异常驱动审视③):`buildReport` 顶部一页纸——完成 N 张 / 驳回 M 次 / Token 总量(约 X,附有效 Y)/ 异常事件计数(超时 + 幽灵回收,读现有字段与 healthHints)/ 一句话质量趋势;纯聚合现有字段、不新建状态,无数据时优雅空态(一句话「近 24 小时无活动记录」)
156
+ - **风险队列「待你过目」+ 审视摘要区**(异常驱动审视①,`task-muzikj1y`):`get-tasks` 对每张卡现算 `reviewHint={score,reasons[]}`(纯函数 `core.computeReviewHints`,现算零落库)——四条硬信号恒计入:**被驳回过**(`rejectCount>0` 或 `verification.verdict=rejected` 或 messages 有 rejection 包)/ **上报过歧义**(messages 有 arbitration 裁决包)/ **touches 碰核心文件**(dispatch.mjs|rpc.mjs|core.mjs)/ **full 无 acceptance**;两条软信号仅质量异动告警激活期间计入(联动加严):**diff>300 行**(`deliverable.diff` 的 git diff --stat 解析)/ **续跑≥2 次**(`runs` 中 `resume:true` 计数)。已完成/归档的命中卡带「👁 建议过目」徽章(悬浮全部理由行),FilterBar 加 chip「待你过目」一键只看未阅命中卡;打开详情页即幂等落 `reviewedAt`(唯一新落库字段,`mark-reviewed` RPC 只写一次),已阅卡徽章消失、chip 不计;详情顶部新增「审视摘要区」(仅命中时显示)四问动线——①为什么在这(理由行)/ ②改了什么(`deliverable.summary`+diffStat,标「Worker 汇报」来源徽标)/ ③机器怎么验的(`verification` 结论+验收脚本+驳回/仲裁史,标「Verifier 实证」/「系统记录」徽标)/ ④怎么亲自确认(复用 `verification.userTest` 自测指南,缺省显示 tier=internal 提示);报告「批次摘要」段加一行「未过目 N 张」
119
157
  - 统计区耗时同口径拆分:「平均排队 / 平均执行」双行展示(平均验收单列不变)
120
158
  - 统计区新增「调研 ROI」对比行:resolved/archived 卡按有无调研注入分组现算卡数 / 平均执行时长 / 平均 token(双组总样本 ≥4 才渲染,无调研组明显更慢时数字 warn 色提示)
121
159
 
@@ -147,7 +185,7 @@ draft → pending → in-progress → verifying → resolved → archived
147
185
  - **子任务**:父子层级 + 上下文继承 + 父任务自动流转 + 级联归档(僵尸态出清:`archive-task` 对「无活跃 run 的 in-progress」——典型如被 parentKick 推进后子任务已全部归档的史诗——直接放行,有活跃 run 的仍拒);**归档子任务仍计入史诗进度并在详情留档可见**——进度分子口径 `settled = resolved | cancelled | archived`、分母也含归档,归档一张子卡不会再让史诗进度从 `0/10` 退化成 `0/9`(进度只增不减),全归档的父卡也照常显示徽章;详情子任务清单不排归档行(灰化 + 行尾「已归档」徽章 + 沉底排序,点击仍可进子卡看留档)
148
186
  - **死会话零打扰**:15s 派发心跳对无活 root 的会话板直接跳过(不读盘/不写盘/零日志,会话重开后自动恢复派发);`create-task` 拒绝不完整 sessionId(防裸短 id 建出幻影板)
149
187
  - **面板轮询渲染短路**:`get-tasks` 响应附 `tasksHash`,任务列表无变化时前端跳过重渲染(usage/池状态等轻量字段照常刷新)
150
- - **看板抽屉稳定性(isRoot 蝶变防抖)**:生成开始/结束瞬间 agents 树重建会让 `isRoot` 出现单次瞬态 `false`——现在需**连续 3 次(≈9s)false** 才收起抽屉(会话生命周期内「曾确认 true」粘滞),看板不再在生成状态切换时突然消失;从未确认 root 的子代理会话仍即时隐藏入口,保护语义不变
188
+ - **看板抽屉稳定性(isRoot 持久语义 + 蝶变防抖)**:`isRoot` 判定改读**持久 parentSession**(根会话=无父)——休眠根会话(有板有卡但无活 agent)也算 root,入口不再分钟级消失;子代理会话(parentSession 指向父)恒不显示入口。客户端防抖仍保留兜底:已确认 root 的会话需**连续 3 次(≈9s)false** 才收起抽屉(会话生命周期内「曾确认 true」粘滞),看板不再在生成状态切换时突然消失;从未确认 root 的子代理会话仍即时隐藏入口,保护语义不变
151
189
  - 卡片识别层:父卡显示「📦 史诗 · settled/total」徽章 + 3px 迷你进度条(`settled = resolved | cancelled | archived`,total 同口径含归档;有在跑子任务时附「▸ 在跑:标题」行);子卡标题下显示「↳ 父任务标题」;依赖未满足的待办卡底部灰字「⛓ 等待「依赖标题」」(childStats 由 host 现算,缺字段一律不渲染)
152
190
  - 详情父子区块:父卡详情列子任务清单(状态色点 + 标题,**含已归档留档**——归档行灰化 + 行尾「已归档」徽章并沉底,点击直达子卡详情,标题行汇总 settled/total);子卡详情顶行「↳ 史诗:父标题」点击回跳父卡
153
191
  - **可选 hooks(史诗可选点位:前置/后置各是一次真实 agent 运行)**:`epic.hooks = { pre: { enabled, prompt, state, runId }, post: 同构 }`——**前置**让子任务具备开跑条件(串行闸门:未完成前该史诗的子任务一张都不派),**后置**把这批活收口(全部子任务了结后跑一遍,完成后史诗才转验证中);prompt 只给薄框架契约,具体动作由 hook agent **自行决策**,**吃不准就走歧义上报**(失败即转阻塞等人裁决:重试/跳过/放弃,不自动重跑)。hook run 记在 `epic.runs[]`(role `hook-pre`/`hook-post`),卡片带相位徽章(`⏳ 前置准备中` / `🧪 收尾中` / `⚠️ hook 失败待裁决`,无 hooks 的老卡零渲染),详情页 hooks 区可编辑 enabled+prompt、查看 state/耗时并跳转 run 会话、失败一键跳裁决区;**hooks 仅主窗口可设**(工具与 RPC 双通道门禁),commit/push 不入任何默认形态
@@ -217,11 +255,11 @@ Team 托管档独有(调度员体验):
217
255
  > 兼容:旧的 `set-board-mode` / `set-team-mode` 两个 RPC 原样保留(旧客户端与脚本不受影响),
218
256
  > 内部仍以 `boardMode` + `teamMode` 两个字段落盘,老看板文件无损;`get-tasks` 额外返回派生字段 `workMode` 供 UI 单点读取。
219
257
 
220
- ## 13 个 Agent 工具
258
+ ## 14 个 Agent 工具
221
259
 
222
260
  | 类别 | 工具 |
223
261
  |---|---|
224
- | 任务管理 | `task_create` / `task_list` / `task_context` / `task_update` / `task_claim` / `task_resolve` / `task_verify` / `task_archive` |
262
+ | 任务管理 | `task_create` / `task_list` / `task_context` / `task_update` / `task_claim` / `task_resolve` / `task_verify` / `task_archive` / `task_cancel` |
225
263
  | 池治理 | `task_terminate` / `task_intervene` / `task_arbitrate` |
226
264
  | 子代理上报 | `board_report` / `board_verdict` |
227
265
 
package/index.mjs CHANGED
@@ -63,13 +63,14 @@ export function apply(ctx) {
63
63
  var storeDeps = { sessionCwd: session.sessionCwd, poolCycle: null }
64
64
  var store = createStore(ctx, state, storeDeps)
65
65
  var notify = createNotify(ctx, state, { rt: store.rt, rootForSession: session.rootForSession, withTimeout: session.withTimeout, isDispatched: session.isDispatched })
66
+ storeDeps.notifyBoardCorrupt = notify.notifyBoardCorrupt // 晚绑定收口(腐坏隔离/抢救通知;store.rt 只在 apply 完成后才触发)
66
67
  var dispatch = createDispatch(ctx, state, {
67
68
  rt: store.rt, wt: store.wt, mutateLocked: store.mutateLocked, kickCycle: store.kickCycle,
68
69
  rootForSession: session.rootForSession,
69
70
  // sessionCwd 不再注入 dispatch(预研清单瘦身后派发侧不读盘);rpc 侧仍需要(epic 预检路径存在性)
70
71
  withTimeout: session.withTimeout, runsFor: session.runsFor, feedbackOn: session.feedbackOn, epicSplitOn: session.epicSplitOn,
71
72
  pushSysNote: notify.pushSysNote, maybeNotify: notify.maybeNotify, notifyTaskDone: notify.notifyTaskDone,
72
- notifyDispatched: notify.notifyDispatched,
73
+ notifyDispatched: notify.notifyDispatched, notifyLateReject: notify.notifyLateReject,
73
74
  })
74
75
  storeDeps.poolCycle = dispatch.poolCycle // 晚绑定收口
75
76
  // 僵尸 epic 出清配套(反馈 n-mutma3mmwceq):archive-task 门禁的活性判定。
@@ -80,9 +81,10 @@ export function apply(ctx) {
80
81
  getActorId: session.getActorId, resolveRoot: session.resolveRoot, toolSessionId: session.toolSessionId, rpcSessionId: session.rpcSessionId,
81
82
  rootForSession: session.rootForSession, deriveWorkMode: session.deriveWorkMode, runsFor: session.runsFor,
82
83
  rt: store.rt, mutateLocked: store.mutateLocked,
83
- maybeNotify: notify.maybeNotify, notifyTaskDone: notify.notifyTaskDone,
84
+ maybeNotify: notify.maybeNotify, notifyTaskDone: notify.notifyTaskDone, notifyLateReject: notify.notifyLateReject,
84
85
  pushSysNote: notify.pushSysNote, sessionCwd: session.sessionCwd, // 调研门禁③:epic 发布预检汇总投递 + 预研路径相对解析根
85
86
  spawnOneShot: dispatch.spawnOneShot, accumulateRunUsage: dispatch.accumulateRunUsage, readContextPack: dispatch.readContextPack,
87
+ closeRunHistory: dispatch.closeRunHistory, // 手动终止 run 关账(doTerminate 状态无关清理,task-mv1pqy27)
86
88
  // 上报通道结算入口(task-muwkhqf8):board_report/board_verdict 把任务推进到落定态时,
87
89
  // 由 rpc 侧顺手对「该任务当前的 continuable Worker rec」补做收尾三件套
88
90
  // (关 run 结局 / usage 落账 / 摘超时臂)——治「工具上报完成后 idle 事件没到 → run 永远 running」。
@@ -58,12 +58,14 @@
58
58
  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) } }, [])
59
59
  function setQ2(v) { state.filterQ = v; notify() }
60
60
  function togglePrio(p) { var i = state.filterPrio.indexOf(p); if (i >= 0) state.filterPrio.splice(i, 1); else state.filterPrio.push(p); notify() }
61
- function clearAll() { state.filterQ = ''; state.filterPrio = []; state.filterTag = ''; notify() }
62
- var hasFilter = q.trim() || state.filterPrio.length > 0 || ft
61
+ function toggleReview() { state.filterReview = !state.filterReview; notify() }
62
+ function clearAll() { state.filterQ = ''; state.filterPrio = []; state.filterTag = ''; state.filterReview = false; notify() }
63
+ var hasFilter = q.trim() || state.filterPrio.length > 0 || ft || state.filterReview
63
64
  var chipBase = { fontSize: 10, padding: '2px 8px', borderRadius: 10, border: '1px solid ' + C.border, cursor: 'pointer', background: 'transparent', color: C.text2, transition: 'all .15s' }
64
65
  return React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 6, marginBottom: 8, flexWrap: 'wrap' } },
65
66
  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 } }),
66
67
  ['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]) }),
68
+ React.createElement('button', { key: 'review', onClick: toggleReview, title: '只看命中风险信号且未阅的已完成/归档卡(已阅卡不计)', style: Object.assign({}, chipBase, state.filterReview ? { background: C.warn, color: C_INV, borderColor: C.warn } : {}) }, '👁 待你过目'),
67
69
  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,
68
70
  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)
69
71
  }
@@ -160,7 +162,7 @@
160
162
  // Enter/Space 触发与点击相同的激活行为(多选=切换选中,否则开详情);
161
163
  // focus 态用主题色 C.brand 2px outline(不用浏览器默认蓝框,保持主题一致)
162
164
  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() } }
163
- 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' } },
165
+ 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' } },
164
166
  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,
165
167
  // hook 相位徽章:与 📦 徽章**并存**(相位是 epic 的附加过程态,不是替代进度)
166
168
  epic ? hookPhaseBadge(t) : null,
@@ -190,35 +192,49 @@
190
192
  function ArchiveView() {
191
193
  var _R = React; var useState = _R.useState
192
194
  var _a = useState(state.archQ || ''), q = _a[0], setQ = _a[1]
193
- // 归档数据源口径对齐仪表盘(反馈 n-muyg3e0m9z7i):直接读 state.tasks(get-tasks 全量含归档),
195
+ // 归档 tab 数据源口径对齐仪表盘(反馈 n-muyg3e0m9z7i):直接读 state.tasks(get-tasks 全量含归档),
194
196
  // 不再依赖独立 state.archived/fetchArchived——旧路径异步填库却未接入 TopPanel 的 notify 更新项,
195
197
  // 归档 tab 打开后停在「暂无归档任务」空态,而仪表盘「已归档」计数来自 state.tasks(全量),二者对不上。
196
- // 全量 + 按归档时间倒序(archivedAt 优先,回退 resolvedAt/createdAt)+ 顶部注明条数。
197
- var archived = state.tasks.filter(function (t) { return t.status === 'archived' })
198
+ // 全量 + 按归档/取消时间倒序(archivedAt 优先,回退 cancelledAt/resolvedAt/createdAt)+ 顶部注明条数。
199
+ // 含 cancelled(反馈 n-mv0zbqk... 取消通道):取消卡在待办列消失(cancelled 不在 COLUMNS)后,
200
+ // 归入本 tab 可见、可「恢复待办」——否则作废卡从 UI 上彻底蒸发、无法找回。
201
+ var archived = state.tasks.filter(function (t) { return t.status === 'archived' || t.status === 'cancelled' })
202
+ var cancelledCount = archived.filter(function (t) { return t.status === 'cancelled' }).length // 文案口径修正:仪表盘「已归档」只数 archived,本 tab 含 cancelled,须单列
198
203
  var list = archived.filter(function (t) {
199
204
  if (!q) return true
200
205
  var qq = q.toLowerCase()
201
206
  return (t.title || '').toLowerCase().indexOf(qq) >= 0 || (t.id || '').toLowerCase().indexOf(qq) >= 0 || (t.tags || []).join(' ').toLowerCase().indexOf(qq) >= 0
202
207
  }).sort(function (a, b) {
203
- // 归档时间倒序:archivedAt 优先,回退 resolvedAt/createdAt(老卡可能缺 archivedAt)
204
- function archTs(t) { return t.archivedAt || t.resolvedAt || t.createdAt || '' }
208
+ // 归档/取消时间倒序:archivedAt 优先,回退 cancelledAt/resolvedAt/createdAt(老卡可能缺 archivedAt)
209
+ function archTs(t) { return t.archivedAt || t.cancelledAt || t.resolvedAt || t.createdAt || '' }
205
210
  return (archTs(b) || '').localeCompare(archTs(a) || '')
206
211
  })
207
- function restore(id) { rpc('update-task', { taskId: id, resetToPending: true }).then(function () { fetchTasks() }).catch(function () {}) }
212
+ // 工作模式(与 fetchTasks 同口径):list=清单模式(恢复不重派)不弹;auto/team 恢复即重派 → confirm 门禁
213
+ var wm = state.workMode || (state.teamMode ? 'team' : (state.boardMode === 'auto' ? 'auto' : 'list'))
214
+ function restore(id) {
215
+ if (wm !== 'list') {
216
+ if (!window.confirm('恢复待办后将按当前工作模式立即重新派发,消耗一轮 Worker+Verifier token。继续?')) return
217
+ }
218
+ rpc('update-task', { taskId: id, resetToPending: true }).then(function () { fetchTasks() }).catch(function () {})
219
+ }
208
220
  return React.createElement('div', null,
209
221
  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 } }),
210
- React.createElement('div', { style: { fontSize: 10, color: C.text2, marginBottom: 6 } }, '共 ' + archived.length + ' 条归档(全量,与仪表盘「已归档」同口径)'),
211
- archived.length === 0 ? React.createElement('div', { style: { fontSize: 11, color: C.text2, padding: 12, textAlign: 'center' } }, '暂无归档任务') :
222
+ React.createElement('div', { style: { fontSize: 10, color: C.text2, marginBottom: 6 }, title: '归档 tab 含已归档 + 已取消两类;仪表盘「已归档」只数已归档,不数已取消' }, '共 ' + archived.length + ' 条' + (cancelledCount > 0 ? '(含已取消 ' + cancelledCount + ' 条)' : '')),
223
+ archived.length === 0 ? React.createElement('div', { style: { fontSize: 11, color: C.text2, padding: 12, textAlign: 'center' } }, '暂无归档/已取消任务') :
212
224
  list.length === 0 ? React.createElement('div', { style: { fontSize: 11, color: C.text2, padding: 12, textAlign: 'center' } }, '无匹配结果') :
213
225
  list.map(function (t) {
214
- return React.createElement('div', { key: t.id, style: { padding: '6px 8px', marginBottom: 5, background: C.card, border: '1px solid ' + C.border, borderRadius: 6 } },
226
+ // 归档行点击进详情(用户 2026-10-09 指令):复用 state.detailId 机制,与看板卡同路径;
227
+ // 详情页对 archived 的操作区既有门禁不回归(恢复待办=保存并重置、删除仅 draft/pending/blocked)。
228
+ 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' } },
215
229
  React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 6 } },
216
230
  React.createElement('span', { style: { fontSize: 11, fontWeight: 600, color: C.text, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, title: t.title }, t.title),
231
+ 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,
217
232
  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,
218
- React.createElement('span', { style: { fontSize: 9, color: C.text2 } }, t.archivedAt ? ago(t.archivedAt) : '')),
233
+ t.status === 'cancelled' ? React.createElement('span', { title: '已取消(可恢复待办)', style: { fontSize: 9, padding: '0 4px', borderRadius: 2, background: 'color-mix(in srgb, ' + C.warn + ' 20%, transparent)', color: C.warn, fontWeight: 600, whiteSpace: 'nowrap', flexShrink: 0 } }, '已取消') : null,
234
+ React.createElement('span', { style: { fontSize: 9, color: C.text2 } }, (t.archivedAt || t.cancelledAt) ? ago(t.archivedAt || t.cancelledAt) : '')),
219
235
  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,
220
236
  React.createElement('div', { style: { display: 'flex', gap: 5, marginTop: 4, flexWrap: 'wrap' } },
221
- 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' } }, '↩ 恢复待办'),
237
+ 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' } }, '↩ 恢复待办'),
222
238
  (function () {
223
239
  var runs = historyRuns(t)
224
240
  if (!runs.length) return React.createElement('span', { style: { fontSize: 9, color: C.text2, opacity: 0.6, padding: '2px 0' } }, '无历史会话')
@@ -227,7 +243,7 @@
227
243
  var seq = r.role === 'verifier' ? (++vN) : (++wN)
228
244
  var isV = r.role === 'verifier'
229
245
  return React.createElement('button', {
230
- key: i, onClick: function () { if (uiWorkspaceSvc) uiWorkspaceSvc.openSession(r.id) },
246
+ key: i, onClick: function (e) { e.stopPropagation(); if (uiWorkspaceSvc) uiWorkspaceSvc.openSession(r.id) },
231
247
  title: (isV ? 'Verifier' : 'Worker') + ' 第 ' + seq + ' 次' + (r.at ? ' · ' + ago(r.at) : '') + (r.model ? ' · ' + r.model : '') + '(' + r.id + ')',
232
248
  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' }
233
249
  }, '→ ' + (isV ? 'V' : 'W') + '#' + seq)
@@ -248,6 +264,7 @@
248
264
  var _tc = useState(''), touchesRaw = _tc[0], setTouchesRaw = _tc[1]
249
265
  var _dp = useState({}), depSel = _dp[0], setDepSel = _dp[1]
250
266
  var _ac = useState(''), acc = _ac[0], setAcc = _ac[1]
267
+ var _tg = useState(''), tagsRaw = _tg[0], setTagsRaw = _tg[1]
251
268
  var _dr = useState(wm === 'team'), asDraft = _dr[0], setAsDraft = _dr[1]
252
269
  var _bs = useState(false), busy = _bs[0], setBusy = _bs[1]
253
270
  var _er = useState(''), err = _er[0], setErr = _er[1]
@@ -262,9 +279,10 @@
262
279
  var t = title.trim()
263
280
  if (!t) { setErr('⚠️ 请先填写标题'); return }
264
281
  var touches = touchesRaw.split(/[\n,,;;]/).map(function (s) { return s.trim() }).filter(function (s) { return s.length > 0 })
282
+ var tags = tagsRaw.split(/[,,]/).map(function (s) { return s.trim() }).filter(function (s) { return s.length > 0 })
265
283
  var deps = cands.filter(function (x) { return depSel[x.id] }).map(function (x) { return x.id })
266
284
  setBusy(true); setErr(''); setOkMsg(''); setWarn('')
267
- rpc('create-task', { title: t, description: desc, priority: prio, pipeline: pipe, touches: touches, dependsOn: deps, acceptance: acc.trim(), draft: asDraft }).then(function (r) {
285
+ 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) {
268
286
  setBusy(false)
269
287
  if (r && r.ok === false) { setErr('⚠️ ' + (r.error || '创建失败')); return }
270
288
  fetchTasks()
@@ -300,6 +318,7 @@
300
318
  React.createElement('option', { value: 'full' }, '全流程(执行+验证)'),
301
319
  React.createElement('option', { value: 'work' }, '免验证(只做不验)'),
302
320
  React.createElement('option', { value: 'direct' }, '主窗口处理')), '管线 = 任务走哪种流程:全流程 = Worker 做 + Verifier 验;免验证 = 只做不验;主窗口处理 = 你在对话里直接办'))),
321
+ field('标签(逗号分隔,可空)', React.createElement('input', { value: tagsRaw, onChange: function (e) { setTagsRaw(e.target.value) }, placeholder: '如 bug, epic, 待验收', style: inp }), '标签 = 用于筛选的逗号分隔关键字(自动 trim + 去空)'),
303
322
  field('touches(文件/glob,逗号或换行分隔,可空)', React.createElement('input', { value: touchesRaw, onChange: function (e) { setTouchesRaw(e.target.value) }, placeholder: 'src/a.mjs, src/**', style: inp }), 'touches = 本卡要改动的文件(glob 匹配);声明后并行任务间防互踩(文件锁)'),
304
323
  field('依赖(全部完成后才派发,可空)', cands.length === 0
305
324
  ? React.createElement('div', { style: { fontSize: 10, color: C.text2 } }, '当前无草稿/待办任务可选')
@@ -210,7 +210,7 @@
210
210
  function batchDigest() {
211
211
  var now = Date.now(), since = now - 86400000
212
212
  var all = (Array.isArray(state.tasks) ? state.tasks : []).concat(Array.isArray(state.archived) ? state.archived : [])
213
- var resolved = 0, rejected = 0, tokTot = 0, tokEff = 0, timeouts = 0, trendResolved = 0, trendFirstPass = 0
213
+ var resolved = 0, rejected = 0, tokTot = 0, tokEff = 0, timeouts = 0, trendResolved = 0, trendFirstPass = 0, reviewPending = 0
214
214
  function in24(iso) { var d = Date.parse(iso); return isFinite(d) && d >= since && d <= now }
215
215
  function pos(v) { var n = Number(v); return isFinite(n) && n > 0 ? n : 0 }
216
216
  all.forEach(function (t) {
@@ -229,13 +229,15 @@
229
229
  tokTot += tot; tokEff += inp + outp + cw
230
230
  if (r.outcome === 'timeout/error' || r.outcome === 'timeout') timeouts++
231
231
  })
232
+ // ===== 未过目计数(异常驱动审视①⑤):命中风险信号且未阅的已完成/归档卡 =====
233
+ if (reviewPending(t)) reviewPending++
232
234
  })
233
235
  var ghosts = 0
234
236
  var hints = Array.isArray(state.healthHints) ? state.healthHints : []
235
237
  hints.forEach(function (h) { var m = h && h.text ? String(h.text).match(/本轮回收 (\d+) 个幽灵活跃表项/) : null; if (m) ghosts += pos(m[1]) })
236
238
  var trend = trendResolved > 0 ? ('一次通过率 ' + Math.round(trendFirstPass / trendResolved * 100) + '%(近 24h 完成 ' + trendResolved + ' 张中 ' + trendFirstPass + ' 张零驳回)') : '近 24h 无完成记录,暂无质量趋势'
237
239
  var has = resolved > 0 || rejected > 0 || tokTot > 0 || timeouts > 0 || ghosts > 0 || trendResolved > 0
238
- return { has: has, resolved: resolved, rejected: rejected, tokTot: tokTot, tokEff: tokEff, timeouts: timeouts, ghosts: ghosts, trend: trend }
240
+ return { has: has, resolved: resolved, rejected: rejected, tokTot: tokTot, tokEff: tokEff, timeouts: timeouts, ghosts: ghosts, trend: trend, reviewPending: reviewPending }
239
241
  }
240
242
 
241
243
  function buildReport() {
@@ -249,10 +251,12 @@
249
251
  lines.push('- Token:约 ' + fmtTokens(dg.tokTot) + (dg.tokEff > 0 ? '(其中有效 ' + fmtTokens(dg.tokEff) + ')' : ''))
250
252
  lines.push('- 异常事件:超时 ' + dg.timeouts + ' 次 · 幽灵回收 ' + dg.ghosts + ' 次')
251
253
  lines.push('- 质量趋势:' + dg.trend)
254
+ lines.push('- 未过目:' + dg.reviewPending + ' 张')
252
255
  lines.push('')
253
256
  } else {
254
257
  lines.push('## 批次摘要(近 24 小时)')
255
258
  lines.push('近 24 小时无活动记录(无完成 / 驳回 / 消耗 / 异常事件)')
259
+ lines.push('- 未过目:' + dg.reviewPending + ' 张')
256
260
  lines.push('')
257
261
  }
258
262
  var groups = { inProgress: [], verifying: [], pending: [], blocked: [], resolved: [] }
@@ -578,13 +582,14 @@
578
582
  // 近 14 天柱形通用画法(① 通过率 / ③ 超时率共用):cells = [{ k, rate(null=当日无样本), tip }]
579
583
  // 今日柱 brand 高亮 + 率值顶标;无样本日画 3px 灰基线(title 写明无样本,不假装 0%)。
580
584
  function qtDayBars(cells, color, todayKey) {
581
- return React.createElement('div', { style: { display: 'flex', alignItems: 'flex-end', gap: 3, height: 46 } },
585
+ return React.createElement('div', { style: { display: 'flex', alignItems: 'flex-end', gap: 3, height: 46, minWidth: 0, overflow: 'hidden' } },
582
586
  cells.map(function (c) {
583
587
  var isToday = c.k === todayKey
584
588
  var h = c.rate === null ? 3 : Math.max(4, Math.round(c.rate * 34))
589
+ h = Math.min(22, h) // clamp 柱高:100% 柱封顶 22px——46px 绘图容器扣掉上下率值/日期标签与 gap/描边后不越出,防单日 100% 黑柱向上盖住右列
585
590
  var kp = c.k.split('-')
586
591
  var dLabel = Number(kp[1]) + '/' + Number(kp[2])
587
- return React.createElement('div', { key: c.k, style: { flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'flex-end', gap: 1 }, title: c.tip },
592
+ return React.createElement('div', { key: c.k, style: { flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'flex-end', gap: 1 }, title: c.tip },
588
593
  c.rate !== null ? React.createElement('span', { style: { fontSize: 8, color: isToday ? C.brand : C.text2 } }, mpPct(c.rate)) : null,
589
594
  React.createElement('div', { style: { width: '100%', maxWidth: 22, height: h + 'px', borderRadius: 2, background: c.rate === null ? C.nested : (isToday ? C.brand : color), border: '1px solid ' + (isToday ? C.brand : (c.rate === null ? C.border : color)) } }),
590
595
  React.createElement('span', { style: { fontSize: 8, color: isToday ? C.brand : C.text2 } }, dLabel))
@@ -629,7 +634,7 @@
629
634
  })
630
635
  var fpHas = false; fpCells.forEach(function (c) { if (c.rate !== null) fpHas = true })
631
636
  var fpToday = fpCells[fpCells.length - 1]
632
- var block1 = React.createElement('div', { style: { flex: '1 1 260px', minWidth: 230 } },
637
+ var block1 = React.createElement('div', { style: { flex: '1 1 260px', minWidth: 230, overflow: 'hidden' } },
633
638
  blockHead('一次通过率(近 14 天)', React.createElement('span', null, '今日 ', React.createElement('span', { style: { color: fpToday.rate !== null ? C.brand : C.text2, fontWeight: 600 } }, fpToday.rate !== null ? mpPct(fpToday.rate) : '—')), '一次通过率 = 当天落定「已完成」任务里零驳回(rejectCount=0)的占比;驳回 = Verifier 验收判 reject'),
634
639
  fpHas ? qtDayBars(fpCells, C.ok, todayKey)
635
640
  : React.createElement('div', { style: { fontSize: 10, color: C.text2 } }, '近 14 天窗口内暂无 resolved 任务落定'))
@@ -657,7 +662,7 @@
657
662
  })
658
663
  var toHas = false; toCells.forEach(function (c) { if (c.rate !== null) toHas = true })
659
664
  var toToday = toCells[toCells.length - 1]
660
- var block3 = React.createElement('div', { style: { flex: '1 1 260px', minWidth: 230 } },
665
+ var block3 = React.createElement('div', { style: { flex: '1 1 260px', minWidth: 230, overflow: 'hidden' } },
661
666
  blockHead('超时率走势(近 14 天)', React.createElement('span', null, '今日 ', React.createElement('span', { style: { color: toToday.rate !== null ? C.warn : C.text2, fontWeight: 600 } }, toToday.rate !== null ? mpPct(toToday.rate) : '—')), '超时率 = 超时落定(timeout/error)的 run 占当天 run 的比例'),
662
667
  toHas ? qtDayBars(toCells, C.warn, todayKey)
663
668
  : React.createElement('div', { style: { fontSize: 10, color: C.text2 } }, '近 14 天窗口内暂无 run 落定'))
@@ -734,7 +739,7 @@
734
739
  function HealthHints() {
735
740
  var hints = (Array.isArray(state.healthHints) ? state.healthHints : []).filter(function (h) { return h && h.text })
736
741
  if (!hints.length) return null
737
- // 两级配色:warn=⚠️ 琥珀(C.warn);info=ℹ️ 蓝灰(DSH 现有 business 信息色——色板无独立 info 档)
742
+ // 三级配色:err=🚨 红(C.err,数据安全红线);warn=⚠️ 琥珀(C.warn);info=ℹ️ 蓝灰(DSH 现有 business 信息色)
738
743
  var infoColor = 'var(--dsw-alias-state-business-primary)'
739
744
  return React.createElement('div', { style: { padding: '8px 10px', background: C.card, border: '1px solid ' + C.border, borderRadius: 6, marginBottom: 12 } },
740
745
  React.createElement('div', { style: { fontSize: 11, fontWeight: 600, color: C.text2, marginBottom: 6, display: 'flex', alignItems: 'center', gap: 4 } }, ic('activity', 11), '架构健康'),
@@ -742,9 +747,10 @@
742
747
  React.createElement('div', { style: { fontSize: 9, color: C.text2, marginBottom: 5, lineHeight: 1.5 } }, '运行时自检告警,多为自动恢复的瞬时异常。术语:settle = 任务结算通道;touches = 任务声明的改动文件(文件锁);p90 = 执行时长第 90 百分位'),
743
748
  hints.map(function (h, i) {
744
749
  var warn = h.level === 'warn'
745
- var col = warn ? C.warn : infoColor
750
+ var err = h.level === 'err'
751
+ var col = err ? C.err : (warn ? C.warn : infoColor)
746
752
  return React.createElement('div', { key: i, style: { display: 'flex', alignItems: 'baseline', gap: 5, fontSize: 10, lineHeight: 1.5, color: col, marginBottom: 3 } },
747
- React.createElement('span', { style: { flexShrink: 0 } }, warn ? '⚠️' : 'ℹ️'),
753
+ React.createElement('span', { style: { flexShrink: 0 } }, err ? '🚨' : (warn ? '⚠️' : 'ℹ️')),
748
754
  React.createElement('span', null, String(h.text)))
749
755
  }))
750
756
  }
@@ -194,6 +194,11 @@
194
194
  ['circle', { cx: 12, cy: 12, r: 10 }],
195
195
  ['rect', { width: 6, height: 6, x: 9, y: 9 }]
196
196
  ],
197
+ // 取消入口图标(详情页「取消」按钮;语义=作废不派发,与删除 trash-2 区分)
198
+ 'ban': [
199
+ ['circle', { cx: 12, cy: 12, r: 10 }],
200
+ ['path', { d: 'm4.9 4.9 14.2 14.2' }]
201
+ ],
197
202
  'plus': [
198
203
  ['path', { d: 'M5 12h14' }],
199
204
  ['path', { d: 'M12 5v14' }]
@@ -229,7 +234,7 @@
229
234
  blocked: { title: '阻塞:卡住待处理(等依赖/等主窗口裁决)', empty: '暂无阻塞——任务卡住待裁决时会出现在这里' }
230
235
  }
231
236
  var reqEpoch = 0 // 会话切换纪元:切会话时自增,旧会话在途响应按纪元丢弃
232
- 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: '' }
237
+ 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: '' }
233
238
 
234
239
  // #16 快捷键:Esc 逐级关闭(详情→看板→面板);输入框聚焦时不劫持
235
240
  try {
@@ -351,10 +356,14 @@
351
356
  state.poolStatus = (d && d.poolStatus) || null
352
357
  // token 消耗聚合(board 级,host 端现算):范围守卫——响应回来时范围若已变,保留旧值不覆盖
353
358
  // (新范围的那次请求会带着新聚合回来;老 host 不返回该字段 → null,零渲染)
354
- // ===== usageSummary 变化检测(task-muwq9u04:选范围不重渲染)=====
355
- // 病根:范围切换只让 state.usageSummary 换对象,tasksHash 与四个 cfg 开关都不变 → 无 notify
356
- // → Token 区冻在旧数字上,要等下一次任意 notify(改任务/切开关)才翻新。
357
- // 为什么用「上一次的 JSON 串」比对而不是对象引用:host 每次 get-tasks 都现算聚合、**必然返回新对象**
359
+ // ===== usageSummary 变化检测 + React 订阅(task-muwq9u04 → task-mv10lvud 根修)=====
360
+ // 病根(task-mv10lvud 实测「点今天也没变化」):范围切换时 setRange 先换 state.dateRange(新对象
361
+ // → setDR 身份变化 → 第一帧渲染成功),随后 fetchTasks 响应把 state.usageSummary 换成新对象并 notify(),
362
+ // 但 TopPanel 的 update() 里所有 setX 值都 Object.is 全等(tasks 引用不变、布尔不变)→ React 18 全量
363
+ // bailout,零重渲染 → Token 区永远拿不到新聚合。notify ≠ React 重渲染,须有「新身份的订阅值」做载体。
364
+ // 修法:TopPanel 补 usageSummary 专属 React state(setUsage(state.usageSummary)),host 每次响应都是
365
+ // 新对象,身份变化天然触发渲染(订阅见 TopPanel 的 update())。
366
+ // 为什么本处用「上一次的 JSON 串」比对而不是对象引用:host 每次 get-tasks 都现算聚合、**必然返回新对象**
358
367
  // (引用比较恒真)→ 3s 轮询每轮都 notify,tasksHash 的渲染节约当场作废。JSON 串只在这份聚合
359
368
  // 真变了时才不等(KB 级体积、3s 一次,代价可忽略);赋值前先快照,赋值后比对。
360
369
  var usageJsonPrev = JSON.stringify(state.usageSummary || null)
@@ -498,18 +507,40 @@
498
507
  }).catch(function (e) { if (onMsg) onMsg('⚠️ ' + String(e)); return { ok: false, error: String(e) } })
499
508
  }
500
509
 
510
+ // ===== 取消通道(与 host cancel-task / task_cancel 门禁一一对应)=====
511
+ // 可取消状态:草稿/待办/阻塞(未进入执行)。in-progress/verifying 需先终止;resolved 引导归档。
512
+ // 取消=显式放弃:置 cancelled + 留档可检索、可在归档 tab「恢复待办」;与删除不同,取消不丢数据。
513
+ function canCancel(t) { return !!t && (t.status === 'draft' || t.status === 'pending' || t.status === 'blocked') }
514
+ // confirm 预警(与 note「取消后不再派发,可在归档恢复」同口径);失败时 host 门禁原因原样回显。
515
+ function cancelTask(id, title, onMsg) {
516
+ var t = getTask(id)
517
+ var name = title || (t && t.title) || id
518
+ if (!window.confirm('取消后不再派发,可在归档恢复。确认取消「' + name + '」?')) return Promise.resolve({ ok: false, cancelled: true })
519
+ return rpc('cancel-task', { taskId: id }).then(function (r) {
520
+ if (r && r.ok === false) { if (onMsg) onMsg('⚠️ ' + (r.error || '取消失败')) }
521
+ else if (onMsg) onMsg('🚫 已取消「' + name + '」(可在归档 tab 恢复待办)')
522
+ fetchTasks()
523
+ return r
524
+ }).catch(function (e) { if (onMsg) onMsg('⚠️ ' + String(e)); return { ok: false, error: String(e) } })
525
+ }
526
+
501
527
  function onDragStart(e, task) { e.dataTransfer.setData('text/plain', JSON.stringify({ id: task.id, status: task.status })); e.dataTransfer.effectAllowed = 'move'; state.dragTask = task.id; notify() }
502
528
  function onDragEnd() { state.dragTask = null; state.dragOver = null; notify() }
503
529
  function onColDragOver(e, st) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; if (state.dragOver !== st) { state.dragOver = st; notify() } }
504
530
  function onColDragLeave(st) { if (state.dragOver === st) { state.dragOver = null; notify() } }
505
531
  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 (_) {} }
506
532
 
507
- // #13 筛选:文本(标题/描述/ID)+ 优先级多选 + 标签
533
+ // ===== 风险队列「待你过目」客户端单一事实源(异常驱动审视①)=====
534
+ // 命中=host 现算的 reviewHint.score>0;待过目=命中 + 未阅(reviewedAt 空)+ 终态(resolved/archived)。
535
+ // 徽章 / chip 过滤 / digest 计数三处同用这一个谓词,避免口径分叉。
536
+ function reviewPending(t) { return !!(t && t.reviewHint && t.reviewHint.score > 0 && !t.reviewedAt && (t.status === 'resolved' || t.status === 'archived')) }
537
+ // #13 筛选:文本(标题/描述/ID)+ 优先级多选 + 标签 + 待过目队列
508
538
  function passFilter(t) {
509
539
  var q = state.filterQ.trim().toLowerCase()
510
540
  if (q && (t.title + ' ' + (t.description || '') + ' ' + t.id).toLowerCase().indexOf(q) < 0) return false
511
541
  if (state.filterPrio.length > 0 && state.filterPrio.indexOf(t.priority || 'medium') < 0) return false
512
542
  if (state.filterTag && (t.tags || []).indexOf(state.filterTag) < 0) return false
543
+ if (state.filterReview && !reviewPending(t)) return false
513
544
  return true
514
545
  }
515
546
  function allTags() { var s = {}; state.tasks.forEach(function (t) { (t.tags || []).forEach(function (g) { s[g] = 1 }) }); return Object.keys(s) }
@@ -571,15 +602,16 @@
571
602
  var _ndo = useState(state.notifyDispatch), ndOn = _ndo[0], setNdOn = _ndo[1]; var _nno = useState(state.notifyDone), nnOn = _nno[0], setNnOn = _nno[1]
572
603
  var _eso = useState(state.epicSplit), esOn = _eso[0], setEsOn = _eso[1] // 史诗拆分总开关勾选态(设置区「功能」)
573
604
  var _vug = useState(state.verifyUserGuide), vugOn = _vug[0], setVugOn = _vug[1] // 自测指南开关勾选态(设置区「验收」)
574
- 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) }; listeners.push(update); update(); return function () { var i = listeners.indexOf(update); if (i >= 0) listeners.splice(i, 1) } }, [])
605
+ var _usg = useState(state.usageSummary), setUsage = _usg[1] // Token 区聚合订阅(task-mv10lvud 根修):usageSummary 换新对象即触发渲染
606
+ 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) } }, [])
575
607
  if (!open) return null
576
608
  if (!state.isRootStable) return null // 子代理会话不渲染看板面板(读 isRootStable:瞬态 false 不闪,见 isRoot 蝶变防抖)
577
609
  var active = tasks.filter(function (t) { return t.status !== 'archived' }); var archived = tasks.filter(function (t) { return t.status === 'archived' })
578
- var hasFilter = state.filterQ.trim() || state.filterPrio.length > 0 || state.filterTag
610
+ var hasFilter = state.filterQ.trim() || state.filterPrio.length > 0 || state.filterTag || state.filterReview
579
611
  if (hasFilter) { active = active.filter(passFilter); archived = archived.filter(passFilter) }
580
612
  var content
581
613
  if (view === 'dashboard') { content = React.createElement(Dashboard) }
582
- else if (view === 'archive') { content = React.createElement(ArchiveView) }
614
+ else if (view === 'archive') { content = detailId ? React.createElement(DetailView) : React.createElement(ArchiveView) }
583
615
  else if (view === 'team') { content = React.createElement(TeamView) }
584
616
  else if (detailId) { content = React.createElement(DetailView) }
585
617
  else {