dsh-agent-board 1.4.0 → 1.5.1

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/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, boardHome } = 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(主窗口当调度员:默认草稿 + 裁决歧义)
@@ -71,22 +230,141 @@ export function apply(ctx) {
71
230
  // 看板文件用「家目录绝对路径 + node:fs」直读写——不再走 fs 服务的相对路径解析
72
231
  // (fs 服务的相对路径解析根 = 进程启动 cwd,cwd 一变所有看板静默读成空板;
73
232
  // 曾因此导致整个看板状态异常。绝对路径对此永久免疫)
74
- function boardPath(sid) { return path.join(os.homedir(), '.dsh', 'tasks-' + sid + '.json') }
233
+ function boardPath(sid) { return path.join(boardHome(), 'tasks-' + sid + '.json') }
75
234
  function fileFor(sid) { return boardPath(sid) } // 保留旧名兼容调用点,但已是绝对路径
235
+
236
+ // ===== 看板跨重启继承(会话根 id 漂移 → 不分裂)=====
237
+ // 背景(看板反馈 n-mur1bmrwvoge):DSH 重启后同一会话在宿主侧的根 id 可能变
238
+ // (实测 55b0879a → 7b44fb23),看板按 ownerSession 分文件 → 新卡落新板,
239
+ // 旧板(73 张卡历史)从默认视图消失,用户视角就是「看板被清空了」。
240
+ // 归属改为「工作区」:文件里记 ownerCwd;以新 sid 访问且本 sid 无文件时,
241
+ // 若同工作区存在**唯一**「原主已死(不在 agents.roots())」的看板 → 接管它。
242
+ // 防误继承三闸门:① 只认 ownerCwd 严格相等;② 原主必须不在存活 root 集合里;
243
+ // ③ 候选必须唯一 —— 多候选一律不动(宁可留孤儿板等人工认领,也不合并错板)。
244
+ // 触发点在 rt() 的「文件不存在」分支:工具 / RPC / UI 三通道都走 rt,天然统一受益。
245
+ function activeRootIds() { try { var rs = ctx.agents && ctx.agents.roots(); var s = {}; if (Array.isArray(rs)) for (var i = 0; i < rs.length; i++) s[String(rs[i].id)] = true; return s } catch (_) { return {} } }
246
+ // 找可接管的孤儿看板:返回文件名,或 null(含「多候选」——多候选刻意返回 null)
247
+ function findAdoptableBoardFile(sid, cwd) {
248
+ if (!cwd) return null // 本会话工作区未知 → 绝不猜(猜错就是合并错板)
249
+ var home = boardHome()
250
+ var files
251
+ try { files = fsNode.readdirSync(home) } catch (_) { return null }
252
+ var alive = activeRootIds()
253
+ var hits = []
254
+ for (var i = 0; i < files.length; i++) {
255
+ var f = String(files[i] || '')
256
+ if (f.indexOf('tasks-') !== 0 || f.slice(-5) !== '.json') continue
257
+ var owner = f.slice(6, -5)
258
+ if (!owner || owner === sid) continue // 本 sid 的文件不该走到这里(本函数只在「本板不存在」时调用)
259
+ if (alive[owner]) continue // 原主还活着 → 不是孤儿板,绝不碰
260
+ var d = null
261
+ try { d = JSON.parse(fsNode.readFileSync(path.join(home, f), 'utf8')) } catch (_) { continue }
262
+ if (!vt(d)) continue
263
+ if (d.ownerCwd !== cwd) continue // 老文件没 ownerCwd(undefined)→ 天然不匹配,不会被误接管
264
+ hits.push({ file: f, sid: owner })
265
+ }
266
+ if (hits.length > 1) { // 多候选:记录一行但不自动动(多板并存时人工 list-boards 处理)
267
+ console.error('[task-board] 继承跳过:工作区 ' + cwd + ' 有 ' + hits.length + ' 个候选孤儿板(' + hits.map(function (h) { return h.sid }).join(', ') + '),不自动接管')
268
+ return null
269
+ }
270
+ return hits.length === 1 ? hits[0] : null
271
+ }
272
+ // 瞬时占用白名单(与 wt() 同款):Windows 上 rename/unlink 的目标名被并发读句柄、Defender、
273
+ // 索引器短暂占用时会抛这几个码——退避后重试即可;其它错误(跨设备 EXDEV、只读等)立即放弃。
274
+ var TRANSIENT_FS = { EPERM: 1, EBUSY: 1, ENOTEMPTY: 1, EACCES: 1 }
275
+ function isTransientFs(e) { return !!(e && TRANSIENT_FS[e.code]) }
276
+ // rename 退避重试:成功 true;非瞬时错误或重试耗尽 false(由调用方决定降级/回滚)
277
+ async function renameRetry(from, to, tries) {
278
+ var n = tries || 6
279
+ for (var i = 0; i < n; i++) {
280
+ try { await fsNode.promises.rename(from, to); return true }
281
+ catch (e) {
282
+ if (!isTransientFs(e) || i === n - 1) { console.error('[task-board] rename failed (' + from + ' -> ' + to + '):', String(e)); return false }
283
+ await new Promise(function (r) { setTimeout(r, 60 * (i + 1)) })
284
+ }
285
+ }
286
+ return false
287
+ }
288
+ // unlink 退避重试:ENOENT 视为已达成;重试耗尽返回 false(调用方记一行日志,不再阻断继承)
289
+ async function unlinkRetry(p, tries) {
290
+ var n = tries || 6
291
+ for (var i = 0; i < n; i++) {
292
+ try { await fsNode.promises.unlink(p); return true }
293
+ catch (e) {
294
+ if (e && e.code === 'ENOENT') return true
295
+ if (!isTransientFs(e) || i === n - 1) { console.error('[task-board] unlink failed (' + p + '):', String(e)); return false }
296
+ await new Promise(function (r) { setTimeout(r, 60 * (i + 1)) })
297
+ }
298
+ }
299
+ return false
300
+ }
301
+ // 接管落盘:两条路径,都保证「接管后看板目录里只剩新 sid 那一个文件」(不残留 .adopt-/.tmp 之类残件——
302
+ // 残留一份含全部历史卡的旧板 = 用户磁盘上多一份完整看板,下次继承还会把它算成候选)。
303
+ // ① 主路径 rename(p → np):原子、复用 wt() 同款 EPERM/EBUSY 退避重试。注意 rename 只搬内容,
304
+ // 文件里 ownerSession 仍写着旧 sid,所以必须紧接着用 wt() 把改写后的内容原子写回 np;
305
+ // 写回失败则把文件改名退回 p —— 绝不留下「名字是新 sid、内容写着旧主」的半成品(否则 rt()
306
+ // 读到 ownerSession 不匹配会退回空板,用户视角仍是「看板被清空」)。
307
+ // ② 兜底 rename 走不通(跨设备/顽固占用)→ wt() 写新板 + unlinkRetry 删旧板。先写后删:
308
+ // 任何时刻磁盘上至少有一份完整看板。
309
+ async function adoptBoard(sid, cand, cwd) {
310
+ var p = boardPath(cand.sid), np = boardPath(sid)
311
+ // 目标名已被占用 → 绝不接管(防覆盖:本会话看板若只是「这一刻读不到」,覆盖等于把本板换成孤儿板)
312
+ if (cand.sid === sid || fsNode.existsSync(np)) return false
313
+ var c = null
314
+ try { c = JSON.parse(await fsNode.promises.readFile(p, 'utf8')) } catch (e) { console.error('[task-board] 继承失败(读旧板):', String(e)); return false }
315
+ if (!vt(c)) return false
316
+ c.ownerSession = sid
317
+ if (typeof cwd === 'string' && cwd) c.ownerCwd = cwd
318
+ if (await renameRetry(p, np)) { // ① 主路径:先改名,再把 ownerSession 改写写回
319
+ try {
320
+ await wt(sid, c)
321
+ console.error('[task-board] 继承看板 ' + cand.sid + ' → ' + sid + '(工作区 ' + cwd + ',原主已不在 roots)')
322
+ return true
323
+ } catch (e) {
324
+ console.error('[task-board] 继承失败(改写 ownerSession):', String(e))
325
+ await renameRetry(np, p) // 回滚到旧名:下次 rt 还能再试,且不留半成品
326
+ return false
327
+ }
328
+ }
329
+ try { await wt(sid, c) } catch (e) { console.error('[task-board] 继承失败(写新板):', String(e)); return false }
330
+ await unlinkRetry(p)
331
+ console.error('[task-board] 继承看板 ' + cand.sid + ' → ' + sid + '(工作区 ' + cwd + ',原主已不在 roots;rename 不通,走写新+删旧)')
332
+ return true
333
+ }
76
334
  async function rt(sid) {
77
- var r
78
- try { r = await fsNode.promises.readFile(boardPath(sid), 'utf8') } catch (_) { return seed(sid) } // 不存在/不可读 → 空板
335
+ var r = null
336
+ try { r = await fsNode.promises.readFile(boardPath(sid), 'utf8') } catch (e) {
337
+ // 只有「文件确实不存在(ENOENT)」才触发继承:EACCES/EPERM/被占 等说明本会话看板很可能存在、
338
+ // 只是这一刻读不到——此时去接管会把别的板写到本会话文件名上(改前行为只是内存空板,不落盘)。
339
+ if (!e || e.code !== 'ENOENT') { console.error('[task-board] 读看板失败(不触发继承): ' + boardPath(sid) + ' :: ' + String(e)); return seedNoPersist(sid) }
340
+ // 本 sid 无看板文件:先试「同工作区唯一孤儿板」继承,接不到才种新板(种板即带 ownerCwd,供下次重启继承)
341
+ try {
342
+ var cwd0 = sessionCwd(sid)
343
+ var cand = findAdoptableBoardFile(sid, cwd0)
344
+ if (cand && await adoptBoard(sid, cand, cwd0)) r = await fsNode.promises.readFile(boardPath(sid), 'utf8')
345
+ } catch (e2) { console.error('[task-board] 继承流程异常(降级为空板):', String(e2)) }
346
+ if (r === null) return seed(sid, sessionCwd(sid))
347
+ }
79
348
  try {
80
349
  var d = JSON.parse(r)
81
- if (vt(d) && d.ownerSession === sid) { teamModeCache[sid] = !!d.teamMode; return normalizeBoard(d) }
82
- return seed(sid)
350
+ if (vt(d) && d.ownerSession === sid) {
351
+ teamModeCache[sid] = !!d.teamMode; var nd = normalizeBoard(d); feedbackCache[sid] = nd.feedbackEnabled !== false
352
+ // ownerCwd 回填(只改内存,随下一次写盘落盘):老看板文件没有该字段,而继承判定靠它——
353
+ // 不写盘就永远不能匿名继承。这里不额外做 IO,避免把只读路径变成写路径。
354
+ if (!nd.ownerCwd) { var cw = sessionCwd(sid); if (cw) nd.ownerCwd = cw }
355
+ return nd
356
+ }
357
+ return seed(sid, sessionCwd(sid))
83
358
  } catch (_) {
84
359
  // JSON 截断/损坏(如强杀打断写盘):隔离留档再种新板——数据不丢,坏文件也不反复 poison
85
360
  console.error('[task-board] board file corrupt, quarantining: ' + boardPath(sid))
86
361
  fsNode.promises.rename(boardPath(sid), boardPath(sid) + '.corrupt-' + Date.now()).catch(function () {})
87
- return seed(sid)
362
+ return seedNoPersist(sid)
88
363
  }
89
364
  }
365
+ // 瞬时读失败/坏文件隔离后的空板必须禁止回写:否则一个「读不到」的瞬间就会把 106 张卡覆成空板。
366
+ // __noPersist 用 non-enumerable 挂载——即使将来有路径漏判把它写出去,JSON.stringify 也会跳过该字段。
367
+ function seedNoPersist(sid) { var d = seed(sid, sessionCwd(sid)); try { Object.defineProperty(d, '__noPersist', { value: true, enumerable: false }) } catch (_) {} return d }
90
368
  // 原子写盘:先写临时文件再 rename——强杀若发生在写盘中途,磁盘上最多留个 .tmp 残件,
91
369
  // 看板本体永远不会是截断的半个 JSON(此前非原子直写,kill 中写 = 看板被 seed 清空)
92
370
  // Windows 特有问题:rename 目标被并发读句柄/Defender/索引器短暂占用时抛 EPERM/EBUSY
@@ -116,7 +394,7 @@ export function apply(ctx) {
116
394
  // 写成功后异步触发一次 poolCycle(派发/回收反应快),按会话去抖避免连环触发
117
395
  var cyclePending = {}
118
396
  function kickCycle(sid) { if (cyclePending[sid]) return; cyclePending[sid] = true; var tm = ctx.timer; var go = function () { cyclePending[sid] = false; poolCycle(sid).catch(function () {}) }; if (tm) tm.timeout(50).then(go); else Promise.resolve().then(go) }
119
- function mutateLocked(sid, mutate, skipKick) { return withLock(sid, async function () { var d = await rt(sid); var r = await mutate(d); if (r !== null && r !== undefined) { await wt(sid, d); if (!skipKick) kickCycle(sid); return r } return r }) }
397
+ function mutateLocked(sid, mutate, skipKick) { return withLock(sid, async function () { var d = await rt(sid); if (d && d.__noPersist) { console.error('[task-board] 看板暂时不可读,拒绝在空板上覆写(防瞬时读失败清板): ' + sid); return { ok: false, error: '看板暂时不可读,请重试' } } var r = await mutate(d); if (r !== null && r !== undefined) { await wt(sid, d); if (!skipKick) kickCycle(sid); return r } return r }) }
120
398
  function jo() { return { schema: { type: 'object', additionalProperties: true }, render: function (a, v) { return [{ type: 'text', text: JSON.stringify(v, null, 2) }] } } }
121
399
  // touches(文件级排他声明)归一化:非字符串项过滤掉,空数组 = 不声明(无锁语义)。
122
400
  // 上限 20 条,与 contextFiles 上限一致,防止 prompt/看板文件被超长清单撑爆。
@@ -136,6 +414,21 @@ export function apply(ctx) {
136
414
  // 超时保护:run 挂死时走失败重试路径
137
415
  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
416
 
417
+ // ===== 学习飞轮 v1:候选教训信号(只产信号,不做存储)=====
418
+ // 看板在这里只做一件事:把「Verifier 驳回 / 主窗口仲裁结论」变成一条结构化的候选教训,
419
+ // 落进 t.messages(kind='lesson-candidate':详情页可见 + 带「沉淀」按钮)。
420
+ // 零耦合红线:不调用任何笔记/记忆工具的 API、不写任何外部文件——教训最终存到哪、要不要存,
421
+ // 全由主窗口 agent 自己用可用工具决定。触发前一律先过 feedbackEnabled 总开关(关掉 = 不生成)。
422
+ function lessonSource(t) { return '任务 ' + t.id + '「' + String(t.title || '').slice(0, 60) + '」 · ' + new Date().toISOString() }
423
+ function pushRejectLesson(d, t, reason, at) {
424
+ if (!cfg(d).feedbackEnabled) return false
425
+ return pushLesson(t, lessonText('任务「' + String(t.title || '') + '」(' + t.id + ') 被 Verifier 驳回', [['错误做法', String(reason || '(Verifier 未给出理由)')]], lessonSource(t)), at, 'system')
426
+ }
427
+ function pushArbitrationLesson(d, t, question, answer, at) {
428
+ if (!cfg(d).feedbackEnabled) return false
429
+ return pushLesson(t, lessonText('任务「' + String(t.title || '') + '」(' + t.id + ') 的歧义裁决', [['疑问', question], ['裁决结论', answer]], lessonSource(t)), at, 'system')
430
+ }
431
+
139
432
 
140
433
  // ===== 状态流转(已抽取到 lib/core.mjs)=====
141
434
  // ===== 一次性派发引擎(v74 去池化重写)=====
@@ -234,7 +527,7 @@ export function apply(ctx) {
234
527
  try { pack = await readContextPack(sid, t) } catch (e) { console.error('[task-board] context pack read failed:', String(e)) }
235
528
  // user prompt 只留一行指引,内容走上下文注入区块
236
529
  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() }
530
+ 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
531
  if (modelOverride) {
239
532
  // list-models 返回的 id 是 "provider/model" 复合格式(如 "cmss/zhanlu/glm-5.2"),
240
533
  // 但 AgentOptions 的 provider 和 model 是分开的——整串塞进 model 会报 UNKNOWN_MODEL
@@ -296,6 +589,37 @@ export function apply(ctx) {
296
589
  } catch (e) { console.error('[task-board] settle ' + rec.role + ' failed (task ' + rec.taskId + '):', String(e)) }
297
590
  // 历史会话留档:记录该次 run 的结局(完成/失败/硬超时),详情页可据此标注阶段状态
298
591
  closeRunHistory(sid, rec.taskId, String(rec.run.id), failed ? (err ? 'timeout/error' : 'incomplete') : 'completed').catch(function () {})
592
+ // ===== token 消耗结算:读该次 run 的 v4 日志聚合 usage,累加到任务 =====
593
+ // 放在状态推进之后:统计是附加信息,读日志失败/无 usage 时静默跳过,绝不影响结算语义。
594
+ // Worker 失败重试、驳回重做都会各走一次 settleRun,因此多轮消耗天然累加(runs 计数)。
595
+ await accumulateRunUsage(sid, rec)
596
+ }
597
+
598
+ // 把一次 run 的 token 消耗累加到任务(t.usage):总量/输入/输出/缓存读写 + 按模型小计 + runs 计数。
599
+ // 模型小计的 key:优先本次派发显式覆盖的模型(rec.model),否则用日志里记录的会话模型。
600
+ async function accumulateRunUsage(sid, rec) {
601
+ var u = null
602
+ try { u = readRunUsage(String(rec.run.id)) } catch (_) { u = null }
603
+ if (!u || !u.total) return
604
+ try {
605
+ await mutateLocked(sid, function (d) {
606
+ var t = d.tasks.find(function (x) { return x.id === rec.taskId })
607
+ if (!t) return null
608
+ if (!t.usage) t.usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0, runs: 0, models: {} }
609
+ // 逐字段兜底 0:脏数据(半写/老形态)也不会把 NaN 带进看板(NaN 会让工具输出的 lossless-JSON 校验拒整条结果)
610
+ t.usage.input = (t.usage.input || 0) + u.input
611
+ t.usage.output = (t.usage.output || 0) + u.output
612
+ t.usage.cacheRead = (t.usage.cacheRead || 0) + u.cacheRead
613
+ t.usage.cacheWrite = (t.usage.cacheWrite || 0) + u.cacheWrite
614
+ t.usage.total = (t.usage.total || 0) + u.total
615
+ t.usage.runs = (t.usage.runs || 0) + 1
616
+ if (!t.usage.models) t.usage.models = {}
617
+ var mk = rec.model || u.model || '(未知模型)'
618
+ t.usage.models[mk] = (t.usage.models[mk] || 0) + u.total
619
+ t.usage.updatedAt = new Date().toISOString()
620
+ return { ok: true }
621
+ })
622
+ } catch (e) { console.error('[task-board] usage accumulate failed (task ' + rec.taskId + '):', String(e)) }
299
623
  }
300
624
 
301
625
  async function settleWorker(sid, rec, output, failed, errText) {
@@ -355,6 +679,8 @@ export function apply(ctx) {
355
679
  verifyApply(d, t, String(rec.run.id), approved ? 'approved' : 'rejected', trimmed.slice(0, 200))
356
680
  if (!approved) {
357
681
  t.rejectCount = (t.rejectCount || 0) + 1
682
+ // 学习飞轮 v1:Verifier 驳回 → 候选教训(场景/错误做法/来源),随 t.verification.at 判重
683
+ pushRejectLesson(d, t, vsecs.verifySummary || trimmed, t.verification.at)
358
684
  if (t.rejectCount >= 3) { t.status = 'blocked'; ah(t, 'in-progress', 'blocked', 'system', 'verifier 驳回 x' + t.rejectCount + ',待人工裁决') }
359
685
  else { t.status = 'pending'; t.claimedBy = null; t.claimedAt = null; ah(t, 'in-progress', 'pending', 'system', '驳回重派:新 Worker 将携带驳回原因继续') }
360
686
  }
@@ -601,7 +927,7 @@ export function apply(ctx) {
601
927
  // 不能走 resolveRoot:该函数在每个 agent 每次 prompt 组装时同步执行,
602
928
  // resolveRoot 全表扫 agent 注册表会把宿主事件循环卡死(曾导致全局界面卡顿、用户消息延迟渲染)。
603
929
  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。'
930
+ 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
931
  },
606
932
  })
607
933
  ctx.effect(function () { return disposeSection })
@@ -643,7 +969,7 @@ export function apply(ctx) {
643
969
  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
970
  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
971
  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 } }) } }))
972
+ 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
973
 
648
974
  // ===== RPC =====
649
975
  // get-tasks 是纯读路径(rt 只读文件)——poolCycle 由 15s 心跳 + 写入后 kickCycle 驱动,
@@ -651,8 +977,13 @@ export function apply(ctx) {
651
977
  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
978
  // poolStatus 防幽灵:只保留指向当前活跃任务的条目(重启后内存 runs 清空,文件快照可能残留)
653
979
  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] }) }
980
+ // 学习飞轮 v1:把 feedbackEnabled 显式放进返回体(normalizeBoard 已按老看板补默认 true),
981
+ // 客户端据此做能力检测——开关关闭时渲染层不生成候选、不显示「沉淀」按钮。
982
+ d.feedbackEnabled = cfg(d).feedbackEnabled
654
983
  // 工作模式派生字段:UI 只读这一个字段决定三档选中态(不落盘,写入仍走 boardMode/teamMode)
655
984
  d.workMode = deriveWorkMode(d)
985
+ // ===== board 级 token 消耗聚合(现算,不落盘):总量 + 输入/输出/缓存读 + 按模型 + 任务 Top8 =====
986
+ d.usageSummary = aggregateUsageSummary(d.tasks)
656
987
  return d })
657
988
  // 派发前上下文预览:主 agent 用它确认"我将注入给子代理的材料"是否足够(不发任务、不落盘)
658
989
  handle('preview-context', async function (args) {
@@ -674,48 +1005,31 @@ export function apply(ctx) {
674
1005
  // (不再从看板路径反推 workspace——那依赖进程 cwd 凑巧等于工作区,曾是隐性 bug)
675
1006
  // v0.1.7 起日志文件名带格式版本号(session.v4.jsonl.zstd),旧会话是
676
1007
  // 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
- }
1008
+ // 定位与分帧和 readRunUsage 共用同一套 helper(findRunLog / readLogBytes / readLogFrames)。
1009
+ var log = findRunLog(child)
687
1010
  if (!log) return { ok: true, activity: null, reason: 'log not found' }
688
1011
  // 只同步读末尾 2MB(日志追加写,末帧必在尾部)——整文件 readFileSync 在大日志上
689
1012
  // 会造成数十毫秒级同步 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])
1013
+ var buf = readLogBytes(log, 2 * 1024 * 1024)
1014
+ if (!buf) return { ok: true, activity: null, reason: 'log unreadable' }
697
1015
  // 追加写多帧格式:最新事件在末帧。但末帧可能只有 step/end、turn/end 这类
698
1016
  // 结算事件(v4 一帧只装一个step的增量),单解一帧经常捞不到动作 → 从新到旧
699
1017
  // 最多回扫 3 帧,找到第一个动作摘要即止(成本仍受控:每帧只是一次 zstd 解压)
1018
+ var frames = readLogFrames(buf, 3)
700
1019
  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 (_) {}
1020
+ for (var fi = 0; fi < frames.length && !activity; fi++) {
1021
+ var evs = frames[fi]
1022
+ for (var i = evs.length - 1; i >= 0 && !activity; i--) {
1023
+ var e = evs[i]
1024
+ var dta = (e && e.data) || {}
1025
+ if (e && e.type === 'tool/call' && dta.name) activity = '🔧 ' + dta.name + ' ' + String(dta.arguments || '').replace(/\s+/g, ' ').slice(0, 90)
1026
+ // v4:助手文本在 assistant/message 的 content 块里;v3 及以前是 assistant/chunk 流片
1027
+ else if (e && e.type === 'assistant/message') {
1028
+ var msg = dta.message || {}
1029
+ var blocks = Array.isArray(msg.content) ? msg.content : []
1030
+ 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 } }
1031
+ }
1032
+ 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
1033
  }
720
1034
  }
721
1035
  return { ok: true, activity: activity }
@@ -723,7 +1037,7 @@ export function apply(ctx) {
723
1037
  })
724
1038
  // 全局多会话总览:聚合本机所有看板的任务计数(只读,供 dashboard 跨会话视图)
725
1039
  handle('list-boards', async function (args) {
726
- var home = path.join(os.homedir(), '.dsh')
1040
+ var home = boardHome()
727
1041
  var out = []
728
1042
  var files
729
1043
  try { files = fsNode.readdirSync(home).filter(function (f) { return f.indexOf('tasks-') === 0 && f.slice(-5) === '.json' }) } catch (e) { return { ok: true, boards: [] } }
@@ -748,8 +1062,44 @@ export function apply(ctx) {
748
1062
  })
749
1063
  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
1064
  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) }) })
1065
+ 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
1066
  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 }) })
1067
+ // ===== 任务删除通道(真删,无 undo)=====
1068
+ // 背景:archive-task 只收 resolved/cancelled,草稿/误建卡片此前没有任何下线通道(只能永远挂着)。
1069
+ // 状态门禁(delete-task 与 batch-op delete 共用本函数,保证两条入口语义完全一致):
1070
+ // draft / pending / blocked → 允许删(未产生任何执行痕迹,删了不丢信息)
1071
+ // in-progress / verifying → 拒绝,提示先 terminate-agent 终止(避免把在跑的 run 变成孤儿)
1072
+ // resolved / cancelled → 拒绝,引导用 archive-task(已落定任务留档可检索)
1073
+ // archived → 幂等 ok(已不在活跃看板里,重复调用不报错)
1074
+ // 未归档子任务(parentId 指向本任务且 status !== 'archived')存在时拒删:父卡一删子任务的
1075
+ // parentId 就成了悬空引用(checkParentAuto / 上下文继承都会失效),必须先处理子任务。
1076
+ // 返回 { err: '...' } 或 { mode: 'already' };调用方按需转成各自的返回体。
1077
+ function deleteGate(d, t) {
1078
+ if (t.status === 'in-progress' || t.status === 'verifying') return { err: '任务正在执行中,请先用 terminate-agent 终止(in-progress 回待办、verifying 换 verifier 接手)再删除' }
1079
+ if (t.status === 'resolved' || t.status === 'cancelled') return { err: '已落定任务请用归档(archive-task),不要删除' }
1080
+ if (t.status === 'archived') return { mode: 'already' }
1081
+ // 允许态只剩 draft / pending / blocked;其他未知状态(老看板/人工改档)一律拒删,宁可保守
1082
+ if (t.status !== 'draft' && t.status !== 'pending' && t.status !== 'blocked') return { err: '任务状态 ' + t.status + ' 不在可删除范围(仅草稿/待办/阻塞可删)' }
1083
+ var live = gsb(t.id, d.tasks).filter(function (c) { return c.status !== 'archived' })
1084
+ if (live.length) return { err: '该任务还有 ' + live.length + ' 个未归档子任务(' + live.map(function (c) { return c.title || c.id }).slice(0, 3).join('、') + '),请先删除或归档子任务' }
1085
+ return { mode: 'ok' }
1086
+ }
1087
+ // 单任务删除:真删(从 d.tasks 数组移除),不写 history(记录随任务一起消失),不留档、无 undo。
1088
+ // console.error 留一行操作日志,便于事后溯源"某个卡片什么时候被谁删了"。
1089
+ handle('delete-task', async function (args) {
1090
+ var sid = rpcSessionId(args); var actor = getActorId()
1091
+ if (!args.taskId) return { ok: false, error: 'taskId required' }
1092
+ return mutateLocked(sid, function (d) {
1093
+ var t = d.tasks.find(function (x) { return x.id === args.taskId })
1094
+ if (!t) return { ok: false, error: 'not found' }
1095
+ var g = deleteGate(d, t)
1096
+ if (g.mode === 'already') return { ok: true, deleted: t.id, alreadyArchived: true }
1097
+ if (g.err) return { ok: false, error: g.err }
1098
+ d.tasks = d.tasks.filter(function (x) { return x.id !== t.id })
1099
+ console.error('[task-board] delete-task: ' + t.id + ' «' + String(t.title || '').slice(0, 60) + '» (status=' + t.status + ') by ' + actor)
1100
+ return { ok: true, deleted: t.id }
1101
+ })
1102
+ })
753
1103
  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
1104
  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) } }) })
755
1105
  handle('set-team-mode', async function (args) { var sid = rpcSessionId(args); return mutateLocked(sid, function (d) { d.teamMode = !!args.enabled; if (d.teamMode) d.boardMode = 'auto'; teamModeCache[sid] = d.teamMode; return { ok: true, teamMode: d.teamMode, boardMode: d.boardMode, workMode: deriveWorkMode(d) } }) })
@@ -781,9 +1131,14 @@ export function apply(ctx) {
781
1131
  var t = d.tasks.find(function (x) { return x.id === taskId })
782
1132
  if (!t) return { ok: false, error: 'not found' }
783
1133
  if (!t.escalation) return { ok: false, error: 'not escalated' }
1134
+ var escQ = String(t.escalation.question || '') // 先留档:下面 delete 后就没得取了
784
1135
  delete t.escalation
785
1136
  if (!Array.isArray(t.messages)) t.messages = []
786
- t.messages.push({ kind: 'arbitration', text: answer || '', at: new Date().toISOString(), by: actor, action: act })
1137
+ var arbAt = new Date().toISOString()
1138
+ t.messages.push({ kind: 'arbitration', text: answer || '', at: arbAt, by: actor, action: act })
1139
+ // 学习飞轮 v1:主窗口的裁决结论是最值钱的教训来源(Worker 会在同一个坑里反复上报)
1140
+ // → 生成候选教训(场景/疑问/裁决结论),由主窗口决定要不要沉淀进笔记/记忆工具。
1141
+ pushArbitrationLesson(d, t, escQ, answer || '', arbAt)
787
1142
  ah(t, t.status, t.status, actor, (act === 'hold' ? '主窗口裁决(挂起冻结): ' : '主窗口裁决: ') + (answer || '').slice(0, 200))
788
1143
  // in-progress 的歧义任务:回 pending 重派(新 Worker 带裁决上下文);verifying 的 verifier 故障升级:保持待审,下轮派新 verifier
789
1144
  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 +1186,13 @@ export function apply(ctx) {
831
1186
  async function doTerminate(sid, actor, taskId) {
832
1187
  var rec = runsFor(sid)[taskId]
833
1188
  var label = 'no-active-run'
834
- if (rec) { delete runsFor(sid)[taskId]; label = rec.role + ':' + taskId; try { await rec.run.dispose() } catch (_) {} }
1189
+ if (rec) {
1190
+ delete runsFor(sid)[taskId]; label = rec.role + ':' + taskId
1191
+ try { await rec.run.dispose() } catch (_) {}
1192
+ // 手动终止的 run 不会走 settleRun(runsFor 已摘除,结算路径会早退),
1193
+ // 但它确实消耗了 token——在这里补一次结算,避免终止即丢账。
1194
+ await accumulateRunUsage(sid, rec)
1195
+ }
835
1196
  await mutateLocked(sid, function (d) {
836
1197
  var t = d.tasks.find(function (x) { return x.id === taskId })
837
1198
  if (!t) return null
@@ -894,13 +1255,24 @@ export function apply(ctx) {
894
1255
  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
1256
  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
1257
  // ===== 池中 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) {
1258
+ 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
1259
  var sid = toolSessionId(); var actor = getActorId()
899
1260
  var result = await mutateLocked(sid, function (d) {
900
1261
  var t = d.tasks.find(function (x) { return x.id === args.taskId })
901
1262
  if (!t) return { ok: false, error: 'not found' }
902
1263
  if (t.status !== 'in-progress') return { ok: false, error: 'not in-progress (状态: ' + t.status + ')' }
903
1264
  if (!Array.isArray(t.messages)) t.messages = []
1265
+ // ===== 里程碑进展通道(kind=progress)=====
1266
+ // 定位:长任务执行期间的「还在正确路上」的轻量证明;不进回执聚合、不通知主窗口(纯静默可见)。
1267
+ // 防表演式汇报:只在有实际产物/结论时报(契约写在 buildWorkerPrompt 里),且不写 ah() 历史(避免刷屏)。
1268
+ if (args.kind === 'progress') {
1269
+ var ptext = String(args.question || '').trim().slice(0, 200)
1270
+ if (!ptext) return { ok: false, error: 'question required for progress (一行进展摘要)' }
1271
+ var pat = new Date().toISOString()
1272
+ t.messages.push({ kind: 'progress', text: ptext, at: pat, by: actor })
1273
+ t.lastProgress = { text: ptext, at: pat } // 覆盖式:卡片只展示最新一条
1274
+ return { ok: true, progress: t.lastProgress }
1275
+ }
904
1276
  if (args.kind === 'escalate') {
905
1277
  var at = new Date().toISOString()
906
1278
  t.escalation = { question: (args.question || '').slice(0, 2000), at: at, by: actor }
@@ -931,6 +1303,8 @@ export function apply(ctx) {
931
1303
  t.verification = { verdict: args.verdict, summary: args.summary || '', checks: args.checks || '', at: new Date().toISOString(), by: actor }
932
1304
  verifyApply(d, t, actor, args.verdict, (args.summary || '').slice(0, 200))
933
1305
  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 + ',待人工裁决') } }
1306
+ // 学习飞轮 v1:工具通道驳回同样生成候选教训(与文本通道同一条判重口径,不会重复落)
1307
+ if (!approved) pushRejectLesson(d, t, (args.summary || '') + (args.checks ? '\n' + args.checks : ''), t.verification.at)
934
1308
  return { ok: true, task: t }
935
1309
  })
936
1310
  if (result && result.ok && result.task) {
@@ -957,20 +1331,53 @@ export function apply(ctx) {
957
1331
  }
958
1332
  return { ok: true, models: out }
959
1333
  })
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 } }) })
1334
+ // ===== 学习飞轮 v1:沉淀推送通道(push-lesson)=====
1335
+ // 详情页「沉淀」按钮 → 把结构化候选教训 followup 给主窗口 agent,由它用自己可用的
1336
+ // 笔记/记忆工具(如 note_manage)决定沉淀到哪、或评估后忽略。
1337
+ // 这就是全部:看板不知道对方有没有记忆工具、也不知道最终存到哪(零耦合红线)。
1338
+ // feedbackEnabled 关闭 → 直接拒绝(UI 侧也不会渲染按钮,这里是双保险)。
1339
+ handle('push-lesson', async function (args) {
1340
+ var sid = rpcSessionId(args)
1341
+ var text = (typeof args.text === 'string') ? args.text.trim().slice(0, 4000) : ''
1342
+ if (!text) return { ok: false, error: 'text required' }
1343
+ var d = await rt(sid)
1344
+ if (!cfg(d).feedbackEnabled) return { ok: false, error: 'feedback disabled' }
1345
+ var root = rootForSession(sid)
1346
+ if (!root) return { ok: false, error: 'no root agent for session' }
1347
+ var delivered = false
1348
+ try {
1349
+ root.followup(makeMsg('📚 [任务看板] 候选教训沉淀请求\n\n' + text + '\n\n请用你可用的笔记/记忆工具(如 note_manage)沉淀,或评估后忽略。', 'notice', '教训沉淀: ' + String(args.taskId || '')))
1350
+ delivered = true
1351
+ } catch (e) { console.error('[task-board] push-lesson followup failed:', String(e)) }
1352
+ return { ok: true, delivered: delivered }
1353
+ })
1354
+ 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 } }) })
1355
+ 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
1356
  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
- // ===== #14 批量操作:archive(仅 resolved/cancelled)/ set-priority(全部)=====
1357
+ // ===== #14 批量操作:archive(仅 resolved/cancelled)/ set-priority(全部)/ delete(真删,无 undo)=====
1358
+ // delete op 与单任务 delete-task 走同一套 deleteGate 门禁(状态 + 未归档子任务),
1359
+ // done/skipped 语义与 archive 完全一致:门禁不过就进 skipped(附 reasons[id] 一行原因)。
1360
+ // 注意:批量删除是**真删**(从 tasks 数组移除),不产生 undo 快照——batch-undo 对 delete 无意义。
964
1361
  handle('batch-op', async function (args) {
965
1362
  var sid = rpcSessionId(args); var actor = getActorId()
966
1363
  var ids = Array.isArray(args.ids) ? args.ids : []
967
1364
  if (ids.length === 0) return { ok: false, error: 'no ids' }
968
1365
  return mutateLocked(sid, function (d) {
969
- var done = 0, skipped = []
1366
+ var done = 0, skipped = [], reasons = {}
1367
+ // skip 点统一收口:追加 id + 原因(原因只进 reasons,不改 skipped 的 string[] 老契约)
1368
+ function skip(id, why) { skipped.push(id); if (why) reasons[id] = why }
970
1369
  ids.forEach(function (id) {
971
1370
  var t = d.tasks.find(function (x) { return x.id === id })
972
- if (!t) { skipped.push(id); return }
973
- if (args.op === 'archive') {
1371
+ if (!t) { skip(id, '任务不存在'); return }
1372
+ if (args.op === 'delete') {
1373
+ var g = deleteGate(d, t)
1374
+ if (g.err) { skip(id, g.err); return }
1375
+ // archived 幂等:已归档的不再删(也不计入 done,与 archive 对已归档项的处理保持一致)
1376
+ if (g.mode === 'already') { skip(id, '已归档'); return }
1377
+ d.tasks = d.tasks.filter(function (x) { return x.id !== id })
1378
+ console.error('[task-board] batch delete: ' + id + ' «' + String(t.title || '').slice(0, 60) + '» (status=' + t.status + ') by ' + actor)
1379
+ done++
1380
+ } else if (args.op === 'archive') {
974
1381
  if (t.status !== 'resolved' && t.status !== 'cancelled') { skipped.push(id); return }
975
1382
  var ps = t.status; t.status = 'archived'; t.archivedAt = new Date().toISOString(); ah(t, ps, 'archived', actor, 'batch archive'); done++
976
1383
  } else if (args.op === 'set-priority') {
@@ -981,10 +1388,16 @@ export function apply(ctx) {
981
1388
  t.status = 'pending'; ah(t, 'draft', 'pending', actor, 'batch publish'); done++
982
1389
  } else { skipped.push(id) }
983
1390
  })
984
- return { ok: true, done: done, skipped: skipped }
1391
+ // reasons 只在 delete op 下有内容(其他 op 的跳过原因沿用"未命中门禁"的老行为),
1392
+ // 老客户端只读 skipped.length,多一个可选字段无感。
1393
+ return { ok: true, done: done, skipped: skipped, reasons: reasons }
985
1394
  })
986
1395
  })
987
1396
  // #16 批量撤销:按快照恢复 priority(任何状态)与 status(仅 archive→resolved 回滚)
1397
+ // 明确不支持 op='delete':删除是真删(任务对象已从 tasks 数组移除,快照里只剩 id/priority/status),
1398
+ // 没有任何可恢复的原始字段,撤销只能凭空造一张残缺卡片——所以 delete 不产生 undo 快照,
1399
+ // 客户端也不为 delete 显示「↩️ 撤销」按钮(snapshot 只在 done>0 且 op!=='delete' 时保留)。
1400
+ // 若老客户端硬发 op='delete' 快照进来,落到 else 分支只做 priority 回填,不会凭空复活任务。
988
1401
  handle('batch-undo', async function (args) {
989
1402
  var sid = rpcSessionId(args); var actor = getActorId()
990
1403
  var snap = args && args.snapshot