dsh-agent-board 1.7.0 → 1.7.2

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
@@ -20,8 +20,9 @@ export function vt(d) { return d && typeof d === 'object' && Array.isArray(d.tas
20
20
 
21
21
  // ===== touches 文件级排他(glob 最小匹配器,零依赖)=====
22
22
  // 背景:并行 Worker 改同一批文件会互踩(diff 冲突/一方覆盖另一方)。任务可声明
23
- // touches: string[](glob),派发器发现与「活动(in-progress)任务的 touches」冲突
23
+ // touches: string[](glob),派发器发现与「持锁任务的 touches」冲突
24
24
  // 则本轮跳过该候选(记入 pickDispatch 返回的 blockedTouches),等锁释放再派。
25
+ // 持锁口径见 holdsFiles(in-progress/verifying/resolved 都持锁,归档才真释放)。
25
26
  // 语义(宁可偏严不可漏拦):
26
27
  // 1. 先归一化:\ → /、去 './' 前缀、去尾部 '/';空串或非字符串忽略。
27
28
  // 2. a === b 视为冲突。
@@ -113,10 +114,22 @@ export function classifyPipeline(t) {
113
114
  // dispose 释放并发位——人在线时由人决策,人不在时系统兜底。
114
115
  // feedbackEnabled(学习飞轮 v1 总开关,默认开):关掉后不生成候选教训、prompt 不提软召回、
115
116
  // 详情页沉淀按钮不渲染。老看板文件没有该字段 → 默认 true(normalizeBoard 补齐)。
117
+ // 回执开关(设置区「通知」小节,双布尔,缺省 true = 现状不变):
118
+ // notifyDispatch=false → 派发回执(任务被 Worker/Verifier 领走时播报)不入聚合队列;
119
+ // notifyDone=false → 完成回执(任务完成/阻塞时聚合播报)不入聚合队列。
120
+ // 只闸「回执」两条入口;歧义裁决通知(notifyMainWindow)是裁决通道不是回执,不接入开关。
121
+ // 史诗拆分总开关 epicSplit(入池配置弹层「功能」小节,缺省 true = 现状不变):**只关引导,不禁机制**——
122
+ // false → ① Team 提示词第 6 条 TEAM_SPLIT_RULE 整条不注入(policy.splitRuleOf);② create-task RPC 与
123
+ // task_create 工具的返回体不再附 suggestSplit 软提示(policy.withSplitHint 第三参门禁)。
124
+ // 机制照常:显式传 parentId 建子卡、史诗自动收口(checkParentAuto)、hooks 状态机都不看这个开关——
125
+ // 用户/主窗口明确要拆的时候不受阻(关的是"主动劝你拆",不是"不许你拆")。
126
+ // 边界:task_create 工具描述里的 TASK_SIZE_CONTRACT 是**静态工具契约**(工具定义会快照进 request header,
127
+ // dsh-session 校验 description 必须是 string),没有按板动态能力,因此不随本开关走——这也是「关引导」
128
+ // 只覆盖两处**动态引导**(Team 提示词条款 + suggestSplit 软提示)的原因。
116
129
  export function cfg(d) {
117
130
  var soft = Math.max(1, Math.min(480, d.softTimeoutMin || 30))
118
131
  var hard = Math.max(soft, Math.min(1440, d.hardTimeoutMin || 120))
119
- 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 }
132
+ 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, notifyDispatch: d.notifyDispatch !== false, notifyDone: d.notifyDone !== false, epicSplit: d.epicSplit !== false }
120
133
  }
121
134
 
122
135
  // ===== 看板数据目录(跨重启继承用)=====
@@ -133,7 +146,7 @@ export function boardHome() { return path.join(boardDirName(), '.dsh') }
133
146
  // ownerCwd(跨重启继承):创建该看板的会话工作区路径,继承判定全靠它——取不到就省略字段
134
147
  // (绝不落空串,否则「路径读不到的多个会话」会被误判成同一工作区)。
135
148
  export function seed(sid, ownerCwd) {
136
- var d = { 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: [] }
149
+ var d = { version: 12, ownerSession: sid, boardMode: 'auto', teamMode: false, feedbackEnabled: true, notifyDispatch: true, notifyDone: true, epicSplit: true, minWorkers: 1, maxWorkers: 3, minVerifiers: 0, maxVerifiers: 2, workerModel: '', verifierModel: '', softTimeoutMin: 30, hardTimeoutMin: 120, poolStatus: { workers: [], verifiers: [] }, tasks: [] }
137
150
  if (typeof ownerCwd === 'string' && ownerCwd) d.ownerCwd = ownerCwd
138
151
  return d
139
152
  }
@@ -141,10 +154,15 @@ export function seed(sid, ownerCwd) {
141
154
  // touches 兼容:老任务没有该字段照常(这里只把「存在但非数组」的脏值收敛成数组,
142
155
  // 避免 holdsFiles/touchesConflict 里 Array.isArray 判定之外还有第三种形态)
143
156
  // feedbackEnabled 兼容:老看板没有该字段(或落了脏值)一律补 true——默认开,行为与 v1 之前一致。
157
+ // notifyDispatch/notifyDone(回执开关)同法:老看板文件没有该字段 → 补 true,缺省开 = 现状不变。
158
+ // epicSplit(史诗拆分总开关)同法:老看板没有该字段(或脏值)→ 补 true,缺省开 = 引导照旧。
144
159
  export function normalizeBoard(d) {
145
160
  if (d && typeof d === 'object') {
146
161
  if (!d.poolStatus || typeof d.poolStatus !== 'object' || !Array.isArray(d.poolStatus.workers) || !Array.isArray(d.poolStatus.verifiers)) d.poolStatus = { workers: [], verifiers: [] }
147
162
  if (typeof d.feedbackEnabled !== 'boolean') d.feedbackEnabled = true
163
+ if (typeof d.notifyDispatch !== 'boolean') d.notifyDispatch = true
164
+ if (typeof d.notifyDone !== 'boolean') d.notifyDone = true
165
+ if (typeof d.epicSplit !== 'boolean') d.epicSplit = true
148
166
  if (Array.isArray(d.tasks)) {
149
167
  for (var i = 0; i < d.tasks.length; i++) {
150
168
  var t = d.tasks[i]
@@ -158,10 +176,183 @@ export function normalizeBoard(d) {
158
176
  // ===== 状态流转 =====
159
177
  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 }
160
178
  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) }
161
- 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 }
162
- 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 }
179
+ // 子任务「已了结」终态口径:resolved(已完成/已验收)+ cancelled(人主动放弃该子任务范围)。
180
+ // 为什么 cancelled 也算:cancelled 是人的显式决定,该子任务范围已关闭;若不算,一张被取消的
181
+ // 子任务会把 epic 永久钉在 in-progress(手动取消的卡反而制造死卡)。验收时人仍可在 epic 上驳回。
182
+ // 注:archived 不算——归档是「收尾/清理」动作,不应反向推动父卡流转(父卡归档时会级联归档子任务)。
183
+ export function isChildSettled(t) { return !!t && (t.status === 'resolved' || t.status === 'cancelled') }
184
+ // 史诗父卡自动收口(共享 helper,唯一判定口径):父卡存在、父卡 in-progress、且全部子任务 ∈ 终态完成集
185
+ // → 父卡转 verifying(交人验收)。幂等:父卡已 verifying/resolved/archived 一律返回 null,重复调用无副作用。
186
+ export function maybeAutoCloseParent(d, childTask) {
187
+ if (!d || !childTask || !isb(childTask)) return null
188
+ var p = gpt(childTask, d.tasks)
189
+ if (!p || p.status !== 'in-progress') return null
190
+ var s = gsb(p.id, d.tasks)
191
+ if (!s.length || !s.every(function (x) { return isChildSettled(x) })) return null
192
+ // ===== post 延迟 verifying(hooks=agent run 接线③)=====
193
+ // epic 声明了 post hook 且尚未收口(state !== 'done')→ 不直接转 verifying,先挂 post 闸门:
194
+ // state='idle' → 本轮不转 verifying,只置 running 等 poolCycle 补 spawn hook-post run
195
+ // (core 保持纯函数:只落状态 + 返回父卡,spawn 由 dispatch 侧做)
196
+ // state='running' → 已在收口途中,poolCycle 会补 spawn(idle 与 running 同分支,二者共用
197
+ // hook-post 幂等占用标记 hooks.post.pending)
198
+ // state='failed' → 收口失败已 blocked 等人裁决,绝不自动收口(回归安静态,避免每轮重复触发)
199
+ // 未声明/未启用 post → 直接转 verifying(既有行为逐字不变),并清掉可能残留的待跑标记。
200
+ if (hookOn(p, 'post')) {
201
+ var pst = hookState(p, 'post')
202
+ if (pst === 'failed') return null
203
+ if (pst !== 'done') {
204
+ p.hooks.post.pending = true // hook-post 幂等占用标记:poolCycle 见它才 spawn(spawn 后清除,失败再置回)
205
+ p.hooks.post.state = 'running'
206
+ ah(p, p.status, p.status, 'system', 'auto: all subtasks settled,post hook 收口未完成,延迟 verifying')
207
+ return p
208
+ }
209
+ }
210
+ if (p.hooks && p.hooks.post && p.hooks.post.pending) delete p.hooks.post.pending
211
+ p.status = 'verifying'
212
+ p.resolvedAt = new Date().toISOString()
213
+ p.resolution = 'all subtasks resolved'
214
+ ah(p, 'in-progress', 'verifying', 'system', 'auto: all subtasks resolved')
215
+ return p
216
+ }
217
+ // 兼容名:既有调用点(verifyApply)与 TEAM_SPLIT_RULE / README 的对外语义名保持不变,内部委托共享 helper。
218
+ export function checkParentAuto(d, t) { return maybeAutoCloseParent(d, t) }
219
+ 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 }; var p = maybeAutoCloseParent(d, t); if (p) { r.parentUpdated = true }; return r }
163
220
  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 }
164
221
 
222
+ // ===== 史诗 hooks=agent run(宿主生命周期接线)=====
223
+ // 定位:hook 点 = 一次**真实 agent 运行**(不是声明式命令、不走 shell),挂在 epic 卡上、
224
+ // 由派发周期 spawn 成一次性子代理 run(role 'hook-pre' / 'hook-post'),run 结算把 state 推进。
225
+ // 数据形态(写在卡上,重启可恢复——state 机不靠内存):
226
+ // epic.hooks = { pre: { enabled, prompt, state: 'idle'|'running'|'done'|'failed', runId }, post: 同构 }
227
+ // 三条红线:
228
+ // ① 点位可选——epic 未声明 hooks 时全链路零变化(pickDispatch / maybeAutoCloseParent / poolCycle
229
+ // 都只在 hooks 存在且 enabled 时才进入分支,老 epic 行为逐字不变);
230
+ // ② 薄框架——prompt 只给契约与上下文,做什么由 hook agent 自行决策,吃不准就歧义上报;
231
+ // ③ commit/push 不进任何默认形态——默认文案明确写「不要默认提交/推送」,收口动作全由 agent 自己判断。
232
+ export var HOOK_PHASES = ['pre', 'post']
233
+ export var HOOK_STATES = ['idle', 'running', 'done', 'failed']
234
+ // 建卡/更新卡的 hooks 浅校验(工具 task_create/task_update 与 RPC create-task/update-task 共用同一口径)。
235
+ // 口径:只认 { pre?, post? } 两键;每项 { enabled?:bool, prompt?:string, state?:enum, runId?:string };
236
+ // prompt 非空字符串(≤4000,超长截断);state/runId 允许传(重启后人工恢复现场);未知键忽略、不报错。
237
+ // 返回 { hooks } 或 { error }——调用方把 error 原样回给主窗口(宁早报错,别静默存下一坨跑不起来的配置)。
238
+ export function normalizeHooks(input) {
239
+ if (input === null) return { hooks: null }
240
+ if (typeof input !== 'object' || Array.isArray(input)) return { error: 'hooks 必须是对象({ pre?, post? })' }
241
+ var out = {}
242
+ var dels = []
243
+ for (var i = 0; i < HOOK_PHASES.length; i++) {
244
+ var ph = HOOK_PHASES[i]
245
+ var raw = input[ph]
246
+ if (raw === undefined) continue
247
+ // 显式 null = 撤掉该点位(un-declare)。它必须原样穿到 mergeHooks——否则「只撤 pre」的意图会在
248
+ // 归一化阶段被吃掉,task_update hooks:{pre:null} 变成空操作(实测踩过)。
249
+ if (raw === null) { dels.push(ph); out[ph] = null; continue }
250
+ if (typeof raw !== 'object' || Array.isArray(raw)) return { error: 'hooks.' + ph + ' 必须是对象' }
251
+ var h = { enabled: raw.enabled === undefined ? true : !!raw.enabled, prompt: '', state: 'idle', runId: null }
252
+ if (raw.prompt !== undefined) {
253
+ if (typeof raw.prompt !== 'string') return { error: 'hooks.' + ph + '.prompt 必须是字符串' }
254
+ h.prompt = raw.prompt.slice(0, 4000)
255
+ }
256
+ if (!String(h.prompt).trim()) return { error: 'hooks.' + ph + '.prompt 不能为空(薄框架只给契约,契约本体由主窗口写)' }
257
+ if (raw.state !== undefined) {
258
+ if (HOOK_STATES.indexOf(raw.state) < 0) return { error: 'hooks.' + ph + '.state 必须是 ' + HOOK_STATES.join('/') }
259
+ h.state = raw.state
260
+ }
261
+ if (raw.runId !== undefined && raw.runId !== null) h.runId = String(raw.runId)
262
+ // 内部机器标记(pending)原样穿过去:主窗口若为恢复现场整条重传 pre,不该把待跑标记洗掉
263
+ if (raw.pending) h.pending = true
264
+ out[ph] = h
265
+ }
266
+ // 只有「纯删除」时才返回仅含 null 的对象;有真实点位时把 null 一并带上(mergeHooks 逐点位处理)
267
+ if (!dels.length) { var clean = {}; for (var j = 0; j < HOOK_PHASES.length; j++) { if (out[HOOK_PHASES[j]] && out[HOOK_PHASES[j]] !== null) clean[HOOK_PHASES[j]] = out[HOOK_PHASES[j]] } return { hooks: clean } }
268
+ return { hooks: out }
269
+ }
270
+ // 已有 hooks 与本次提交的钩子做**浅合并**(task_update 只想改 prompt 时不必重复整条 pre/post):
271
+ // 未提交的键保留原值;提交 null 表示删除该点位(un-declare)。
272
+ export function mergeHooks(prev, next) {
273
+ var out = {}
274
+ var base = (prev && typeof prev === 'object') ? prev : {}
275
+ for (var i = 0; i < HOOK_PHASES.length; i++) {
276
+ var ph = HOOK_PHASES[i]
277
+ if (base[ph]) out[ph] = base[ph]
278
+ }
279
+ if (!next) return out
280
+ for (var j = 0; j < HOOK_PHASES.length; j++) {
281
+ var p2 = HOOK_PHASES[j]
282
+ if (next[p2] === undefined) continue
283
+ if (next[p2] === null) { delete out[p2]; continue }
284
+ out[p2] = next[p2]
285
+ }
286
+ return out
287
+ }
288
+ // 该点位是否「已声明且启用」——pickDispatch 闸门与 poolCycle 触发共用的唯一判定口径
289
+ export function hookOn(owner, phase) { var h = owner && owner.hooks && owner.hooks[phase]; return !!(h && h.enabled) }
290
+ export function hookState(owner, phase) { var h = owner && owner.hooks && owner.hooks[phase]; return (h && h.state) || 'idle' }
291
+ // 状态机写入(唯一入口,保证 ah 留痕):state 写在 epic 卡上(重启可恢复)。
292
+ export function hookSetState(p, phase, state, actor, note) {
293
+ var h = p.hooks && p.hooks[phase]
294
+ var from = (h && h.state) || 'idle'
295
+ h.state = state
296
+ ah(p, p.status, p.status, actor || 'system', 'hooks.' + phase + ': ' + from + ' → ' + state + (note ? '(' + note + ')' : ''))
297
+ return h
298
+ }
299
+ // hook run 结算(纯函数,dispatch.settleHook 持锁段调用;抽出来是为了能单测状态机):
300
+ // ok=true pre → hooks.pre='done'(串行闸门打开,下轮起子任务正常派发)
301
+ // ok=true post → hooks.post='done' + epic 转 verifying(收口完成,交人验收)
302
+ // ok=false pre/post → 该点位 state='failed' + epic 转 blocked + escalation 挂卡(歧义上报:
303
+ // 重试/跳过/放弃,由主窗口裁决),绝不自动重试——故障 hook 反复重跑只会烧钱。
304
+ // 幂等:点位已 done、或 epic 已有未裁决 escalation 时返回 { already: true },不覆盖不改状态。
305
+ export function applyHookSettle(d, epicId, phase, ok, output, runId, errText) {
306
+ var p = d.tasks.find(function (x) { return x.id === epicId })
307
+ if (!p) return null
308
+ var h = (p.hooks && p.hooks[phase]) || null
309
+ if (!h) return { task: p, already: true }
310
+ if (h.state === 'done') return { task: p, already: true }
311
+ if (p.escalation) { h.runId = null; delete h.pending; return { task: p, already: true } }
312
+ h.runId = null
313
+ delete h.pending
314
+ if (!ok) {
315
+ var why = String(errText || '').slice(0, 150) || '未给出有效结论'
316
+ hookSetState(p, phase, 'failed', String(runId), 'hook run 失败')
317
+ p.lastError = ('hooks.' + phase + ' run 失败: ' + why).slice(0, 300)
318
+ p.escalation = {
319
+ question: 'hooks.' + phase + '(' + (phase === 'pre' ? '前置准备' : '收口') + ')失败:' + why + '\n' +
320
+ (output ? 'hook agent 输出(截断):\n' + String(output).slice(0, 1200) + '\n' : '') +
321
+ '请裁决:重试(把 hooks.' + phase + '.state 置回 idle 即可重跑)/ 跳过(置为 done 放行)/ 放弃(终止该史诗)。',
322
+ at: new Date().toISOString(), by: String(runId),
323
+ }
324
+ if (!Array.isArray(p.messages)) p.messages = []
325
+ p.messages.push({ kind: 'escalation', text: p.escalation.question, at: p.escalation.at, by: String(runId) })
326
+ ah(p, p.status, 'blocked', String(runId), 'hooks.' + phase + ' 失败,待主窗口裁决(重试/跳过/放弃)')
327
+ p.status = 'blocked'
328
+ return { task: p, blocked: true }
329
+ }
330
+ hookSetState(p, phase, 'done', String(runId), 'hook run 完成')
331
+ if (phase === 'post') {
332
+ var from = p.status
333
+ p.status = 'verifying'
334
+ p.resolvedAt = new Date().toISOString()
335
+ p.resolution = 'hooks.post settled'
336
+ ah(p, from, 'verifying', 'system', 'auto: post hook 收口完成')
337
+ return { task: p, closed: true }
338
+ }
339
+ return { task: p, preDone: true }
340
+ }
341
+ // ===== pre 串行闸门(纯函数,pickDispatch 过滤用)=====
342
+ // 语义:子任务候选命中派发前,先看它所在 epic 的前置 hook 是否已完成——
343
+ // hooks.pre 未声明/未启用 → 放行(老 epic 零变化)
344
+ // state='done' → 放行(前置准备完成,下轮起子任务正常派发)
345
+ // state='idle' → **拦下**:本轮不派子任务,由 poolCycle 改 spawn hook-pre run(串行闸门)
346
+ // state='running' → 拦下:hook run 还在跑,跳过本轮(不重复 spawn)
347
+ // state='failed' → 拦下:已 blocked 等人裁决(重试/跳过/放弃),绝不自动放行
348
+ // 串行闸门语义 = 「一个 epic 的子任务在 pre hook 完成之前一张都不派」,天然并发安全。
349
+ export function preHookGate(epic) {
350
+ if (!epic || !hookOn(epic, 'pre')) return { pass: true, reason: 'none' }
351
+ var st = hookState(epic, 'pre')
352
+ if (st === 'done') return { pass: true, reason: 'done' }
353
+ return { pass: false, reason: st }
354
+ }
355
+
165
356
  // ===== 史诗父卡语义层 =====
166
357
  // 父卡自动流转:子任务被派发时(poolCycle 占位 claim 的 dispatch 分支调用),
167
358
  // 若父卡 status 为 pending → 父卡转 in-progress(ah 记「首个子任务派发,史诗进入推进态」),
@@ -172,20 +363,27 @@ export function verifyApply(d, t, sid, verdict, comment) { var ps = t.status; if
172
363
  export function parentKickOnDispatch(d, t) { if (!isb(t)) return null; var p = gpt(t, d.tasks); if (!p || p.status !== 'pending') return null; p.status = 'in-progress'; ah(p, 'pending', 'in-progress', 'system', '首个子任务派发,史诗进入推进态'); return p }
173
364
 
174
365
  // childStats 聚合(get-tasks 返回体字段,按 tasks 现算零存储):
175
- // { <parentId>: { total, resolved, active, activeTitle } }
176
- // 口径:total=该 parentId 的非归档子任务数;resolved=其中 resolved/archived(归档子任务
177
- // 已在 total 口径被排除,archived 分支保留仅为防御性写明口径);active=in-progress 数;
178
- // activeTitle=第一个 in-progress 子任务标题(按看板顺序,无则空串)。
179
- // 无非归档子任务的父卡不出键(史诗归档时子任务级联归档,键自然消失)。
366
+ // { <parentId>: { total, settled, resolved, active, activeTitle } }
367
+ // 口径:total=该 parentId 的**全部**子任务数(**含已归档**);settled=其中已了结数,分子口径
368
+ // settled = resolved | cancelled | archived;resolved=settled 的兼容别名(既有对外字段名,同值,
369
+ // 老前端/老断言按 resolved 读仍成立);active=in-progress 数;activeTitle=第一个 in-progress
370
+ // 子任务标题(按看板顺序,无则空串)。active/activeTitle 口径不变:archived 永不算 active。
371
+ // 为什么归档必须计入(task-muupgfot):归档=人已显式了结该卡;若把 archived 从 total/settled 里
372
+ // 一起排除,归档一张子卡会让史诗进度从 0/10「退化」成 0/9——分母无故缩水、分子永不前进,
373
+ // 正是用户报的「有子任务完成后史诗进度从 0/10 变 0/9」。进度只增不减是这条口径的红线。
374
+ // 近似说明:v1.7.1 起 archive-task 放行「无活跃 run 的 in-progress 僵尸卡」,故 archived 不再严格
375
+ // 等于「曾经完成」;但归档动作本身仍是人的显式了结,进度语义上计入是对的(僵尸出清场景里父卡
376
+ // 通常随之归档,不影响在板卡片的展示)。注意与 isChildSettled(父卡自动收口口径,archived 不算)
377
+ // 的区别:那条管「是否推动父卡流转」,本条只管「进度分母/分子的展示口径」,两者刻意不合并。
180
378
  export function aggregateChildStats(tasks) {
181
379
  var out = {}
182
380
  var list = Array.isArray(tasks) ? tasks : []
183
381
  for (var i = 0; i < list.length; i++) {
184
382
  var t = list[i]
185
- if (!t || !isb(t) || t.status === 'archived') continue
186
- var s = out[t.parentId] || (out[t.parentId] = { total: 0, resolved: 0, active: 0, activeTitle: '' })
383
+ if (!t || !isb(t)) continue
384
+ var s = out[t.parentId] || (out[t.parentId] = { total: 0, settled: 0, resolved: 0, active: 0, activeTitle: '' })
187
385
  s.total++
188
- if (t.status === 'resolved' || t.status === 'archived') s.resolved++
386
+ if (t.status === 'resolved' || t.status === 'cancelled' || t.status === 'archived') { s.settled++; s.resolved++ }
189
387
  if (t.status === 'in-progress') { s.active++; if (!s.activeTitle) s.activeTitle = String(t.title || '') }
190
388
  }
191
389
  return out
@@ -251,6 +449,39 @@ export function pushLesson(t, text, at, by) {
251
449
  return true
252
450
  }
253
451
 
452
+ // ===== 驳回包全量带回(task-muvg15p5)=====
453
+ // 问题实证:驳回信息此前只走 history(histNotes 每条截 300 字),board_verdict 工具通道更把
454
+ // checks(逐条核对证据 = 真正可执行的驳回细节)整段丢弃;手动驳回 comment 可为空、且不写
455
+ // t.verification → 重派 Worker 只拿到一句被截断的「rejected」,无从据此返工。
456
+ // 修法(零新通道):三条驳回路径统一往 t.messages 追一条 kind='rejection' 的完整驳回包,
457
+ // buildMessages 会把 t.messages 全量拼进 Worker prompt(每条 2000 字,远超 history 的 300 字)。
458
+ // history 保持现状不动——它是审计轨,不是执行载荷。
459
+ // 文本口径:有 summary/checks 时「验收驳回 · <summary>」+「核对项:<checks>」;两者皆空(手动驳回
460
+ // 未填原因)时给一句可执行的兜底,而不是留一条空消息。
461
+ export function rejectionText(summary, checks) {
462
+ var s = String(summary == null ? '' : summary).trim()
463
+ var c = String(checks == null ? '' : checks).trim()
464
+ if (!s && !c) return '(驳回方未填写原因,请先自查交付物与验收脚本差距)'
465
+ return '验收驳回 · ' + s + (c ? '\n\n核对项:\n' + c : '')
466
+ }
467
+ // 往任务 messages 追一条完整驳回包(kind='rejection')。轻量判重(与 pushLesson 同口径):
468
+ // 同一 at 或正文前 80 字相同视为同一事件——文本结算路径在工具通道已处理时 already=true 会整段
469
+ // 跳过,判重是第二道保险,杜绝同一驳回双推。返回 true = 本次新落一条。
470
+ export function pushRejection(t, summary, checks, at, by) {
471
+ if (!t) return false
472
+ if (!Array.isArray(t.messages)) t.messages = []
473
+ var stamp = at || new Date().toISOString()
474
+ var text = rejectionText(summary, checks)
475
+ var head = text.slice(0, 80)
476
+ for (var i = 0; i < t.messages.length; i++) {
477
+ var m = t.messages[i]
478
+ if (!m || m.kind !== 'rejection') continue
479
+ if (m.at === stamp || String(m.text || '').slice(0, 80) === head) return false
480
+ }
481
+ t.messages.push({ kind: 'rejection', text: text, at: stamp, by: by || 'system' })
482
+ return true
483
+ }
484
+
254
485
  // ===== 一次性子代理 prompt 构建(上下文由主窗口 agent 写入 description/instructions,系统只追加生命周期记录)=====
255
486
  // 学习飞轮 v1 软召回引导(feedbackEnabled 开时才拼进 prompt):环境里若有笔记/记忆类工具,先查历史教训再动手。
256
487
  // 只是"提示先搜"——看板不代查、不调用任何记忆工具、也无从知道有没有这类工具(零耦合)。
@@ -358,6 +589,11 @@ export function buildFileOutline(content) {
358
589
  }
359
590
  return out
360
591
  }
592
+ // 口径说明(epicSplit 总开关审计):Worker prompt **不含任何拆分引导条款**——拆分引导只出现在主窗口侧
593
+ // 两处动态面(Team 提示词第 6 条 + create-task/task_create 的 suggestSplit 软提示),Worker 拿到的是
594
+ // 已经建好的单张卡(要拆也轮不到它拆,真觉得大应走歧义上报)。故 epicSplit=false 时 Worker prompt 字面
595
+ // 与 true 时逐字相同(单测锁定 parity,防止将来有人往这里塞拆分条款而漏接门禁);buildWorkerPrompt
596
+ // 因此不引入 epicSplit 形参——没有条款可跳过,加个无用参数只会是死代码。
361
597
  export function buildWorkerPrompt(t, pack, feedbackEnabled) {
362
598
  var notes = histNotes(t)
363
599
  var msgs = buildMessages(t)
@@ -384,22 +620,74 @@ export function buildVerifierPrompt(t, pack) {
384
620
  if (notes) p += '\n\n该任务的过程记录(歧义上报/主窗口裁决/驳回/干预,若有):\n' + notes + '\n注意:若过程记录显示主窗口已裁决改变任务方向,以裁决后的方向为验收标准。'
385
621
  if (msgs) p += '\n\n该任务的详细消息(裁决答案/干预指令/歧义原文等):\n' + msgs
386
622
  if (pack) p += '\n\n' + pack
623
+ // 跑偏归因条款(调研遵循·host 三件套 ②):驳回理由注明「立单缺调研」——归因计入驳回热点统计,
624
+ // 供主窗口分诊「立单缺料 vs Worker 执行问题」,缺料占高了就该把建卡调研门禁拧紧。
625
+ p += '\n驳回归因:若 Worker 的产出明显因缺少调研上下文而跑偏/绕路,驳回时请在驳回理由里注明「立单缺调研」(归因会计入驳回热点统计)。'
387
626
  p += '\n\n结论契约(双模,工具优先):\n1. 优先调用 board_verdict 工具(taskId=' + t.id + ', verdict=approved/rejected, summary=测试概要, checks=逐条核对证据含行号)。\n2. 工具不可用则首行 APPROVED: <结论> 或 REJECTED: <结论>,然后 ## 测试概要 / ## 核对项 分段。'
388
627
  return p
389
628
  }
390
629
 
630
+ // ===== hook run prompt(薄框架模板,hooks=agent run)=====
631
+ // hook 点 = 一次真实 agent 运行:prompt 只给「契约一句话 + epic 上下文 + 子任务清单」,
632
+ // 具体做什么由 hook agent 按现场自行决策;吃不准/信息不足一律歧义上报,禁止硬闯。
633
+ // commit/push 不进任何默认形态——前置不许默认提交,后置明确写「不要默认提交/推送」(红线③)。
634
+ // pre 契约:让这批子任务具备开跑条件(做什么准备由 agent 判断,可只读调研后什么都不改)。
635
+ // post 契约:把这批已完成的工作收口(验证/总结/(自行决定并自负其责的)提交都算)。
636
+ export function buildHookPrompt(epic, phase, childTasks) {
637
+ var kids = Array.isArray(childTasks) ? childTasks : []
638
+ var pre = phase !== 'post'
639
+ var p = '你是一个一次性史诗 hook 执行 Agent(hooks=' + (pre ? 'pre' : 'post') + ' run)。本次运行由任务看板的派发周期发起,完成(或上报歧义)后本会话即销毁。\n\n'
640
+ p += 'hook 点位:' + (pre ? 'pre(前置准备闸门)' : 'post(收口闸门)') + '\n'
641
+ p += '所属史诗:' + epic.id + ' · ' + String(epic.title || '') + '\n'
642
+ p += '史诗状态:' + String(epic.status || '') + '\n'
643
+ p += '史诗描述:' + String(epic.description || '(无)').slice(0, 2000) + '\n'
644
+ if (epic.context && epic.context.instructions) p += '史诗指引:' + String(epic.context.instructions).slice(0, 1000) + '\n'
645
+ p += '\n主窗口给本次 hook 的运行契约(薄框架:只定边界,不做具体动作安排):\n' + String((epic.hooks && epic.hooks[phase] && epic.hooks[phase].prompt) || '(未填写)').slice(0, 4000) + '\n'
646
+ p += '\n该史诗的子任务清单(' + kids.length + ' 个):\n'
647
+ if (!kids.length) p += '- (暂无子任务)\n'
648
+ for (var i = 0; i < kids.length; i++) {
649
+ var c = kids[i]
650
+ p += '- [' + (c.status || '') + '] ' + c.id + ' · ' + String(c.title || '').slice(0, 120)
651
+ if (c.pipeline) p += '(管线 ' + c.pipeline + ')'
652
+ p += '\n'
653
+ }
654
+ p += '\n你的任务:' + (pre
655
+ ? '让这批子任务具备开跑条件。做什么准备由你根据上下文判断(可只读调研、制定方案、补齐约定,也可以判断为「无需准备」)。'
656
+ : '把这批已完成的工作收口。收口动作由你判断(通常可能涉及验证、总结,也可能涉及提交——自行决策并自负其责)。')
657
+ p += '信息不足、吃不准、或需要用户/主窗口决策时:优先调用 board_report(kind=escalate, taskId=' + epic.id + ', question=疑问);工具不可用则输出以 [ESCALATE] 开头的说明。不要猜测、不要硬闯。\n'
658
+ p += '红灯纪律:不要默认提交(git commit)或推送(git push)——除非上面的运行契约明确要求,或你判断确实是本次收口不可省略的一步;那也要在完成说明里写清做了什么、为什么。\n'
659
+ if (!pre) p += '本次收口完成后,史诗将自动转入 verifying(交人验收)。\n'
660
+ p += '\n完成契约(双模,工具优先):\n'
661
+ if (pre) p += '1. 完成时:优先调用 board_report 工具(kind=complete, taskId=' + epic.id + ',summary=做了什么准备/changes=改动清单(无改动就写「无」)/selfTest=自测情况/diffStat=变更概要)。工具不可用则按分段格式输出(## 开发描述 / ## 改动清单 / ## 自测情况 / ## diff 概要)。完成即代表 pre 闸门放行,之后 epic 的子任务会开始派发。\n'
662
+ else p += '1. 完成时:优先调用 board_report 工具(kind=complete, taskId=' + epic.id + ',summary=收口做了什么/changes=改动清单(无改动就写「无」)/selfTest=自测情况/diffStat=变更概要)。工具不可用则按分段格式输出(## 开发描述 / ## 改动清单 / ## 自测情况 / ## diff 概要)。\n'
663
+ p += ' **board_report 调用成功即本次运行终点:立即结束输出,不要再修改/验证任何文件**。\n'
664
+ p += '2. 歧义/信息不足:board_report(kind=escalate, taskId=' + epic.id + ', question=疑问);工具不可用则输出以 [ESCALATE] 开头的说明。上报后直接结束本轮。\n'
665
+ p += '3. 进展汇报:只在有实际产物/结论时报(board_report kind="progress"),禁止表演式汇报。\n'
666
+ return p
667
+ }
668
+
391
669
  // ===== 文件锁持有集合(touches 排他)=====
392
- // 仅 in-progress + claimedBy + 声明了 touches 的任务持有文件锁:
393
- // - verifying 不持有(Worker 已按契约停笔,锁随 in-progress→verifying 自动释放;
394
- // 驳回回 in-progress 时重新持有);
395
- // - pending/blocked/draft 没有 Worker 在改文件,不持有。
670
+ // 锁随卡的生命周期走,归档(archived)= 真释放(反馈 n-muupqg81u575:验收后-提交前窗口期
671
+ // 锁已放,下一卡 Worker 污染同树 commit——连续批次三次复发)。
672
+ // 持锁三态(声明了 touches 为前提):
673
+ // - in-progress + claimedBy:Worker 正在改文件(claimedBy 为空=占位未落座/僵尸,不算持锁);
674
+ // - verifying:Worker 已停笔但**尚未落定**——驳回会回 in-progress 让同一批文件继续被改,
675
+ // 且验收通过后的提交窗口期仍属于本卡(不再随 in-progress→verifying 放锁);
676
+ // - resolved:验收/完成已通过,但主窗口还没提交——锁一直持到 archived。
677
+ // 不持有:pending/blocked/draft(没有 Worker 在改文件)、cancelled(放弃语义:不再产出,
678
+ // 立刻放锁,避免被废弃的卡长期堵住同批文件)、archived(已归档=真释放)。
679
+ // 边界(有意为之的背压):resolved 卡若长期不归档,后续冲突卡会一直 pending 滞留——
680
+ // 这是逼主窗口验收后尽快归档的信号;UI 上 waitingForTouches 行会显示持锁卡 id(含 resolved 卡),
681
+ // 滞留原因对用户可见,不需要额外的超时放锁逻辑。
396
682
  // 返回 [{id, touches}],id 用于 blockedTouches.conflicts 展示"在等谁"。
397
683
  export function holdsFiles(d) {
398
684
  var out = []
399
685
  if (!d || !Array.isArray(d.tasks)) return out
400
686
  for (var i = 0; i < d.tasks.length; i++) {
401
687
  var t = d.tasks[i]
402
- if (t.status === 'in-progress' && t.claimedBy && Array.isArray(t.touches) && t.touches.length) out.push({ id: t.id, touches: t.touches })
688
+ if (!t || !Array.isArray(t.touches) || !t.touches.length) continue
689
+ var holds = t.status === 'verifying' || t.status === 'resolved' || (t.status === 'in-progress' && !!t.claimedBy)
690
+ if (holds) out.push({ id: t.id, touches: t.touches })
403
691
  }
404
692
  return out
405
693
  }
@@ -419,6 +707,9 @@ export function touchesConflict(t, holds) {
419
707
  // 不进 pendings,改记 [{id, conflicts:[持有任务id...]}],由 index.mjs 的 poolCycle 写展示态字段
420
708
  // t.waitingForTouches(每心跳刷新的 UI 展示,不参与其他逻辑)。verifs 不受 touches 影响(Verifier 只读)。
421
709
  // 注意:frozen/dependsOn/escalation/上限 的优先级不变——先过滤再算 touches 冲突。
710
+ // pre hook 串行闸门(hooks=agent run 接线①):候选所属 epic 的 hooks.pre 未 done 时整批不派
711
+ // (见 preHookGate),由 poolCycle 改 spawn hook-pre run——同一 epic 的子任务在准备完成前
712
+ // 一张都不派,这是「pre 闸门」的串行语义;未声明 hooks 的 epic 逐字不受影响。
422
713
  export function pickDispatch(d, capW, capV, busyTaskIds) {
423
714
  var blockedTouches = []
424
715
  var pendings = []
@@ -426,7 +717,7 @@ export function pickDispatch(d, capW, capV, busyTaskIds) {
426
717
  // 防止同一轮 cycle 派出的两个任务声明重叠 touches。
427
718
  var holds = holdsFiles(d)
428
719
  if (capW > 0) {
429
- 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 })
720
+ 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 && preHookGate(gpt(t, d.tasks)).pass })
430
721
  .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 || '') })
431
722
  for (var i = 0; i < cands.length && pendings.length < capW; i++) {
432
723
  var conflicts = touchesConflict(cands[i], holds)
@@ -438,7 +729,7 @@ export function pickDispatch(d, capW, capV, busyTaskIds) {
438
729
  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) : []
439
730
  return { pendings: pendings, verifs: verifs, blockedTouches: blockedTouches }
440
731
  }
441
- // 孤儿回收判定:in-progress 且 claimedBy 非主会话、无活跃 run、无 escalation、超 2 分钟
732
+ // 孤儿回收判定:in-progress 且 claimedBy 非主会话、无活跃 run、无 escalation、超 2 分钟:in-progress 且 claimedBy 非主会话、无活跃 run、无 escalation、超 2 分钟
442
733
  export function isOrphan(d, t, runs, now) {
443
734
  return t.status === 'in-progress' && t.claimedBy && t.claimedBy !== d.ownerSession && !(runs && runs[t.id]) && !t.escalation && (now - new Date(t.claimedAt || 0).getTime()) > 120000
444
735
  }
@@ -459,6 +750,13 @@ export function isTreeGlob(p) { var v = normTouch(p); return v === '**' || /\/\*
459
750
  // ②③ 的触发前提:pipeline≠'direct' 且 touches 非空(direct 主窗口直接处理、无 touches 不指望调研材料)。
460
751
  // 返回 string[];调用方自行合并为一条 warning 字段(可选字段,老调用方无感)。
461
752
  export var EMPTY_DESC_WARNING = '任务描述为空——Worker 只能凭标题猜需求,建议补一句目标/约束'
753
+ // ②的文案单独成常量:rpc 双通道在「触发②且 touches 有可建议文件」时要认出这条并补尾巴
754
+ // (attachContextSuggestions),字面量若散在两处会漂移。
755
+ export var NO_RESEARCH_WARNING = '未附调研上下文(contextFiles/contextNotes)——Worker 将自行 grep 定位,建议补上预研文件路径或勾选无需调研'
756
+ // ②触发且 suggestContextFiles 非空时,warning 尾巴补的最省力动作指引(不含 ';',不破坏多 warning 合并分隔)
757
+ export var NO_RESEARCH_HINT = '(可直接 task_update contextFiles 补上:touches 指向的文件就是最相关的调研现场)'
758
+ // verify 驳回响应的重派提示(task_verify 工具 + verify-task RPC 的 rejected 分支挂载;字段可选,老调用方无感)
759
+ export var REJECT_REDISPATCH_HINT = '建议:驳回原因写进 description,并用 contextNotes 补调研结论后再重派——新 Worker 没有上一轮记忆'
462
760
  export function createTaskWarnings(t) {
463
761
  var out = []
464
762
  if (!t || typeof t !== 'object') return out
@@ -468,12 +766,60 @@ export function createTaskWarnings(t) {
468
766
  var cx = t.context || {}
469
767
  var hasFiles = Array.isArray(cx.files) && cx.files.length > 0
470
768
  var hasNotes = !!(cx.notes && String(cx.notes).trim())
471
- if (!hasFiles && !hasNotes) out.push('未附调研上下文(contextFiles/contextNotes)——Worker 将自行 grep 定位,建议补上预研文件路径或勾选无需调研')
769
+ if (!hasFiles && !hasNotes) out.push(NO_RESEARCH_WARNING)
472
770
  if (touches.some(isTreeGlob)) out.push('touches 含整树 glob 会串行化整个批次——修复类任务建议精确到文件级')
473
771
  }
474
772
  return out
475
773
  }
476
774
 
775
+ // ===== touches → suggestedContextFiles 自动桥接(调研遵循·host 三件套 ①)=====
776
+ // 让遵守成为最省力路径:建卡触发「无调研上下文」warning 时,直接把 touches 里的具体文件
777
+ // 提炼成可一键采纳的 contextFiles 建议(响应挂 suggestedContextFiles 字段)。
778
+ // 只取「非 glob 的具体文件路径」:含 * ? [ ] { } 任一通配符的条目跳过(glob 指认不了单个现场);
779
+ // 锚点 :L 段剥掉(存在性是对文件而言的,建议也只到文件级)。exists(path) 由调用方注入
780
+ // (fs.existsSync + 会话工作区相对解析包装);缺省时退化为只做 glob/锚点过滤(与 epicPrecheck 同口径)。
781
+ // 去重按剥锚后的原串(不强行归一 './'——相对原样返回,调用方怎么写就怎么收)。
782
+ // 上限 20 条(与 contextFiles 上限一致);exists 抛异常视为不存在(建议错了比没有更糟,宁缺勿滥)。
783
+ export var SUGGEST_CONTEXT_MAX = 20
784
+ var GLOB_CHARS = /[*?\[\]{}]/
785
+ export function suggestContextFiles(touches, exists) {
786
+ var out = []
787
+ var seen = {}
788
+ var list = Array.isArray(touches) ? touches : []
789
+ for (var i = 0; i < list.length && out.length < SUGGEST_CONTEXT_MAX; i++) {
790
+ var raw = list[i]
791
+ if (typeof raw !== 'string' || !raw.trim()) continue
792
+ var file = parseAnchorPath(raw.trim()).file
793
+ if (!file || GLOB_CHARS.test(file)) continue
794
+ if (seen[file]) continue
795
+ if (typeof exists === 'function') {
796
+ var ok = false
797
+ try { ok = !!exists(file) } catch (_) { ok = false }
798
+ if (!ok) continue
799
+ }
800
+ seen[file] = true
801
+ out.push(file)
802
+ }
803
+ return out
804
+ }
805
+
806
+ // 建卡响应的建议桥接(task_create 工具 + create-task RPC 双通道同口径):
807
+ // warnings 里有「无调研上下文」警告且 touches 能提炼出建议时——
808
+ // ① 该条 warning 尾巴补 NO_RESEARCH_HINT(指认最省力动作);
809
+ // ② out 挂 suggestedContextFiles 字段(可选字段,老调用方无感)。
810
+ // 不触发/无建议 → 返回 null,out 与 warnings 形态完全不变(不改既有 warning 判定逻辑,只加建议通道)。
811
+ // 注意:必须在 warnings join(';') 之前调用,尾巴才会进最终 warning 文案。
812
+ export function attachContextSuggestions(out, warnings, touches, exists) {
813
+ if (!out || !Array.isArray(warnings)) return null
814
+ var idx = warnings.indexOf(NO_RESEARCH_WARNING)
815
+ if (idx < 0) return null
816
+ var suggested = suggestContextFiles(touches, exists)
817
+ if (!suggested.length) return null
818
+ warnings[idx] = NO_RESEARCH_WARNING + NO_RESEARCH_HINT
819
+ out.suggestedContextFiles = suggested
820
+ return suggested
821
+ }
822
+
477
823
  // epic 发布预检:父卡 publish 时对其子任务做轻量调研注入预检。
478
824
  // 口径:只看 pipeline≠direct 的非归档子任务;「无调研注入」= context.files/context.notes 皆空,
479
825
  // 或 files 列了路径但全部不存在(exists 回调判定,锚点 :L 段先剥掉再查——存在性是对文件而言的)。
@@ -505,3 +851,38 @@ export function epicPrecheckNote(pre) {
505
851
  var list = pre.missing.slice(0, 5).map(function (m) { return m.id + '「' + m.title + '」' + (m.reason ? '(' + m.reason + ')' : '') }).join('、')
506
852
  return 'epic 发布预检:' + pre.total + ' 个子任务中 ' + pre.missing.length + ' 个无调研注入:' + list + (pre.missing.length > 5 ? ' 等' : '') + '——建议先补 contextFiles/contextNotes 再发布'
507
853
  }
854
+
855
+ // ===== tasksHash:任务列表渲染的变更检测(轮询渲染节约,反馈 n-mut9rzs2mkhg)=====
856
+ // 背景:客户端 3s 固定全量轮询 get-tasks,任务没变也全量重渲染(实测单卡 12.7KB×20/min)。
857
+ // host 在 get-tasks 响应附 tasksHash,kernel 存 state.tasksHash,hash 相同则跳过
858
+ // state.tasks 赋值 + notify(传输仍全量,省的是渲染;短期方案)。
859
+ // 序列化口径(单测锁定):
860
+ // - 只挑「驱动列表/详情渲染」的字段:id/status/priority/title/description/parentId/tags/
861
+ // claimedBy/claimedAt/resolvedAt/verifiedAt/archivedAt/lastError/lastProgress.text/
862
+ // frozen/stuckSince/escalation.question。
863
+ // escalation 必须在内:新歧义要触发面板自动弹开,而 board_report escalate 只写
864
+ // escalation/messages/history,不动其他任何字段——漏掉它自动弹开就死了。
865
+ // childStats 由 tasks 现算,其输入(子任务 status/parentId)已随上述字段覆盖。
866
+ // - history/messages/usage/context 不参与:不驱动列表渲染,结算写账/进展消息若参与
867
+ // 会让 hash 频繁抖动,短路失效。
868
+ // - 顺序有关:按数组序拼接——看板渲染本就按数组序,顺序变化也该重渲染。
869
+ // - hash 用 djb2-xor(32 位无符号,base36 输出):实现极小零依赖。hash 只承担
870
+ // 「渲染短路」语义,不承担正确性——极低概率碰撞的最坏后果是少渲染一轮。
871
+ export function tasksHash(tasks) {
872
+ var list = Array.isArray(tasks) ? tasks : []
873
+ var parts = []
874
+ for (var i = 0; i < list.length; i++) {
875
+ var t = list[i] || {}
876
+ parts.push([
877
+ t.id, t.status, t.priority, t.title, t.description, t.parentId,
878
+ Array.isArray(t.tags) ? t.tags.join(',') : '',
879
+ t.claimedBy, t.claimedAt, t.resolvedAt, t.verifiedAt, t.archivedAt,
880
+ t.lastError, t.lastProgress && t.lastProgress.text,
881
+ t.frozen ? '1' : '', t.stuckSince, t.escalation && t.escalation.question
882
+ ].join('\u0001'))
883
+ }
884
+ var s = parts.join('\u0002')
885
+ var h = 5381
886
+ for (var j = 0; j < s.length; j++) h = (((h << 5) + h) ^ s.charCodeAt(j)) >>> 0
887
+ return h.toString(36)
888
+ }