dsh-agent-board 1.0.1 → 1.1.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
@@ -31,4 +31,19 @@ dsh plugin --profile web add dsh-agent-board@latest
31
31
 
32
32
  - 仓库:<https://github.com/PPawnsir/task-board-plugin>
33
33
  - 本包即源码,直接维护(v68 起拆除了动态→静态转换层,v74 起去池化)
34
- - 单元测试:`npm test`(node --test,30 例纯逻辑用例,无需重启 dsh)
34
+ - 单元测试:`npm test`(node --test,30+ 例纯逻辑用例,无需重启 dsh)
35
+
36
+ ## 开发热循环(免重启 E2E)
37
+
38
+ host 代码(`index.mjs` / `lib/*.mjs`)改动必须进程重启才能加载,但不必动你正在用的主实例——
39
+ 仓库提供一个独立 dev 实例 + 文件监听自动重启方案:
40
+
41
+ ```sh
42
+ npm run dev # = node scripts/dev-watch.cjs
43
+ # 浏览器打开 http://127.0.0.1:3081(独立 dev profile,不影响主实例)
44
+ ```
45
+
46
+ - 改 host 代码 → 监听器自动重启 dev 实例(约 3-5s)→ 刷新 3081 页面即可验证
47
+ - 改 `lib/client.js` → **不用重启**,浏览器刷新即生效(client 包按请求从磁盘读)
48
+ - dev profile 在 `~/.dsh/profiles/dev`,只挂 base + web-app + 本插件;会话/看板数据与主实例共享 `~/.dsh`,两边互不干扰
49
+ - 官方 `cordis-plugin-hmr` 行在 web 部署里被 `disabled: true`(reload 生命周期未测完),实测对 link: 插件静默无效,故用 watcher 重启替代
package/index.mjs CHANGED
@@ -6,7 +6,7 @@
6
6
  // {name, description, parameters, output, execute} 普通对象,这里内联等价实现。
7
7
  // parameters 已是完整 JSON Schema,原样透传;output 透传 schema+render。
8
8
  import * as core from './lib/core.mjs'
9
- const { ah, isb, gsb, gpt, vt, validateDeps, depsSatisfied, depsCancelled, classifyPipeline, seed, cfg, claimCheck, claimApply, checkParentAuto, resolveApply, verifyApply, parseSections, parseVerdict, isEscalation, outputText, histNotes, buildWorkerPrompt, buildVerifierPrompt, pickDispatch, isOrphan, PRIO_RANK } = core
9
+ 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 } = core
10
10
 
11
11
  function defineTool(options) {
12
12
  var userExecute = options.execute
@@ -34,7 +34,22 @@ export function apply(ctx) {
34
34
 
35
35
  // ===== 工具函数 =====
36
36
  function getActorId() { const a = ctx.agents; if (a) { const i = a.currentInitiator(); if (i) return String(i.id) } return 'unknown' }
37
- function resolveRoot(sid) { const agentsSvc = ctx.agents; if (!agentsSvc) return sid; var cur = sid; var roots = agentsSvc.roots(); var rids = []; for (var i = 0; i < roots.length; i++) rids.push(String(roots[i].id)); if (rids.indexOf(cur) >= 0) return cur; var all = agentsSvc.list(); var visited = {}; while (!visited[cur]) { visited[cur] = true; var o = null; for (var j = 0; j < all.length; j++) { if (agentsSvc.isOwnedBy(cur, all[j])) { o = all[j]; break } }; if (!o) break; cur = String(o.id); if (rids.indexOf(cur) >= 0) return cur }; return cur }
37
+ // resolveRoot 是同步热点(每个工具守卫/RPC/prompt 组装都调),agent 注册表可能有几百个子代理,
38
+ // 每次调用 roots()+list()+全表 isOwnedBy 扫描会反复卡宿主事件循环 → memoize 10s TTL。
39
+ // 父子归属在一个 agent 存活期内不变,10s 过期窗口足够安全。
40
+ var _rootCache = {}
41
+ function resolveRootUncached(sid) { const agentsSvc = ctx.agents; if (!agentsSvc) return sid; var cur = sid; var roots = agentsSvc.roots(); var rids = []; for (var i = 0; i < roots.length; i++) rids.push(String(roots[i].id)); if (rids.indexOf(cur) >= 0) return cur; var all = agentsSvc.list(); var visited = {}; while (!visited[cur]) { visited[cur] = true; var o = null; for (var j = 0; j < all.length; j++) { if (agentsSvc.isOwnedBy(cur, all[j])) { o = all[j]; break } }; if (!o) break; cur = String(o.id); if (rids.indexOf(cur) >= 0) return cur }; return cur }
42
+ function resolveRoot(sid) {
43
+ var now = Date.now()
44
+ var c = _rootCache[sid]
45
+ if (c && now - c.at < 10000) return c.root
46
+ var r = resolveRootUncached(sid)
47
+ // 缓存上限:超 512 个 key 时清掉过期项,防长进程累积
48
+ var keys = Object.keys(_rootCache)
49
+ if (keys.length > 512) { for (var i = 0; i < keys.length; i++) { if (now - _rootCache[keys[i]].at >= 10000) delete _rootCache[keys[i]] } }
50
+ _rootCache[sid] = { root: r, at: now }
51
+ return r
52
+ }
38
53
  function toolSessionId() { var sid = resolveRoot(getActorId()); touchSession(sid); return sid }
39
54
  function rpcSessionId(args) { var sid = (args && typeof args.sessionId === 'string' && args.sessionId.length > 0) ? args.sessionId : resolveRoot(getActorId()); touchSession(sid); return sid }
40
55
  // 已知会话集合:心跳驱动这些会话的 poolCycle(摆脱对客户端轮询的依赖)
@@ -44,7 +59,7 @@ export function apply(ctx) {
44
59
  // teamMode 缓存:由 rt() 同步,供 systemPrompt 动态引导段读取(v65)
45
60
  var teamModeCache = {}
46
61
  function fileFor(sid) { return '.dsh/tasks-' + sid + '.json' }
47
- async function rt(sid) { try { var t = await fs.resolve(fileFor(sid)); var r = await fs.readText(t); var d = JSON.parse(r); if (vt(d) && d.ownerSession === sid) { teamModeCache[sid] = !!d.teamMode; return d }; return seed(sid) } catch (_) { return seed(sid) } }
62
+ async function rt(sid) { try { var t = await fs.resolve(fileFor(sid)); var r = await fs.readText(t); var d = JSON.parse(r); if (vt(d) && d.ownerSession === sid) { teamModeCache[sid] = !!d.teamMode; return normalizeBoard(d) }; return seed(sid) } catch (_) { return seed(sid) } }
48
63
  async function wt(sid, d) { var c = JSON.stringify(d, null, 2); try { var t = await fs.resolve(fileFor(sid)); await fs.writeText(t, c) } catch (e) { console.error('[task-board] write:', String(e)); throw e } }
49
64
  // 每会话一条 promise 链,串行化所有 读-改-写,消除并发写竞争
50
65
  var fileLocks = {}
@@ -58,7 +73,10 @@ export function apply(ctx) {
58
73
  // 按会话找 root agent(静态插件挂 host 层后多会话共存,不能"取第一个"——会把 worker 挂到别的会话上)
59
74
  function rootForSession(sid) { var s = ctx.agents; if (!s) return undefined; var r = s.roots(); for (var i = 0; i < r.length; i++) { if (String(r[i].id) === sid) return r[i] } return undefined }
60
75
  function makeSignal() { try { return new AbortController().signal } catch (_) { return { aborted: false, addEventListener: function () {}, removeEventListener: function () {} } } }
61
- function makeMsg(text) { return { id: 'm' + Date.now() + Math.random().toString(36).slice(2, 6), role: 'user', content: [{ type: 'text', text: text }], source: { kind: 'user' } } }
76
+ // makeMsg 支持插件来源标记(参考 dsh-notes 派发模式):
77
+ // form 'recall' = 背景回执(召回上下文,非指令);'notice' = 需注意的通知(带一行 summary)
78
+ // 不再用 kind:'user'——插件消息不该冒充用户在说话,模型可据 form 正确理解语义
79
+ function makeMsg(text, form, summary) { var src = { kind: 'plugin', plugin: 'dsh-agent-board' }; if (form) { src.form = form; if (form === 'notice' && summary) src.summary = summary }; return { id: 'm' + Date.now() + Math.random().toString(36).slice(2, 6), role: 'user', content: [{ type: 'text', text: text }], source: src } }
62
80
  // 超时保护:run 挂死时走失败重试路径
63
81
  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') })]) }
64
82
 
@@ -85,6 +103,38 @@ export function apply(ctx) {
85
103
  _cachedProvider = names[0]; return _cachedProvider
86
104
  }
87
105
 
106
+ // 主窗口预研文件:t.context.files 里的路径由主 agent 选择性指定(它调研时读过哪些文件),
107
+ // host 在派发时从磁盘读最新内容注入 prompt——Worker/Verifier 不用从零重复调研。
108
+ // 上限:单文件 8KB、总计 40KB,超出截断并标注。
109
+ async function readContextPack(t) {
110
+ var paths = (t.context && Array.isArray(t.context.files)) ? t.context.files : []
111
+ var notes = (t.context && typeof t.context.notes === 'string') ? t.context.notes : ''
112
+ if (!paths.length && !notes.trim()) return ''
113
+ var out = [], total = 0
114
+ for (var i = 0; i < paths.length && total < 40960; i++) {
115
+ var p = String(paths[i] || '')
116
+ if (!p) continue
117
+ try {
118
+ var content = await fs.readText(await fs.resolve(p))
119
+ var truncated = false
120
+ if (content.length > 8192) { content = content.slice(0, 8192); truncated = true }
121
+ if (total + content.length > 40960) { content = content.slice(0, 40960 - total); truncated = true }
122
+ out.push({ path: p, content: content, truncated: truncated })
123
+ total += content.length
124
+ } catch (e) {
125
+ out.push({ path: p, content: '[读取失败: ' + String(e).slice(0, 120) + ']', truncated: false })
126
+ }
127
+ }
128
+ return buildContextPackSection(out, notes.slice(0, 8000))
129
+ }
130
+
131
+ // 预研文件注入通道:内容不混进 user prompt,而是通过 systemPrompt.context 以「上下文注入」
132
+ // 区块呈现(与 skill-catalog 等系统注入同形态)。
133
+ // 首轮竞速:子代理的首次 prompt 组装发生在 subagents.start() 返回之前,packByChild 还没写入
134
+ // → spawn 前把 pack 放进 pendingPacks,provider 按父子归属(isOwnedBy 父 agent)即时认领。
135
+ var packByChild = {}
136
+ var pendingPacks = []
137
+
88
138
  async function spawnOneShot(sid, t, role) {
89
139
  var subagents = ctx.subagents; if (!subagents) return null
90
140
  var parent = rootForSession(sid); if (!parent) { console.error('[task-board] no root agent for session ' + sid + ', skip spawn'); return null }
@@ -92,7 +142,11 @@ export function apply(ctx) {
92
142
  var modelOverride = ''
93
143
  if (role === 'verifier') { var dd = await rt(sid); modelOverride = (typeof dd.verifierModel === 'string' && dd.verifierModel.trim()) ? dd.verifierModel.trim() : ''; if (modelOverride && badModels[modelKey(sid, modelOverride)]) { console.error('[task-board] model ' + modelOverride + ' circuited, using parent model'); modelOverride = '' } }
94
144
  else if (role === 'worker') { var dw = await rt(sid); modelOverride = (typeof dw.workerModel === 'string' && dw.workerModel.trim()) ? dw.workerModel.trim() : ''; if (modelOverride && badModels[modelKey(sid, modelOverride)]) { console.error('[task-board] model ' + modelOverride + ' circuited, using parent model'); modelOverride = '' } }
95
- var req = { label: role + ':' + t.id, prompt: [{ type: 'text', text: role === 'worker' ? buildWorkerPrompt(t) : buildVerifierPrompt(t) }], parent: parent, signal: makeSignal() }
145
+ var pack = ''
146
+ try { pack = await readContextPack(t) } catch (e) { console.error('[task-board] context pack read failed:', String(e)) }
147
+ // user prompt 只留一行指引,内容走上下文注入区块
148
+ var packNote = pack ? '本任务附带主窗口预研文件,已通过「上下文注入」区提供(含文件完整内容),直接基于其内容工作,不要重复读取这些文件。' : ''
149
+ var req = { label: role + ':' + t.id, prompt: [{ type: 'text', text: role === 'worker' ? buildWorkerPrompt(t, packNote) : buildVerifierPrompt(t, packNote) }], parent: parent, signal: makeSignal() }
96
150
  if (modelOverride) {
97
151
  // list-models 返回的 id 是 "provider/model" 复合格式(如 "cmss/zhanlu/glm-5.2"),
98
152
  // 但 AgentOptions 的 provider 和 model 是分开的——整串塞进 model 会报 UNKNOWN_MODEL
@@ -101,12 +155,17 @@ export function apply(ctx) {
101
155
  else req.agentOptions = { model: modelOverride }
102
156
  }
103
157
  var run
158
+ var ppEntry = pack ? { pack: pack, parent: parent, at: Date.now() } : null
159
+ if (ppEntry) pendingPacks.push(ppEntry)
104
160
  try { run = await subagents.start(providerName, req) } catch (e) {
105
161
  if (modelOverride) { console.error('[task-board] model override failed, fallback to parent model:', String(e)); delete req.agentOptions; try { run = await subagents.start(providerName, req) } catch (e2) { console.error('[task-board] spawn ' + role + ' failed:', String(e2)); return null } }
106
162
  else { console.error('[task-board] spawn ' + role + ' failed:', String(e)); return null }
163
+ } finally {
164
+ if (ppEntry) { var ppi = pendingPacks.indexOf(ppEntry); if (ppi >= 0) pendingPacks.splice(ppi, 1) }
107
165
  }
108
166
  var rec = { run: run, role: role, taskId: t.id, startedAt: Date.now(), model: modelOverride }
109
167
  runsFor(sid)[t.id] = rec
168
+ if (pack) packByChild[String(run.id)] = pack
110
169
  if (!dispatchedEver[sid]) dispatchedEver[sid] = {}
111
170
  dispatchedEver[sid][String(run.id)] = true
112
171
  // 30min 硬超时(一次性 run 没有看门狗,挂死不能白占并发位)→ 走失败重试路径
@@ -118,6 +177,7 @@ export function apply(ctx) {
118
177
  async function settleRun(sid, rec, res, err) {
119
178
  if (runsFor(sid)[rec.taskId] !== rec) return // 已被 terminate 等路径处理
120
179
  delete runsFor(sid)[rec.taskId]
180
+ delete packByChild[String(rec.run.id)] // 上下文注入缓存随 run 销毁
121
181
  try { await rec.run.dispose() } catch (_) {}
122
182
  var output = outputText(res)
123
183
  var failed = !!err || (res && res.stopReason && res.stopReason !== 'completed')
@@ -203,7 +263,7 @@ export function apply(ctx) {
203
263
  function notifyMainWindow(sid, t, question) {
204
264
  var root = rootForSession(sid)
205
265
  if (!root) return
206
- try { root.followup(makeMsg('⚠️ [任务看板] Worker 上报歧义,等待裁决:\n\n任务: ' + t.title + ' (' + t.id + ')\n\n疑问:\n' + question.slice(0, 1500) + '\n\n请在看板详情页裁决,或直接回复指示。裁决后会有新 Worker 带着裁决答案接手。')) } catch (e) { console.error('[task-board] escalate notify failed:', String(e)) }
266
+ try { root.followup(makeMsg('⚠️ [任务看板] Worker 上报歧义,等待裁决:\n\n任务: ' + t.title + ' (' + t.id + ')\n\n疑问:\n' + question.slice(0, 1500) + '\n\n请在看板详情页裁决,或直接回复指示。裁决后会有新 Worker 带着裁决答案接手。', 'notice', '任务待裁决: ' + t.title)) } catch (e) { console.error('[task-board] escalate notify failed:', String(e)) }
207
267
  }
208
268
  function maybeNotify(sid, task) { if (task && task.escalation) { notifyMainWindow(sid, task, task.escalation.question) } }
209
269
 
@@ -212,8 +272,19 @@ export function apply(ctx) {
212
272
  // 批量聚合:任务多时每任务一条 followup 会把主窗口 turn 队列打满(用户输入排队等回执处理完才刷新),
213
273
  // 改为 45s 窗口(或满 5 条)聚合为一条摘要;发送前等主窗口空闲,不打断对话。
214
274
  var receiptBuf = {}
275
+ // 回执幂等去重表:key = 任务id + 类别 + 完成事件指纹(deliverable/verification/resolvedAt/末条history 时间戳)。
276
+ // 同一完成事件被任何路径(工具直报/run 结算/未来回归)重复通知时指纹一致 → 吞掉;
277
+ // 驳回后重做完成 → 时间戳全换新 → 指纹不同 → 正常回执。
278
+ var receiptedKeys = {}
215
279
  function notifyTaskDone(sid, t, kind) {
216
280
  if (!t || !isDispatched(sid, t.claimedBy)) return
281
+ var lastHist = (t.history && t.history.length) ? String(t.history[t.history.length - 1].timestamp || '') : ''
282
+ var stamp = [kind, (t.deliverable && t.deliverable.at) || '', (t.verification && t.verification.at) || '', t.resolvedAt || '', lastHist].join('|')
283
+ var key = t.id + ':' + stamp
284
+ if (receiptedKeys[key]) return
285
+ var rkeys = Object.keys(receiptedKeys)
286
+ if (rkeys.length > 512) { var rnow = Date.now(); for (var ri = 0; ri < rkeys.length; ri++) { if (rnow - receiptedKeys[rkeys[ri]] > 3600000) delete receiptedKeys[rkeys[ri]] } }
287
+ receiptedKeys[key] = Date.now()
217
288
  var buf = receiptBuf[sid] || (receiptBuf[sid] = { items: [], timer: null })
218
289
  var lastNote = (t.history && t.history.length) ? String(t.history[t.history.length - 1].note || '') : ''
219
290
  buf.items.push({ kind: kind, title: t.title, id: t.id, summary: (t.deliverable && t.deliverable.summary) || '', note: lastNote })
@@ -242,7 +313,7 @@ export function apply(ctx) {
242
313
  }
243
314
  lines.push('', '可用 task_list 查看全部;阻塞项可在看板拖回待办重新投放。')
244
315
  var text = lines.join('\n')
245
- function send() { try { root.followup(makeMsg(text)) } catch (e) { console.error('[task-board] receipt flush failed:', String(e)) } }
316
+ function send() { try { root.followup(makeMsg(text, 'recall')) } catch (e) { console.error('[task-board] receipt flush failed:', String(e)) } }
246
317
  if (typeof root.whenIdle === 'function') {
247
318
  var waited = withTimeout(root.whenIdle(), 300000, 'receipt-idle-wait') // 最多等 5 分钟,超时也发(不能丢回执)
248
319
  Promise.resolve(waited).then(send).catch(send)
@@ -313,17 +384,45 @@ export function apply(ctx) {
313
384
  text: function (assembleCtx) {
314
385
  var agent = assembleCtx && assembleCtx.agent
315
386
  if (!agent) return ''
316
- var aid = String(agent.id || '')
317
- var sid = resolveRoot(aid)
318
- // P0 修复:只对 root agent 自身注入。Worker/Verifier 的 resolveRoot 也会返回主会话 sid,
319
- // 但 agent.id !== sid 说明是子代理——子代理有自己的 prompt 契约,不需要"你是主窗口"引导。
320
- // 之前缺这行守卫 → Worker 也收到"请用 task_create 派发任务"→ Worker 误认自己是主窗口。
321
- if (aid !== sid) return ''
322
- if (!teamModeCache[sid]) return ''
323
- return '【任务看板 Team 模式已开启】\n本会话的任务看板处于 Team 模式。请遵循以下工作方式:\n1. 涉及代码改动、文件创建、命令执行等实质性工作时,优先用 task_create 提交为看板任务(由一次性 Worker/Verifier 子代理执行与验收),不要自己直接动手实现。\n2. 你仍保有全部工具能力——调研、读代码、讨论方案、回答问题时直接进行,无需提交任务。\n3. 创建任务时,务必在 description 里写清子代理需要的上下文:涉及哪些文件(路径)、相关代码的约束/约定、前置条件等。子代理是全新会话、无你的会话记忆,如果上下文不够,子代理需要从零开始自行调研,效率会大打折扣甚至跑偏方向。\n4. Worker 上报歧义时会通过 task_arbitrate 等待你裁决,请及时响应。驳回重派时同样:新 Worker 没有上一轮的记忆,驳回原因会在 prompt 里,但额外上下文需你在 description 里补上。\n5. 任务尽量一次写清 description/dependsOn/acceptance;需要分步建设的用 task_create draft:true 先建草稿,补全后 publish。'
387
+ // O(1) 快路径:root agent 的 id 就是其会话 id(Agent.id === SessionId),
388
+ // teamModeCache 按会话 id 键——Worker/Verifier 自己的会话 id 不在缓存里,天然不会误注入。
389
+ // 不能走 resolveRoot:该函数在每个 agent 每次 prompt 组装时同步执行,
390
+ // resolveRoot 全表扫 agent 注册表会把宿主事件循环卡死(曾导致全局界面卡顿、用户消息延迟渲染)。
391
+ if (!teamModeCache[String(agent.id)]) return ''
392
+ return '【任务看板 Team 模式已开启】\n本会话的任务看板处于 Team 模式。请遵循以下工作方式:\n1. 涉及代码改动、文件创建、命令执行等实质性工作时,优先用 task_create 提交为看板任务(由一次性 Worker/Verifier 子代理执行与验收),不要自己直接动手实现。\n2. 你仍保有全部工具能力——调研、读代码、讨论方案、回答问题时直接进行,无需提交任务。\n3. 创建任务时,务必在 description 里写清任务目标和约束;调研结论/原始需求/思路用 contextNotes 带上,调研时读过的关键文件用 contextFiles 把路径带上——两者都会通过「上下文注入」通道传给子代理(独立注入区块,不占对话流)。子代理是全新会话、无你的会话记忆,上下文不够它需要从零自行调研,效率大打折扣甚至跑偏方向。\n4. Worker 上报歧义时会通过 task_arbitrate 等待你裁决,请及时响应。驳回重派时同样:新 Worker 没有上一轮的记忆,驳回原因会在 prompt 里,但额外上下文需你在 description 里补上。\n5. 创建多个相互关联的任务时,必须「先草稿后发布」:所有任务先用 task_create draft:true 建为草稿(草稿不会被派发领取),等全部任务创建完成、dependsOn 依赖关系都写好后,再逐个 task_update publish=true 统一发布。严禁直接创建 pending 任务再后补依赖——依赖还没写入,后向任务就会被 Worker 提前领走。'
324
393
  },
325
394
  })
326
395
  ctx.effect(function () { return disposeSection })
396
+ // 预研文件上下文注入:Worker/Verifier 的预研文件内容以「上下文注入」区块呈现
397
+ // (与 skill-catalog 同形态),不混进 user prompt。按子代理会话 id 命中,O(1),
398
+ // 其他 agent 组装时零成本返回空串。
399
+ var disposeCtxPack = sysPrompt.context({
400
+ name: 'task-board:context-pack',
401
+ order: 50,
402
+ text: function (assembleCtx) {
403
+ var agent = assembleCtx && assembleCtx.agent
404
+ if (!agent) return ''
405
+ var aid = String(agent.id)
406
+ console.error('[task-board][ctxpack] assemble agent=' + aid + ' packKeys=' + Object.keys(packByChild).join(',') + ' pending=' + pendingPacks.length)
407
+ var hit = packByChild[aid]
408
+ if (hit) { console.error('[task-board][ctxpack] HIT packByChild for ' + aid); return hit }
409
+ // 首轮竞速自愈:start() 返回前的首次组装按父子归属从 pendingPacks 认领
410
+ var agentsSvc = ctx.agents
411
+ if (!agentsSvc) return ''
412
+ var now = Date.now()
413
+ for (var i = pendingPacks.length - 1; i >= 0; i--) {
414
+ var pp = pendingPacks[i]
415
+ if (now - pp.at > 60000) { pendingPacks.splice(i, 1); continue }
416
+ try {
417
+ var owned = agentsSvc.isOwnedBy(aid, pp.parent)
418
+ console.error('[task-board][ctxpack] isOwnedBy(' + aid + ', parent=' + (pp.parent && pp.parent.id) + ')=' + owned)
419
+ if (owned) { packByChild[aid] = pp.pack; return pp.pack }
420
+ } catch (e) { console.error('[task-board][ctxpack] isOwnedBy threw: ' + String(e)) }
421
+ }
422
+ return ''
423
+ },
424
+ })
425
+ ctx.effect(function () { return disposeCtxPack })
327
426
  }
328
427
 
329
428
  // ===== Tools =====
@@ -333,8 +432,8 @@ export function apply(ctx) {
333
432
  ctx.tools.register(defineTool({ name: 'task_resolve', description: '提交验证(verifying)或阻塞(blocked)。', parameters: { type: 'object', properties: { taskId: { type: 'string' }, status: { type: 'string', enum: ['verifying', 'blocked'] }, resolution: { type: 'string' } }, required: ['taskId', 'status'] }, 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 !== 'in-progress') return { ok: false, error: 'not in-progress' }; if (t.claimedBy !== actor) return { ok: false, error: 'not claimed by you' }; if (args.status === 'verifying' && !args.resolution) return { ok: false, error: 'resolution required' }; return resolveApply(d, t, actor, args.status, args.resolution, args.resolution || args.status) }) } }))
334
433
  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) }) } }))
335
434
  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 }) } }))
336
- ctx.tools.register(defineTool({ name: 'task_update', description: '更新任务字段,可选重置为 pending。dependsOn/pipeline 也可更新(环检测会拒绝成环依赖)。', 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' } }, pipeline: { type: 'string', enum: ['full', 'work', 'direct'] }, resetToPending: { type: 'boolean' }, 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.publish) { if (t.status !== 'draft') return { ok: false, error: 'not a draft' }; t.status = 'pending'; ah(t, 'draft', 'pending', actor, 'published') } 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 } }) } }))
337
- ctx.tools.register(defineTool({ name: 'task_create', description: '创建新任务到当前会话看板。acceptance 可选:硬性验收脚本命令(如 "node --test src/x.test.js"),Worker 必须实际运行、Verifier 必须独立复跑。dependsOn 可选:依赖任务 id 数组,依赖全部完成后才会被派发。pipeline 可选:full(默认,工作+验证)/work(只做不验)/direct(不进池,主窗口直接处理)。', 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' } }, pipeline: { type: 'string', enum: ['full', 'work', 'direct'] }, draft: { type: 'boolean', description: 'true 创建为草稿(不派发不可领取),补全信息后用 task_update publish=true 发布' } }, 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(); var t = { id: args.id || ('task-' + Date.now().toString(36)), title: args.title, description: args.description || '', status: args.draft ? 'draft' : 'pending', priority: args.priority || 'medium', tags: args.tags || [], parentId: args.parentId || null, subtaskStrategy: null, assignMode: 'auto', assignee: null, context: { files: [], docs: [], instructions: args.instructions || '', relatedTasks: [], prerequisites: '' }, acceptance: args.acceptance || '', dependsOn: args.dependsOn || [], pipeline: args.pipeline || '', claimedBy: null, claimedAt: null, createdAt: now, resolvedAt: null, verifiedAt: null, verifiedBy: null, archivedAt: null, resolution: null, messages: [], history: [{ from: 'created', to: args.draft ? 'draft' : 'pending', timestamp: now, actor: actor, note: args.draft ? 'created as draft' : 'created' }] }; if (!t.pipeline) t.pipeline = classifyPipeline(t); t.pipelineAuto = !args.pipeline; d.tasks.push(t); return { ok: true, task: t } }) } }))
435
+ ctx.tools.register(defineTool({ name: 'task_update', description: '更新任务字段,可选重置为 pending。dependsOn/pipeline 也可更新(环检测会拒绝成环依赖)。contextFiles 可更新预研文件清单。', 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' }, 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.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 } }) } }))
436
+ 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 可选:调研结论/原始需求/思路等非文件类上下文,同样走上下文注入。', 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'] }, draft: { type: 'boolean', description: 'true 创建为草稿(不派发不可领取),补全信息后用 task_update publish=true 发布' } }, 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(); var t = { id: args.id || ('task-' + Date.now().toString(36)), title: args.title, description: args.description || '', status: args.draft ? '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 || [], pipeline: args.pipeline || '', claimedBy: null, claimedAt: null, createdAt: now, resolvedAt: null, verifiedAt: null, verifiedBy: null, archivedAt: null, resolution: null, messages: [], history: [{ from: 'created', to: args.draft ? 'draft' : 'pending', timestamp: now, actor: actor, note: args.draft ? 'created as draft' : 'created' }] }; if (!t.pipeline) t.pipeline = classifyPipeline(t); t.pipelineAuto = !args.pipeline; d.tasks.push(t); return { ok: true, task: t } }) } }))
338
437
 
339
438
  // ===== RPC =====
340
439
  // get-tasks 是纯读路径(rt 只读文件)——poolCycle 由 15s 心跳 + 写入后 kickCycle 驱动,
@@ -344,7 +443,7 @@ export function apply(ctx) {
344
443
  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) }) })
345
444
  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) }) })
346
445
  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 }) })
347
- 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.publish) { if (t.status !== 'draft') return { ok: false, error: 'not a draft' }; t.status = 'pending'; ah(t, 'draft', 'pending', actor, 'published') } 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 } }) })
446
+ 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.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 } }) })
348
447
  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 } }) })
349
448
  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 } }) })
350
449
  // 裁决回流(v74 一次性模型):原 Worker 已结束,答案写入 history 后任务回 pending,
@@ -370,7 +469,7 @@ export function apply(ctx) {
370
469
  var rec = runsFor(sid)[taskId]
371
470
  var delivered = false
372
471
  if (rec && rec.run && rec.run.localAgent) {
373
- try { rec.run.localAgent.followup(makeMsg('[高优先级干预] 来自主窗口/用户的指令:\n\n' + msg + '\n\n请优先响应此指令,然后继续当前任务。')); delivered = true } catch (_) {}
472
+ try { rec.run.localAgent.followup(makeMsg('[高优先级干预] 来自主窗口/用户的指令:\n\n' + msg + '\n\n请优先响应此指令,然后继续当前任务。', 'notice', '高优干预: ' + taskId)); delivered = true } catch (_) {}
374
473
  }
375
474
  await mutateLocked(sid, function (d) { var t = d.tasks.find(function (x) { return x.id === taskId }); if (t) { if (!Array.isArray(t.messages)) t.messages = []; t.messages.push({ kind: 'intervention', text: msg, at: new Date().toISOString(), by: actor }); ah(t, t.status, t.status, actor, '高优干预: ' + msg.slice(0, 200) + (delivered ? '' : '(无活跃 run,随下次派发注入)')) }; return t })
376
475
  return { ok: true, delivered: delivered }
@@ -496,7 +595,7 @@ export function apply(ctx) {
496
595
  return { ok: true, models: out }
497
596
  })
498
597
  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() : ''; return { ok: true } }) })
499
- 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(); var t = { id: args.id || ('task-' + Date.now().toString(36)), title: args.title || 'Untitled', description: args.description || '', status: args.draft ? 'draft' : 'pending', priority: args.priority || 'medium', tags: args.tags || [], parentId: args.parentId || null, subtaskStrategy: null, assignMode: 'auto', assignee: null, context: { files: [], docs: [], instructions: args.instructions || '', relatedTasks: [], prerequisites: '' }, acceptance: args.acceptance || '', dependsOn: args.dependsOn || [], pipeline: args.pipeline || '', claimedBy: null, claimedAt: null, createdAt: now, resolvedAt: null, verifiedAt: null, verifiedBy: null, archivedAt: null, resolution: null, messages: [], history: [{ from: 'created', to: args.draft ? 'draft' : 'pending', timestamp: now, actor: actor, note: args.draft ? 'created as draft' : 'created' }] }; if (!t.pipeline) { t.pipeline = classifyPipeline(t); t.pipelineAuto = true }; d.tasks.push(t); return { ok: true, task: t } }) })
598
+ 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(); var t = { id: args.id || ('task-' + Date.now().toString(36)), title: args.title || 'Untitled', description: args.description || '', status: args.draft ? '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 || [], pipeline: args.pipeline || '', claimedBy: null, claimedAt: null, createdAt: now, resolvedAt: null, verifiedAt: null, verifiedBy: null, archivedAt: null, resolution: null, messages: [], history: [{ from: 'created', to: args.draft ? 'draft' : 'pending', timestamp: now, actor: actor, note: args.draft ? 'created as draft' : 'created' }] }; if (!t.pipeline) { t.pipeline = classifyPipeline(t); t.pipelineAuto = true }; d.tasks.push(t); return { ok: true, task: t } }) })
500
599
  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) } } })
501
600
  // ===== #14 批量操作:archive(仅 resolved/cancelled)/ set-priority(全部)=====
502
601
  handle('batch-op', async function (args) {
@@ -514,6 +613,9 @@ export function apply(ctx) {
514
613
  } else if (args.op === 'set-priority') {
515
614
  if (['low', 'medium', 'high', 'critical'].indexOf(args.value) < 0) { skipped.push(id); return }
516
615
  t.priority = args.value; ah(t, t.status, t.status, actor, '批量设优先级: ' + args.value); done++
616
+ } else if (args.op === 'publish') {
617
+ if (t.status !== 'draft') { skipped.push(id); return }
618
+ t.status = 'pending'; ah(t, 'draft', 'pending', actor, 'batch publish'); done++
517
619
  } else { skipped.push(id) }
518
620
  })
519
621
  return { ok: true, done: done, skipped: skipped }
@@ -568,3 +670,4 @@ export function apply(ctx) {
568
670
 
569
671
  console.log('[task-board] v74 loaded (pool removed: one-shot dispatch, context injected per task, dispose on settle)')
570
672
  }
673
+
package/lib/client.js CHANGED
@@ -516,6 +516,8 @@ function apply(ctx) {
516
516
  React.createElement('div', { style: { fontSize: 11, fontWeight: 600, color: C.text2, marginBottom: 3 } }, '任务描述'),
517
517
  React.createElement('textarea', { value: editDesc, onChange: function (e) { setEditDesc(e.target.value) }, rows: 3, style: { width: '100%', fontSize: 12, padding: '6px 8px', marginBottom: 6, border: '1px solid ' + C.border, borderRadius: 4, background: C.card, color: C.text, resize: 'vertical', boxSizing: 'border-box', fontFamily: 'inherit' } }),
518
518
  task.context && task.context.instructions ? React.createElement('div', { style: { fontSize: 11, color: C.text2, marginBottom: 6, padding: '4px 6px', background: C.nested, borderRadius: 4 } }, '指引: ' + task.context.instructions) : null,
519
+ task.context && Array.isArray(task.context.files) && task.context.files.length > 0 ? React.createElement('div', { style: { fontSize: 11, color: C.text2, marginBottom: 6, padding: '4px 6px', background: C.nested, borderRadius: 4, display: 'flex', alignItems: 'flex-start', gap: 4 } }, ic('file-text', 11), React.createElement('span', null, '预研文件(派发时注入子代理): ' + task.context.files.join('、'))) : null,
520
+ task.context && task.context.notes ? React.createElement('div', { style: { fontSize: 11, color: C.text2, marginBottom: 6, padding: '4px 6px', background: C.nested, borderRadius: 4, whiteSpace: 'pre-wrap', maxHeight: 100, overflowY: 'auto' } }, '调研笔记: ' + task.context.notes) : null,
519
521
  React.createElement(DepsSection, { task: task }),
520
522
  task.acceptance ? React.createElement('div', { style: { fontSize: 11, color: C.text, marginBottom: 6, padding: '5px 8px', background: C.nested, borderRadius: 4, borderLeft: '2px solid ' + C.ok, fontFamily: 'monospace', display: 'flex', alignItems: 'center', gap: 4 } }, ic('flask-conical', 11), '硬性验收: ' + task.acceptance) : null,
521
523
  task.resolution ? React.createElement('div', { style: { fontSize: 11, color: C.text, marginBottom: 6, padding: '5px 8px', background: C.nested, borderRadius: 4, borderLeft: '2px solid ' + C.warn, maxHeight: 120, overflowY: 'auto', whiteSpace: 'pre-wrap' } }, '📝 ' + task.resolution) : null,
package/lib/core.mjs CHANGED
@@ -60,7 +60,11 @@ export function classifyPipeline(t) {
60
60
  export function cfg(d) { 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)) } }
61
61
 
62
62
  // ===== 看板文件种子 =====
63
- export function seed(sid) { return { version: 12, ownerSession: sid, boardMode: 'auto', teamMode: false, minWorkers: 1, maxWorkers: 3, minVerifiers: 0, maxVerifiers: 2, workerModel: '', verifierModel: '', tasks: [] } }
63
+ // poolStatus 必须始终在种子/归一化里存在:task_list 工具输出 poolStatus: d.poolStatus,
64
+ // 缺字段 = undefined → 工具结果的 lossless-JSON 校验会拒("value is not lossless JSON")
65
+ export function seed(sid) { return { version: 12, ownerSession: sid, boardMode: 'auto', teamMode: false, minWorkers: 1, maxWorkers: 3, minVerifiers: 0, maxVerifiers: 2, workerModel: '', verifierModel: '', poolStatus: { workers: [], verifiers: [] }, tasks: [] } }
66
+ // 旧文件缺 poolStatus 的归一化(读路径兜底,保证任何历史文件都满足工具输出契约)
67
+ export function normalizeBoard(d) { if (d && typeof d === 'object') { if (!d.poolStatus || typeof d.poolStatus !== 'object' || !Array.isArray(d.poolStatus.workers) || !Array.isArray(d.poolStatus.verifiers)) d.poolStatus = { workers: [], verifiers: [] } }; return d }
64
68
 
65
69
  // ===== 状态流转 =====
66
70
  export function claimCheck(d, t, sid) { if (CLAIMABLE.indexOf(t.status) < 0) return 'cannot claim in ' + t.status; if (t.claimedBy && t.claimedBy !== sid && t.status === 'in-progress') return 'claimed by ' + t.claimedBy; if (d.boardMode === 'manual' || t.assignMode === 'manual') { if (t.assignee && t.assignee !== sid) return 'assigned to ' + t.assignee }; if (isb(t)) { var p = gpt(t, d.tasks); if (!p) return 'parent not found'; if (p.status !== 'in-progress' && p.status !== 'verifying') return 'parent not in-progress' }; var mc = d.tasks.filter(function (x) { return x.claimedBy === sid && (x.status === 'in-progress' || x.status === 'verifying') && !isb(x) }); if (!isb(t) && mc.length >= MAX_CLAIMED) return 'max ' + MAX_CLAIMED + ' active'; return null }
@@ -101,7 +105,25 @@ export function buildMessages(t) {
101
105
  if (!Array.isArray(t.messages) || t.messages.length === 0) return ''
102
106
  return t.messages.map(function (m) { return '### [' + (m.kind || 'note') + '] (' + (m.at || '') + ' by ' + (m.by || '') + ')\n' + String(m.text || '').slice(0, 2000) }).join('\n\n')
103
107
  }
104
- export function buildWorkerPrompt(t) {
108
+ // 主窗口预研上下文段:笔记(调研结论/原始需求/思路)+ 文件,由 host 组装后传入(core 保持纯函数不碰 IO)
109
+ // 注意:systemPrompt context 通道做严格 {{var}} 插值,内容里的 {{...}} 会直接抛异常
110
+ // 毁掉子代理的整个 prompt 组装——必须把 { 连写整 run 拆开(肉眼可读,插值器不再触发)。
111
+ // 教训:replace(/\{\{/g,'{ {') 是开变换——三连括号 {{{lo}}}(Python f-string)会得到
112
+ // "{ {{lo}}}",替换结果自己又造出 {{。按"连 { 整 run 拆开"才是封闭变换。
113
+ function sanitizeCtx(s) { return String(s).replace(/\{+/g, function (m) { return m.length === 1 ? m : m.split('').join(' ') }) }
114
+ export function buildContextPackSection(files, notes) {
115
+ var parts = []
116
+ if (notes && String(notes).trim()) parts.push('### 主窗口调研笔记(结论/思路/原始需求,直接采信)\n' + sanitizeCtx(String(notes)))
117
+ if (Array.isArray(files) && files.length) {
118
+ parts.push('主窗口预研文件(主窗口创建任务前已读过以下内容,直接使用,不要重复读取;标"截断"的内容可按需补读):')
119
+ for (var i = 0; i < files.length; i++) {
120
+ var f = files[i]
121
+ parts.push('### ' + sanitizeCtx(f.path) + (f.truncated ? '(截断)' : '') + '\n' + sanitizeCtx(f.content))
122
+ }
123
+ }
124
+ return parts.length ? parts.join('\n\n') : ''
125
+ }
126
+ export function buildWorkerPrompt(t, pack) {
105
127
  var notes = histNotes(t)
106
128
  var msgs = buildMessages(t)
107
129
  var p = '你是一个一次性任务执行 Worker。完成下面这个任务,完成后本会话即销毁。\n\ntaskId: ' + t.id + '\n任务: ' + t.title + '\n描述: ' + (t.description || '')
@@ -109,10 +131,11 @@ export function buildWorkerPrompt(t) {
109
131
  if (t.acceptance) p += '\n硬性验收脚本: ' + t.acceptance + '\n(必须实际运行该命令并在自测情况中粘贴真实输出;未通过不得上报完成)'
110
132
  if (notes) p += '\n\n该任务的过程记录(歧义上报/主窗口裁决/驳回/干预,请务必遵循最新裁决方向):\n' + notes
111
133
  if (msgs) p += '\n\n该任务的详细消息(裁决答案/干预指令/歧义原文等,请务必遵循):\n' + msgs
134
+ if (pack) p += '\n\n' + pack
112
135
  p += '\n\n完成契约(双模,工具优先):\n1. 完成时:优先调用 board_report 工具(kind=complete, taskId=' + t.id + ',summary=开发描述/changes=改动清单/selfTest=自测情况);工具不可用则按分段格式输出(## 开发描述 / ## 改动清单 / ## 自测情况)。\n2. 歧义/信息不足/需用户决策时:优先调用 board_report(kind=escalate, taskId=' + t.id + ', question=疑问);工具不可用则输出以 [ESCALATE] 开头的说明。不要猜测。上报歧义后直接结束本轮——裁决后会有新 Worker 带着裁决答案接手。'
113
136
  return p
114
137
  }
115
- export function buildVerifierPrompt(t) {
138
+ export function buildVerifierPrompt(t, pack) {
116
139
  var notes = histNotes(t)
117
140
  var msgs = buildMessages(t)
118
141
  var p = '你是一个一次性任务审核 Verifier。审查下面这个任务的完成质量,给出结论后本会话即销毁。\n\ntaskId: ' + t.id + '\n任务: ' + t.title + '\n描述: ' + (t.description || '').slice(0, 500)
@@ -122,6 +145,7 @@ export function buildVerifierPrompt(t) {
122
145
  if (t.acceptance) p += '\n硬性验收脚本: ' + t.acceptance + '\n(必须独立复跑该命令并把真实输出贴进核对项;脚本失败必须 REJECTED)'
123
146
  if (notes) p += '\n\n该任务的过程记录(歧义上报/主窗口裁决/驳回/干预,若有):\n' + notes + '\n注意:若过程记录显示主窗口已裁决改变任务方向,以裁决后的方向为验收标准。'
124
147
  if (msgs) p += '\n\n该任务的详细消息(裁决答案/干预指令/歧义原文等):\n' + msgs
148
+ if (pack) p += '\n\n' + pack
125
149
  p += '\n\n结论契约(双模,工具优先):\n1. 优先调用 board_verdict 工具(taskId=' + t.id + ', verdict=approved/rejected, summary=测试概要, checks=逐条核对证据含行号)。\n2. 工具不可用则首行 APPROVED: <结论> 或 REJECTED: <结论>,然后 ## 测试概要 / ## 核对项 分段。'
126
150
  return p
127
151
  }
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "dsh-agent-board",
3
3
  "description": "DeepSeek Harness 智能看板:任务卡片 + 一次性 Worker/Verifier 派发 + 依赖调度 + 管线分档 + Team 模式(歧义上报主窗口裁决)",
4
- "version": "1.0.1",
4
+ "version": "1.1.0",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
7
7
  "scripts": {
8
8
  "test": "node --test test/core.test.mjs",
9
- "prepublishOnly": "node --check index.mjs && node --check lib/client.js && node --check lib/core.mjs && npm test"
9
+ "prepublishOnly": "node --check index.mjs && node --check lib/client.js && node --check lib/core.mjs && npm test",
10
+ "dev": "node scripts/dev-watch.cjs"
10
11
  },
11
12
  "exports": {
12
13
  ".": "./index.mjs",
@@ -154,6 +154,64 @@ test('buildVerifierPrompt: 注入交付物 + 验收脚本 + messages', () => {
154
154
  assert.match(p, /做完了/); assert.match(p, /npm test/); assert.match(p, /board_verdict/); assert.match(p, /方案B/)
155
155
  })
156
156
 
157
+ // ===== 预研文件段(主窗口选择性注入,host 读盘后传入)=====
158
+ test('buildContextPackSection: 空清单返回空串', () => {
159
+ assert.equal(core.buildContextPackSection([]), '')
160
+ assert.equal(core.buildContextPackSection(null), '')
161
+ assert.equal(core.buildContextPackSection(undefined), '')
162
+ })
163
+
164
+ test('buildContextPackSection: 文件内容 + 截断标记', () => {
165
+ const s = core.buildContextPackSection([
166
+ { path: 'src/a.ts', content: 'const x = 1', truncated: false },
167
+ { path: 'docs/b.md', content: '说明', truncated: true },
168
+ ])
169
+ assert.match(s, /主窗口预研文件/); assert.match(s, /不要重复读取/)
170
+ assert.match(s, /### src\/a\.ts/); assert.match(s, /const x = 1/)
171
+ assert.match(s, /### docs\/b\.md(截断)/); assert.match(s, /说明/)
172
+ })
173
+
174
+ test('buildWorkerPrompt/buildVerifierPrompt: 注入预研文件段', () => {
175
+ const pack = core.buildContextPackSection([{ path: 'src/x.js', content: 'hello', truncated: false }])
176
+ const t = mkTask({ id: 'pk' })
177
+ assert.match(core.buildWorkerPrompt(t, pack), /### src\/x\.js/)
178
+ assert.match(core.buildWorkerPrompt(t, pack), /hello/)
179
+ assert.match(core.buildVerifierPrompt(t, pack), /### src\/x\.js/)
180
+ // 不传 pack 时不出现该段
181
+ assert.doesNotMatch(core.buildWorkerPrompt(t), /主窗口预研文件/)
182
+ })
183
+
184
+ test('buildContextPackSection: {{ }} 插值净化(context 通道严格插值会抛异常)', () => {
185
+ const s = core.buildContextPackSection([{ path: 'src/tpl.vue', content: '<div>{{ msg }}</div>', truncated: false }])
186
+ assert.doesNotMatch(s, /\{\{/) // 不允许残留插值触发器
187
+ assert.match(s, /\{ \{ msg \}\}/) // 内容可读性保留
188
+ })
189
+
190
+ test('buildContextPackSection: 三连花括号(Python f-string)封闭净化', () => {
191
+ // 真实事故:f"{{{lo}}}" 经 replace(/\{\{/g,'{ {') 变成 "{ {{lo}}}"——替换结果自己又造出 {{
192
+ const s = core.buildContextPackSection([{ path: 'gen.py', content: 'quant = f"{{{lo}}}" + f"{{{lo},{hi}}}"', truncated: false }])
193
+ assert.doesNotMatch(s, /\{\{/) // 净化必须是封闭变换
194
+ assert.match(s, /\{ \{ \{lo\}\}\}/) // 可读性保留
195
+ // 五连括号 + 单括号混合
196
+ const s2 = core.buildContextPackSection([{ path: 'x', content: 'a{{{{{b}}}}}{c}{{d}}', truncated: false }])
197
+ assert.doesNotMatch(s2, /\{\{/)
198
+ })
199
+
200
+ test('buildContextPackSection: 笔记(思路/原始需求)注入 + 净化', () => {
201
+ const s = core.buildContextPackSection([{ path: 'a.js', content: 'x', truncated: false }], '用户原话:要做成{{可配置}}的')
202
+ assert.match(s, /主窗口调研笔记/)
203
+ assert.match(s, /用户原话:要做成\{ \{可配置\}\}的/) // 笔记里的 {{}} 也被净化
204
+ assert.match(s, /### a\.js/) // 文件段同时存在
205
+ })
206
+
207
+ test('buildContextPackSection: 仅笔记无文件也可注入', () => {
208
+ const s = core.buildContextPackSection([], '思路:先改 A 再改 B')
209
+ assert.match(s, /主窗口调研笔记/); assert.match(s, /先改 A 再改 B/)
210
+ assert.doesNotMatch(s, /预研文件/)
211
+ assert.equal(core.buildContextPackSection([], ''), '')
212
+ assert.equal(core.buildContextPackSection(null, ' '), '')
213
+ })
214
+
157
215
  // ===== 派发决策 =====
158
216
  test('pickDispatch: 优先级排序 + 并发上限 + 排除项', () => {
159
217
  const tasks = [
@@ -213,6 +271,20 @@ test('seed: 初始看板结构', () => {
213
271
  const d = core.seed('s1')
214
272
  assert.equal(d.ownerSession, 's1'); assert.equal(d.boardMode, 'auto'); assert.deepEqual(d.tasks, [])
215
273
  assert.ok(core.vt(d))
274
+ // poolStatus 必须存在——task_list 工具输出它,undefined 会被 lossless-JSON 校验拒
275
+ assert.deepEqual(d.poolStatus, { workers: [], verifiers: [] })
276
+ })
277
+
278
+ test('normalizeBoard: 旧文件补 poolStatus', () => {
279
+ const legacy = { tasks: [] }
280
+ const d = core.normalizeBoard(legacy)
281
+ assert.deepEqual(d.poolStatus, { workers: [], verifiers: [] })
282
+ // 已有合法 poolStatus 不动
283
+ const ok = { tasks: [], poolStatus: { workers: [{ id: 'w1' }], verifiers: [] } }
284
+ assert.equal(core.normalizeBoard(ok).poolStatus.workers[0].id, 'w1')
285
+ // 残缺的 poolStatus(缺 verifiers 数组)也修复
286
+ const broken = { tasks: [], poolStatus: { workers: [] } }
287
+ assert.deepEqual(core.normalizeBoard(broken).poolStatus, { workers: [], verifiers: [] })
216
288
  })
217
289
 
218
290
  test('cfg: 边界夹紧', () => {