dsh-agent-board 1.3.1 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/core.mjs CHANGED
@@ -14,6 +14,54 @@ export function gsb(p, a) { return a.filter(function (x) { return x.parentId ===
14
14
  export function gpt(t, a) { return isb(t) ? a.find(function (x) { return x.id === t.parentId }) : undefined }
15
15
  export function vt(d) { return d && typeof d === 'object' && Array.isArray(d.tasks) }
16
16
 
17
+ // ===== touches 文件级排他(glob 最小匹配器,零依赖)=====
18
+ // 背景:并行 Worker 改同一批文件会互踩(diff 冲突/一方覆盖另一方)。任务可声明
19
+ // touches: string[](glob),派发器发现与「活动(in-progress)任务的 touches」冲突
20
+ // 则本轮跳过该候选(记入 pickDispatch 返回的 blockedTouches),等锁释放再派。
21
+ // 语义(宁可偏严不可漏拦):
22
+ // 1. 先归一化:\ → /、去 './' 前缀、去尾部 '/';空串或非字符串忽略。
23
+ // 2. a === b 视为冲突。
24
+ // 3. 'dir/**' 目录前缀覆盖(含两侧都 /** 时前缀互相包含);`*` 只当通配符处理,
25
+ // 不做通用 glob 展开——最小实现,够用且不会误判。
26
+ // 4. '*.ext' 后缀:两侧都是 '*.ext' 比扩展名;一侧是具体路径看 endsWith('/'+glob)
27
+ // 或本身 endsWith。
28
+ // 5. 裸文件名(无 '/' 且非 '*.ext')只在「另一侧也是裸名」时比 basename 相等;
29
+ // 含 '/' 的具体路径不参与裸名规则(否则 'a/core.mjs' 会误撞裸名 'core.mjs')。
30
+ // 保守倾向:不确定即算冲突(误拦只是晚一轮派发,漏拦会让两个 Worker 互踩)。
31
+ function normTouch(s) {
32
+ var v = String(s).replace(/\\/g, '/').trim()
33
+ while (v.slice(0, 2) === './') v = v.slice(2)
34
+ while (v.length > 1 && v.charAt(v.length - 1) === '/') v = v.slice(0, -1)
35
+ return v
36
+ }
37
+ function baseOf(p) { var i = p.lastIndexOf('/'); return i < 0 ? p : p.slice(i + 1) }
38
+ function isStarDot(p) { return p.length > 2 && p.indexOf('*') === 0 && p.charAt(1) === '.' && p.indexOf('/') < 0 }
39
+ function dirLockPrefix(p) { return p.length > 3 && p.slice(-3) === '/**' ? p.slice(0, -2) : '' }
40
+ function matchOne(pattern, subject) {
41
+ var p = normTouch(pattern), s = normTouch(subject)
42
+ if (!p || !s) return false
43
+ if (p === s) return true
44
+ // 目录锁:'dir/**' 覆盖 dir 下的任意路径(两个方向都试,任一侧声明目录锁即锁定整棵子树)
45
+ var pfx = dirLockPrefix(p); if (pfx && s.indexOf(pfx) === 0) return true
46
+ var sfx = dirLockPrefix(s); if (sfx && p.indexOf(sfx) === 0) return true
47
+ var ps = isStarDot(p), ss = isStarDot(s)
48
+ if (ps && ss) return p === s // 两侧都是通配扩展名:只有完全相同才算(*.js vs *.ts 不冲突)
49
+ if (ps) return s.length > p.length - 1 && s.slice(-(p.length - 1)) === p.slice(1)
50
+ if (ss) return p.length > s.length - 1 && p.slice(-(s.length - 1)) === s.slice(1)
51
+ // 含 '/' 的具体路径:一侧是另一侧目录下的文件即冲突(src/lib 覆盖 src/lib/core.mjs)
52
+ if (p.indexOf('/') >= 0 || s.indexOf('/') >= 0) return s.indexOf(p + '/') === 0 || p.indexOf(s + '/') === 0
53
+ return baseOf(p) === baseOf(s)
54
+ }
55
+ // 两组 glob 是否可能命中同一批文件(对称判定)
56
+ export function patOverlap(a, b) { if (typeof a !== 'string' || typeof b !== 'string') return false; return matchOne(a, b) || matchOne(b, a) }
57
+ // 已声明 touches 的任务集合是否与本任务声明的 touches 冲突
58
+ export function overlapsTouches(mine, theirs) {
59
+ var m = Array.isArray(mine) ? mine : []
60
+ var th = Array.isArray(theirs) ? theirs : []
61
+ for (var i = 0; i < m.length; i++) { for (var j = 0; j < th.length; j++) { if (patOverlap(m[i], th[j])) return true } }
62
+ return false
63
+ }
64
+
17
65
  // ===== 依赖校验(#18)=====
18
66
  // 存在性 + 自引用 + DFS 环检测;返回错误消息或 null
19
67
  export function validateDeps(d, taskId, deps) {
@@ -59,25 +107,42 @@ export function classifyPipeline(t) {
59
107
  // ===== 配置(一次性派发模型:max*=并发上限;min* 字段保留仅为兼容旧看板文件,引擎不使用)=====
60
108
  // 两级超时:软超时(默认 30min)只上报主窗口提醒,不杀 run;硬超时(默认 120min)才兜底
61
109
  // dispose 释放并发位——人在线时由人决策,人不在时系统兜底。
110
+ // feedbackEnabled(学习飞轮 v1 总开关,默认开):关掉后不生成候选教训、prompt 不提软召回、
111
+ // 详情页沉淀按钮不渲染。老看板文件没有该字段 → 默认 true(normalizeBoard 补齐)。
62
112
  export function cfg(d) {
63
113
  var soft = Math.max(1, Math.min(480, d.softTimeoutMin || 30))
64
114
  var hard = Math.max(soft, Math.min(1440, d.hardTimeoutMin || 120))
65
- return { minWorkers: Math.max(0, Math.min(10, d.minWorkers || 1)), maxWorkers: Math.max(1, Math.min(10, d.maxWorkers || 3)), minVerifiers: Math.max(0, Math.min(5, d.minVerifiers || 0)), maxVerifiers: Math.max(0, Math.min(5, d.maxVerifiers || 2)), softTimeoutMin: soft, hardTimeoutMin: hard }
115
+ return { minWorkers: Math.max(0, Math.min(10, d.minWorkers || 1)), maxWorkers: Math.max(1, Math.min(10, d.maxWorkers || 3)), minVerifiers: Math.max(0, Math.min(5, d.minVerifiers || 0)), maxVerifiers: Math.max(0, Math.min(5, d.maxVerifiers || 2)), softTimeoutMin: soft, hardTimeoutMin: hard, feedbackEnabled: d.feedbackEnabled !== false }
66
116
  }
67
117
 
68
118
  // ===== 看板文件种子 =====
69
119
  // poolStatus 必须始终在种子/归一化里存在:task_list 工具输出 poolStatus: d.poolStatus,
70
120
  // 缺字段 = undefined → 工具结果的 lossless-JSON 校验会拒("value is not lossless JSON")
71
- export function seed(sid) { return { version: 12, ownerSession: sid, boardMode: 'auto', teamMode: false, minWorkers: 1, maxWorkers: 3, minVerifiers: 0, maxVerifiers: 2, workerModel: '', verifierModel: '', softTimeoutMin: 30, hardTimeoutMin: 120, poolStatus: { workers: [], verifiers: [] }, tasks: [] } }
121
+ export function seed(sid) { return { version: 12, ownerSession: sid, boardMode: 'auto', teamMode: false, feedbackEnabled: true, minWorkers: 1, maxWorkers: 3, minVerifiers: 0, maxVerifiers: 2, workerModel: '', verifierModel: '', softTimeoutMin: 30, hardTimeoutMin: 120, poolStatus: { workers: [], verifiers: [] }, tasks: [] } }
72
122
  // 旧文件缺 poolStatus 的归一化(读路径兜底,保证任何历史文件都满足工具输出契约)
73
- 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 }
123
+ // touches 兼容:老任务没有该字段照常(这里只把「存在但非数组」的脏值收敛成数组,
124
+ // 避免 holdsFiles/touchesConflict 里 Array.isArray 判定之外还有第三种形态)
125
+ // feedbackEnabled 兼容:老看板没有该字段(或落了脏值)一律补 true——默认开,行为与 v1 之前一致。
126
+ export function normalizeBoard(d) {
127
+ if (d && typeof d === 'object') {
128
+ if (!d.poolStatus || typeof d.poolStatus !== 'object' || !Array.isArray(d.poolStatus.workers) || !Array.isArray(d.poolStatus.verifiers)) d.poolStatus = { workers: [], verifiers: [] }
129
+ if (typeof d.feedbackEnabled !== 'boolean') d.feedbackEnabled = true
130
+ if (Array.isArray(d.tasks)) {
131
+ for (var i = 0; i < d.tasks.length; i++) {
132
+ var t = d.tasks[i]
133
+ if (t && t.touches !== undefined && !Array.isArray(t.touches)) t.touches = []
134
+ }
135
+ }
136
+ }
137
+ return d
138
+ }
74
139
 
75
140
  // ===== 状态流转 =====
76
141
  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 }
77
142
  export function claimApply(d, t, sid, note) { var ps = t.status; t.status = 'in-progress'; t.claimedBy = sid; t.claimedAt = new Date().toISOString(); ah(t, ps, 'in-progress', sid, note) }
78
143
  export function checkParentAuto(d, t) { if (!isb(t)) return null; var p = gpt(t, d.tasks); if (!p || p.status !== 'in-progress') return null; var s = gsb(p.id, d.tasks); if (s.every(function (x) { return x.status === 'resolved' })) { p.status = 'verifying'; p.resolvedAt = new Date().toISOString(); p.resolution = 'all subtasks resolved'; ah(p, 'in-progress', 'verifying', 'system', 'auto: all subtasks resolved'); return p }; return null }
79
144
  export function resolveApply(d, t, sid, status, resolution, note) { var ps = t.status; if (status === 'verifying' && t.pipeline && t.pipeline !== 'full') { status = 'resolved' } t.status = status; t.resolution = resolution || null; t.resolvedAt = new Date().toISOString(); ah(t, ps, status, sid, note); var r = { ok: true, task: t }; if (status === 'verifying' && isb(t)) { var s = gsb(t.parentId, d.tasks); if (s.every(function (x) { return x.status === 'resolved' || x.id === t.id })) { var p = gpt(t, d.tasks); if (p && p.status === 'in-progress') { p.status = 'verifying'; p.resolvedAt = new Date().toISOString(); p.resolution = 'all subtasks done'; ah(p, 'in-progress', 'verifying', 'system', 'auto'); r.parentUpdated = true } } }; return r }
80
- export function verifyApply(d, t, sid, verdict, comment) { var ps = t.status; if (verdict === 'approved') { t.status = 'resolved'; t.verifiedAt = new Date().toISOString(); t.verifiedBy = sid; ah(t, ps, 'resolved', sid, 'approved' + (comment ? ': ' + comment : '')) } else { t.status = 'in-progress'; t.resolvedAt = null; t.resolution = null; ah(t, ps, 'in-progress', sid, 'rejected' + (comment ? ': ' + comment : '')) }; var r = { ok: true, task: t }; if (verdict === 'approved' && isb(t)) { var p = checkParentAuto(d, t); if (p) { r.parentUpdated = true } }; return r }
145
+ export function verifyApply(d, t, sid, verdict, comment) { var ps = t.status; if (verdict === 'approved') { t.status = 'resolved'; t.verifiedAt = new Date().toISOString(); t.verifiedBy = sid; delete t.frozen; delete t.frozenAt; delete t.frozenBy; ah(t, ps, 'resolved', sid, 'approved' + (comment ? ': ' + comment : '')) } else { t.status = 'in-progress'; t.resolvedAt = null; t.resolution = null; ah(t, ps, 'in-progress', sid, 'rejected' + (comment ? ': ' + comment : '')) }; var r = { ok: true, task: t }; if (verdict === 'approved' && isb(t)) { var p = checkParentAuto(d, t); if (p) { r.parentUpdated = true } }; return r }
81
146
 
82
147
  // ===== 输出解析(文本降级路径)=====
83
148
  // parseSections: 解析 ## 分段输出为结构化字段(容错:无分段时返回空对象,调用方降级)
@@ -105,7 +170,44 @@ export function isEscalation(output) { return /\[ESCALATE\]/i.test(output || '')
105
170
  // 从 run.result.output(ContentBlock[])提取纯文本
106
171
  export function outputText(res) { if (!res || !res.output) return ''; var parts = []; for (var i = 0; i < res.output.length; i++) { var b = res.output[i]; if (b && b.type === 'text' && b.text) parts.push(b.text) } return parts.join('\n') }
107
172
 
173
+ // ===== 学习飞轮 v1:候选教训信号(只产信号,不做存储)=====
174
+ // 设计红线(零耦合):看板只把「候选教训」当作一条结构化的 messages 记录落盘(详情页可见),
175
+ // 绝不调用任何笔记/记忆工具的 API、不写任何外部文件、也不知道对方最终把教训存到哪。
176
+ // 沉淀动作由主窗口 agent 自己选择可用工具完成(push-lesson RPC 只负责把候选 followup 过去)。
177
+ // 触发点(两处都先过 feedbackEnabled 总开关):Verifier 驳回、主窗口仲裁结论。
178
+ // 候选教训文本:三段式 markdown(场景 / 明细段… / 来源);明细段每段截 300 字。
179
+ // parts = [[标签, 正文], ...],如 [['错误做法', 驳回理由]] 或 [['疑问', q], ['裁决结论', a]]。
180
+ export function lessonText(scene, parts, source) {
181
+ var lines = ['场景: ' + String(scene == null ? '' : scene).trim().slice(0, 200)]
182
+ var list = Array.isArray(parts) ? parts : []
183
+ for (var i = 0; i < list.length && i < 4; i++) {
184
+ if (!list[i]) continue
185
+ lines.push(String(list[i][0]) + ': ' + String(list[i][1] == null ? '' : list[i][1]).trim().slice(0, 300))
186
+ }
187
+ lines.push('来源: ' + String(source == null ? '' : source))
188
+ return lines.join('\n')
189
+ }
190
+ // 往任务 messages 追一条候选教训(kind='lesson-candidate')。轻量判重:同一 at 或正文前 80 字相同
191
+ // 即视为同一事件(同一驳回/裁决可能被工具通道与 run 结算两条路径各触发一次)→ 不重复落。
192
+ // 返回 true = 本次新落一条(调用方据此决定要不要提示 UI/主窗口)。
193
+ export function pushLesson(t, text, at, by) {
194
+ if (!t || !text) return false
195
+ if (!Array.isArray(t.messages)) t.messages = []
196
+ var stamp = at || new Date().toISOString()
197
+ var head = String(text).slice(0, 80)
198
+ for (var i = 0; i < t.messages.length; i++) {
199
+ var m = t.messages[i]
200
+ if (!m || m.kind !== 'lesson-candidate') continue
201
+ if (m.at === stamp || String(m.text || '').slice(0, 80) === head) return false
202
+ }
203
+ t.messages.push({ kind: 'lesson-candidate', text: String(text), at: stamp, by: by || 'system' })
204
+ return true
205
+ }
206
+
108
207
  // ===== 一次性子代理 prompt 构建(上下文由主窗口 agent 写入 description/instructions,系统只追加生命周期记录)=====
208
+ // 学习飞轮 v1 软召回引导(feedbackEnabled 开时才拼进 prompt):环境里若有笔记/记忆类工具,先查历史教训再动手。
209
+ // 只是"提示先搜"——看板不代查、不调用任何记忆工具、也无从知道有没有这类工具(零耦合)。
210
+ export var LESSON_RECALL_HINT = '开工前如环境装有笔记/记忆类工具(如 note_search),先检索相关历史教训再动手。'
109
211
  // 过程记录注入:驳回/裁决/干预 history + messages(裁决答案/干预指令/歧义原文)是系统管理的,必须带给子代理
110
212
  export function histNotes(t) { return (t.history || []).filter(function (h) { return h.note && (/歧义|裁决|驳回|干预|rejected/i.test(h.note)) }).map(function (h) { return '- [' + h.timestamp + '] ' + String(h.note).slice(0, 300) }).join('\n') }
111
213
  export function buildMessages(t) {
@@ -130,7 +232,7 @@ export function buildContextPackSection(files, notes) {
130
232
  }
131
233
  return parts.length ? parts.join('\n\n') : ''
132
234
  }
133
- export function buildWorkerPrompt(t, pack) {
235
+ export function buildWorkerPrompt(t, pack, feedbackEnabled) {
134
236
  var notes = histNotes(t)
135
237
  var msgs = buildMessages(t)
136
238
  var p = '你是一个一次性任务执行 Worker。完成下面这个任务,完成后本会话即销毁。\n\ntaskId: ' + t.id + '\n任务: ' + t.title + '\n描述: ' + (t.description || '')
@@ -139,7 +241,10 @@ export function buildWorkerPrompt(t, pack) {
139
241
  if (notes) p += '\n\n该任务的过程记录(歧义上报/主窗口裁决/驳回/干预,请务必遵循最新裁决方向):\n' + notes
140
242
  if (msgs) p += '\n\n该任务的详细消息(裁决答案/干预指令/歧义原文等,请务必遵循):\n' + msgs
141
243
  if (pack) p += '\n\n' + pack
142
- p += '\n\n完成契约(双模,工具优先):\n1. 完成时:优先调用 board_report 工具(kind=complete, taskId=' + t.id + ',summary=开发描述/changes=改动清单/selfTest=自测情况/diffStat=变更概要);工具不可用则按分段格式输出(## 开发描述 / ## 改动清单 / ## 自测情况 / ## diff 概要)。\n diffStat 要求:若本次改动发生在 git 仓库内,运行 git diff --stat(含 git status --short),把输出贴进 diffStat(≤1500 字符);关键逻辑变更可附 ≤20 行核心片段。非代码任务/无 git 仓库可省略。\n **board_report 调用成功即任务终点:立即结束输出,不要再修改/验证任何文件**。上报后任务即刻进入验收,你继续改动会让代码在验收口径之外漂移、且阻塞 Verifier 派发(实测有 Worker 上报后又自测 16 分钟);上报后发现新问题的,写进 selfTest 备注交由 Verifier/主窗口裁决。\n2. 歧义/信息不足/需用户决策时:优先调用 board_report(kind=escalate, taskId=' + t.id + ', question=疑问);工具不可用则输出以 [ESCALATE] 开头的说明。不要猜测。上报歧义后直接结束本轮——裁决后会有新 Worker 带着裁决答案接手。'
244
+ // 学习飞轮 v1 软召回(feedbackEnabled 关闭时不出现):只提示"先查历史教训",看板绝不代查——
245
+ // 环境里有没有笔记/记忆类工具、教训库长什么样,都是 Worker 自己判断的事(零耦合)。
246
+ if (feedbackEnabled !== false) p += '\n\n' + LESSON_RECALL_HINT
247
+ p += '\n\n完成契约(双模,工具优先):\n1. 完成时:优先调用 board_report 工具(kind=complete, taskId=' + t.id + ',summary=开发描述/changes=改动清单/selfTest=自测情况/diffStat=变更概要);工具不可用则按分段格式输出(## 开发描述 / ## 改动清单 / ## 自测情况 / ## diff 概要)。\n diffStat 要求:若本次改动发生在 git 仓库内,运行 git diff --stat(含 git status --short),把输出贴进 diffStat(≤1500 字符);关键逻辑变更可附 ≤20 行核心片段。非代码任务/无 git 仓库可省略。\n **board_report 调用成功即任务终点:立即结束输出,不要再修改/验证任何文件**。上报后任务即刻进入验收,你继续改动会让代码在验收口径之外漂移、且阻塞 Verifier 派发(实测有 Worker 上报后又自测 16 分钟);上报后发现新问题的,写进 selfTest 备注交由 Verifier/主窗口裁决。\n2. 歧义/信息不足/需用户决策时:优先调用 board_report(kind=escalate, taskId=' + t.id + ', question=疑问);工具不可用则输出以 [ESCALATE] 开头的说明。不要猜测。上报歧义后直接结束本轮——裁决后会有新 Worker 带着裁决答案接手。\n3. 进展汇报(较大任务):按里程碑推进,每完成一个可验证的里程碑调用一次 board_report(kind="progress", taskId=' + t.id + ', question=一行进展摘要,≤200 字符)。只在有实际产物/结论时报;禁止定时汇报或表演式汇报。'
143
248
  return p
144
249
  }
145
250
  export function buildVerifierPrompt(t, pack) {
@@ -157,13 +262,55 @@ export function buildVerifierPrompt(t, pack) {
157
262
  return p
158
263
  }
159
264
 
265
+ // ===== 文件锁持有集合(touches 排他)=====
266
+ // 仅 in-progress + claimedBy + 声明了 touches 的任务持有文件锁:
267
+ // - verifying 不持有(Worker 已按契约停笔,锁随 in-progress→verifying 自动释放;
268
+ // 驳回回 in-progress 时重新持有);
269
+ // - pending/blocked/draft 没有 Worker 在改文件,不持有。
270
+ // 返回 [{id, touches}],id 用于 blockedTouches.conflicts 展示"在等谁"。
271
+ export function holdsFiles(d) {
272
+ var out = []
273
+ if (!d || !Array.isArray(d.tasks)) return out
274
+ for (var i = 0; i < d.tasks.length; i++) {
275
+ var t = d.tasks[i]
276
+ if (t.status === 'in-progress' && t.claimedBy && Array.isArray(t.touches) && t.touches.length) out.push({ id: t.id, touches: t.touches })
277
+ }
278
+ return out
279
+ }
280
+ // 候选任务(pending)与一组锁持有者是否冲突;返回持有者 id 列表(空数组 = 无冲突)
281
+ export function touchesConflict(t, holds) {
282
+ var out = []
283
+ if (!t || !Array.isArray(holds)) return out
284
+ for (var i = 0; i < holds.length; i++) { if (holds[i] && holds[i].id !== t.id && overlapsTouches(t.touches, holds[i].touches)) out.push(holds[i].id) }
285
+ return out
286
+ }
287
+
160
288
  // ===== 派发决策(纯函数版,poolCycle 持锁段调用)=====
161
- // 返回 { pendings, verifs }:按优先级排序的可派发任务清单
289
+ // 返回 { pendings, verifs, blockedTouches }:按优先级排序的可派发任务清单。
290
+ // frozen(裁决挂起冻结)不参与任何自动派发:pending 不派 Worker、verifying 不派 Verifier;
291
+ // 只能由主窗口显式解冻(unfreeze-task RPC / task_update unfreeze:true)后重新入池。
292
+ // blockedTouches(touches 文件级排他):候选声明了 touches 且与活动任务的 touches 冲突 →
293
+ // 不进 pendings,改记 [{id, conflicts:[持有任务id...]}],由 index.mjs 的 poolCycle 写展示态字段
294
+ // t.waitingForTouches(每心跳刷新的 UI 展示,不参与其他逻辑)。verifs 不受 touches 影响(Verifier 只读)。
295
+ // 注意:frozen/dependsOn/escalation/上限 的优先级不变——先过滤再算 touches 冲突。
162
296
  export function pickDispatch(d, capW, capV, busyTaskIds) {
163
- var pendings = capW > 0 ? d.tasks.filter(function (t) { return t.status === 'pending' && !t.claimedBy && t.assignMode !== 'manual' && t.pipeline !== 'direct' && depsSatisfied(d, t) && !t.escalation })
164
- .sort(function (a, b) { var p = (PRIO_RANK[b.priority] || 2) - (PRIO_RANK[a.priority] || 2); return p !== 0 ? p : (a.createdAt || '').localeCompare(b.createdAt || '') }).slice(0, capW) : []
165
- var verifs = capV > 0 ? d.tasks.filter(function (t) { return t.status === 'verifying' && (!t.pipeline || t.pipeline === 'full') && !t.escalation && t.verifierRun !== 'spawn-pending' && !(busyTaskIds && busyTaskIds[t.id]) }).slice(0, capV) : []
166
- return { pendings: pendings, verifs: verifs }
297
+ var blockedTouches = []
298
+ var pendings = []
299
+ // 锁持有集合在本轮内动态增长:一旦某候选被纳入本轮派发,它立即成为持有者,
300
+ // 防止同一轮 cycle 派出的两个任务声明重叠 touches。
301
+ var holds = holdsFiles(d)
302
+ if (capW > 0) {
303
+ var cands = d.tasks.filter(function (t) { return t.status === 'pending' && !t.claimedBy && !t.frozen && t.assignMode !== 'manual' && t.pipeline !== 'direct' && depsSatisfied(d, t) && !t.escalation })
304
+ .sort(function (a, b) { var p = (PRIO_RANK[b.priority] || 2) - (PRIO_RANK[a.priority] || 2); return p !== 0 ? p : (a.createdAt || '').localeCompare(b.createdAt || '') })
305
+ for (var i = 0; i < cands.length && pendings.length < capW; i++) {
306
+ var conflicts = touchesConflict(cands[i], holds)
307
+ if (conflicts.length) { blockedTouches.push({ id: cands[i].id, conflicts: conflicts }); continue }
308
+ pendings.push(cands[i])
309
+ if (Array.isArray(cands[i].touches) && cands[i].touches.length) holds.push({ id: cands[i].id, touches: cands[i].touches })
310
+ }
311
+ }
312
+ var verifs = capV > 0 ? d.tasks.filter(function (t) { return t.status === 'verifying' && !t.frozen && (!t.pipeline || t.pipeline === 'full') && !t.escalation && t.verifierRun !== 'spawn-pending' && !(busyTaskIds && busyTaskIds[t.id]) }).slice(0, capV) : []
313
+ return { pendings: pendings, verifs: verifs, blockedTouches: blockedTouches }
167
314
  }
168
315
  // 孤儿回收判定:in-progress 且 claimedBy 非主会话、无活跃 run、无 escalation、超 2 分钟
169
316
  export function isOrphan(d, t, runs, now) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-agent-board",
3
3
  "description": "DeepSeek Harness 智能看板:任务卡片 + 一次性 Worker/Verifier 派发 + 依赖调度 + 管线分档 + Team 模式(歧义上报主窗口裁决)",
4
- "version": "1.3.1",
4
+ "version": "1.5.0",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
7
7
  "scripts": {