dsh-agent-board 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -93,6 +93,20 @@ dsh plugin --profile web remove dsh-agent-board
93
93
  - Esc 逐级关闭(详情 → 看板 → 面板)
94
94
  - 全部结构性图标为 Lucide 线性 SVG(`currentColor` 跟随主题,浅深色自适应)
95
95
 
96
+ ### 仪表盘(Token 消耗)
97
+
98
+ - 仪表盘视图新增「Token 消耗」区:本看板累计总量 + 输入 / 输出 / 缓存读(缓存写非零时一并展示)拆分、按模型分布条形图、任务消耗 **Top 8**(标题可点击直达该任务详情);进行中的卡片右上角显示本任务已累计消耗(`⛁ 数字`)
99
+ - 数据来源:每次 Worker/Verifier run 结算时读该 run 的 v4 会话日志(`~/.dsh/sessions/*/<runId>/session.v4.jsonl.zstd`),把 `assistant/message` 事件的 `usage`(`inputTokens` / `outputTokens` / `cacheReadTokens` / `cacheWriteTokens` / `totalTokens`,字段形状以真实日志为准)按 zstd 帧逐帧累加到任务 `usage`(含按模型小计与 `runs` 计数,多轮重跑/驳回重做自动累加),`get-tasks` 再现算 board 级 `usageSummary`(总量 / 按模型 / Top8,不落盘额外表)——**只做展示、不做计费断言**,日志读不到或没有 usage 时一律显示「暂无数据」
100
+
101
+ ### 学习反馈(候选教训信号 → 主窗口沉淀)
102
+
103
+ - **信号源架构(零耦合)**:看板只产「候选教训**信号**」,不做**存储**——不调用任何笔记/记忆工具的 API、不写任何外部文件、也不知道教训最终被存到哪;用不用、存进哪个工具(如 `note_search` / `note_manage`)完全由主窗口 agent 自己决定
104
+ - **自动生成候选**(两处触发):① Verifier 驳回 → 一条 `lesson-candidate` 消息(场景 / 错误做法 / 来源);② 主窗口裁决 Worker 歧义 → 一条 `lesson-candidate` 消息(场景 / 疑问 / 裁决结论)。同一事件按「同时间戳 / 同内容前缀」轻量判重只落一条,且不写 `history` 流转记录(不刷屏)
105
+ - **详情页「沉淀」按钮**:把该条候选教训经 `push-lesson` RPC followup 给主窗口 agent(提示语写明「请用你可用的笔记/记忆工具沉淀,或评估后忽略」),推送成功后按钮变「✅ 已推送」置灰
106
+ - **软召回引导**:Worker prompt 与 Team 模式提示词都会加一句「开工前如环境装有笔记/记忆类工具(如 note_search),先检索相关历史教训再动手」(Team 档另外提醒把检索到的教训写进任务的 `contextNotes`)
107
+ - **开关 `feedbackEnabled`**(⚙️ 设置区「学习反馈」,默认**开**):关掉后不生成候选、prompt 不提软召回、详情页候选卡片与「沉淀」按钮整个不渲染;老看板文件没有该字段 → 读路径自动补 `true`(与升级前行为一致)
108
+ - **两插件完全独立**:`dsh-agent-board` 与笔记类插件(如 `dsh-notes-plugin`)之间没有任何依赖、服务调用或文件直写——看板只发一条 followup 文本,怎么用由主窗口 agent 决定
109
+
96
110
  ### 任务模型
97
111
 
98
112
  ```
@@ -106,7 +120,11 @@ draft → pending → in-progress → verifying → resolved → archived
106
120
  - **管线分档**:`full`(执行+验证)/ `work`(只做不验)/ `direct`(不进池,主窗口直接处理),创建时按规则自动分类、可手动覆盖
107
121
  - **硬性验收**:`acceptance` 字段写验收脚本命令,Worker 必须实际运行、Verifier 必须独立复跑
108
122
  - **文件级排他**:`touches` 声明本任务要改的文件/glob(如 `["src/**", "README.md"]`);进行中的任务持有文件锁,派发器发现候选与活动任务 touches 重叠就跳过本轮(卡片显示 `🔒 等文件释放`,详情页列出在等谁),锁在提交验收/完成后自动释放——避免并行 Worker 改同一批文件互踩。手动「派发」遇到冲突会列出冲突任务,确认后才以 `force` 越权派发
123
+ - **里程碑进展通道**:Worker 每完成一个可验证的里程碑,可调用 `board_report`(`kind: "progress"`,`question` 写一行进展摘要 ≤200 字符)上报——进行中的卡片显示「📈 最近进展 · 相对时间」(覆盖式只留最新一条),详情页消息流保留全部 progress 条目
124
+ - **防表演式汇报**:进展契约只写在 Worker prompt 里、且要求「有实际产物/结论才报」(禁止定时汇报);progress **静默不通知主窗口**(不进回执聚合),也不写 `history` 流转记录,避免刷屏
109
125
  - **子任务**:父子层级 + 上下文继承 + 父任务自动流转 + 级联归档
126
+ - **任务粒度建议**:单任务 **10~30 分钟**可独立完成为甜区;预计超过 30 分钟的大任务先建一张 **epic 父卡**(`pipeline: direct`,不进池派发),再挂若干 10~30 分钟的子任务(`task_create` 传 `parentId=父卡 id`,有先后顺序用 `dependsOn` 串联),子任务全部完成后父卡自动流转(`checkParentAuto`)——`task_create` 工具描述与 Team 模式提示词都写了这条契约
127
+ - **suggestSplit 软提示**:`task_create` / `create-task` 发现描述超 500 字符、或标题/描述命中「全量 / 整体 / 系统级 / 全面 / 重构 / 所有模块 / 整个」等史诗特征词时,返回体附带一行 `suggestSplit` 建议文案(**只提示,不阻断创建与派发**;未命中则不出现该字段,老调用方无感)
110
128
 
111
129
  ### 一次性派发(v74 去池化)
112
130
 
@@ -153,6 +171,7 @@ Team 托管档独有(调度员体验):
153
171
  | 子代理上报 | `board_report` / `board_verdict` |
154
172
 
155
173
  > 管理工具仅主窗口可用(子代理调用会被拒绝);`board_report`/`board_verdict` 是子代理的专用上报通道。
174
+ > `board_report` 的 `kind` 三档:`complete`(交付完成)/ `escalate`(歧义上报等裁决)/ `progress`(里程碑进展,静默可见、不通知)。
156
175
 
157
176
  ## 仓库结构
158
177
 
@@ -161,7 +180,7 @@ Team 托管档独有(调度员体验):
161
180
  │ ├── index.mjs # host 端:IO 编排(工具/RPC/一次性派发引擎接线)
162
181
  │ ├── lib/core.mjs # 纯逻辑核心:状态机/依赖/分类/prompt/解析(无 IO,可单测)
163
182
  │ ├── lib/client.js # client 端(ModuleLoader 包装,图标统一走 ICONS + ic())
164
- │ ├── test/core.test.mjs # 单元测试(node --test,54 例)
183
+ │ ├── test/core.test.mjs # 单元测试(node --test,72 例)
165
184
  │ ├── package.json # dsh.bundle.patch + dsh.client 元数据
166
185
  │ └── cordis.patch.yml # bundle 挂载行
167
186
  └── docs/
@@ -208,7 +227,7 @@ git push --follow-tags # tag 推送触发流水线
208
227
  - **README 单一来源**:本文件(根 README)即唯一来源;发版前在 `packages/dsh-agent-board` 跑一次 `npm run sync-readme` 同步进包(npm 页面展示的是包内 README)
209
228
  - 需在仓库 **Settings → Secrets and variables → Actions** 配置 `NPM_TOKEN`
210
229
  (npm granular access token:bypass 2FA + direct publish)
211
- - 日常 push / PR 有 `test.yml` 跑语法检查 + 54 例单测
230
+ - 日常 push / PR 有 `test.yml` 跑语法检查 + 60 例单测
212
231
  - 本地手动发布仍然可用:`npm publish --registry=https://registry.npmjs.org`(本机默认源是镜像时必须显式指定)
213
232
 
214
233
  ## License
package/index.mjs CHANGED
@@ -10,7 +10,7 @@ import { zstdDecompressSync } from 'node:zlib'
10
10
  import os from 'node:os'
11
11
  import path from 'node:path'
12
12
  import fsNode from 'node:fs'
13
- const { ah, isb, gsb, gpt, vt, validateDeps, depsSatisfied, depsCancelled, classifyPipeline, seed, normalizeBoard, cfg, claimCheck, claimApply, checkParentAuto, resolveApply, verifyApply, parseSections, parseVerdict, isEscalation, outputText, histNotes, buildContextPackSection, buildWorkerPrompt, buildVerifierPrompt, pickDispatch, isOrphan, PRIO_RANK, patOverlap, overlapsTouches, touchesConflict, holdsFiles } = core
13
+ const { ah, isb, gsb, gpt, vt, validateDeps, depsSatisfied, depsCancelled, classifyPipeline, seed, normalizeBoard, cfg, claimCheck, claimApply, checkParentAuto, resolveApply, verifyApply, parseSections, parseVerdict, isEscalation, outputText, histNotes, buildContextPackSection, buildWorkerPrompt, buildVerifierPrompt, pickDispatch, isOrphan, PRIO_RANK, patOverlap, overlapsTouches, touchesConflict, holdsFiles, lessonText, pushLesson, LESSON_RECALL_HINT } = core
14
14
 
15
15
  function defineTool(options) {
16
16
  var userExecute = options.execute
@@ -27,6 +27,161 @@ function defineTool(options) {
27
27
  }
28
28
  }
29
29
 
30
+ // ===== 任务粒度治理(全软方案:只引导/提示,绝不阻断创建与派发)=====
31
+ // 背景:agent 容易把史诗级大任务整坨塞进看板(长程 + 跑偏风险 + 返工成本高)。
32
+ // 落地三件套(都不带硬闸门):
33
+ // 1) task_create 工具描述里的粒度契约 TASK_SIZE_CONTRACT;
34
+ // 2) Team 模式系统提示词第 6 条 TEAM_SPLIT_RULE(epic 父卡 + 子任务 + checkParentAuto 收尾);
35
+ // 3) create-task RPC / task_create 工具返回体命中时附加 suggestSplit 一行建议(不落盘、不改状态)。
36
+ export var TASK_SIZE_CONTRACT = '建议粒度:单任务 10~30 分钟可独立完成。超出此范围的大任务请先拆分——建一张 epic 卡(parentId 体系)再挂子任务,别整坨塞进来。'
37
+ export var SUGGEST_SPLIT_TEXT = '任务看起来偏大(建议单任务 10~30 分钟):考虑拆分子任务(parentId)或收窄边界'
38
+ // Team 模式提示词第 6 条:大任务的 epic 拆分流程(父卡 pipeline=direct 不派发,子任务全 resolved 后父卡由 checkParentAuto 自动转 verifying)
39
+ export var TEAM_SPLIT_RULE = '6. 大任务必须拆分:预计超过 30 分钟的任务,先建一张 epic 父卡(pipeline 传 direct,不派发),再拆成若干 10~30 分钟的子任务(task_create 传 parentId=父卡 id,有先后顺序的用 dependsOn 串联)。子任务全部完成后父卡会自动标记完成(checkParentAuto)。'
40
+ // 史诗特征词:命中即视为"整坨塞进来"的典型信号(与 TASK_SIZE_CONTRACT 配套;无 /g,可安全复用)
41
+ var EPIC_WORDS = /全量|整体|系统级|全面|重构|所有模块|整个/
42
+ // description 长度阈值(字符):超过它说明描述密度远超"10~30 分钟单任务"应有体量
43
+ var SPLIT_DESC_LIMIT = 500
44
+ // 软闸门判定:description > 500 字符,或 title+description 命中史诗特征词 → 返回建议文案;否则返回 ''
45
+ // 纯函数、无 IO、不改任务字段——调用方拿到空串即视为粒度正常(falsy 判断即可)
46
+ export function suggestSplitOf(t) {
47
+ var desc = String((t && t.description) || '')
48
+ var text = String((t && t.title) || '') + ' ' + desc
49
+ if (desc.length > SPLIT_DESC_LIMIT || EPIC_WORDS.test(text)) return SUGGEST_SPLIT_TEXT
50
+ return ''
51
+ }
52
+ // 统一出口:只在命中时附加字段,老调用方拿到的返回体形态完全不变(多一个可选字段而已)
53
+ export function withSplitHint(out, t) {
54
+ var hint = suggestSplitOf(t)
55
+ if (hint && out && typeof out === 'object') out.suggestSplit = hint
56
+ return out
57
+ }
58
+
59
+ // ===== Token 消耗统计(v4 会话日志 usage 聚合)=====
60
+ // 数据源:Worker/Verifier 一次性子会话的 v4 追加写日志
61
+ // ~/.dsh/sessions/<bucket>/<childSessionId>/session.v4.jsonl.zstd
62
+ // 字段形状(用本机真实日志逐帧核对后确认,2026-10):
63
+ // type === 'assistant/message' 的事件带 data.usage =
64
+ // { inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, totalTokens }
65
+ // 模型名不在 assistant/message 上,而在同会话的 request/context(data.model,
66
+ // 与 request/header 的 data.header.config.model 同源)——模型小计以日志记录的为准。
67
+ // 定位/分帧逻辑与 agent-activity RPC 共用(同一套日志名探测 + zstd 帧头切分),
68
+ // 区别只是 usage 要扫全部帧、activity 只看最新几帧。
69
+ // 约束:只做展示、不做计费断言;读不到日志/无 usage 一律返回 null(调用方降级为「暂无数据」),绝不抛错。
70
+ var USAGE_LOG_NAMES = ['session.v4.jsonl.zstd', 'session.v3.jsonl.zstd', 'session.v2.jsonl.zstd', 'session.jsonl.zstd']
71
+ var ZSTD_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd])
72
+
73
+ // 定位某个 run(子会话 id)的日志文件:bucket × 版本名逐个探测(新版本名优先);找不到/不可读返回 null
74
+ export function findRunLog(runId, sessionsRoot) {
75
+ if (!runId) return null
76
+ try {
77
+ var root = sessionsRoot || path.join(os.homedir(), '.dsh', 'sessions')
78
+ var buckets = fsNode.readdirSync(root)
79
+ for (var bi = 0; bi < buckets.length; bi++) {
80
+ for (var ni = 0; ni < USAGE_LOG_NAMES.length; ni++) {
81
+ var cand = path.join(root, buckets[bi], String(runId), USAGE_LOG_NAMES[ni])
82
+ if (fsNode.existsSync(cand)) return cand
83
+ }
84
+ }
85
+ } catch (_) {}
86
+ return null
87
+ }
88
+
89
+ // 读日志字节:tailBytes > 0 时只读末尾这么多字节(activity 热路径用),否则读整份(usage 结算用)。
90
+ // 尾部截断可能切在帧中间,交给 readLogFrames 逐帧 try/catch 降级。
91
+ function readLogBytes(logPath, tailBytes) {
92
+ try {
93
+ var sz = fsNode.statSync(logPath).size
94
+ var start = (tailBytes > 0 && sz > tailBytes) ? sz - tailBytes : 0
95
+ var buf = Buffer.alloc(sz - start)
96
+ var fd = fsNode.openSync(logPath, 'r')
97
+ try { fsNode.readSync(fd, buf, 0, buf.length, start) } finally { fsNode.closeSync(fd) }
98
+ return buf
99
+ } catch (_) { return null }
100
+ }
101
+
102
+ // 追加写多帧格式:按帧头 MAGIC 切段、逐帧解压、逐行 JSON.parse。
103
+ // 返回帧数组(从新到旧),每帧是事件数组(保持帧内原序);limit > 0 只取最新 limit 帧。
104
+ // 坏帧/半帧(强杀截断、MAGIC 假命中、尾部切在压缩流中间)整帧跳过,绝不抛错。
105
+ function readLogFrames(buf, limit) {
106
+ var out = []
107
+ if (!buf || !buf.length) return out
108
+ var offs = []
109
+ var at = buf.indexOf(ZSTD_MAGIC)
110
+ while (at >= 0) { offs.push(at); at = buf.indexOf(ZSTD_MAGIC, at + 1) }
111
+ for (var k = offs.length - 1; k >= 0; k--) {
112
+ if (limit > 0 && out.length >= limit) break
113
+ var end = k + 1 < offs.length ? offs[k + 1] : buf.length
114
+ var evs = []
115
+ try {
116
+ var text = zstdDecompressSync(buf.subarray(offs[k], end)).toString('utf8')
117
+ var lines = text.split('\n')
118
+ for (var i = 0; i < lines.length; i++) {
119
+ if (!lines[i]) continue
120
+ try { evs.push(JSON.parse(lines[i])) } catch (_) {}
121
+ }
122
+ } catch (_) { continue }
123
+ out.push(evs)
124
+ }
125
+ return out
126
+ }
127
+
128
+ // 聚合一次 run 的全部 token 消耗:{input, output, cacheRead, cacheWrite, total, model}。
129
+ // 逐帧扫 assistant/message 的 data.usage 累加(无 usage 的事件跳过、没有 usage 事件返回 null)。
130
+ // total 优先取日志自带的 totalTokens,缺失时才用 输入+输出+缓存读+缓存写 兜底。
131
+ export function readRunUsage(runId, sessionsRoot) {
132
+ try {
133
+ var log = findRunLog(runId, sessionsRoot)
134
+ if (!log) return null
135
+ // 整份读取:usage 必须全量累加(单次 run 日志量级 MB,结算时只读一次)。
136
+ // 极端超大日志封顶 64MB——只丢最老的历史帧,好过结算路径被一次同步 IO 拖住。
137
+ var buf = readLogBytes(log, 64 * 1024 * 1024)
138
+ if (!buf) return null
139
+ var frames = readLogFrames(buf, 0)
140
+ var out = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0, model: '' }
141
+ var hasUsage = false
142
+ for (var fi = 0; fi < frames.length; fi++) {
143
+ var evs = frames[fi]
144
+ for (var i = 0; i < evs.length; i++) {
145
+ var e = evs[i]; var dta = (e && e.data) || {}
146
+ if (e && e.type === 'request/context' && dta.model && !out.model) out.model = String(dta.model)
147
+ if (!e || e.type !== 'assistant/message') continue
148
+ var u = dta.usage
149
+ if (!u) continue
150
+ var inp = numOr0(u.inputTokens), outp = numOr0(u.outputTokens)
151
+ var cr = numOr0(u.cacheReadTokens), cw = numOr0(u.cacheWriteTokens)
152
+ out.input += inp; out.output += outp; out.cacheRead += cr; out.cacheWrite += cw
153
+ out.total += numOr0(u.totalTokens) || (inp + outp + cr + cw)
154
+ hasUsage = true
155
+ }
156
+ }
157
+ return hasUsage ? out : null
158
+ } catch (e) {
159
+ // 日志解析失败一律降级:usage 只是展示统计,绝不影响结算与状态流转
160
+ console.error('[task-board] readRunUsage failed (' + runId + '):', String(e))
161
+ return null
162
+ }
163
+ }
164
+ function numOr0(v) { var n = Number(v); return isFinite(n) && n > 0 ? n : 0 }
165
+
166
+ // board 级聚合(get-tasks 现算,不落盘额外表):总量 + 输入/输出/缓存读拆分 + 按模型小计 + 任务 Top8。
167
+ // 归档任务同样计入(它们确实消耗过 token);无 usage 的任务跳过。
168
+ export function aggregateUsageSummary(tasks) {
169
+ var s = { total: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, byModel: {}, topTasks: [] }
170
+ var list = Array.isArray(tasks) ? tasks : []
171
+ for (var i = 0; i < list.length; i++) {
172
+ var t = list[i]; var u = t && t.usage
173
+ if (!u) continue
174
+ s.total += u.total || 0; s.input += u.input || 0; s.output += u.output || 0
175
+ s.cacheRead += u.cacheRead || 0; s.cacheWrite += u.cacheWrite || 0
176
+ var ms = u.models || {}
177
+ for (var mk in ms) { if (Object.prototype.hasOwnProperty.call(ms, mk)) s.byModel[mk] = (s.byModel[mk] || 0) + (ms[mk] || 0) }
178
+ if (u.total) s.topTasks.push({ id: t.id, title: t.title, total: u.total, runs: u.runs || 0 })
179
+ }
180
+ s.topTasks.sort(function (a, b) { return b.total - a.total })
181
+ s.topTasks = s.topTasks.slice(0, 8)
182
+ return s
183
+ }
184
+
30
185
  export const name = 'dsh-agent-board'
31
186
  export const inject = ['fs', 'timer', 'subagents', 'agents', 'tools', 'webServer']
32
187
 
@@ -62,6 +217,10 @@ export function apply(ctx) {
62
217
  function touchSession(sid) { if (sid && typeof sid === 'string' && sid !== 'unknown') knownSessions[sid] = Date.now() }
63
218
  // teamMode 缓存:由 rt() 同步,供 systemPrompt 动态引导段读取(v65)
64
219
  var teamModeCache = {}
220
+ // feedbackEnabled 缓存(学习飞轮 v1):同样由 rt() 同步——systemPrompt 的组装是同步函数,
221
+ // 不能在里面读文件。与 teamModeCache 同源同生命周期(引导段本来就要 teamMode 命中才渲染)。
222
+ var feedbackCache = {}
223
+ function feedbackOn(sid) { return feedbackCache[sid] !== false }
65
224
  // 工作模式三档收敛(UI 一维化):内部仍存 boardMode+teamMode 两个 flag(老数据/老 RPC 无损),
66
225
  // workMode 是纯派生字段——由两 flag 算出,不落盘(normalizeBoard 无需改动)。
67
226
  // 'team' → boardMode=auto + teamMode=true(主窗口当调度员:默认草稿 + 裁决歧义)
@@ -78,7 +237,7 @@ export function apply(ctx) {
78
237
  try { r = await fsNode.promises.readFile(boardPath(sid), 'utf8') } catch (_) { return seed(sid) } // 不存在/不可读 → 空板
79
238
  try {
80
239
  var d = JSON.parse(r)
81
- if (vt(d) && d.ownerSession === sid) { teamModeCache[sid] = !!d.teamMode; return normalizeBoard(d) }
240
+ if (vt(d) && d.ownerSession === sid) { teamModeCache[sid] = !!d.teamMode; var nd = normalizeBoard(d); feedbackCache[sid] = nd.feedbackEnabled !== false; return nd }
82
241
  return seed(sid)
83
242
  } catch (_) {
84
243
  // JSON 截断/损坏(如强杀打断写盘):隔离留档再种新板——数据不丢,坏文件也不反复 poison
@@ -136,6 +295,21 @@ export function apply(ctx) {
136
295
  // 超时保护:run 挂死时走失败重试路径
137
296
  function withTimeout(promise, ms, label) { var timer = ctx.timer; if (!timer) return promise; return Promise.race([promise, timer.timeout(ms).then(function () { throw new Error(label + ' timeout ' + ms + 'ms') })]) }
138
297
 
298
+ // ===== 学习飞轮 v1:候选教训信号(只产信号,不做存储)=====
299
+ // 看板在这里只做一件事:把「Verifier 驳回 / 主窗口仲裁结论」变成一条结构化的候选教训,
300
+ // 落进 t.messages(kind='lesson-candidate':详情页可见 + 带「沉淀」按钮)。
301
+ // 零耦合红线:不调用任何笔记/记忆工具的 API、不写任何外部文件——教训最终存到哪、要不要存,
302
+ // 全由主窗口 agent 自己用可用工具决定。触发前一律先过 feedbackEnabled 总开关(关掉 = 不生成)。
303
+ function lessonSource(t) { return '任务 ' + t.id + '「' + String(t.title || '').slice(0, 60) + '」 · ' + new Date().toISOString() }
304
+ function pushRejectLesson(d, t, reason, at) {
305
+ if (!cfg(d).feedbackEnabled) return false
306
+ return pushLesson(t, lessonText('任务「' + String(t.title || '') + '」(' + t.id + ') 被 Verifier 驳回', [['错误做法', String(reason || '(Verifier 未给出理由)')]], lessonSource(t)), at, 'system')
307
+ }
308
+ function pushArbitrationLesson(d, t, question, answer, at) {
309
+ if (!cfg(d).feedbackEnabled) return false
310
+ return pushLesson(t, lessonText('任务「' + String(t.title || '') + '」(' + t.id + ') 的歧义裁决', [['疑问', question], ['裁决结论', answer]], lessonSource(t)), at, 'system')
311
+ }
312
+
139
313
 
140
314
  // ===== 状态流转(已抽取到 lib/core.mjs)=====
141
315
  // ===== 一次性派发引擎(v74 去池化重写)=====
@@ -234,7 +408,7 @@ export function apply(ctx) {
234
408
  try { pack = await readContextPack(sid, t) } catch (e) { console.error('[task-board] context pack read failed:', String(e)) }
235
409
  // user prompt 只留一行指引,内容走上下文注入区块
236
410
  var packNote = pack ? '本任务附带主窗口预研文件,已通过「上下文注入」区提供(含文件完整内容),直接基于其内容工作,不要重复读取这些文件。' : ''
237
- var req = { label: role + ':' + t.id, prompt: [{ type: 'text', text: role === 'worker' ? buildWorkerPrompt(t, packNote) : buildVerifierPrompt(t, packNote) }], parent: parent, signal: makeSignal() }
411
+ var req = { label: role + ':' + t.id, prompt: [{ type: 'text', text: role === 'worker' ? buildWorkerPrompt(t, packNote, cfg(dsnap).feedbackEnabled) : buildVerifierPrompt(t, packNote) }], parent: parent, signal: makeSignal() }
238
412
  if (modelOverride) {
239
413
  // list-models 返回的 id 是 "provider/model" 复合格式(如 "cmss/zhanlu/glm-5.2"),
240
414
  // 但 AgentOptions 的 provider 和 model 是分开的——整串塞进 model 会报 UNKNOWN_MODEL
@@ -296,6 +470,37 @@ export function apply(ctx) {
296
470
  } catch (e) { console.error('[task-board] settle ' + rec.role + ' failed (task ' + rec.taskId + '):', String(e)) }
297
471
  // 历史会话留档:记录该次 run 的结局(完成/失败/硬超时),详情页可据此标注阶段状态
298
472
  closeRunHistory(sid, rec.taskId, String(rec.run.id), failed ? (err ? 'timeout/error' : 'incomplete') : 'completed').catch(function () {})
473
+ // ===== token 消耗结算:读该次 run 的 v4 日志聚合 usage,累加到任务 =====
474
+ // 放在状态推进之后:统计是附加信息,读日志失败/无 usage 时静默跳过,绝不影响结算语义。
475
+ // Worker 失败重试、驳回重做都会各走一次 settleRun,因此多轮消耗天然累加(runs 计数)。
476
+ await accumulateRunUsage(sid, rec)
477
+ }
478
+
479
+ // 把一次 run 的 token 消耗累加到任务(t.usage):总量/输入/输出/缓存读写 + 按模型小计 + runs 计数。
480
+ // 模型小计的 key:优先本次派发显式覆盖的模型(rec.model),否则用日志里记录的会话模型。
481
+ async function accumulateRunUsage(sid, rec) {
482
+ var u = null
483
+ try { u = readRunUsage(String(rec.run.id)) } catch (_) { u = null }
484
+ if (!u || !u.total) return
485
+ try {
486
+ await mutateLocked(sid, function (d) {
487
+ var t = d.tasks.find(function (x) { return x.id === rec.taskId })
488
+ if (!t) return null
489
+ if (!t.usage) t.usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0, runs: 0, models: {} }
490
+ // 逐字段兜底 0:脏数据(半写/老形态)也不会把 NaN 带进看板(NaN 会让工具输出的 lossless-JSON 校验拒整条结果)
491
+ t.usage.input = (t.usage.input || 0) + u.input
492
+ t.usage.output = (t.usage.output || 0) + u.output
493
+ t.usage.cacheRead = (t.usage.cacheRead || 0) + u.cacheRead
494
+ t.usage.cacheWrite = (t.usage.cacheWrite || 0) + u.cacheWrite
495
+ t.usage.total = (t.usage.total || 0) + u.total
496
+ t.usage.runs = (t.usage.runs || 0) + 1
497
+ if (!t.usage.models) t.usage.models = {}
498
+ var mk = rec.model || u.model || '(未知模型)'
499
+ t.usage.models[mk] = (t.usage.models[mk] || 0) + u.total
500
+ t.usage.updatedAt = new Date().toISOString()
501
+ return { ok: true }
502
+ })
503
+ } catch (e) { console.error('[task-board] usage accumulate failed (task ' + rec.taskId + '):', String(e)) }
299
504
  }
300
505
 
301
506
  async function settleWorker(sid, rec, output, failed, errText) {
@@ -355,6 +560,8 @@ export function apply(ctx) {
355
560
  verifyApply(d, t, String(rec.run.id), approved ? 'approved' : 'rejected', trimmed.slice(0, 200))
356
561
  if (!approved) {
357
562
  t.rejectCount = (t.rejectCount || 0) + 1
563
+ // 学习飞轮 v1:Verifier 驳回 → 候选教训(场景/错误做法/来源),随 t.verification.at 判重
564
+ pushRejectLesson(d, t, vsecs.verifySummary || trimmed, t.verification.at)
358
565
  if (t.rejectCount >= 3) { t.status = 'blocked'; ah(t, 'in-progress', 'blocked', 'system', 'verifier 驳回 x' + t.rejectCount + ',待人工裁决') }
359
566
  else { t.status = 'pending'; t.claimedBy = null; t.claimedAt = null; ah(t, 'in-progress', 'pending', 'system', '驳回重派:新 Worker 将携带驳回原因继续') }
360
567
  }
@@ -601,7 +808,7 @@ export function apply(ctx) {
601
808
  // 不能走 resolveRoot:该函数在每个 agent 每次 prompt 组装时同步执行,
602
809
  // resolveRoot 全表扫 agent 注册表会把宿主事件循环卡死(曾导致全局界面卡顿、用户消息延迟渲染)。
603
810
  if (!teamModeCache[String(agent.id)]) return ''
604
- return '【任务看板 Team 模式已开启】\n本会话的任务看板处于 Team 模式。请遵循以下工作方式:\n1. 涉及代码改动、文件创建、命令执行等实质性工作时,优先用 task_create 提交为看板任务(由一次性 Worker/Verifier 子代理执行与验收),不要自己直接动手实现。\n2. 你仍保有全部工具能力——调研、读代码、讨论方案、回答问题时直接进行,无需提交任务。\n3. 创建任务时,务必在 description 里写清任务目标和约束;调研结论/原始需求/思路用 contextNotes 带上,调研时读过的关键文件用 contextFiles 把路径带上——两者都会通过「上下文注入」通道传给子代理(独立注入区块,不占对话流)。子代理是全新会话、无你的会话记忆,上下文不够它需要从零自行调研,效率大打折扣甚至跑偏方向。\n4. Worker 上报歧义时会通过 task_arbitrate 等待你裁决,请及时响应。驳回重派时同样:新 Worker 没有上一轮的记忆,驳回原因会在 prompt 里,但额外上下文需你在 description 里补上。\n5. Team 模式下 task_create 默认建为草稿(草稿不会被派发领取)。把所有任务的 dependsOn 依赖关系、contextNotes/contextFiles 都补完后,再逐个 task_update publish=true 统一发布。确实需要立即派发的单个任务才显式传 draft:false。'
811
+ return '【任务看板 Team 模式已开启】\n本会话的任务看板处于 Team 模式。请遵循以下工作方式:\n1. 涉及代码改动、文件创建、命令执行等实质性工作时,优先用 task_create 提交为看板任务(由一次性 Worker/Verifier 子代理执行与验收),不要自己直接动手实现。\n2. 你仍保有全部工具能力——调研、读代码、讨论方案、回答问题时直接进行,无需提交任务。\n3. 创建任务时,务必在 description 里写清任务目标和约束;调研结论/原始需求/思路用 contextNotes 带上,调研时读过的关键文件用 contextFiles 把路径带上——两者都会通过「上下文注入」通道传给子代理(独立注入区块,不占对话流)。子代理是全新会话、无你的会话记忆,上下文不够它需要从零自行调研,效率大打折扣甚至跑偏方向。\n4. Worker 上报歧义时会通过 task_arbitrate 等待你裁决,请及时响应。驳回重派时同样:新 Worker 没有上一轮的记忆,驳回原因会在 prompt 里,但额外上下文需你在 description 里补上。\n5. Team 模式下 task_create 默认建为草稿(草稿不会被派发领取)。把所有任务的 dependsOn 依赖关系、contextNotes/contextFiles 都补完后,再逐个 task_update publish=true 统一发布。确实需要立即派发的单个任务才显式传 draft:false。\n' + TEAM_SPLIT_RULE + (feedbackOn(String(agent.id)) ? '\n' + LESSON_RECALL_HINT + '把检索到的相关历史教训写进任务的 contextNotes,让子代理少踩重复的坑。' : '')
605
812
  },
606
813
  })
607
814
  ctx.effect(function () { return disposeSection })
@@ -643,7 +850,7 @@ export function apply(ctx) {
643
850
  ctx.tools.register(defineTool({ name: 'task_verify', description: '验收:approved→resolved,rejected→in-progress。子任务全完成父任务自动verifying。', parameters: { type: 'object', properties: { taskId: { type: 'string' }, verdict: { type: 'string', enum: ['approved', 'rejected'] }, comment: { type: 'string' } }, required: ['taskId', 'verdict'] }, output: jo(), execute: async function (args) { var __ra = getActorId(); if (resolveRoot(__ra) !== __ra) return { ok: false, error: '看板管理工具仅主窗口可用(子代理无看板权限)' }; var sid = toolSessionId(); var actor = getActorId(); return mutateLocked(sid, function (d) { var t = d.tasks.find(function (x) { return x.id === args.taskId }); if (!t) return { ok: false, error: 'not found' }; if (t.status !== 'verifying') return { ok: false, error: 'not verifying' }; return verifyApply(d, t, actor, args.verdict, args.comment) }) } }))
644
851
  ctx.tools.register(defineTool({ name: 'task_archive', description: '归档已解决/已取消任务。', parameters: { type: 'object', properties: { taskId: { type: 'string' } }, required: ['taskId'] }, output: jo(), execute: async function (args) { var __ra = getActorId(); if (resolveRoot(__ra) !== __ra) return { ok: false, error: '看板管理工具仅主窗口可用(子代理无看板权限)' }; var sid = toolSessionId(); var actor = getActorId(); return mutateLocked(sid, function (d) { var a = d.tasks; var t = a.find(function (x) { return x.id === args.taskId }); if (!t) return { ok: false, error: 'not found' }; if (t.status !== 'resolved' && t.status !== 'cancelled') return { ok: false, error: 'only resolved/cancelled' }; var ps = t.status; t.status = 'archived'; t.archivedAt = new Date().toISOString(); ah(t, ps, 'archived', actor, 'archived'); var ca = 0; gsb(t.id, a).forEach(function (c) { if (c.status !== 'archived') { ah(c, c.status, 'archived', actor, 'cascade'); c.status = 'archived'; c.archivedAt = new Date().toISOString(); ca++ } }); var r = { ok: true, task: t }; if (ca) r.childrenArchived = ca; return r }) } }))
645
852
  ctx.tools.register(defineTool({ name: 'task_update', description: '更新任务字段,可选重置为 pending。dependsOn/pipeline 也可更新(环检测会拒绝成环依赖)。contextFiles 可更新预研文件清单。unfreeze:true 解除裁决挂起冻结(frozen)并触发派发。', parameters: { type: 'object', properties: { taskId: { type: 'string' }, title: { type: 'string' }, description: { type: 'string' }, priority: { type: 'string', enum: ['low', 'medium', 'high', 'critical'] }, assignMode: { type: 'string', enum: ['auto', 'manual'] }, assignee: { type: 'string' }, dependsOn: { type: 'array', items: { type: 'string' } }, contextFiles: { type: 'array', items: { type: 'string' }, description: '预研文件路径(替换式更新),派发时经上下文注入传给子代理' }, contextNotes: { type: 'string', description: '预研笔记(替换式更新):调研结论/原始需求/思路' }, pipeline: { type: 'string', enum: ['full', 'work', 'direct'] }, resetToPending: { type: 'boolean' }, unfreeze: { type: 'boolean', description: '解除冻结(frozen)并立即触发派发,用于 hold 裁决补完上下文后重新入池' }, publish: { type: 'boolean', description: '发布草稿为 pending(仅 draft 状态有效)' } }, required: ['taskId'] }, output: jo(), execute: async function (args) { var __ra = getActorId(); if (resolveRoot(__ra) !== __ra) return { ok: false, error: '看板管理工具仅主窗口可用(子代理无看板权限)' }; var sid = toolSessionId(); var actor = getActorId(); return mutateLocked(sid, function (d) { var t = d.tasks.find(function (x) { return x.id === args.taskId }); if (!t) return { ok: false, error: 'not found' }; if (args.title !== undefined) t.title = args.title; if (args.description !== undefined) t.description = args.description; if (args.priority !== undefined) t.priority = args.priority; if (args.assignMode !== undefined) t.assignMode = args.assignMode; if (args.assignee !== undefined) t.assignee = args.assignee || null; if (args.dependsOn !== undefined) { var derr = validateDeps(d, t.id, args.dependsOn); if (derr) return { ok: false, error: derr }; t.dependsOn = args.dependsOn } if (args.pipeline !== undefined) { t.pipeline = args.pipeline; t.pipelineAuto = false } if (args.contextFiles !== undefined) { if (!t.context) t.context = { files: [], docs: [], instructions: '', notes: '', relatedTasks: [], prerequisites: '' }; t.context.files = Array.isArray(args.contextFiles) ? args.contextFiles.map(String).slice(0, 20) : [] } if (args.contextNotes !== undefined) { if (!t.context) t.context = { files: [], docs: [], instructions: '', notes: '', relatedTasks: [], prerequisites: '' }; t.context.notes = typeof args.contextNotes === 'string' ? args.contextNotes.slice(0, 8000) : '' } if (args.publish) { if (t.status !== 'draft') return { ok: false, error: 'not a draft' }; t.status = 'pending'; ah(t, 'draft', 'pending', actor, 'published') } if (args.unfreeze && t.frozen) { delete t.frozen; delete t.frozenAt; delete t.frozenBy; ah(t, t.status, t.status, actor, '解除冻结,重新进入派发池') } if (args.resetToPending) { var ps = t.status; t.status = 'pending'; t.claimedBy = null; t.claimedAt = null; t.resolvedAt = null; t.resolution = null; ah(t, ps, 'pending', actor, 'reset to pending after edit') }; return { ok: true, task: t } }) } }))
646
- ctx.tools.register(defineTool({ name: 'task_create', description: '创建新任务到当前会话看板。acceptance 可选:硬性验收脚本命令(如 "node --test src/x.test.js"),Worker 必须实际运行、Verifier 必须独立复跑。dependsOn 可选:依赖任务 id 数组,依赖全部完成后才会被派发。pipeline 可选:full(默认,工作+验证)/work(只做不验)/direct(不进池,主窗口直接处理)。contextFiles 可选:你在调研中已经读过的关键文件路径数组——派发时 host 会从磁盘读取最新内容,通过「上下文注入」通道传给 Worker/Verifier(呈现为独立注入区块,不占对话流),避免子代理从零重复调研。contextNotes 可选:调研结论/原始需求/思路等非文件类上下文,同样走上下文注入。touches 可选:本任务将要改动的文件路径/glob 数组(如 "src/x.mjs"、"src/**")——派发器在同一时刻只派发 touches 不冲突的任务,避免并行 Worker 改同一批文件互踩;任务进行中持有文件锁,提交验收/完成后释放。', parameters: { type: 'object', properties: { id: { type: 'string' }, title: { type: 'string' }, description: { type: 'string' }, priority: { type: 'string', enum: ['low', 'medium', 'high', 'critical'] }, tags: { type: 'array', items: { type: 'string' } }, parentId: { type: 'string' }, instructions: { type: 'string' }, acceptance: { type: 'string' }, dependsOn: { type: 'array', items: { type: 'string' } }, contextFiles: { type: 'array', items: { type: 'string' }, description: '预研文件路径(相对 workspace 或绝对路径),内容将在派发时经上下文注入传给子代理' }, contextNotes: { type: 'string', description: '预研笔记:调研结论/原始需求/思路等(≤8000 字符),经上下文注入传给子代理' }, pipeline: { type: 'string', enum: ['full', 'work', 'direct'] }, touches: { type: 'array', items: { type: 'string' }, description: '本任务将改动的文件路径/glob(文件级排他锁):与活动任务 touches 冲突时不派发,等锁释放;支持 "src/**" 目录、"./a/b.mjs"、裸文件名等写法' }, draft: { type: 'boolean', description: '创建为草稿(不派发)。Team 模式下缺省即为 true——补全 dependsOn/上下文后用 task_update publish=true 统一发布;非 Team 模式缺省 false,显式 draft:false 可跳过草稿' } }, required: ['title'] }, output: jo(), execute: async function (args) { var __ra = getActorId(); if (resolveRoot(__ra) !== __ra) return { ok: false, error: '看板管理工具仅主窗口可用(子代理无看板权限)' }; var sid = toolSessionId(); var actor = getActorId(); return mutateLocked(sid, function (d) { if (args.id && d.tasks.find(function (x) { return x.id === args.id })) return { ok: false, error: 'duplicate id: ' + args.id }; if (args.dependsOn && args.dependsOn.length) { var derr = validateDeps(d, args.id || '(pending)', args.dependsOn); if (derr) return { ok: false, error: derr } }; var now = new Date().toISOString(); /* Team 模式护栏:draft 缺省跟随 teamMode(先补齐依赖/上下文再统一 publish);显式 draft:false 保留为立即派发的逃生门 */ var asDraft = args.draft === undefined ? !!d.teamMode : !!args.draft; var t = { id: args.id || ('task-' + Date.now().toString(36)), title: args.title, description: args.description || '', status: asDraft ? 'draft' : 'pending', priority: args.priority || 'medium', tags: args.tags || [], parentId: args.parentId || null, subtaskStrategy: null, assignMode: 'auto', assignee: null, context: { files: (Array.isArray(args.contextFiles) ? args.contextFiles.map(String).slice(0, 20) : []), docs: [], instructions: args.instructions || '', notes: (typeof args.contextNotes === 'string' ? args.contextNotes.slice(0, 8000) : ''), relatedTasks: [], prerequisites: '' }, acceptance: args.acceptance || '', dependsOn: args.dependsOn || [], touches: normTouches(args.touches), pipeline: args.pipeline || '', claimedBy: null, claimedAt: null, createdAt: now, resolvedAt: null, verifiedAt: null, verifiedBy: null, archivedAt: null, resolution: null, waitingForTouches: null, messages: [], history: [{ from: 'created', to: asDraft ? 'draft' : 'pending', timestamp: now, actor: actor, note: asDraft ? 'created as draft' : 'created' }] }; if (!t.pipeline) t.pipeline = classifyPipeline(t); t.pipelineAuto = !args.pipeline; d.tasks.push(t); return { ok: true, task: t } }) } }))
853
+ ctx.tools.register(defineTool({ name: 'task_create', description: '创建新任务到当前会话看板。acceptance 可选:硬性验收脚本命令(如 "node --test src/x.test.js"),Worker 必须实际运行、Verifier 必须独立复跑。dependsOn 可选:依赖任务 id 数组,依赖全部完成后才会被派发。pipeline 可选:full(默认,工作+验证)/work(只做不验)/direct(不进池,主窗口直接处理)。contextFiles 可选:你在调研中已经读过的关键文件路径数组——派发时 host 会从磁盘读取最新内容,通过「上下文注入」通道传给 Worker/Verifier(呈现为独立注入区块,不占对话流),避免子代理从零重复调研。contextNotes 可选:调研结论/原始需求/思路等非文件类上下文,同样走上下文注入。touches 可选:本任务将要改动的文件路径/glob 数组(如 "src/x.mjs"、"src/**")——派发器在同一时刻只派发 touches 不冲突的任务,避免并行 Worker 改同一批文件互踩;任务进行中持有文件锁,提交验收/完成后释放。' + TASK_SIZE_CONTRACT, parameters: { type: 'object', properties: { id: { type: 'string' }, title: { type: 'string' }, description: { type: 'string' }, priority: { type: 'string', enum: ['low', 'medium', 'high', 'critical'] }, tags: { type: 'array', items: { type: 'string' } }, parentId: { type: 'string' }, instructions: { type: 'string' }, acceptance: { type: 'string' }, dependsOn: { type: 'array', items: { type: 'string' } }, contextFiles: { type: 'array', items: { type: 'string' }, description: '预研文件路径(相对 workspace 或绝对路径),内容将在派发时经上下文注入传给子代理' }, contextNotes: { type: 'string', description: '预研笔记:调研结论/原始需求/思路等(≤8000 字符),经上下文注入传给子代理' }, pipeline: { type: 'string', enum: ['full', 'work', 'direct'] }, touches: { type: 'array', items: { type: 'string' }, description: '本任务将改动的文件路径/glob(文件级排他锁):与活动任务 touches 冲突时不派发,等锁释放;支持 "src/**" 目录、"./a/b.mjs"、裸文件名等写法' }, draft: { type: 'boolean', description: '创建为草稿(不派发)。Team 模式下缺省即为 true——补全 dependsOn/上下文后用 task_update publish=true 统一发布;非 Team 模式缺省 false,显式 draft:false 可跳过草稿' } }, required: ['title'] }, output: jo(), execute: async function (args) { var __ra = getActorId(); if (resolveRoot(__ra) !== __ra) return { ok: false, error: '看板管理工具仅主窗口可用(子代理无看板权限)' }; var sid = toolSessionId(); var actor = getActorId(); return mutateLocked(sid, function (d) { if (args.id && d.tasks.find(function (x) { return x.id === args.id })) return { ok: false, error: 'duplicate id: ' + args.id }; if (args.dependsOn && args.dependsOn.length) { var derr = validateDeps(d, args.id || '(pending)', args.dependsOn); if (derr) return { ok: false, error: derr } }; var now = new Date().toISOString(); /* Team 模式护栏:draft 缺省跟随 teamMode(先补齐依赖/上下文再统一 publish);显式 draft:false 保留为立即派发的逃生门 */ var asDraft = args.draft === undefined ? !!d.teamMode : !!args.draft; var t = { id: args.id || ('task-' + Date.now().toString(36)), title: args.title, description: args.description || '', status: asDraft ? 'draft' : 'pending', priority: args.priority || 'medium', tags: args.tags || [], parentId: args.parentId || null, subtaskStrategy: null, assignMode: 'auto', assignee: null, context: { files: (Array.isArray(args.contextFiles) ? args.contextFiles.map(String).slice(0, 20) : []), docs: [], instructions: args.instructions || '', notes: (typeof args.contextNotes === 'string' ? args.contextNotes.slice(0, 8000) : ''), relatedTasks: [], prerequisites: '' }, acceptance: args.acceptance || '', dependsOn: args.dependsOn || [], touches: normTouches(args.touches), pipeline: args.pipeline || '', claimedBy: null, claimedAt: null, createdAt: now, resolvedAt: null, verifiedAt: null, verifiedBy: null, archivedAt: null, resolution: null, waitingForTouches: null, messages: [], history: [{ from: 'created', to: asDraft ? 'draft' : 'pending', timestamp: now, actor: actor, note: asDraft ? 'created as draft' : 'created' }] }; if (!t.pipeline) t.pipeline = classifyPipeline(t); t.pipelineAuto = !args.pipeline; d.tasks.push(t); return withSplitHint({ ok: true, task: t }, t) }) } }))
647
854
 
648
855
  // ===== RPC =====
649
856
  // get-tasks 是纯读路径(rt 只读文件)——poolCycle 由 15s 心跳 + 写入后 kickCycle 驱动,
@@ -651,8 +858,13 @@ export function apply(ctx) {
651
858
  handle('get-tasks', async function (args) { var sid = rpcSessionId(args); var d = await rt(sid); d.sessionId = sid; var __ag = ctx.agents; d.isRoot = true; if (__ag) { var __roots = __ag.roots(); var __rids = []; for (var __i = 0; __i < __roots.length; __i++) __rids.push(String(__roots[__i].id)); d.isRoot = __rids.indexOf(sid) >= 0 }
652
859
  // poolStatus 防幽灵:只保留指向当前活跃任务的条目(重启后内存 runs 清空,文件快照可能残留)
653
860
  if (d.poolStatus) { var __act = {}; (d.tasks || []).forEach(function (t) { if (t.status === 'in-progress' || t.status === 'verifying') __act[t.id] = true }); d.poolStatus.workers = (d.poolStatus.workers || []).filter(function (w) { return __act[w.taskId] }); d.poolStatus.verifiers = (d.poolStatus.verifiers || []).filter(function (v) { return __act[v.taskId] }) }
861
+ // 学习飞轮 v1:把 feedbackEnabled 显式放进返回体(normalizeBoard 已按老看板补默认 true),
862
+ // 客户端据此做能力检测——开关关闭时渲染层不生成候选、不显示「沉淀」按钮。
863
+ d.feedbackEnabled = cfg(d).feedbackEnabled
654
864
  // 工作模式派生字段:UI 只读这一个字段决定三档选中态(不落盘,写入仍走 boardMode/teamMode)
655
865
  d.workMode = deriveWorkMode(d)
866
+ // ===== board 级 token 消耗聚合(现算,不落盘):总量 + 输入/输出/缓存读 + 按模型 + 任务 Top8 =====
867
+ d.usageSummary = aggregateUsageSummary(d.tasks)
656
868
  return d })
657
869
  // 派发前上下文预览:主 agent 用它确认"我将注入给子代理的材料"是否足够(不发任务、不落盘)
658
870
  handle('preview-context', async function (args) {
@@ -674,48 +886,31 @@ export function apply(ctx) {
674
886
  // (不再从看板路径反推 workspace——那依赖进程 cwd 凑巧等于工作区,曾是隐性 bug)
675
887
  // v0.1.7 起日志文件名带格式版本号(session.v4.jsonl.zstd),旧会话是
676
888
  // session.v3.jsonl.zstd / session.jsonl.zstd——按新到旧逐个探测。
677
- var sessionsRoot = path.join(os.homedir(), '.dsh', 'sessions')
678
- var log = null
679
- var LOG_NAMES = ['session.v4.jsonl.zstd', 'session.v3.jsonl.zstd', 'session.v2.jsonl.zstd', 'session.jsonl.zstd']
680
- var buckets = fsNode.readdirSync(sessionsRoot)
681
- for (var bi = 0; bi < buckets.length && !log; bi++) {
682
- for (var ni = 0; ni < LOG_NAMES.length; ni++) {
683
- var cand = path.join(sessionsRoot, buckets[bi], child, LOG_NAMES[ni])
684
- if (fsNode.existsSync(cand)) { log = cand; break }
685
- }
686
- }
889
+ // 定位与分帧和 readRunUsage 共用同一套 helper(findRunLog / readLogBytes / readLogFrames)。
890
+ var log = findRunLog(child)
687
891
  if (!log) return { ok: true, activity: null, reason: 'log not found' }
688
892
  // 只同步读末尾 2MB(日志追加写,末帧必在尾部)——整文件 readFileSync 在大日志上
689
893
  // 会造成数十毫秒级同步 I/O,多任务轮询时叠加成全局卡顿
690
- var sz = fsNode.statSync(log).size
691
- var TAIL = 2 * 1024 * 1024
692
- var start = sz > TAIL ? sz - TAIL : 0
693
- var buf = Buffer.alloc(sz - start)
694
- var fd = fsNode.openSync(log, 'r')
695
- try { fsNode.readSync(fd, buf, 0, buf.length, start) } finally { fsNode.closeSync(fd) }
696
- var MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd])
894
+ var buf = readLogBytes(log, 2 * 1024 * 1024)
895
+ if (!buf) return { ok: true, activity: null, reason: 'log unreadable' }
697
896
  // 追加写多帧格式:最新事件在末帧。但末帧可能只有 step/end、turn/end 这类
698
897
  // 结算事件(v4 一帧只装一个step的增量),单解一帧经常捞不到动作 → 从新到旧
699
898
  // 最多回扫 3 帧,找到第一个动作摘要即止(成本仍受控:每帧只是一次 zstd 解压)
899
+ var frames = readLogFrames(buf, 3)
700
900
  var activity = null
701
- for (var frame = 0; frame < 3 && !activity; frame++) {
702
- var last = buf.lastIndexOf(MAGIC, frame === 0 ? buf.length - 1 : last - 1)
703
- if (last < 0) break
704
- var text = zstdDecompressSync(buf.subarray(last)).toString('utf8')
705
- var lines = text.split('\n').filter(Boolean)
706
- for (var i = lines.length - 1; i >= 0 && !activity; i--) {
707
- try {
708
- var e = JSON.parse(lines[i])
709
- var dta = e.data || {}
710
- if (e.type === 'tool/call' && dta.name) activity = '🔧 ' + dta.name + ' ' + String(dta.arguments || '').replace(/\s+/g, ' ').slice(0, 90)
711
- // v4:助手文本在 assistant/message 的 content 块里;v3 及以前是 assistant/chunk 流片
712
- else if (e.type === 'assistant/message') {
713
- var msg = dta.message || {}
714
- var blocks = Array.isArray(msg.content) ? msg.content : []
715
- for (var b = blocks.length - 1; b >= 0; b--) { if (blocks[b] && blocks[b].type === 'text' && String(blocks[b].text || '').trim()) { activity = '💬 ' + String(blocks[b].text).replace(/\s+/g, ' ').slice(0, 120); break } }
716
- }
717
- else if (e.type === 'assistant/chunk' && dta.block && dta.block.type === 'text' && dta.block.text && dta.block.text.trim()) activity = '💬 ' + dta.block.text.replace(/\s+/g, ' ').slice(0, 120)
718
- } catch (_) {}
901
+ for (var fi = 0; fi < frames.length && !activity; fi++) {
902
+ var evs = frames[fi]
903
+ for (var i = evs.length - 1; i >= 0 && !activity; i--) {
904
+ var e = evs[i]
905
+ var dta = (e && e.data) || {}
906
+ if (e && e.type === 'tool/call' && dta.name) activity = '🔧 ' + dta.name + ' ' + String(dta.arguments || '').replace(/\s+/g, ' ').slice(0, 90)
907
+ // v4:助手文本在 assistant/message 的 content 块里;v3 及以前是 assistant/chunk 流片
908
+ else if (e && e.type === 'assistant/message') {
909
+ var msg = dta.message || {}
910
+ var blocks = Array.isArray(msg.content) ? msg.content : []
911
+ for (var b = blocks.length - 1; b >= 0; b--) { if (blocks[b] && blocks[b].type === 'text' && String(blocks[b].text || '').trim()) { activity = '💬 ' + String(blocks[b].text).replace(/\s+/g, ' ').slice(0, 120); break } }
912
+ }
913
+ else if (e && e.type === 'assistant/chunk' && dta.block && dta.block.type === 'text' && dta.block.text && dta.block.text.trim()) activity = '💬 ' + dta.block.text.replace(/\s+/g, ' ').slice(0, 120)
719
914
  }
720
915
  }
721
916
  return { ok: true, activity: activity }
@@ -748,7 +943,7 @@ export function apply(ctx) {
748
943
  })
749
944
  handle('claim-task', async function (args) { var sid = rpcSessionId(args); var actor = getActorId(); return mutateLocked(sid, function (d) { var t = d.tasks.find(function (x) { return x.id === args.taskId }); if (!t) return { ok: false, error: 'not found' }; var err = claimCheck(d, t, actor); if (err) return { ok: false, error: err }; claimApply(d, t, actor, 'manual claim via board'); return { ok: true, task: t } }) })
750
945
  handle('resolve-task', async function (args) { var sid = rpcSessionId(args); var actor = getActorId(); return mutateLocked(sid, function (d) { var t = d.tasks.find(function (x) { return x.id === args.taskId }); if (!t) return { ok: false, error: 'not found' }; if (t.status !== 'in-progress') return { ok: false, error: 'not in-progress' }; return resolveApply(d, t, actor, args.status, args.resolution, args.resolution || args.status) }) })
751
- handle('verify-task', async function (args) { var sid = rpcSessionId(args); var actor = getActorId(); return mutateLocked(sid, function (d) { var t = d.tasks.find(function (x) { return x.id === args.taskId }); if (!t) return { ok: false, error: 'not found' }; if (t.status !== 'verifying') return { ok: false, error: 'not verifying' }; delete t.escalation; delete t.verifyRetries; return verifyApply(d, t, actor, args.verdict, args.comment) }) })
946
+ handle('verify-task', async function (args) { var sid = rpcSessionId(args); var actor = getActorId(); return mutateLocked(sid, function (d) { var t = d.tasks.find(function (x) { return x.id === args.taskId }); if (!t) return { ok: false, error: 'not found' }; if (t.status !== 'verifying') return { ok: false, error: 'not verifying' }; delete t.escalation; delete t.verifyRetries; var r = verifyApply(d, t, actor, args.verdict, args.comment); if (args.verdict === 'rejected') pushRejectLesson(d, t, args.comment); return r }) })
752
947
  handle('archive-task', async function (args) { var sid = rpcSessionId(args); var actor = getActorId(); return mutateLocked(sid, function (d) { var a = d.tasks; var t = a.find(function (x) { return x.id === args.taskId }); if (!t) return { ok: false, error: 'not found' }; if (t.status !== 'resolved' && t.status !== 'cancelled') return { ok: false, error: 'cannot archive' }; var ps = t.status; t.status = 'archived'; t.archivedAt = new Date().toISOString(); ah(t, ps, 'archived', actor, 'manual archive'); var ca = 0; gsb(t.id, a).forEach(function (c) { if (c.status !== 'archived') { ah(c, c.status, 'archived', actor, 'cascade'); c.status = 'archived'; c.archivedAt = new Date().toISOString(); ca++ } }); var r = { ok: true, task: t }; if (ca) r.childrenArchived = ca; return r }) })
753
948
  handle('update-task', async function (args) { var sid = rpcSessionId(args); var actor = getActorId(); return mutateLocked(sid, function (d) { var t = d.tasks.find(function (x) { return x.id === args.taskId }); if (!t) return { ok: false, error: 'not found' }; if (args.title !== undefined) t.title = args.title; if (args.description !== undefined) t.description = args.description; if (args.priority !== undefined) t.priority = args.priority; if (args.assignMode !== undefined) t.assignMode = args.assignMode; if (args.assignee !== undefined) t.assignee = args.assignee || null; if (args.dependsOn !== undefined) { var derr = validateDeps(d, t.id, args.dependsOn); if (derr) return { ok: false, error: derr }; t.dependsOn = args.dependsOn } if (args.pipeline !== undefined) { t.pipeline = args.pipeline; t.pipelineAuto = false } if (args.contextFiles !== undefined) { if (!t.context) t.context = { files: [], docs: [], instructions: '', notes: '', relatedTasks: [], prerequisites: '' }; t.context.files = Array.isArray(args.contextFiles) ? args.contextFiles.map(String).slice(0, 20) : [] } if (args.contextNotes !== undefined) { if (!t.context) t.context = { files: [], docs: [], instructions: '', notes: '', relatedTasks: [], prerequisites: '' }; t.context.notes = typeof args.contextNotes === 'string' ? args.contextNotes.slice(0, 8000) : '' } if (args.publish) { if (t.status !== 'draft') return { ok: false, error: 'not a draft' }; t.status = 'pending'; ah(t, 'draft', 'pending', actor, 'published') } if (args.unfreeze && t.frozen) { delete t.frozen; delete t.frozenAt; delete t.frozenBy; ah(t, t.status, t.status, actor, '解除冻结,重新进入派发池') } if (args.resetToPending) { var ps = t.status; t.status = 'pending'; t.claimedBy = null; t.claimedAt = null; t.resolvedAt = null; t.resolution = null; ah(t, ps, 'pending', actor, 'reset to pending after edit') }; return { ok: true, task: t } }) })
754
949
  handle('set-board-mode', async function (args) { var sid = rpcSessionId(args); return mutateLocked(sid, function (d) { d.boardMode = args.mode === 'manual' ? 'manual' : 'auto'; if (d.boardMode === 'manual' && d.teamMode) { d.teamMode = false; teamModeCache[sid] = false }; return { ok: true, boardMode: d.boardMode, teamMode: !!d.teamMode, workMode: deriveWorkMode(d) } }) })
@@ -781,9 +976,14 @@ export function apply(ctx) {
781
976
  var t = d.tasks.find(function (x) { return x.id === taskId })
782
977
  if (!t) return { ok: false, error: 'not found' }
783
978
  if (!t.escalation) return { ok: false, error: 'not escalated' }
979
+ var escQ = String(t.escalation.question || '') // 先留档:下面 delete 后就没得取了
784
980
  delete t.escalation
785
981
  if (!Array.isArray(t.messages)) t.messages = []
786
- t.messages.push({ kind: 'arbitration', text: answer || '', at: new Date().toISOString(), by: actor, action: act })
982
+ var arbAt = new Date().toISOString()
983
+ t.messages.push({ kind: 'arbitration', text: answer || '', at: arbAt, by: actor, action: act })
984
+ // 学习飞轮 v1:主窗口的裁决结论是最值钱的教训来源(Worker 会在同一个坑里反复上报)
985
+ // → 生成候选教训(场景/疑问/裁决结论),由主窗口决定要不要沉淀进笔记/记忆工具。
986
+ pushArbitrationLesson(d, t, escQ, answer || '', arbAt)
787
987
  ah(t, t.status, t.status, actor, (act === 'hold' ? '主窗口裁决(挂起冻结): ' : '主窗口裁决: ') + (answer || '').slice(0, 200))
788
988
  // in-progress 的歧义任务:回 pending 重派(新 Worker 带裁决上下文);verifying 的 verifier 故障升级:保持待审,下轮派新 verifier
789
989
  if (t.status === 'in-progress') { var ps = t.status; t.status = 'pending'; t.claimedBy = null; t.claimedAt = null; ah(t, ps, 'pending', 'system', act === 'hold' ? '带裁决挂起冻结(不参与自动派发)' : '带裁决重新排队') }
@@ -831,7 +1031,13 @@ export function apply(ctx) {
831
1031
  async function doTerminate(sid, actor, taskId) {
832
1032
  var rec = runsFor(sid)[taskId]
833
1033
  var label = 'no-active-run'
834
- if (rec) { delete runsFor(sid)[taskId]; label = rec.role + ':' + taskId; try { await rec.run.dispose() } catch (_) {} }
1034
+ if (rec) {
1035
+ delete runsFor(sid)[taskId]; label = rec.role + ':' + taskId
1036
+ try { await rec.run.dispose() } catch (_) {}
1037
+ // 手动终止的 run 不会走 settleRun(runsFor 已摘除,结算路径会早退),
1038
+ // 但它确实消耗了 token——在这里补一次结算,避免终止即丢账。
1039
+ await accumulateRunUsage(sid, rec)
1040
+ }
835
1041
  await mutateLocked(sid, function (d) {
836
1042
  var t = d.tasks.find(function (x) { return x.id === taskId })
837
1043
  if (!t) return null
@@ -894,13 +1100,24 @@ export function apply(ctx) {
894
1100
  ctx.tools.register(defineTool({ name: 'task_arbitrate', description: 'Team 模式:裁决 Worker 上报的歧义(escalation)。action=resume(默认)裁决后任务回派发池立即重派;action=hold 任务挂起冻结、不参与自动派发(留给主窗口补 dependsOn/上下文,补完后用 task_update unfreeze:true 解冻)。', parameters: { type: 'object', properties: { taskId: { type: 'string' }, answer: { type: 'string' }, action: { type: 'string', enum: ['resume', 'hold'], description: 'resume(默认)=裁决后重新派发;hold=挂起冻结不派发' } }, required: ['taskId', 'answer'] }, output: jo(), execute: async function (args) { var __ra = getActorId(); if (resolveRoot(__ra) !== __ra) return { ok: false, error: '看板管理工具仅主窗口可用(子代理无看板权限)' }; return doResolveEscalation(toolSessionId(), getActorId(), args.taskId, args.answer, args.action) } }))
895
1101
  ctx.tools.register(defineTool({ name: 'task_intervene', description: 'Team 模式:向执行某任务的池中 Agent 发起高优先级指令(steer 通道,当前 step 结束即响应;无活跃 run 时记录随下次派发注入)。', parameters: { type: 'object', properties: { taskId: { type: 'string' }, message: { type: 'string' } }, required: ['taskId', 'message'] }, output: jo(), execute: async function (args) { var __ra = getActorId(); if (resolveRoot(__ra) !== __ra) return { ok: false, error: '看板管理工具仅主窗口可用(子代理无看板权限)' }; return doIntervene(toolSessionId(), getActorId(), args.taskId, args.message) } }))
896
1102
  // ===== 池中 Agent 结构化回报工具(双模:工具优先,文本分段为降级路径)=====
897
- ctx.tools.register(defineTool({ name: 'board_report', description: '[任务看板 Worker 专用] 上报任务结果。kind=complete 时填 summary/changes/selfTest/diffStat(git 仓库内改动附 git diff --stat 概要);kind=escalate 时填 question(歧义上报,等待主窗口裁决)。', parameters: { type: 'object', properties: { taskId: { type: 'string' }, kind: { type: 'string', enum: ['complete', 'escalate'] }, summary: { type: 'string' }, changes: { type: 'string' }, selfTest: { type: 'string' }, diffStat: { type: 'string', description: '变更概要:git diff --stat(含 git status --short)输出,≤1500 字符' }, question: { type: 'string' } }, required: ['taskId', 'kind'] }, output: jo(), execute: async function (args) {
1103
+ ctx.tools.register(defineTool({ name: 'board_report', description: '[任务看板 Worker 专用] 上报任务结果。kind=complete 时填 summary/changes/selfTest/diffStat(git 仓库内改动附 git diff --stat 概要);kind=escalate 时填 question(歧义上报,等待主窗口裁决);kind=progress 时填 question(一行里程碑进展摘要,≤200 字符)——只在有实际产物/结论时报,禁止定时或表演式汇报,静默不通知主窗口。', parameters: { type: 'object', properties: { taskId: { type: 'string' }, kind: { type: 'string', enum: ['complete', 'escalate', 'progress'] }, summary: { type: 'string' }, changes: { type: 'string' }, selfTest: { type: 'string' }, diffStat: { type: 'string', description: '变更概要:git diff --stat(含 git status --short)输出,≤1500 字符' }, question: { type: 'string', description: 'kind=escalate:歧义原文;kind=progress:一行里程碑进展摘要(≤200 字符)' } }, required: ['taskId', 'kind'] }, output: jo(), execute: async function (args) {
898
1104
  var sid = toolSessionId(); var actor = getActorId()
899
1105
  var result = await mutateLocked(sid, function (d) {
900
1106
  var t = d.tasks.find(function (x) { return x.id === args.taskId })
901
1107
  if (!t) return { ok: false, error: 'not found' }
902
1108
  if (t.status !== 'in-progress') return { ok: false, error: 'not in-progress (状态: ' + t.status + ')' }
903
1109
  if (!Array.isArray(t.messages)) t.messages = []
1110
+ // ===== 里程碑进展通道(kind=progress)=====
1111
+ // 定位:长任务执行期间的「还在正确路上」的轻量证明;不进回执聚合、不通知主窗口(纯静默可见)。
1112
+ // 防表演式汇报:只在有实际产物/结论时报(契约写在 buildWorkerPrompt 里),且不写 ah() 历史(避免刷屏)。
1113
+ if (args.kind === 'progress') {
1114
+ var ptext = String(args.question || '').trim().slice(0, 200)
1115
+ if (!ptext) return { ok: false, error: 'question required for progress (一行进展摘要)' }
1116
+ var pat = new Date().toISOString()
1117
+ t.messages.push({ kind: 'progress', text: ptext, at: pat, by: actor })
1118
+ t.lastProgress = { text: ptext, at: pat } // 覆盖式:卡片只展示最新一条
1119
+ return { ok: true, progress: t.lastProgress }
1120
+ }
904
1121
  if (args.kind === 'escalate') {
905
1122
  var at = new Date().toISOString()
906
1123
  t.escalation = { question: (args.question || '').slice(0, 2000), at: at, by: actor }
@@ -931,6 +1148,8 @@ export function apply(ctx) {
931
1148
  t.verification = { verdict: args.verdict, summary: args.summary || '', checks: args.checks || '', at: new Date().toISOString(), by: actor }
932
1149
  verifyApply(d, t, actor, args.verdict, (args.summary || '').slice(0, 200))
933
1150
  if (!approved) { t.rejectCount = (t.rejectCount || 0) + 1; if (t.rejectCount >= 3) { t.status = 'blocked'; ah(t, 'in-progress', 'blocked', 'system', 'verifier 驳回 x' + t.rejectCount + ',待人工裁决') } }
1151
+ // 学习飞轮 v1:工具通道驳回同样生成候选教训(与文本通道同一条判重口径,不会重复落)
1152
+ if (!approved) pushRejectLesson(d, t, (args.summary || '') + (args.checks ? '\n' + args.checks : ''), t.verification.at)
934
1153
  return { ok: true, task: t }
935
1154
  })
936
1155
  if (result && result.ok && result.task) {
@@ -957,8 +1176,28 @@ export function apply(ctx) {
957
1176
  }
958
1177
  return { ok: true, models: out }
959
1178
  })
960
- handle('set-board-config', async function (args) { var sid = rpcSessionId(args); return mutateLocked(sid, function (d) { if (args.key === 'maxWorkers') d.maxWorkers = Math.max(1, Math.min(10, args.value || 3)); else if (args.key === 'maxVerifiers') d.maxVerifiers = Math.max(0, Math.min(5, args.value || 0)); else if (args.key === 'workerModel') d.workerModel = typeof args.value === 'string' ? args.value.trim() : ''; else if (args.key === 'verifierModel') d.verifierModel = typeof args.value === 'string' ? args.value.trim() : ''; else if (args.key === 'softTimeoutMin') d.softTimeoutMin = Math.max(1, Math.min(480, Number(args.value) || 30)); else if (args.key === 'hardTimeoutMin') d.hardTimeoutMin = Math.max(1, Math.min(1440, Number(args.value) || 120)); return { ok: true } }) })
961
- handle('create-task', async function (args) { var sid = rpcSessionId(args); var actor = getActorId(); return mutateLocked(sid, function (d) { if (args.id && d.tasks.find(function (x) { return x.id === args.id })) return { ok: false, error: 'duplicate id' }; if (args.dependsOn && args.dependsOn.length) { var derr = validateDeps(d, args.id || '(pending)', args.dependsOn); if (derr) return { ok: false, error: derr } }; var now = new Date().toISOString(); /* Team 模式护栏:draft 缺省跟随 teamMode(先补齐依赖/上下文再统一 publish);显式 draft:false 保留为立即派发的逃生门 */ var asDraft = args.draft === undefined ? !!d.teamMode : !!args.draft; var t = { id: args.id || ('task-' + Date.now().toString(36)), title: args.title || 'Untitled', description: args.description || '', status: asDraft ? 'draft' : 'pending', priority: args.priority || 'medium', tags: args.tags || [], parentId: args.parentId || null, subtaskStrategy: null, assignMode: 'auto', assignee: null, context: { files: (Array.isArray(args.contextFiles) ? args.contextFiles.map(String).slice(0, 20) : []), docs: [], instructions: args.instructions || '', notes: (typeof args.contextNotes === 'string' ? args.contextNotes.slice(0, 8000) : ''), relatedTasks: [], prerequisites: '' }, acceptance: args.acceptance || '', dependsOn: args.dependsOn || [], touches: normTouches(args.touches), pipeline: args.pipeline || '', claimedBy: null, claimedAt: null, createdAt: now, resolvedAt: null, verifiedAt: null, verifiedBy: null, archivedAt: null, resolution: null, waitingForTouches: null, messages: [], history: [{ from: 'created', to: asDraft ? 'draft' : 'pending', timestamp: now, actor: actor, note: asDraft ? 'created as draft' : 'created' }] }; if (!t.pipeline) { t.pipeline = classifyPipeline(t); t.pipelineAuto = true }; d.tasks.push(t); return { ok: true, task: t } }) })
1179
+ // ===== 学习飞轮 v1:沉淀推送通道(push-lesson)=====
1180
+ // 详情页「沉淀」按钮 → 把结构化候选教训 followup 给主窗口 agent,由它用自己可用的
1181
+ // 笔记/记忆工具(如 note_manage)决定沉淀到哪、或评估后忽略。
1182
+ // 这就是全部:看板不知道对方有没有记忆工具、也不知道最终存到哪(零耦合红线)。
1183
+ // feedbackEnabled 关闭 → 直接拒绝(UI 侧也不会渲染按钮,这里是双保险)。
1184
+ handle('push-lesson', async function (args) {
1185
+ var sid = rpcSessionId(args)
1186
+ var text = (typeof args.text === 'string') ? args.text.trim().slice(0, 4000) : ''
1187
+ if (!text) return { ok: false, error: 'text required' }
1188
+ var d = await rt(sid)
1189
+ if (!cfg(d).feedbackEnabled) return { ok: false, error: 'feedback disabled' }
1190
+ var root = rootForSession(sid)
1191
+ if (!root) return { ok: false, error: 'no root agent for session' }
1192
+ var delivered = false
1193
+ try {
1194
+ root.followup(makeMsg('📚 [任务看板] 候选教训沉淀请求\n\n' + text + '\n\n请用你可用的笔记/记忆工具(如 note_manage)沉淀,或评估后忽略。', 'notice', '教训沉淀: ' + String(args.taskId || '')))
1195
+ delivered = true
1196
+ } catch (e) { console.error('[task-board] push-lesson followup failed:', String(e)) }
1197
+ return { ok: true, delivered: delivered }
1198
+ })
1199
+ handle('set-board-config', async function (args) { var sid = rpcSessionId(args); return mutateLocked(sid, function (d) { if (args.key === 'maxWorkers') d.maxWorkers = Math.max(1, Math.min(10, args.value || 3)); else if (args.key === 'maxVerifiers') d.maxVerifiers = Math.max(0, Math.min(5, args.value || 0)); else if (args.key === 'workerModel') d.workerModel = typeof args.value === 'string' ? args.value.trim() : ''; else if (args.key === 'verifierModel') d.verifierModel = typeof args.value === 'string' ? args.value.trim() : ''; else if (args.key === 'softTimeoutMin') d.softTimeoutMin = Math.max(1, Math.min(480, Number(args.value) || 30)); else if (args.key === 'hardTimeoutMin') d.hardTimeoutMin = Math.max(1, Math.min(1440, Number(args.value) || 120)); else if (args.key === 'feedbackEnabled') { d.feedbackEnabled = !!args.value; feedbackCache[sid] = d.feedbackEnabled } return { ok: true } }) })
1200
+ handle('create-task', async function (args) { var sid = rpcSessionId(args); var actor = getActorId(); return mutateLocked(sid, function (d) { if (args.id && d.tasks.find(function (x) { return x.id === args.id })) return { ok: false, error: 'duplicate id' }; if (args.dependsOn && args.dependsOn.length) { var derr = validateDeps(d, args.id || '(pending)', args.dependsOn); if (derr) return { ok: false, error: derr } }; var now = new Date().toISOString(); /* Team 模式护栏:draft 缺省跟随 teamMode(先补齐依赖/上下文再统一 publish);显式 draft:false 保留为立即派发的逃生门 */ var asDraft = args.draft === undefined ? !!d.teamMode : !!args.draft; var t = { id: args.id || ('task-' + Date.now().toString(36)), title: args.title || 'Untitled', description: args.description || '', status: asDraft ? 'draft' : 'pending', priority: args.priority || 'medium', tags: args.tags || [], parentId: args.parentId || null, subtaskStrategy: null, assignMode: 'auto', assignee: null, context: { files: (Array.isArray(args.contextFiles) ? args.contextFiles.map(String).slice(0, 20) : []), docs: [], instructions: args.instructions || '', notes: (typeof args.contextNotes === 'string' ? args.contextNotes.slice(0, 8000) : ''), relatedTasks: [], prerequisites: '' }, acceptance: args.acceptance || '', dependsOn: args.dependsOn || [], touches: normTouches(args.touches), pipeline: args.pipeline || '', claimedBy: null, claimedAt: null, createdAt: now, resolvedAt: null, verifiedAt: null, verifiedBy: null, archivedAt: null, resolution: null, waitingForTouches: null, messages: [], history: [{ from: 'created', to: asDraft ? 'draft' : 'pending', timestamp: now, actor: actor, note: asDraft ? 'created as draft' : 'created' }] }; if (!t.pipeline) { t.pipeline = classifyPipeline(t); t.pipelineAuto = true }; d.tasks.push(t); return withSplitHint({ ok: true, task: t }, t) }) })
962
1201
  handle('list-children', async function (args) { var sid = rpcSessionId(args); var subs = ctx.subagents; if (!subs) return { ok: true, children: [] }; try { var list = await subs.listChildren(sid); var children = (list || []).map(function (c) { return { id: String(c.sessionId || c.id || ''), label: String(c.label || c.title || c.mode || '') } }).filter(function (c) { return c.id.length > 0 }); return { ok: true, children: children } } catch (e) { return { ok: true, children: [], error: String(e) } } })
963
1202
  // ===== #14 批量操作:archive(仅 resolved/cancelled)/ set-priority(全部)=====
964
1203
  handle('batch-op', async function (args) {
package/lib/client.js CHANGED
@@ -193,6 +193,10 @@ function apply(ctx) {
193
193
  'plus': [
194
194
  ['path', { d: 'M5 12h14' }],
195
195
  ['path', { d: 'M12 5v14' }]
196
+ ],
197
+ 'book-open': [
198
+ ['path', { d: 'M12 7v14' }],
199
+ ['path', { d: 'M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z' }]
196
200
  ]
197
201
  }
198
202
  // 图标+文字内联组合的快捷渲染
@@ -209,7 +213,7 @@ function apply(ctx) {
209
213
  }
210
214
  var COLUMNS = ['draft', 'pending', 'in-progress', 'verifying', 'resolved', 'blocked']
211
215
  var reqEpoch = 0 // 会话切换纪元:切会话时自增,旧会话在途响应按纪元丢弃
212
- 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, isRoot: 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, archSort: 'time-desc', dateRange: { from: '', to: '' }, rfOpen: false, gboOpen: false, activity: {}, archived: [], archQ: '', globalBoards: [], createOpen: false, createFlash: '' }
216
+ 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, lessonPushed: {}, isRoot: 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, archSort: 'time-desc', dateRange: { from: '', to: '' }, rfOpen: false, gboOpen: false, activity: {}, archived: [], archQ: '', globalBoards: [], createOpen: false, createFlash: '', usageSummary: null }
213
217
  // #16 快捷键:Esc 逐级关闭(详情→看板→面板);输入框聚焦时不劫持
214
218
  try {
215
219
  var onKey = function (e) {
@@ -251,6 +255,9 @@ function apply(ctx) {
251
255
  state.softTimeoutMin = (d && d.softTimeoutMin) || 30
252
256
  state.hardTimeoutMin = (d && d.hardTimeoutMin) || 120
253
257
  state.poolStatus = (d && d.poolStatus) || null
258
+ state.usageSummary = (d && d.usageSummary) || null // token 消耗聚合(board 级,host 端现算)
259
+ // 学习飞轮 v1 能力检测:老 host 不返回该字段 → 视为开启(默认开);只有显式 false 才关。
260
+ state.feedbackEnabled = !(d && d.feedbackEnabled === false)
254
261
  state.isRoot = !d || d.isRoot !== false
255
262
  if (state.isRoot === false && state.open) { state.open = false; state.detailId = null } // 子代理会话:强制收起看板
256
263
  if (d && d.dispatchInfo && d.dispatchInfoAt && Date.now() - new Date(d.dispatchInfoAt).getTime() < 120000) { state.dispatchInfo = d.dispatchInfo } else { state.dispatchInfo = '' } // 瞬时通知 2min 内有效,过期强制清空(服务端写后不清曾致残留数天)
@@ -481,12 +488,63 @@ function apply(ctx) {
481
488
  }
482
489
  return React.createElement('span', { style: { display: 'inline-flex', alignItems: 'center', gap: 5 } }, React.createElement('button', { onClick: gen, title: '生成本看板 markdown 报告并复制', style: { fontSize: 11, padding: '3px 10px', border: '1px solid ' + C.border, borderRadius: 5, background: C.card, color: C.text, cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: 4 } }, ic('file-down', 12), '生成报告'), msg ? React.createElement('span', { style: { fontSize: 10, color: C.ok } }, msg) : null)
483
490
  }
491
+ // ===== Token 消耗区(数据源:host 端 get-tasks 的 usageSummary)=====
492
+ // usageSummary 由 host 从各任务 t.usage 现算(t.usage 来自 Worker/Verifier 会话 v4 日志的
493
+ // assistant/message.usage 聚合)。这里只做展示、不做计费断言;日志读不到/还没有 run 结算时
494
+ // usageSummary.total 为 0,一律显示「暂无数据」。
495
+ function fmtTokens(n) {
496
+ var v = Number(n) || 0
497
+ if (v >= 1000000000) return (v / 1000000000).toFixed(1) + 'B'
498
+ if (v >= 1000000) return (v / 1000000).toFixed(v >= 10000000 ? 0 : 1) + 'M'
499
+ if (v >= 1000) return (v / 1000).toFixed(v >= 10000 ? 0 : 1) + 'k'
500
+ return String(v)
501
+ }
502
+ // 一行「标签 + 条形 + 数值」:模型分布与任务 Top 同款(在仪表盘里局部渲染,不改动通用 BarRow)
503
+ function UsageRow(props) {
504
+ var pct = props.max > 0 ? Math.max(2, Math.round(props.value / props.max * 100)) : 0
505
+ var clickable = !!props.onClick
506
+ return React.createElement('div', { onClick: props.onClick, title: props.title, style: { display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4, cursor: clickable ? 'pointer' : 'default' } },
507
+ React.createElement('span', { style: { width: 110, fontSize: 10, color: props.labelColor || C.text2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flexShrink: 0 } }, props.label),
508
+ React.createElement('div', { style: { flex: 1, height: 8, background: C.nested, borderRadius: 4, overflow: 'hidden' } },
509
+ React.createElement('div', { style: { height: '100%', width: pct + '%', background: props.color || C.brand, borderRadius: 4, transition: 'width .3s' } })),
510
+ React.createElement('span', { style: { width: 54, fontSize: 10, color: C.text2, textAlign: 'right', flexShrink: 0 } }, fmtTokens(props.value)))
511
+ }
512
+ function TokenUsage(props) {
513
+ var u = props.usage
514
+ var box = { padding: '8px 10px', background: C.card, border: '1px solid ' + C.border, borderRadius: 6, marginBottom: 12 }
515
+ var head = React.createElement('div', { style: { fontSize: 11, fontWeight: 600, color: C.text2, marginBottom: 6, display: 'flex', alignItems: 'center', gap: 4 } }, ic('bar-chart-3', 11), 'Token 消耗')
516
+ if (!u || !u.total) return React.createElement('div', { style: box }, head, React.createElement('div', { style: { fontSize: 10, color: C.text2 } }, '暂无数据(Worker/Verifier 会话日志里还没有 usage 记录)'))
517
+ var models = Object.keys(u.byModel || {}).map(function (m) { return { model: m, total: u.byModel[m] || 0 } }).sort(function (a, b) { return b.total - a.total })
518
+ var maxM = models.length ? (models[0].total || 1) : 1
519
+ var top = u.topTasks || []
520
+ var maxT = top.length ? (top[0].total || 1) : 1
521
+ function mini(label, value) { return React.createElement('span', { style: { fontSize: 10, color: C.text2 } }, label + ' ', React.createElement('span', { style: { color: C.text, fontWeight: 600 } }, fmtTokens(value))) }
522
+ return React.createElement('div', { style: box },
523
+ head,
524
+ React.createElement('div', { style: { display: 'flex', alignItems: 'baseline', gap: 10, flexWrap: 'wrap', marginBottom: 8 } },
525
+ React.createElement('span', { style: { fontSize: 20, fontWeight: 700, color: C.brand } }, fmtTokens(u.total)),
526
+ React.createElement('span', { style: { fontSize: 10, color: C.text2 } }, 'tokens(本看板累计)'),
527
+ mini('输入', u.input), mini('输出', u.output), mini('缓存读', u.cacheRead), u.cacheWrite ? mini('缓存写', u.cacheWrite) : null),
528
+ React.createElement('div', { style: { display: 'flex', gap: 12, flexWrap: 'wrap' } },
529
+ React.createElement('div', { style: { flex: '1 1 240px', minWidth: 200 } },
530
+ React.createElement('div', { style: { fontSize: 10, fontWeight: 600, color: C.text2, marginBottom: 4 } }, '按模型分布'),
531
+ models.length === 0 ? React.createElement('div', { style: { fontSize: 10, color: C.text2 } }, '暂无数据') : models.map(function (m) {
532
+ return React.createElement(UsageRow, { key: m.model, label: m.model, value: m.total, max: maxM, color: C.brand, title: m.model + ':' + String(m.total) + ' tokens' })
533
+ })),
534
+ React.createElement('div', { style: { flex: '1 1 240px', minWidth: 200 } },
535
+ React.createElement('div', { style: { fontSize: 10, fontWeight: 600, color: C.text2, marginBottom: 4 } }, '任务消耗 Top 8'),
536
+ top.length === 0 ? React.createElement('div', { style: { fontSize: 10, color: C.text2 } }, '暂无数据') : top.map(function (x) {
537
+ var t = getTask(x.id)
538
+ return React.createElement(UsageRow, { key: x.id, label: x.title || x.id, value: x.total, max: maxT, color: C.ok, labelColor: t ? C.brand : C.text2, title: x.title + '(' + String(x.total) + ' tokens / ' + (x.runs || 0) + ' 次 run)' + (t ? '——点击查看详情' : ''), onClick: t ? function () { state.detailId = x.id; notify() } : undefined })
539
+ }))))
540
+ }
484
541
  function Dashboard() {
485
542
  var stats = computeStats(state.tasks); var statusOrder = ['pending', 'in-progress', 'verifying', 'resolved', 'blocked', 'archived']; var prioOrder = ['critical', 'high', 'medium', 'low']
486
543
  return React.createElement('div', null,
487
544
  React.createElement(RangeFilter),
488
545
  React.createElement(GlobalBoards),
489
546
  React.createElement('div', { style: { display: 'flex', gap: 6, marginBottom: 12, flexWrap: 'wrap' } }, React.createElement(StatCard, { label: '总任务', value: stats.total, color: C.text }), React.createElement(StatCard, { label: '待办', value: stats.byStatus['pending'] || 0, color: C.text2 }), React.createElement(StatCard, { label: '进行中', value: stats.byStatus['in-progress'] || 0, color: C.brand }), React.createElement(StatCard, { label: '验证中', value: stats.byStatus['verifying'] || 0, color: C.warn }), React.createElement(StatCard, { label: '已完成', value: stats.byStatus['resolved'] || 0, color: C.ok }), React.createElement(StatCard, { label: '已归档', value: stats.byStatus['archived'] || 0, color: C.text2 })),
547
+ React.createElement(TokenUsage, { usage: state.usageSummary }),
490
548
  React.createElement('div', { style: { display: 'flex', gap: 12, marginBottom: 12, flexWrap: 'wrap' } },
491
549
  React.createElement('div', { style: { flex: '1 1 0', minWidth: 200, padding: '8px 10px', background: C.card, border: '1px solid ' + C.border, borderRadius: 6 } }, React.createElement('div', { style: { fontSize: 11, fontWeight: 600, color: C.text2, marginBottom: 6 } }, '按状态分布'), statusOrder.map(function (s) { return React.createElement(BarRow, { key: s, label: statusLabels[s] || s, count: stats.byStatus[s] || 0, total: stats.total, color: statusColors[s] || C.brand }) })),
492
550
  React.createElement('div', { style: { flex: '1 1 0', minWidth: 200, padding: '8px 10px', background: C.card, border: '1px solid ' + C.border, borderRadius: 6 } }, React.createElement('div', { style: { fontSize: 11, fontWeight: 600, color: C.text2, marginBottom: 6 } }, '按优先级分布'), prioOrder.map(function (p) { return React.createElement(BarRow, { key: p, label: prioLabel[p] || p, count: stats.byPriority[p] || 0, total: stats.total, color: prioColor[p] || C.brand }) }))),
@@ -617,7 +675,12 @@ function apply(ctx) {
617
675
  React.createElement('div', { style: { fontSize: 10, fontWeight: 600, color: C.text2, margin: '7px 0 5px' } }, '两级超时(软超时只提醒主窗口,由你决定继续等待或终止)'),
618
676
  React.createElement('div', { style: { display: 'flex', gap: 8, alignItems: 'center' } },
619
677
  React.createElement(MinCfg, { label: '软超时', cfgKey: 'softTimeoutMin', value: props.softT, min: 1, max: 480, tip: '超过后在看板提醒主窗口,不终止 run(1~480 分钟)' }),
620
- React.createElement(MinCfg, { label: '硬超时', cfgKey: 'hardTimeoutMin', value: props.hardT, min: 1, max: 1440, tip: '兜底:人不在线时自动终止挂死 run 并重试(1~1440 分钟)' }))) : null)
678
+ React.createElement(MinCfg, { label: '硬超时', cfgKey: 'hardTimeoutMin', value: props.hardT, min: 1, max: 1440, tip: '兜底:人不在线时自动终止挂死 run 并重试(1~1440 分钟)' })),
679
+ // 学习飞轮 v1 总开关:关掉后不生成候选教训、prompt 不提软召回、详情页不渲染「沉淀」按钮
680
+ React.createElement('div', { style: { fontSize: 10, fontWeight: 600, color: C.text2, margin: '7px 0 5px' } }, '学习反馈'),
681
+ React.createElement('label', { style: { display: 'flex', alignItems: 'center', gap: 5, fontSize: 10, color: C.text, cursor: 'pointer', whiteSpace: 'normal', maxWidth: 260 } },
682
+ React.createElement('input', { type: 'checkbox', checked: !!props.feedbackEnabled, onChange: function (e) { rpc('set-board-config', { key: 'feedbackEnabled', value: e.target.checked }).then(fetchTasks).catch(function () {}) } }),
683
+ React.createElement('span', null, '候选教训(Verifier 驳回/仲裁结论自动生成候选,Worker prompt 提示先检索历史教训)'))) : null)
621
684
  }
622
685
  // #13 筛选:文本(标题/描述/ID)+ 优先级多选 + 标签
623
686
  function passFilter(t) {
@@ -679,7 +742,7 @@ function apply(ctx) {
679
742
  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,
680
743
  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)
681
744
  }
682
- function Card(props) { var t = props.task; var pc = prioColor[t.priority] || prioColor.low; var dragging = state.dragTask === t.id; var preview = (t.deliverable && t.deliverable.summary) || t.resolution; var critGlow = t.priority === 'critical' && !t.escalation; var sel = !!state.selected[t.id]; var pm = pipeOf(t); var depBlock = t.status === 'pending' && depsBlocked(t); return React.createElement('div', { draggable: !state.selectMode, onDragStart: function (e) { onDragStart(e, t) }, onDragEnd: onDragEnd, onClick: function () { 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() } }, style: { 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] || '中')), 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, depBlock ? React.createElement('div', { style: { fontSize: 10, color: C.text2, marginBottom: 2 } }, '⛓ 被 ' + t.dependsOn.filter(function (id) { var d = getTask(id); return !d || (d.status !== 'resolved' && d.status !== 'archived') }).length + ' 个依赖阻塞') : 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' } },
745
+ function Card(props) { var t = props.task; var pc = prioColor[t.priority] || prioColor.low; var dragging = state.dragTask === t.id; var preview = (t.deliverable && t.deliverable.summary) || t.resolution; var critGlow = t.priority === 'critical' && !t.escalation; var sel = !!state.selected[t.id]; var pm = pipeOf(t); var depBlock = t.status === 'pending' && depsBlocked(t); return React.createElement('div', { draggable: !state.selectMode, onDragStart: function (e) { onDragStart(e, t) }, onDragEnd: onDragEnd, onClick: function () { 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() } }, style: { 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] || '中'), (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.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, depBlock ? React.createElement('div', { style: { fontSize: 10, color: C.text2, marginBottom: 2 } }, '⛓ 被 ' + t.dependsOn.filter(function (id) { var d = getTask(id); return !d || (d.status !== 'resolved' && d.status !== 'archived') }).length + ' 个依赖阻塞') : 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' } },
683
746
  t.frozen ? React.createElement('span', { title: '已冻结:不参与自动派发(详情页可「解除冻结」)', style: { color: C.brand, fontWeight: 600 } }, '❄ 冻结') : null,
684
747
  (Array.isArray(t.waitingForTouches) && t.waitingForTouches.length) ? React.createElement('span', { title: '等文件锁释放:' + t.waitingForTouches.join('、') + '(touches 冲突,详情页可 force 越权派发)', style: { color: C.warn, fontWeight: 600 } }, '🔒 等文件释放') : null,
685
748
  React.createElement('span', { title: pm.label, style: { fontSize: 9, padding: '0 4px', borderRadius: 2, background: C.nested, border: '1px solid ' + C.border } }, pm.short),
@@ -688,19 +751,46 @@ function apply(ctx) {
688
751
  t.assignee ? React.createElement('span', null, '👤→' + shortId(t.assignee)) : null,
689
752
  durOf(t) ? React.createElement('span', { title: '创建至今耗时' }, '⏱ ' + durOf(t)) : null),
690
753
  (t.status === 'in-progress' || t.status === 'verifying') && state.activity[t.id] ? React.createElement('div', { style: { fontSize: 10, color: C.brand, marginTop: 3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, title: state.activity[t.id] }, '👁 ' + state.activity[t.id]) : null,
754
+ // 里程碑进展(Worker 主动上报的轻量进展,kind=progress):进行中的卡片显示最新一条 + 相对时间,无则不显示
755
+ t.status === 'in-progress' && t.lastProgress && t.lastProgress.text ? React.createElement('div', { style: { fontSize: 10, color: C.text2, marginTop: 3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, title: '最近进展(' + (t.lastProgress.by || '') + ' · ' + fmtTime(t.lastProgress.at) + '):' + t.lastProgress.text }, '📈 ' + t.lastProgress.text + ' · ' + ago(t.lastProgress.at)) : null,
691
756
  t.status === 'verifying' && preview ? React.createElement('div', { style: { fontSize: 10, color: C.text2, marginTop: 3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, '📝 ' + preview) : null) }
692
757
  function ActorLink(props) { var id = props.id; if (!id || id === 'system' || id === 'unknown' || id === 'auto-dispatch') return React.createElement('span', { style: { color: C.text2 } }, id || '-'); return React.createElement('span', { onClick: function (e) { e.stopPropagation(); jumpTo(id) }, style: { color: C.brand, cursor: 'pointer', textDecoration: 'underline' }, title: '跳转到 ' + id }, shortId(id)) }
693
758
  function MsgThread(props) {
694
759
  var msgs = props.messages || []
760
+ // 学习飞轮 v1:总开关关闭时,候选教训卡片连同「沉淀」按钮整个不渲染(不生成也不展示)
761
+ if (!state.feedbackEnabled) msgs = msgs.filter(function (m) { return m && m.kind !== 'lesson-candidate' })
695
762
  if (msgs.length === 0) return null
696
- var kindStyle = { escalation: { color: C.err, label: 'Worker 上报', icon: 'alert-triangle' }, arbitration: { color: C.brand, label: '主窗口裁决', icon: 'scale' }, intervention: { color: C.warn, label: '高优介入', icon: 'zap' } }
763
+ var kindStyle = { escalation: { color: C.err, label: 'Worker 上报', icon: 'alert-triangle' }, arbitration: { color: C.brand, label: '主窗口裁决', icon: 'scale' }, intervention: { color: C.warn, label: '高优介入', icon: 'zap' }, progress: { color: C.text2, label: '进展', icon: 'activity' }, 'lesson-candidate': { color: C.brand, label: '候选教训', icon: 'book-open' } }
764
+ // 「沉淀」按钮语义:把这条候选教训 push 给主窗口 agent,由它自己选笔记/记忆工具落库
765
+ // (看板不知道对方有没有这类工具、也不知道最终存到哪——零耦合)。推送成功即置灰。
766
+ function lessonKey(m, i) { return String(props.taskId || '') + '|' + String((m && m.at) || i) }
767
+ function pushLesson(m, i) {
768
+ var key = lessonKey(m, i)
769
+ rpc('push-lesson', { taskId: props.taskId, text: String(m.text || '') }).then(function (r) {
770
+ state.lessonPushed[key] = (r && r.ok) ? true : 'fail'
771
+ notify()
772
+ }).catch(function () { state.lessonPushed[key] = 'fail'; notify() })
773
+ }
697
774
  return React.createElement('div', { style: { marginBottom: 8 } },
698
775
  React.createElement('div', { style: { fontSize: 11, fontWeight: 600, color: C.text2, marginBottom: 3 } }, '裁决对话 (' + msgs.length + ')'),
699
776
  msgs.map(function (m, i) {
700
777
  var ks = kindStyle[m.kind] || { color: C.text2, label: m.kind }
701
- return React.createElement('div', { key: i, style: { marginBottom: 4, padding: '5px 8px', borderLeft: '2px solid ' + ks.color, background: C.nested, borderRadius: 4 } },
702
- React.createElement('div', { style: { fontSize: 10, fontWeight: 600, color: ks.color, marginBottom: 2, display: 'inline-flex', alignItems: 'center', gap: 3 } }, ic(ks.icon, 10), ks.label + ' · ' + (m.by || '') + ' · ' + ago(m.at)),
703
- React.createElement('div', { style: { fontSize: 10, color: C.text, whiteSpace: 'pre-wrap', maxHeight: 140, overflowY: 'auto' } }, m.text))
778
+ var head = React.createElement('div', { style: { fontSize: 10, fontWeight: 600, color: ks.color, marginBottom: 2, display: 'inline-flex', alignItems: 'center', gap: 3 } }, ic(ks.icon, 10), ks.label + ' · ' + (m.by || '') + ' · ' + ago(m.at))
779
+ var body = React.createElement('div', { style: { fontSize: 10, color: C.text, whiteSpace: 'pre-wrap', maxHeight: 140, overflowY: 'auto' } }, m.text)
780
+ if (m.kind !== 'lesson-candidate') {
781
+ return React.createElement('div', { key: i, style: { marginBottom: 4, padding: '5px 8px', borderLeft: '2px solid ' + ks.color, background: C.nested, borderRadius: 4 } }, head, body)
782
+ }
783
+ var st = state.lessonPushed[lessonKey(m, i)]
784
+ var btnStyle = { fontSize: 9, padding: '2px 7px', borderRadius: 3, flexShrink: 0, cursor: st === true ? 'default' : 'pointer', fontWeight: 600, whiteSpace: 'nowrap' }
785
+ if (st === true) { btnStyle.border = '1px solid ' + C.border; btnStyle.background = 'transparent'; btnStyle.color = C.text2 }
786
+ else if (st === 'fail') { btnStyle.border = 'none'; btnStyle.background = C.err; btnStyle.color = C_INV }
787
+ else { btnStyle.border = '1px solid ' + C.brand; btnStyle.background = 'transparent'; btnStyle.color = C.brand }
788
+ return React.createElement('div', { key: i, style: { marginBottom: 4, padding: '5px 8px', borderLeft: '2px solid ' + C.brand, background: 'color-mix(in srgb, ' + C.brand + ' 6%, transparent)', borderRadius: 4 } },
789
+ React.createElement('div', { style: { display: 'flex', alignItems: 'flex-start', gap: 6 } },
790
+ React.createElement('div', { style: { flex: 1, minWidth: 0 } },
791
+ React.createElement('div', { style: { fontSize: 10, fontWeight: 600, color: ks.color, marginBottom: 2, display: 'inline-flex', alignItems: 'center', gap: 3 } }, '📚', ks.label + ' · ' + (m.by || '') + ' · ' + ago(m.at)),
792
+ body),
793
+ React.createElement('button', { onClick: function () { if (st !== true) pushLesson(m, i) }, disabled: st === true, title: st === true ? '已推送给主窗口 agent' : (st === 'fail' ? '推送失败,点击重试' : '把这条候选教训推给主窗口 agent,由它用笔记/记忆工具沉淀'), style: btnStyle }, st === true ? '✅ 已推送' : (st === 'fail' ? '⚠ 重试' : '沉淀'))))
704
794
  }))
705
795
  }
706
796
  function ArchiveView() {
@@ -969,7 +1059,7 @@ function apply(ctx) {
969
1059
  React.createElement('div', { style: { fontSize: 11, fontWeight: 700, color: task.verification.verdict === 'approved' ? C.ok : C.err, marginBottom: 4, display: 'flex', alignItems: 'center', gap: 4 } }, ic(task.verification.verdict === 'approved' ? 'clipboard-check' : 'clipboard-x', 12), (task.verification.verdict === 'approved' ? '验收通过' : '验收驳回') + ' · ' + (task.verification.by || '') + ' · ' + ago(task.verification.at)),
970
1060
  React.createElement('div', { style: { fontSize: 11, color: C.text, marginBottom: 4, whiteSpace: 'pre-wrap' } }, task.verification.summary || '(无测试概要)'),
971
1061
  task.verification.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.verification.checks)) : null) : null,
972
- React.createElement(MsgThread, { messages: task.messages }),
1062
+ React.createElement(MsgThread, { messages: task.messages, taskId: task.id }),
973
1063
  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,
974
1064
  React.createElement('div', { style: { display: 'flex', gap: 4, flexWrap: 'wrap', marginTop: 6 } },
975
1065
  React.createElement('button', { onClick: saveEdit, disabled: saving, style: { fontSize: 10, padding: '3px 8px', border: 'none', borderRadius: 3, background: C.brand, color: C_INV, cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: 3 } }, saving ? '保存中…' : [ic('save', 10), ' 保存并重置']),
@@ -1015,7 +1105,8 @@ function apply(ctx) {
1015
1105
  var _wmo = useState(state.workMode), workMode = _wmo[0], setWorkModeState = _wmo[1]
1016
1106
  var _cro = useState(state.createOpen), createOpen = _cro[0], setCreateOpen = _cro[1]; var _crf = useState(state.createFlash), createFlash = _crf[0], setCreateFlash = _crf[1]
1017
1107
  var _sto = useState(state.softTimeoutMin), softT = _sto[0], setSoftT = _sto[1]; var _hto = useState(state.hardTimeoutMin), hardT = _hto[0], setHardT = _hto[1]
1018
- 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); setCreateOpen(state.createOpen); setCreateFlash(state.createFlash) }; listeners.push(update); update(); return function () { var i = listeners.indexOf(update); if (i >= 0) listeners.splice(i, 1) } }, [])
1108
+ var _fbo = useState(state.feedbackEnabled), fbEnabled = _fbo[0], setFbEnabled = _fbo[1]
1109
+ 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); setCreateOpen(state.createOpen); setCreateFlash(state.createFlash) }; listeners.push(update); update(); return function () { var i = listeners.indexOf(update); if (i >= 0) listeners.splice(i, 1) } }, [])
1019
1110
  if (!open) return null
1020
1111
  if (state.isRoot === false) return null // 子代理会话不渲染看板面板
1021
1112
  var active = tasks.filter(function (t) { return t.status !== 'archived' }); var archived = tasks.filter(function (t) { return t.status === 'archived' })
@@ -1041,7 +1132,7 @@ function apply(ctx) {
1041
1132
  React.createElement('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '6px 12px', borderBottom: '1px solid ' + C.border, flexShrink: 0, flexWrap: 'wrap', gap: 4 } },
1042
1133
  React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 6 } }, React.createElement('span', { style: { fontWeight: 600, fontSize: 13, color: C.text, display: 'inline-flex', alignItems: 'center', gap: 5 } }, ic('clipboard-list', 15), '智能看板'), React.createElement(ViewTab, null), React.createElement(PoolStatus, null)),
1043
1134
  React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' } },
1044
- React.createElement(PoolCfgPopover, { minW: minW, maxW: maxW, minV: minV, maxV: maxV, workerModel: workerModel, verifierModel: verifierModel, softT: softT, hardT: hardT }),
1135
+ React.createElement(PoolCfgPopover, { minW: minW, maxW: maxW, minV: minV, maxV: maxV, workerModel: workerModel, verifierModel: verifierModel, softT: softT, hardT: hardT, feedbackEnabled: fbEnabled }),
1045
1136
  dispatchInfo ? React.createElement('span', { style: { fontSize: 9, color: C.brand, maxWidth: 120, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, title: dispatchInfo }, dispatchInfo) : null,
1046
1137
  React.createElement('button', { onClick: function () { state.createOpen = true; notify() }, title: '新建任务(可存为草稿)', style: { fontSize: 11, padding: '3px 8px', border: '1px solid ' + C.border, borderRadius: 6, cursor: 'pointer', background: 'transparent', color: C.text2, display: 'inline-flex', alignItems: 'center', gap: 3 } }, ic('plus', 11), '新建任务'),
1047
1138
  createFlash ? React.createElement('span', { style: { fontSize: 10, color: C.ok } }, createFlash) : null,
package/lib/core.mjs CHANGED
@@ -107,22 +107,26 @@ export function classifyPipeline(t) {
107
107
  // ===== 配置(一次性派发模型:max*=并发上限;min* 字段保留仅为兼容旧看板文件,引擎不使用)=====
108
108
  // 两级超时:软超时(默认 30min)只上报主窗口提醒,不杀 run;硬超时(默认 120min)才兜底
109
109
  // dispose 释放并发位——人在线时由人决策,人不在时系统兜底。
110
+ // feedbackEnabled(学习飞轮 v1 总开关,默认开):关掉后不生成候选教训、prompt 不提软召回、
111
+ // 详情页沉淀按钮不渲染。老看板文件没有该字段 → 默认 true(normalizeBoard 补齐)。
110
112
  export function cfg(d) {
111
113
  var soft = Math.max(1, Math.min(480, d.softTimeoutMin || 30))
112
114
  var hard = Math.max(soft, Math.min(1440, d.hardTimeoutMin || 120))
113
- return { minWorkers: Math.max(0, Math.min(10, d.minWorkers || 1)), maxWorkers: Math.max(1, Math.min(10, d.maxWorkers || 3)), minVerifiers: Math.max(0, Math.min(5, d.minVerifiers || 0)), maxVerifiers: Math.max(0, Math.min(5, d.maxVerifiers || 2)), softTimeoutMin: soft, hardTimeoutMin: hard }
115
+ return { minWorkers: Math.max(0, Math.min(10, d.minWorkers || 1)), maxWorkers: Math.max(1, Math.min(10, d.maxWorkers || 3)), minVerifiers: Math.max(0, Math.min(5, d.minVerifiers || 0)), maxVerifiers: Math.max(0, Math.min(5, d.maxVerifiers || 2)), softTimeoutMin: soft, hardTimeoutMin: hard, feedbackEnabled: d.feedbackEnabled !== false }
114
116
  }
115
117
 
116
118
  // ===== 看板文件种子 =====
117
119
  // poolStatus 必须始终在种子/归一化里存在:task_list 工具输出 poolStatus: d.poolStatus,
118
120
  // 缺字段 = undefined → 工具结果的 lossless-JSON 校验会拒("value is not lossless JSON")
119
- export function seed(sid) { return { version: 12, ownerSession: sid, boardMode: 'auto', teamMode: false, minWorkers: 1, maxWorkers: 3, minVerifiers: 0, maxVerifiers: 2, workerModel: '', verifierModel: '', softTimeoutMin: 30, hardTimeoutMin: 120, poolStatus: { workers: [], verifiers: [] }, tasks: [] } }
121
+ export function seed(sid) { return { version: 12, ownerSession: sid, boardMode: 'auto', teamMode: false, feedbackEnabled: true, minWorkers: 1, maxWorkers: 3, minVerifiers: 0, maxVerifiers: 2, workerModel: '', verifierModel: '', softTimeoutMin: 30, hardTimeoutMin: 120, poolStatus: { workers: [], verifiers: [] }, tasks: [] } }
120
122
  // 旧文件缺 poolStatus 的归一化(读路径兜底,保证任何历史文件都满足工具输出契约)
121
123
  // touches 兼容:老任务没有该字段照常(这里只把「存在但非数组」的脏值收敛成数组,
122
124
  // 避免 holdsFiles/touchesConflict 里 Array.isArray 判定之外还有第三种形态)
125
+ // feedbackEnabled 兼容:老看板没有该字段(或落了脏值)一律补 true——默认开,行为与 v1 之前一致。
123
126
  export function normalizeBoard(d) {
124
127
  if (d && typeof d === 'object') {
125
128
  if (!d.poolStatus || typeof d.poolStatus !== 'object' || !Array.isArray(d.poolStatus.workers) || !Array.isArray(d.poolStatus.verifiers)) d.poolStatus = { workers: [], verifiers: [] }
129
+ if (typeof d.feedbackEnabled !== 'boolean') d.feedbackEnabled = true
126
130
  if (Array.isArray(d.tasks)) {
127
131
  for (var i = 0; i < d.tasks.length; i++) {
128
132
  var t = d.tasks[i]
@@ -166,7 +170,44 @@ export function isEscalation(output) { return /\[ESCALATE\]/i.test(output || '')
166
170
  // 从 run.result.output(ContentBlock[])提取纯文本
167
171
  export function outputText(res) { if (!res || !res.output) return ''; var parts = []; for (var i = 0; i < res.output.length; i++) { var b = res.output[i]; if (b && b.type === 'text' && b.text) parts.push(b.text) } return parts.join('\n') }
168
172
 
173
+ // ===== 学习飞轮 v1:候选教训信号(只产信号,不做存储)=====
174
+ // 设计红线(零耦合):看板只把「候选教训」当作一条结构化的 messages 记录落盘(详情页可见),
175
+ // 绝不调用任何笔记/记忆工具的 API、不写任何外部文件、也不知道对方最终把教训存到哪。
176
+ // 沉淀动作由主窗口 agent 自己选择可用工具完成(push-lesson RPC 只负责把候选 followup 过去)。
177
+ // 触发点(两处都先过 feedbackEnabled 总开关):Verifier 驳回、主窗口仲裁结论。
178
+ // 候选教训文本:三段式 markdown(场景 / 明细段… / 来源);明细段每段截 300 字。
179
+ // parts = [[标签, 正文], ...],如 [['错误做法', 驳回理由]] 或 [['疑问', q], ['裁决结论', a]]。
180
+ export function lessonText(scene, parts, source) {
181
+ var lines = ['场景: ' + String(scene == null ? '' : scene).trim().slice(0, 200)]
182
+ var list = Array.isArray(parts) ? parts : []
183
+ for (var i = 0; i < list.length && i < 4; i++) {
184
+ if (!list[i]) continue
185
+ lines.push(String(list[i][0]) + ': ' + String(list[i][1] == null ? '' : list[i][1]).trim().slice(0, 300))
186
+ }
187
+ lines.push('来源: ' + String(source == null ? '' : source))
188
+ return lines.join('\n')
189
+ }
190
+ // 往任务 messages 追一条候选教训(kind='lesson-candidate')。轻量判重:同一 at 或正文前 80 字相同
191
+ // 即视为同一事件(同一驳回/裁决可能被工具通道与 run 结算两条路径各触发一次)→ 不重复落。
192
+ // 返回 true = 本次新落一条(调用方据此决定要不要提示 UI/主窗口)。
193
+ export function pushLesson(t, text, at, by) {
194
+ if (!t || !text) return false
195
+ if (!Array.isArray(t.messages)) t.messages = []
196
+ var stamp = at || new Date().toISOString()
197
+ var head = String(text).slice(0, 80)
198
+ for (var i = 0; i < t.messages.length; i++) {
199
+ var m = t.messages[i]
200
+ if (!m || m.kind !== 'lesson-candidate') continue
201
+ if (m.at === stamp || String(m.text || '').slice(0, 80) === head) return false
202
+ }
203
+ t.messages.push({ kind: 'lesson-candidate', text: String(text), at: stamp, by: by || 'system' })
204
+ return true
205
+ }
206
+
169
207
  // ===== 一次性子代理 prompt 构建(上下文由主窗口 agent 写入 description/instructions,系统只追加生命周期记录)=====
208
+ // 学习飞轮 v1 软召回引导(feedbackEnabled 开时才拼进 prompt):环境里若有笔记/记忆类工具,先查历史教训再动手。
209
+ // 只是"提示先搜"——看板不代查、不调用任何记忆工具、也无从知道有没有这类工具(零耦合)。
210
+ export var LESSON_RECALL_HINT = '开工前如环境装有笔记/记忆类工具(如 note_search),先检索相关历史教训再动手。'
170
211
  // 过程记录注入:驳回/裁决/干预 history + messages(裁决答案/干预指令/歧义原文)是系统管理的,必须带给子代理
171
212
  export function histNotes(t) { return (t.history || []).filter(function (h) { return h.note && (/歧义|裁决|驳回|干预|rejected/i.test(h.note)) }).map(function (h) { return '- [' + h.timestamp + '] ' + String(h.note).slice(0, 300) }).join('\n') }
172
213
  export function buildMessages(t) {
@@ -191,7 +232,7 @@ export function buildContextPackSection(files, notes) {
191
232
  }
192
233
  return parts.length ? parts.join('\n\n') : ''
193
234
  }
194
- export function buildWorkerPrompt(t, pack) {
235
+ export function buildWorkerPrompt(t, pack, feedbackEnabled) {
195
236
  var notes = histNotes(t)
196
237
  var msgs = buildMessages(t)
197
238
  var p = '你是一个一次性任务执行 Worker。完成下面这个任务,完成后本会话即销毁。\n\ntaskId: ' + t.id + '\n任务: ' + t.title + '\n描述: ' + (t.description || '')
@@ -200,7 +241,10 @@ export function buildWorkerPrompt(t, pack) {
200
241
  if (notes) p += '\n\n该任务的过程记录(歧义上报/主窗口裁决/驳回/干预,请务必遵循最新裁决方向):\n' + notes
201
242
  if (msgs) p += '\n\n该任务的详细消息(裁决答案/干预指令/歧义原文等,请务必遵循):\n' + msgs
202
243
  if (pack) p += '\n\n' + pack
203
- p += '\n\n完成契约(双模,工具优先):\n1. 完成时:优先调用 board_report 工具(kind=complete, taskId=' + t.id + ',summary=开发描述/changes=改动清单/selfTest=自测情况/diffStat=变更概要);工具不可用则按分段格式输出(## 开发描述 / ## 改动清单 / ## 自测情况 / ## diff 概要)。\n diffStat 要求:若本次改动发生在 git 仓库内,运行 git diff --stat(含 git status --short),把输出贴进 diffStat(≤1500 字符);关键逻辑变更可附 ≤20 行核心片段。非代码任务/无 git 仓库可省略。\n **board_report 调用成功即任务终点:立即结束输出,不要再修改/验证任何文件**。上报后任务即刻进入验收,你继续改动会让代码在验收口径之外漂移、且阻塞 Verifier 派发(实测有 Worker 上报后又自测 16 分钟);上报后发现新问题的,写进 selfTest 备注交由 Verifier/主窗口裁决。\n2. 歧义/信息不足/需用户决策时:优先调用 board_report(kind=escalate, taskId=' + t.id + ', question=疑问);工具不可用则输出以 [ESCALATE] 开头的说明。不要猜测。上报歧义后直接结束本轮——裁决后会有新 Worker 带着裁决答案接手。'
244
+ // 学习飞轮 v1 软召回(feedbackEnabled 关闭时不出现):只提示"先查历史教训",看板绝不代查——
245
+ // 环境里有没有笔记/记忆类工具、教训库长什么样,都是 Worker 自己判断的事(零耦合)。
246
+ if (feedbackEnabled !== false) p += '\n\n' + LESSON_RECALL_HINT
247
+ p += '\n\n完成契约(双模,工具优先):\n1. 完成时:优先调用 board_report 工具(kind=complete, taskId=' + t.id + ',summary=开发描述/changes=改动清单/selfTest=自测情况/diffStat=变更概要);工具不可用则按分段格式输出(## 开发描述 / ## 改动清单 / ## 自测情况 / ## diff 概要)。\n diffStat 要求:若本次改动发生在 git 仓库内,运行 git diff --stat(含 git status --short),把输出贴进 diffStat(≤1500 字符);关键逻辑变更可附 ≤20 行核心片段。非代码任务/无 git 仓库可省略。\n **board_report 调用成功即任务终点:立即结束输出,不要再修改/验证任何文件**。上报后任务即刻进入验收,你继续改动会让代码在验收口径之外漂移、且阻塞 Verifier 派发(实测有 Worker 上报后又自测 16 分钟);上报后发现新问题的,写进 selfTest 备注交由 Verifier/主窗口裁决。\n2. 歧义/信息不足/需用户决策时:优先调用 board_report(kind=escalate, taskId=' + t.id + ', question=疑问);工具不可用则输出以 [ESCALATE] 开头的说明。不要猜测。上报歧义后直接结束本轮——裁决后会有新 Worker 带着裁决答案接手。\n3. 进展汇报(较大任务):按里程碑推进,每完成一个可验证的里程碑调用一次 board_report(kind="progress", taskId=' + t.id + ', question=一行进展摘要,≤200 字符)。只在有实际产物/结论时报;禁止定时汇报或表演式汇报。'
204
248
  return p
205
249
  }
206
250
  export function buildVerifierPrompt(t, pack) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-agent-board",
3
3
  "description": "DeepSeek Harness 智能看板:任务卡片 + 一次性 Worker/Verifier 派发 + 依赖调度 + 管线分档 + Team 模式(歧义上报主窗口裁决)",
4
- "version": "1.4.0",
4
+ "version": "1.5.0",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
7
7
  "scripts": {
@@ -2,7 +2,16 @@
2
2
  // 覆盖:状态机流转 / 依赖校验与环检测 / 管线分类 / 输出解析 / prompt 构建 / 派发决策 / 孤儿回收
3
3
  import { test } from 'node:test'
4
4
  import assert from 'node:assert/strict'
5
+ import { readFileSync } from 'node:fs'
6
+ import fs from 'node:fs'
7
+ import os from 'node:os'
8
+ import path from 'node:path'
9
+ import zlib from 'node:zlib'
5
10
  import * as core from '../lib/core.mjs'
11
+ // 粒度治理(软闸门)住在 index.mjs(IO 编排层):纯函数 + 契约文案常量导出,便于这里直接断言
12
+ import { suggestSplitOf, withSplitHint, SUGGEST_SPLIT_TEXT, TASK_SIZE_CONTRACT, TEAM_SPLIT_RULE } from '../index.mjs'
13
+ // Token 消耗聚合(纯函数 + 日志读取的优雅降级)住在 index.mjs,直接断言
14
+ import { aggregateUsageSummary, readRunUsage, findRunLog } from '../index.mjs'
6
15
 
7
16
  function mkTask(over) { return Object.assign({ id: 't1', title: 'T', description: '', status: 'pending', priority: 'medium', tags: [], parentId: null, assignMode: 'auto', assignee: null, context: { instructions: '' }, acceptance: '', dependsOn: [], pipeline: 'full', claimedBy: null, claimedAt: null, createdAt: '2026-01-01T00:00:00Z', resolvedAt: null, history: [], messages: [] }, over || {}) }
8
17
  function mkBoard(tasks) { return { version: 11, ownerSession: 's1', boardMode: 'auto', tasks: tasks || [] } }
@@ -518,3 +527,213 @@ test('isOrphan: 刚 claim 的(<2min)不回收', () => {
518
527
  const t = mkTask({ status: 'in-progress', claimedBy: 'run-x', claimedAt: new Date().toISOString() })
519
528
  assert.equal(core.isOrphan(mkBoard([t]), t, {}, Date.now()), false)
520
529
  })
530
+
531
+ // ===== 任务粒度治理(软闸门:只提示,绝不阻断)=====
532
+ test('suggestSplitOf: 常规粒度任务不提示(返回空串)', () => {
533
+ assert.equal(suggestSplitOf(mkTask({ title: '改按钮文案', description: '把按钮文案从 A 改成 B,肉眼确认' })), '')
534
+ assert.equal(suggestSplitOf(mkTask()), '')
535
+ assert.equal(suggestSplitOf(undefined), '')
536
+ })
537
+
538
+ test('suggestSplitOf: description 超过 500 字符触发建议(恰好 500 不触发)', () => {
539
+ assert.equal(suggestSplitOf(mkTask({ description: 'x'.repeat(500) })), '')
540
+ assert.equal(suggestSplitOf(mkTask({ description: 'x'.repeat(501) })), SUGGEST_SPLIT_TEXT)
541
+ })
542
+
543
+ test('suggestSplitOf: 命中史诗特征词触发建议(title / description 任一命中)', () => {
544
+ assert.equal(suggestSplitOf(mkTask({ title: '全量重构派发引擎' })), SUGGEST_SPLIT_TEXT)
545
+ assert.equal(suggestSplitOf(mkTask({ title: 'T', description: '把整个看板 UI 做一遍' })), SUGGEST_SPLIT_TEXT)
546
+ assert.equal(suggestSplitOf(mkTask({ title: 'T', description: '系统级改造' })), SUGGEST_SPLIT_TEXT)
547
+ })
548
+
549
+ test('suggestSplitOf: 建议文案含 10~30 分钟粒度与拆分出口', () => {
550
+ assert.match(SUGGEST_SPLIT_TEXT, /10~30 分钟/)
551
+ assert.match(SUGGEST_SPLIT_TEXT, /parentId/)
552
+ assert.match(SUGGEST_SPLIT_TEXT, /收窄边界/)
553
+ })
554
+
555
+ test('withSplitHint: 命中时才附加 suggestSplit,未命中返回体形态不变(老调用方无感)', () => {
556
+ const big = withSplitHint({ ok: true, task: {} }, mkTask({ title: '全量重写' }))
557
+ assert.equal(big.suggestSplit, SUGGEST_SPLIT_TEXT)
558
+ const small = withSplitHint({ ok: true, task: {} }, mkTask({ title: '改个错别字' }))
559
+ assert.deepEqual(Object.keys(small), ['ok', 'task']) // 不附加 suggestSplit
560
+ assert.equal(small.suggestSplit, undefined)
561
+ })
562
+
563
+ test('粒度治理接线:工具描述/Team 提示词/双出口返回体均已落地(源码级轻量断言)', () => {
564
+ const src = readFileSync(new URL('../index.mjs', import.meta.url), 'utf8')
565
+ assert.match(TASK_SIZE_CONTRACT, /建议粒度:单任务 10~30 分钟/)
566
+ assert.match(TASK_SIZE_CONTRACT, /epic 卡/)
567
+ assert.match(TEAM_SPLIT_RULE, /大任务必须拆分/)
568
+ assert.match(TEAM_SPLIT_RULE, /pipeline 传 direct/)
569
+ assert.match(TEAM_SPLIT_RULE, /parentId=父卡 id/)
570
+ assert.match(TEAM_SPLIT_RULE, /checkParentAuto/)
571
+ assert.ok(src.includes("' + TASK_SIZE_CONTRACT")) // task_create 工具描述已拼接契约
572
+ assert.ok(src.includes("' + TEAM_SPLIT_RULE")) // teamSection 已拼接第 6 条
573
+ assert.equal((src.match(/withSplitHint\(\{ ok: true, task: t \}, t\)/g) || []).length, 2) // task_create 工具 + create-task RPC
574
+ assert.match(src, /全量\|整体\|系统级\|全面\|重构\|所有模块\|整个/) // 史诗特征词表在位
575
+ assert.match(src, /SPLIT_DESC_LIMIT = 500/) // 500 字符阈值在位
576
+ })
577
+
578
+ // ===== Token 消耗统计 =====
579
+ test('aggregateUsageSummary: 空任务/无 usage 任务 → 全零 + 空 Top', () => {
580
+ const s = aggregateUsageSummary([mkTask({ id: 'a' }), mkTask({ id: 'b', usage: null })])
581
+ assert.equal(s.total, 0); assert.equal(s.input, 0); assert.equal(s.output, 0); assert.equal(s.cacheRead, 0)
582
+ assert.deepEqual(s.byModel, {}); assert.deepEqual(s.topTasks, [])
583
+ assert.deepEqual(aggregateUsageSummary(undefined), { total: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, byModel: {}, topTasks: [] })
584
+ })
585
+
586
+ test('aggregateUsageSummary: 总量/输入输出缓存拆分累加 + 按模型小计合并', () => {
587
+ const u1 = { input: 100, output: 20, cacheRead: 1000, cacheWrite: 0, total: 1120, runs: 2, models: { 'deepseek-flash': 1000, 'glm-5': 120 } }
588
+ const u2 = { input: 5, output: 5, cacheRead: 0, cacheWrite: 3, total: 13, runs: 1, models: { 'deepseek-flash': 13 } }
589
+ const s = aggregateUsageSummary([mkTask({ id: 'a', usage: u1 }), mkTask({ id: 'b', usage: u2 }), mkTask({ id: 'c' })])
590
+ assert.equal(s.total, 1133); assert.equal(s.input, 105); assert.equal(s.output, 25)
591
+ assert.equal(s.cacheRead, 1000); assert.equal(s.cacheWrite, 3)
592
+ assert.deepEqual(s.byModel, { 'deepseek-flash': 1013, 'glm-5': 120 })
593
+ })
594
+
595
+ test('aggregateUsageSummary: Top 任务按总量降序、最多 8 条、带 runs 计数', () => {
596
+ const tasks = []
597
+ for (let i = 0; i < 12; i++) tasks.push(mkTask({ id: 't' + i, title: 'T' + i, usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: i * 10, runs: i } }))
598
+ const s = aggregateUsageSummary(tasks)
599
+ assert.equal(s.topTasks.length, 8)
600
+ assert.deepEqual(s.topTasks.map(x => x.total), [110, 100, 90, 80, 70, 60, 50, 40]) // 降序取前 8
601
+ assert.equal(s.topTasks[0].id, 't11'); assert.equal(s.topTasks[0].title, 'T11'); assert.equal(s.topTasks[0].runs, 11)
602
+ })
603
+
604
+ test('readRunUsage: run 不存在 / 日志目录不可读 → null(优雅降级,不抛错)', () => {
605
+ assert.equal(readRunUsage('no-such-run-' + Date.now()), null)
606
+ assert.equal(readRunUsage(''), null)
607
+ assert.equal(findRunLog('no-such-run-' + Date.now()), null)
608
+ })
609
+
610
+ test('readRunUsage: v4 多帧日志真实解析(临时目录造帧 → usage 逐帧累加 + 模型取自 request/context)', (t) => {
611
+ // 只在宿主支持 zstd 压缩时构造样本(Node >= 22.15 / 23.8);否则跳过,避免 CI 假红
612
+ if (typeof zlib.zstdCompressSync !== 'function') { t.skip('zstdCompressSync unavailable'); return }
613
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-usage-'))
614
+ const runId = 'run-usage-test'
615
+ const dir = path.join(root, 'bucket', runId)
616
+ fs.mkdirSync(dir, { recursive: true })
617
+ const frameOf = (events) => zlib.zstdCompressSync(Buffer.from(events.map(e => JSON.stringify(e)).join('\n') + '\n', 'utf8'))
618
+ // 三帧:帧 1 只有模型与一次 usage;帧 2 是半截坏帧(截断,必须被跳过而不是抛错);帧 3 又一次 usage + 无 usage 事件
619
+ const f1 = frameOf([
620
+ { type: 'request/context', data: { model: 'deepseek-flash' } },
621
+ { type: 'assistant/message', data: { usage: { inputTokens: 100, outputTokens: 7, cacheReadTokens: 1000, cacheWriteTokens: 0, totalTokens: 1107 } } }
622
+ ])
623
+ const good = frameOf([{ type: 'assistant/message', data: { usage: { inputTokens: 3, outputTokens: 4, cacheReadTokens: 0, cacheWriteTokens: 5, totalTokens: 12 } } }, { type: 'step/end', data: {} }])
624
+ const bad = good.subarray(0, Math.max(1, good.length - 6)) // 截断帧:解压必失败
625
+ fs.writeFileSync(path.join(dir, 'session.v4.jsonl.zstd'), Buffer.concat([f1, bad, good]))
626
+ const u = readRunUsage(runId, root)
627
+ assert.equal(u.input, 103); assert.equal(u.output, 11); assert.equal(u.cacheRead, 1000)
628
+ assert.equal(u.cacheWrite, 5); assert.equal(u.total, 1119); assert.equal(u.model, 'deepseek-flash')
629
+ // 只有坏帧 / 无 usage 事件 → null(不是 0 值对象,调用方按「暂无数据」降级)
630
+ const dir2 = path.join(root, 'bucket', 'run-no-usage'); fs.mkdirSync(dir2, { recursive: true })
631
+ fs.writeFileSync(path.join(dir2, 'session.v4.jsonl.zstd'), frameOf([{ type: 'step/end', data: {} }]))
632
+ assert.equal(readRunUsage('run-no-usage', root), null)
633
+ fs.rmSync(root, { recursive: true, force: true })
634
+ })
635
+
636
+ test('Token 消耗接线:settleRun 结算累加 + 按模型小计 + get-tasks 聚合 + 仪表盘区块(源码级断言)', () => {
637
+ const host = readFileSync(new URL('../index.mjs', import.meta.url), 'utf8')
638
+ const cli = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
639
+ assert.match(host, /await accumulateRunUsage\(sid, rec\)/) // settleRun(及手动终止)结算路径调用
640
+ assert.match(host, /t\.usage\.models\[mk\] = \(t\.usage\.models\[mk\] \|\| 0\) \+ u\.total/) // 按模型小计累加
641
+ assert.match(host, /rec\.model \|\| u\.model/) // 模型 key 来源:派发覆盖 > 日志记录
642
+ assert.match(host, /inputTokens/) // 字段名与真实 v4 日志一致
643
+ assert.match(host, /d\.usageSummary = aggregateUsageSummary\(d\.tasks\)/) // get-tasks 现算聚合
644
+ assert.match(cli, /React\.createElement\(TokenUsage, \{ usage: state\.usageSummary \}\)/) // 仪表盘插入消耗区
645
+ assert.match(cli, /state\.usageSummary = \(d && d\.usageSummary\) \|\| null/) // 客户端取数
646
+ assert.match(cli, /'⛁ ' \+ fmtTokens\(t\.usage\.total\)/) // 进行中/已完成卡片显示本任务累计
647
+ })
648
+
649
+ // ===== 学习飞轮 v1:候选教训信号 + feedbackEnabled 开关 + 软召回 =====
650
+ test('cfg: feedbackEnabled 默认开(缺字段/脏值都算开),只有显式 false 才关', () => {
651
+ assert.equal(core.cfg({}).feedbackEnabled, true)
652
+ assert.equal(core.cfg({ feedbackEnabled: true }).feedbackEnabled, true)
653
+ assert.equal(core.cfg({ feedbackEnabled: false }).feedbackEnabled, false)
654
+ assert.equal(core.cfg({ feedbackEnabled: 'no' }).feedbackEnabled, true) // 脏值不关(默认开)
655
+ })
656
+
657
+ test('seed/normalizeBoard: feedbackEnabled 新看板默认 true,老看板读路径补 true,显式 false 保留', () => {
658
+ assert.equal(core.seed('s1').feedbackEnabled, true)
659
+ assert.equal(core.normalizeBoard({ tasks: [] }).feedbackEnabled, true) // 老看板无字段 → 默认开
660
+ assert.equal(core.normalizeBoard({ tasks: [], feedbackEnabled: false }).feedbackEnabled, false)
661
+ assert.equal(core.normalizeBoard({ tasks: [], feedbackEnabled: 'x' }).feedbackEnabled, true) // 脏值收敛成默认开
662
+ })
663
+
664
+ test('lessonText: 三段式(场景/明细段/来源),明细段截 300 字', () => {
665
+ const t = core.lessonText('任务「X」(t1) 被 Verifier 驳回', [['错误做法', '理'.repeat(400)]], '任务 t1 · 2026-01-01')
666
+ const lines = t.split('\n')
667
+ assert.equal(lines[0], '场景: 任务「X」(t1) 被 Verifier 驳回')
668
+ assert.ok(lines[1].indexOf('错误做法: ') === 0)
669
+ assert.equal(lines[1].slice('错误做法: '.length).length, 300) // 驳回理由截 300 字
670
+ assert.equal(lines[2], '来源: 任务 t1 · 2026-01-01')
671
+ // 仲裁形态:疑问 + 裁决结论两段
672
+ const a = core.lessonText('任务「Y」(t2) 的歧义裁决', [['疑问', '该走 A 还是 B?'], ['裁决结论', '走 B']], '任务 t2')
673
+ assert.match(a, /^场景: /)
674
+ assert.match(a, /疑问: 该走 A 还是 B?/)
675
+ assert.match(a, /裁决结论: 走 B/)
676
+ assert.match(a, /来源: 任务 t2$/)
677
+ })
678
+
679
+ test('pushLesson: 落 kind=lesson-candidate(by 默认 system),同 at / 同内容前缀判重', () => {
680
+ const t = { messages: [] }
681
+ const txt = core.lessonText('场景', [['错误做法', '驳回理由']], 'src')
682
+ assert.equal(core.pushLesson(t, txt, 'T1', 'system'), true)
683
+ assert.equal(core.pushLesson(t, txt, 'T1', 'system'), false) // 同 at → 判重(同一事件多路径触发)
684
+ assert.equal(core.pushLesson(t, txt, 'T2', 'system'), false) // 同内容前缀 → 判重
685
+ assert.equal(t.messages.length, 1)
686
+ assert.equal(t.messages[0].kind, 'lesson-candidate')
687
+ assert.equal(t.messages[0].at, 'T1'); assert.equal(t.messages[0].by, 'system')
688
+ const other = core.lessonText('另一个场景', [['错误做法', '别的理由']], 'src2')
689
+ assert.equal(core.pushLesson(t, other), true) // 不同事件照常落
690
+ assert.equal(t.messages[1].by, 'system') // by 缺省 system
691
+ assert.equal(core.pushLesson({ messages: [] }, ''), false) // 空文本不落
692
+ })
693
+
694
+ test('buildWorkerPrompt: 软召回引导随 feedbackEnabled 开关(关则整句消失)', () => {
695
+ const t = mkTask({ id: 't1', title: 'X' })
696
+ assert.match(core.buildWorkerPrompt(t), /先检索相关历史教训再动手/) // 缺省(未传)=默认开
697
+ assert.match(core.buildWorkerPrompt(t, '', true), /先检索相关历史教训再动手/)
698
+ assert.doesNotMatch(core.buildWorkerPrompt(t, '', false), /历史教训/) // 关 → 不出现
699
+ assert.doesNotMatch(core.buildWorkerPrompt(t, '', false), /笔记\/记忆类工具/)
700
+ assert.match(core.LESSON_RECALL_HINT, /note_search/) // 引导里点名可用的检索工具类型(只是举例)
701
+ })
702
+
703
+ test('学习飞轮接线:两处触发点 + push-lesson 开关拦截 + prompt/客户端软召回(源码级断言)', () => {
704
+ const host = readFileSync(new URL('../index.mjs', import.meta.url), 'utf8')
705
+ const cli = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
706
+ const coreSrc = readFileSync(new URL('../lib/core.mjs', import.meta.url), 'utf8')
707
+ // 候选教训两处触发:Verifier 驳回(文本通道 + 工具通道 + GUI RPC)与主窗口仲裁结论
708
+ assert.match(host, /pushRejectLesson\(d, t, vsecs\.verifySummary \|\| trimmed, t\.verification\.at\)/)
709
+ assert.match(host, /if \(!approved\) pushRejectLesson\(d, t, \(args\.summary \|\| ''\)/)
710
+ assert.match(host, /if \(args\.verdict === 'rejected'\) pushRejectLesson\(d, t, args\.comment\)/)
711
+ assert.match(host, /pushArbitrationLesson\(d, t, escQ, answer \|\| '', arbAt\)/)
712
+ // 生成前一律过 feedbackEnabled 总开关(关掉 = 不生成、不推)
713
+ assert.equal((host.match(/if \(!cfg\(d\)\.feedbackEnabled\) return false/g) || []).length, 2)
714
+ // 沉淀推送通道:关 → 明确拒绝;开 → followup 给主窗口 agent 自行选择存储工具
715
+ assert.match(host, /handle\('push-lesson'/)
716
+ assert.match(host, /if \(!cfg\(d\)\.feedbackEnabled\) return \{ ok: false, error: 'feedback disabled' \}/)
717
+ assert.match(host, /请用你可用的笔记\/记忆工具(如 note_manage)沉淀,或评估后忽略。/)
718
+ assert.match(host, /root\.followup\(makeMsg\('📚 \[任务看板\] 候选教训沉淀请求/)
719
+ // 开关透出:get-tasks 返回体 + set-board-config 写入 + 缓存同步(systemPrompt 同步读取)
720
+ assert.match(host, /d\.feedbackEnabled = cfg\(d\)\.feedbackEnabled/)
721
+ assert.match(host, /args\.key === 'feedbackEnabled'/)
722
+ assert.match(host, /feedbackCache\[sid\] = d\.feedbackEnabled/)
723
+ assert.match(host, /feedbackCache\[sid\] = nd\.feedbackEnabled !== false/)
724
+ // 软召回:Worker prompt 与 Team 提示词都以开关为条件拼接
725
+ assert.match(host, /buildWorkerPrompt\(t, packNote, cfg\(dsnap\)\.feedbackEnabled\)/)
726
+ assert.match(host, /feedbackOn\(String\(agent\.id\)\) \? '\\n' \+ LESSON_RECALL_HINT/)
727
+ assert.match(coreSrc, /if \(feedbackEnabled !== false\) p \+= '\\n\\n' \+ LESSON_RECALL_HINT/)
728
+ // 客户端:开关读取 + 设置区 checkbox + 候选卡片「沉淀」按钮 + 关闭即整块不渲染
729
+ assert.match(cli, /state\.feedbackEnabled = !\(d && d\.feedbackEnabled === false\)/)
730
+ assert.match(cli, /key: 'feedbackEnabled', value: e\.target\.checked/)
731
+ assert.match(cli, /'lesson-candidate': \{ color: C\.brand, label: '候选教训', icon: 'book-open' \}/)
732
+ assert.match(cli, /rpc\('push-lesson', \{ taskId: props\.taskId, text: String\(m\.text \|\| ''\) \}\)/)
733
+ assert.match(cli, /if \(!state\.feedbackEnabled\) msgs = msgs\.filter\(function \(m\) \{ return m && m\.kind !== 'lesson-candidate' \}\)/)
734
+ // 零耦合红线:不出现任何笔记插件的模块导入/服务获取/API 调用
735
+ // (注释里的历史提及与 prompt 文案里「举例可用工具名」不算耦合)
736
+ assert.doesNotMatch(host, /from 'dsh-notes|require\('dsh-notes|ctx\.get\('notes'\)|uiWorkspace\.openNote|note_manage\(|note_search\(/)
737
+ assert.doesNotMatch(coreSrc, /from 'dsh-notes|ctx\.get\('notes'\)|uiWorkspace\.openNote|note_manage\(|note_search\(/)
738
+ assert.doesNotMatch(cli, /from 'dsh-notes|ctx\.get\('notes'\)|uiWorkspace\.openNote|note_manage\(|note_search\(/)
739
+ })