dsh-agent-board 1.7.1 → 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)
@@ -391,18 +627,67 @@ export function buildVerifierPrompt(t, pack) {
391
627
  return p
392
628
  }
393
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
+
394
669
  // ===== 文件锁持有集合(touches 排他)=====
395
- // 仅 in-progress + claimedBy + 声明了 touches 的任务持有文件锁:
396
- // - verifying 不持有(Worker 已按契约停笔,锁随 in-progress→verifying 自动释放;
397
- // 驳回回 in-progress 时重新持有);
398
- // - 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
+ // 滞留原因对用户可见,不需要额外的超时放锁逻辑。
399
682
  // 返回 [{id, touches}],id 用于 blockedTouches.conflicts 展示"在等谁"。
400
683
  export function holdsFiles(d) {
401
684
  var out = []
402
685
  if (!d || !Array.isArray(d.tasks)) return out
403
686
  for (var i = 0; i < d.tasks.length; i++) {
404
687
  var t = d.tasks[i]
405
- 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 })
406
691
  }
407
692
  return out
408
693
  }
@@ -422,6 +707,9 @@ export function touchesConflict(t, holds) {
422
707
  // 不进 pendings,改记 [{id, conflicts:[持有任务id...]}],由 index.mjs 的 poolCycle 写展示态字段
423
708
  // t.waitingForTouches(每心跳刷新的 UI 展示,不参与其他逻辑)。verifs 不受 touches 影响(Verifier 只读)。
424
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 逐字不受影响。
425
713
  export function pickDispatch(d, capW, capV, busyTaskIds) {
426
714
  var blockedTouches = []
427
715
  var pendings = []
@@ -429,7 +717,7 @@ export function pickDispatch(d, capW, capV, busyTaskIds) {
429
717
  // 防止同一轮 cycle 派出的两个任务声明重叠 touches。
430
718
  var holds = holdsFiles(d)
431
719
  if (capW > 0) {
432
- 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 })
433
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 || '') })
434
722
  for (var i = 0; i < cands.length && pendings.length < capW; i++) {
435
723
  var conflicts = touchesConflict(cands[i], holds)
@@ -441,7 +729,7 @@ export function pickDispatch(d, capW, capV, busyTaskIds) {
441
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) : []
442
730
  return { pendings: pendings, verifs: verifs, blockedTouches: blockedTouches }
443
731
  }
444
- // 孤儿回收判定:in-progress 且 claimedBy 非主会话、无活跃 run、无 escalation、超 2 分钟
732
+ // 孤儿回收判定:in-progress 且 claimedBy 非主会话、无活跃 run、无 escalation、超 2 分钟:in-progress 且 claimedBy 非主会话、无活跃 run、无 escalation、超 2 分钟
445
733
  export function isOrphan(d, t, runs, now) {
446
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
447
735
  }