dsh-agent-board 1.7.4 → 1.7.6

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.
@@ -115,7 +115,7 @@ test('史诗收口③:cancelled 子任务计入终态完成集 → 父卡也
115
115
  assert.equal(core.isChildSettled(c1), true)
116
116
  assert.equal(core.isChildSettled(c2), true) // 人主动取消 = 该子任务范围已了结
117
117
  assert.equal(core.isChildSettled(c3), false)
118
- assert.equal(core.isChildSettled(mkTask({ status: 'archived' })), false) // 归档不算(收尾动作,不反向推动)
118
+ assert.equal(core.isChildSettled(mkTask({ status: 'archived' })), true) // 归档=人的显式了结,计入终态集(反馈 n-muw706h1uymy 根修:交错归档不再卡死父卡)
119
119
  const r = core.resolveApply(d, c3, 'w', 'verifying', 'done')
120
120
  assert.equal(r.parentUpdated, true)
121
121
  assert.equal(p.status, 'verifying')
@@ -157,6 +157,86 @@ test('史诗收口⑥:checkParentAuto 兼容名委托共享 helper(单一判
157
157
  assert.equal(p.resolution, 'all subtasks resolved') // 口径文案统一(旧 resolveApply 分支的 'all subtasks done' 已退役)
158
158
  })
159
159
 
160
+ // ===== 史诗自动收口漏触发根修(task-mux3uvx3,反馈 n-muw706h1uymy,2026-10-06 实证)=====
161
+ // 实证根因(repro 脚本逐条复现,嫌疑点逐个排除):
162
+ // ① isChildSettled 旧口径不含 archived——「resolve 一张归档一张」的交错序列下,末子卡 resolve
163
+ // 时兄卡已归档,`s.every(isChildSettled)` 永远凑不齐(resolveApply/verifyApply 的父卡钩子
164
+ // 其实都跑了,是口径让它哑火);
165
+ // ② archive 路径(task_archive 工具 / archive-task RPC)旧实现为内联代码,从不调用父卡检查——
166
+ // 末子卡归档这一下补不上收口(僵尸子卡直接出清链路更是一直缺这钩子)。
167
+ // 嫌疑排除:settleVerifier→verifyApply 路径的父卡钩子(checkParentAuto)一直在,非挂点。
168
+ // 修法:isChildSettled 计入 archived(与 childStats 进度口径同源)+ archiveApply 共享 helper
169
+ // (归档落定唯一入口)尾部挂 maybeAutoCloseParent。
170
+ test('史诗收口⑦:交错 resolve+archive 序列——末子卡 resolve 时兄卡已归档也能收口(根因复现①)', () => {
171
+ const p = mkTask({ id: 'epic', status: 'in-progress' })
172
+ const c1 = mkTask({ id: 'c1', parentId: 'epic', status: 'in-progress', pipeline: 'work' })
173
+ const c2 = mkTask({ id: 'c2', parentId: 'epic', status: 'in-progress', pipeline: 'work' })
174
+ const c3 = mkTask({ id: 'c3', parentId: 'epic', status: 'in-progress', pipeline: 'work' })
175
+ const d = mkBoard([p, c1, c2, c3])
176
+ core.resolveApply(d, c1, 'w', 'verifying', 'done'); core.archiveApply(d, c1, 'tester')
177
+ assert.equal(c1.status, 'archived')
178
+ assert.equal(p.status, 'in-progress') // 未凑齐不收口(c2/c3 仍在跑)
179
+ core.resolveApply(d, c2, 'w', 'verifying', 'done'); core.archiveApply(d, c2, 'tester')
180
+ assert.equal(p.status, 'in-progress')
181
+ const r = core.resolveApply(d, c3, 'w', 'verifying', 'done') // 此刻 c1/c2 已 archived
182
+ assert.equal(r.parentUpdated, true) // 旧口径此处为 undefined → epic 永久卡死
183
+ assert.equal(p.status, 'verifying')
184
+ assert.equal(p.resolution, 'all subtasks resolved')
185
+ })
186
+
187
+ test('史诗收口⑧:archiveApply 归档路径本身触发父卡检查——末子卡归档即收口(根因复现②,僵尸出清链路)', () => {
188
+ const p = mkTask({ id: 'epic', status: 'in-progress' })
189
+ const c1 = mkTask({ id: 'c1', parentId: 'epic', status: 'resolved' })
190
+ const c2 = mkTask({ id: 'c2', parentId: 'epic', status: 'in-progress', claimedBy: null })
191
+ const d = mkBoard([p, c1, c2])
192
+ const r = core.archiveApply(d, c2, 'tester') // c2 从僵尸 in-progress 直接归档(v1.7.1 archiveErr 放行面)
193
+ assert.equal(c2.status, 'archived'); assert.ok(c2.archivedAt)
194
+ assert.equal(r.parentUpdated, true) // 归档这一下补齐收口(旧实现无父卡钩子)
195
+ assert.equal(p.status, 'verifying')
196
+ assert.equal(p.resolution, 'all subtasks resolved')
197
+ // 顶层卡归档(无 parentId)不触父卡检查、返回体无 parentUpdated
198
+ const solo = mkTask({ id: 'solo', status: 'resolved' })
199
+ const r2 = core.archiveApply(mkBoard([solo]), solo, 'tester')
200
+ assert.equal(solo.status, 'archived'); assert.equal('parentUpdated' in r2, false)
201
+ })
202
+
203
+ test('史诗收口⑨:archiveApply 级联归档父卡不误触收口 + history/note 口径保持', () => {
204
+ // 父卡归档 → 级联子卡;父卡自身无 parentId(maybeAutoCloseParent 第一闸门拦),
205
+ // 级联子卡也不做父卡检查(父卡已 archived 非 in-progress,检查也必返回 null)——零意外流转
206
+ const p = mkTask({ id: 'epic', status: 'in-progress' })
207
+ const c1 = mkTask({ id: 'c1', parentId: 'epic', status: 'resolved' })
208
+ const c2 = mkTask({ id: 'c2', parentId: 'epic', status: 'in-progress', claimedBy: null })
209
+ const d = mkBoard([p, c1, c2])
210
+ const r = core.archiveApply(d, p, 'tester', 'manual archive')
211
+ assert.equal(p.status, 'archived')
212
+ assert.equal(r.childrenArchived, 2)
213
+ assert.equal(c1.status, 'archived'); assert.equal(c2.status, 'archived')
214
+ assert.equal('parentUpdated' in r, false)
215
+ assert.equal(p.history[0].note, 'manual archive') // note 参数透传(RPC 'manual archive' / 工具 'archived')
216
+ assert.equal(c2.history[c2.history.length - 1].note, 'cascade')
217
+ })
218
+
219
+ test('史诗收口⑩:post hook 未收口的 epic 走归档路径同样只挂闸门不转 verifying(hooks 语义不变)', () => {
220
+ const epi = mkTask({ id: 'epic', status: 'in-progress', hooks: { post: { enabled: true, prompt: '收口', state: 'idle' } } })
221
+ const c1 = mkTask({ id: 'c1', parentId: 'epic', status: 'resolved' })
222
+ const c2 = mkTask({ id: 'c2', parentId: 'epic', status: 'resolved' })
223
+ const d = mkBoard([epi, c1, c2])
224
+ const r = core.archiveApply(d, c2, 'tester') // 末子卡归档触发父卡检查
225
+ assert.equal(r.parentUpdated, true) // 检查跑了(返回父卡)
226
+ assert.equal(epi.status, 'in-progress') // 但不直接转 verifying——post 闸门照旧
227
+ assert.equal(epi.hooks.post.pending, true)
228
+ assert.equal(epi.hooks.post.state, 'running')
229
+ assert.equal(epi.resolution, undefined)
230
+ // post 已 done 的 epic:归档路径正常收口(与 resolve/verify 路径同口径)
231
+ const e2 = mkTask({ id: 'e2', status: 'in-progress', hooks: { post: { enabled: true, prompt: '收口', state: 'done' } } })
232
+ const k1 = mkTask({ id: 'k1', parentId: 'e2', status: 'archived' })
233
+ const k2 = mkTask({ id: 'k2', parentId: 'e2', status: 'resolved' })
234
+ const d2 = mkBoard([e2, k1, k2])
235
+ const r2 = core.archiveApply(d2, k2, 'tester')
236
+ assert.equal(r2.parentUpdated, true)
237
+ assert.equal(e2.status, 'verifying')
238
+ })
239
+
160
240
  // ===== 依赖 =====
161
241
  test('validateDeps: 自引用/不存在/成环 全拒绝', () => {
162
242
  const a = mkTask({ id: 'a' }); const b = mkTask({ id: 'b', dependsOn: ['a'] })
@@ -223,6 +303,135 @@ test('parseSections: diff 概要分段解析(Worker 变更概要 → deliverab
223
303
  assert.equal(s.summary, '做了')
224
304
  })
225
305
 
306
+ // ===== Verifier 自测指南(verifyUserGuide,task-muxyyvg0):契约解析 / 开关门禁 / 落账 =====
307
+ test('自测指南①:parseSections 收「## 自测指南」段 → userTest 四字段 + tier 三档', () => {
308
+ // ui 档:四字段齐全(编号列表 steps)
309
+ const s1 = core.parseSections('## 验证概要\n通过\n\n## 自测指南\ngist: 看板卡片加了徽章\ntier: ui\nsteps:\n1. 打开看板面板\n2. 看卡片标题右侧\nexpect: 应看到绿色徽章')
310
+ assert.ok(s1.userTest, '自测指南段必须解析出 userTest')
311
+ assert.equal(s1.userTest.gist, '看板卡片加了徽章')
312
+ assert.equal(s1.userTest.tier, 'ui')
313
+ assert.deepEqual(s1.userTest.steps, ['打开看板面板', '看卡片标题右侧'])
314
+ assert.equal(s1.userTest.expect, '应看到绿色徽章')
315
+ // metric 档 + 横杠列表 + 全角冒号
316
+ const s2 = core.parseSections('## 自测指南\ngist: Token 区多了主窗口行\ntier:metric\nsteps:\n- 打开仪表盘\n- 看 Token 区\nexpect: 出现「主窗口(本会话)」行')
317
+ assert.equal(s2.userTest.tier, 'metric')
318
+ assert.deepEqual(s2.userTest.steps, ['打开仪表盘', '看 Token 区'])
319
+ // internal 档:steps 可空,expect 写「验证靠测试套件」
320
+ const s3 = core.parseSections('## 自测指南\ngist: host 聚合口径修正\ntier: internal\nexpect: 验证靠测试套件')
321
+ assert.equal(s3.userTest.tier, 'internal')
322
+ assert.deepEqual(s3.userTest.steps, [])
323
+ // tier 非法/脏值 → 归 internal(最保守档,不伪造用户可感知面)
324
+ const s4 = core.parseSections('## 自测指南\ngist: x\ntier: 界面级\nexpect: y')
325
+ assert.equal(s4.userTest.tier, 'internal')
326
+ // 「## 自测情况」(Worker 段)不串进 userTest;「## 自测指南」不串进 selfTest(标题口径隔离)
327
+ const s5 = core.parseSections('## 自测情况\nnpm test pass')
328
+ assert.equal(s5.selfTest, 'npm test pass'); assert.equal(s5.userTest, undefined)
329
+ const s6 = core.parseSections('## 自测指南\ngist: g\ntier: ui\nexpect: e')
330
+ assert.equal(s6.selfTest, undefined); assert.ok(s6.userTest)
331
+ // 缺段 → 字段不挂
332
+ assert.equal(core.parseSections('## 验证概要\n通过').userTest, undefined)
333
+ // 段体四字段全空 → 不挂字段(视为没交指南)
334
+ assert.equal(core.parseSections('## 自测指南\n(无)').userTest, undefined)
335
+ })
336
+
337
+ test('自测指南②:normalizeUserTest 归一(非对象/空指南→null;tier 归档;长度封顶)', () => {
338
+ assert.equal(core.normalizeUserTest(null), null)
339
+ assert.equal(core.normalizeUserTest('ui'), null)
340
+ assert.equal(core.normalizeUserTest({}), null) // 空指南不落
341
+ assert.equal(core.normalizeUserTest({ gist: ' ', steps: [], expect: '' }), null)
342
+ const ut = core.normalizeUserTest({ gist: ' 改了 x ', steps: [' 第一步 ', '', null, '第二步'], expect: ' 看到 y ', tier: 'UI' })
343
+ assert.deepEqual(ut, { gist: '改了 x', steps: ['第一步', '第二步'], expect: '看到 y', tier: 'ui' })
344
+ assert.equal(core.normalizeUserTest({ gist: 'g', tier: 'ui(界面可操作)' }).tier, 'ui') // tier 取首词
345
+ assert.equal(core.normalizeUserTest({ gist: 'g', tier: 'bogus' }).tier, 'internal') // 非法归 internal
346
+ assert.equal(core.normalizeUserTest({ gist: 'g' }).tier, 'internal') // 缺省归 internal
347
+ // 封顶:gist ≤300 / expect ≤600 / steps ≤12 条每条 ≤300
348
+ const big = core.normalizeUserTest({ gist: 'g'.repeat(400), expect: 'e'.repeat(700), steps: Array(20).fill('s'.repeat(400)) })
349
+ assert.equal(big.gist.length, 300); assert.equal(big.expect.length, 600)
350
+ assert.equal(big.steps.length, 12); assert.equal(big.steps[0].length, 300)
351
+ })
352
+
353
+ test('自测指南③:buildVerifierPrompt 开关门禁——默认含指南段+诚实护栏;显式 false 整段消失', () => {
354
+ const t = mkTask({ id: 'tx', status: 'verifying' })
355
+ const on = core.buildVerifierPrompt(t, '')
356
+ assert.match(on, /## 自测指南/) // 指南段在 prompt 里
357
+ assert.match(on, /gist: <一句人话/) // 四字段格式
358
+ assert.match(on, /tier: ui \| metric \| internal/) // 三档分级
359
+ assert.match(on, /诚实护栏/) // 诚实护栏写进 prompt
360
+ assert.match(on, /不许编造没验过的操作/)
361
+ assert.match(on, /验证靠测试套件/) // internal 档口径
362
+ assert.match(on, /userTest 参数/) // 工具通道说明
363
+ assert.ok(on.indexOf('结论契约') < on.indexOf('自测指南(给用户看的验收指引'), '指南段追加在 prompt 末尾(结论契约之后)')
364
+ const off = core.buildVerifierPrompt(t, '', false) // 开关关 → 整段不进 prompt(省 token)
365
+ assert.doesNotMatch(off, /自测指南/)
366
+ assert.doesNotMatch(off, /userTest/)
367
+ assert.match(off, /结论契约/) // 其余契约逐字不变
368
+ assert.equal(core.buildVerifierPrompt(t, ''), on) // 缺省第三参 = 开
369
+ })
370
+
371
+ test('自测指南④:文本结算落账——开关开 userTest 落 t.verification;开关关不挂字段(端到端真 settle)', async () => {
372
+ const GUIDE = '\n\n## 自测指南\ngist: 卡片加了徽章\ntier: ui\nsteps:\n1. 打开看板\n2. 看徽章\nexpect: 看到绿徽章'
373
+ async function waitVerification(t) { // 与 waitRej 同口径:轮询等 settle 链尾,防固定 sleep 抖动
374
+ var t0 = Date.now()
375
+ while (Date.now() - t0 < 2000) { if (t.verification && t.status === 'resolved') return true; await new Promise(function (r) { setTimeout(r, 5) }) }
376
+ return !!(t.verification && t.status === 'resolved')
377
+ }
378
+ // 开关开(缺省):APPROVED + 指南段 → userTest 落 t.verification
379
+ const t1 = mkTask({ id: 'ug1', status: 'verifying' })
380
+ const h1 = mkVerifierSettleDispatch(mkBoard([t1]))
381
+ await h1.dispatch.poolCycle(FULL_SID)
382
+ h1.settle('APPROVED\n\n## 验证概要\n复跑通过' + GUIDE)
383
+ assert.equal(await waitVerification(t1), true)
384
+ assert.deepEqual(t1.verification.userTest, { gist: '卡片加了徽章', steps: ['打开看板', '看徽章'], expect: '看到绿徽章', tier: 'ui' })
385
+ // 开关关:输出即使带指南段也不挂字段(省 token 口径的落账侧门禁)
386
+ const t2 = mkTask({ id: 'ug2', status: 'verifying' })
387
+ const board2 = mkBoard([t2]); board2.verifyUserGuide = false
388
+ const h2 = mkVerifierSettleDispatch(board2)
389
+ await h2.dispatch.poolCycle(FULL_SID)
390
+ h2.settle('APPROVED\n\n## 验证概要\n复跑通过' + GUIDE)
391
+ assert.equal(await waitVerification(t2), true)
392
+ assert.equal(t2.verification.userTest, undefined, '开关关 → 落账不挂 userTest')
393
+ // 开关开但缺段 → 不挂字段
394
+ const t3 = mkTask({ id: 'ug3', status: 'verifying' })
395
+ const h3 = mkVerifierSettleDispatch(mkBoard([t3]))
396
+ await h3.dispatch.poolCycle(FULL_SID)
397
+ h3.settle('APPROVED\n\n## 验证概要\n复跑通过')
398
+ assert.equal(await waitVerification(t3), true)
399
+ assert.equal(t3.verification.userTest, undefined, '缺段 → 不挂 userTest')
400
+ })
401
+
402
+ test('自测指南⑤:board_verdict 工具通道——userTest 参数落账 + 开关门禁 + 非法 tier 归 internal', async () => {
403
+ const UT = { gist: '报告多了自测清单', steps: ['打开仪表盘', '点生成报告'], expect: '报告含「本版自测清单」段', tier: 'ui' }
404
+ const b1 = mkBoard([mkTask({ id: 'g1', status: 'verifying' })])
405
+ const r1 = await mkRpcHandlers(b1).__tools['board_verdict'].execute({ taskId: 'g1', verdict: 'approved', summary: '通过', userTest: UT }, {})
406
+ assert.equal(r1.ok, true)
407
+ assert.deepEqual(b1.tasks[0].verification.userTest, UT, '工具通道 userTest 原样落账(归一后)')
408
+ // 开关关:参数带了也不挂字段
409
+ const b2 = mkBoard([mkTask({ id: 'g2', status: 'verifying' })]); b2.verifyUserGuide = false
410
+ await mkRpcHandlers(b2).__tools['board_verdict'].execute({ taskId: 'g2', verdict: 'approved', summary: '通过', userTest: UT }, {})
411
+ assert.equal(b2.tasks[0].verification.userTest, undefined, '开关关 → 工具通道也不挂 userTest')
412
+ // 非法 tier 归 internal;空指南(全空字段)不落
413
+ const b3 = mkBoard([mkTask({ id: 'g3', status: 'verifying' })])
414
+ await mkRpcHandlers(b3).__tools['board_verdict'].execute({ taskId: 'g3', verdict: 'approved', summary: '通过', userTest: { gist: '内部重构', tier: ' bogus ', expect: '验证靠测试套件' } }, {})
415
+ assert.equal(b3.tasks[0].verification.userTest.tier, 'internal')
416
+ const b4 = mkBoard([mkTask({ id: 'g4', status: 'verifying' })])
417
+ await mkRpcHandlers(b4).__tools['board_verdict'].execute({ taskId: 'g4', verdict: 'approved', summary: '通过', userTest: { gist: ' ', steps: [] } }, {})
418
+ assert.equal(b4.tasks[0].verification.userTest, undefined, '空指南不落字段')
419
+ })
420
+
421
+ test('自测指南⑥:cfg/seed/normalizeBoard 三处默认开——老看板缺字段补 true,显式 false 才关', () => {
422
+ assert.equal(core.cfg({}).verifyUserGuide, true) // 缺省开
423
+ assert.equal(core.cfg({ verifyUserGuide: false }).verifyUserGuide, false)
424
+ assert.equal(core.cfg({ verifyUserGuide: 0 }).verifyUserGuide, true) // 脏值非 false → 开(!== false 口径)
425
+ assert.equal(core.seed('s-x').verifyUserGuide, true) // 新板种子自带
426
+ const old = { poolStatus: { workers: [], verifiers: [] }, tasks: [] } // 老看板无字段
427
+ core.normalizeBoard(old)
428
+ assert.equal(old.verifyUserGuide, true) // 读路径补 true
429
+ const off = { verifyUserGuide: false, tasks: [] }
430
+ core.normalizeBoard(off)
431
+ assert.equal(off.verifyUserGuide, false) // 显式 false 不被覆盖
432
+ })
433
+
434
+
226
435
  test('parseVerdict: 行首锚定,历史提及不误判', () => {
227
436
  assert.equal(core.parseVerdict('APPROVED: 通过'), 'APPROVED')
228
437
  assert.equal(core.parseVerdict('REJECTED: 不达标'), 'REJECTED')
@@ -271,6 +480,27 @@ test('buildVerifierPrompt: 注入交付物 + 验收脚本 + messages', () => {
271
480
  assert.match(p, /做完了/); assert.match(p, /npm test/); assert.match(p, /board_verdict/); assert.match(p, /方案B/)
272
481
  })
273
482
 
483
+ test('buildWorkerPrompt: git 纪律红线(禁还原命令)注入,verifier prompt 不注入(源码断言)', () => {
484
+ const t = mkTask({ id: 'g1' })
485
+ const p = core.buildWorkerPrompt(t)
486
+ assert.match(p, /git 纪律红线/)
487
+ assert.match(p, /禁止 git checkout \/ git restore \/ git reset --hard \/ git clean 等还原命令/)
488
+ assert.match(p, /会冲掉并行 Worker 与你自己的未提交在途编辑/)
489
+ assert.match(p, /确实需要干净基线时用 board_report(kind=escalate)上报/)
490
+ // verifier prompt 不动
491
+ const pv = core.buildVerifierPrompt(t)
492
+ assert.doesNotMatch(pv, /git 纪律红线/)
493
+ assert.doesNotMatch(pv, /reset --hard/)
494
+ // 源码级:条款只在 buildWorkerPrompt 体内(切片边界到 buildVerifierPrompt 之前),不在 buildVerifierPrompt 体内
495
+ const coreSrc = readFileSync(new URL('../lib/core.mjs', import.meta.url), 'utf8')
496
+ const wBody = coreSrc.slice(coreSrc.indexOf('export function buildWorkerPrompt'), coreSrc.indexOf('export function buildVerifierPrompt'))
497
+ assert.ok(wBody.length > 500, 'buildWorkerPrompt 切片成功')
498
+ assert.match(wBody, /git 纪律红线/)
499
+ assert.match(wBody, /git reset --hard/)
500
+ const vBody = coreSrc.slice(coreSrc.indexOf('export function buildVerifierPrompt'), coreSrc.indexOf('export function buildHookPrompt'))
501
+ assert.doesNotMatch(vBody, /git 纪律红线/)
502
+ })
503
+
274
504
  // ===== 预研上下文段(瘦身分离形态,task-muvjs392):笔记全文 + 文件清单,不含文件内容本体 =====
275
505
  // 形态:### 主窗口调研笔记(全文 ≤8000 字符)\n…\n\n### 调研文件清单(- 路径:L起-L止 — 一句用途)
276
506
  test('buildContextPackSection: 空清单返回空串', () => {
@@ -384,7 +614,7 @@ test('注入迁移(task-muvjs392):清单进首条 prompt,注入区块通
384
614
  assert.doesNotMatch(dsp, /ctx\.fs|fs\.readText|sliceLines|buildFileOutline/)
385
615
  // ③ 瘦身清单本体直接拼进 worker/verifier 两态 prompt(三态其余不变:hook 仍走 buildHookPrompt)
386
616
  assert.match(dsp, /buildWorkerPrompt\(t, pack, cfg\(dsnap\)\.feedbackEnabled\)/)
387
- assert.match(dsp, /buildVerifierPrompt\(t, pack\)/)
617
+ assert.match(dsp, /buildVerifierPrompt\(t, pack, cfg\(dsnap\)\.verifyUserGuide\)/)
388
618
  assert.match(dsp, /buildHookPrompt\(t, role === 'hook-pre' \? 'pre' : 'post', kids\)/)
389
619
  // ④ readContextPack = 组装清单(不再 await 读盘)——派发点与 rpc 预览点都直接调用
390
620
  assert.match(dsp, /function readContextPack\(t\) \{/)
@@ -1088,6 +1318,103 @@ test('create-task RPC: 空描述 + 无调研上下文 + 整树 glob → 三条
1088
1318
  assert.equal(r.warning.split(';').length, 3)
1089
1319
  })
1090
1320
 
1321
+ // ===== 任务起草 lint(proposal n-muwlpbspl3w2):四规则 lintWarnings[],不阻塞 =====
1322
+ // 与「调研门禁」(createTaskWarnings,瞬态 warning)正交:这是起草质量持久诊断(存 task.lintWarnings[],
1323
+ // get-tasks 透出、详情页 ⚠️ 行)。纯函数 draftLint 在 core.mjs,双仓库歧义读盘经 io 注入。
1324
+
1325
+ test('touchDirPrefix: 剥 glob 尾段得目录前缀(裸文件名/纯 ** 无锚点 → 空)', () => {
1326
+ assert.equal(core.touchDirPrefix('packages/dsh-agent-board/**'), 'packages/dsh-agent-board')
1327
+ assert.equal(core.touchDirPrefix('src/**'), 'src')
1328
+ assert.equal(core.touchDirPrefix('src/x.mjs'), 'src')
1329
+ assert.equal(core.touchDirPrefix('a/b/c.mjs'), 'a/b')
1330
+ assert.equal(core.touchDirPrefix('core.mjs'), '') // 裸文件名无目录锚点,不参与判重
1331
+ assert.equal(core.touchDirPrefix('**'), '')
1332
+ })
1333
+
1334
+ test('ambiguousTouch: ≥2 子目录同名命中 → 返回相对路径原文;绝对路径/盘符/裸名/缺 io → null', () => {
1335
+ const two = { root: 'ws', subdirs: ['task-board-plugin', 'task-board-federation', 'other'], dirExists: (p) => p === path.join('ws', 'task-board-plugin', 'packages/dsh-agent-board') || p === path.join('ws', 'task-board-federation', 'packages/dsh-agent-board') }
1336
+ assert.equal(core.ambiguousTouch(['packages/dsh-agent-board/core.mjs'], two), 'packages/dsh-agent-board/core.mjs')
1337
+ const one = { root: 'ws', subdirs: ['task-board-plugin', 'other'], dirExists: (p) => p === path.join('ws', 'task-board-plugin', 'packages/dsh-agent-board') }
1338
+ assert.equal(core.ambiguousTouch(['packages/dsh-agent-board/core.mjs'], one), null)
1339
+ assert.equal(core.ambiguousTouch(['/abs/pkg/core.mjs'], two), null) // 绝对路径不判
1340
+ assert.equal(core.ambiguousTouch(['C:\\abs\\pkg\\core.mjs'], two), null) // 盘符不判
1341
+ assert.equal(core.ambiguousTouch(['core.mjs'], two), null) // 裸文件名不判
1342
+ assert.equal(core.ambiguousTouch(['src/**'], null), null) // 缺 io → 零 IO 安全
1343
+ assert.equal(core.ambiguousTouch(['src/**'], { subdirs: ['a', 'b'] }), null) // 缺 dirExists → 退化为不触发
1344
+ })
1345
+
1346
+ test('draftLint: 四规则各一触发 + 各一不触发(纯函数矩阵)', () => {
1347
+ const io2 = { root: 'ws', subdirs: ['a', 'b'], dirExists: (p) => p === path.join('ws', 'a', 'pkg') || p === path.join('ws', 'b', 'pkg') }
1348
+ // ① 整树 glob:触发 / 不触发(精确到文件级不触发)
1349
+ const t1 = mkTask({ title: '修复样式', description: 'd', acceptance: 'node --test', touches: ['src/**'] })
1350
+ assert.ok(core.draftLint(t1).includes(core.TREE_GLOB_LINT))
1351
+ assert.ok(!core.draftLint(mkTask({ title: '修复样式', description: 'd', acceptance: 'node --test', touches: ['src/x.mjs'] })).includes(core.TREE_GLOB_LINT))
1352
+ // ② 双仓库歧义:触发 / 不触发(目录前缀在两个子目录下都存在才算歧义)
1353
+ const t2 = mkTask({ title: '修复 x', description: 'd', acceptance: 'node --test', touches: ['pkg/core.mjs'] })
1354
+ assert.match(core.draftLint(t2, io2).find((w) => w.indexOf(core.AMBIGUOUS_PATH_LINT) === 0), /pkg\/core\.mjs/)
1355
+ const io1 = { root: 'ws', subdirs: ['a', 'b'], dirExists: (p) => p === path.join('ws', 'a', 'pkg') }
1356
+ assert.equal(core.draftLint(t2, io1).find((w) => w.indexOf(core.AMBIGUOUS_PATH_LINT) === 0), undefined)
1357
+ // ③ full 无验收:触发 / 不触发(work 档或有验收不触发)
1358
+ assert.ok(core.draftLint(mkTask({ title: '修复 x', description: 'd', pipeline: 'full', acceptance: '' })).includes(core.FULL_NO_ACCEPTANCE_LINT))
1359
+ assert.ok(!core.draftLint(mkTask({ title: '修复 x', description: 'd', pipeline: 'work', acceptance: '' })).includes(core.FULL_NO_ACCEPTANCE_LINT))
1360
+ assert.ok(!core.draftLint(mkTask({ title: '修复 x', description: 'd', pipeline: 'full', acceptance: 'node --test' })).includes(core.FULL_NO_ACCEPTANCE_LINT))
1361
+ // ④ 标题缺动词 / 描述空:触发 / 不触发
1362
+ const t4 = mkTask({ title: '看板规则', description: 'd', acceptance: 'node --test' })
1363
+ assert.ok(core.draftLint(t4).includes(core.TITLE_NO_VERB_LINT))
1364
+ const t4n = mkTask({ title: '修复 lint 规则', description: '明确描述', acceptance: 'node --test' })
1365
+ assert.ok(!core.draftLint(t4n).includes(core.TITLE_NO_VERB_LINT))
1366
+ assert.ok(!core.draftLint(t4n).includes(core.EMPTY_DESC_WARNING))
1367
+ assert.ok(core.draftLint(mkTask({ title: '修复 x', description: '', acceptance: 'node --test' })).includes(core.EMPTY_DESC_WARNING))
1368
+ })
1369
+
1370
+ test('create-task RPC: 起草 lint 落卡 lintWarnings[],get-tasks 透出(不阻断创建)', async () => {
1371
+ const board = mkBoard([])
1372
+ const h = mkRpcHandlers(board)
1373
+ const r = await h['create-task']({ title: '修复 lint 规则', description: '明确描述', touches: ['src/**'], acceptance: 'node --test' })
1374
+ assert.equal(r.ok, true)
1375
+ assert.deepEqual(r.task.lintWarnings, [core.TREE_GLOB_LINT])
1376
+ const got = await h['get-tasks']({})
1377
+ assert.deepEqual(got.tasks[0].lintWarnings, [core.TREE_GLOB_LINT])
1378
+ })
1379
+
1380
+ test('update-task RPC: 改标题后重算 lintWarnings(无动词标题触发,改回动词清空)', async () => {
1381
+ const board = mkBoard([mkTask({ id: 't1', title: '修复 x', description: 'd', acceptance: 'node --test' })])
1382
+ const h = mkRpcHandlers(board)
1383
+ assert.equal(board.tasks[0].lintWarnings, undefined) // 直接建卡无字段(老卡零字段)
1384
+ const r = await h['update-task']({ taskId: 't1', title: '看板规则' })
1385
+ assert.deepEqual(r.task.lintWarnings, [core.TITLE_NO_VERB_LINT])
1386
+ const r2 = await h['update-task']({ taskId: 't1', title: '修复 lint 规则' })
1387
+ assert.deepEqual(r2.task.lintWarnings, [])
1388
+ })
1389
+
1390
+ test('起草 lint:老卡无 lintWarnings 字段 → get-tasks 照常返回不炸(零字段兼容)', async () => {
1391
+ const board = mkBoard([mkTask({ id: 'old', title: '老卡', description: 'd', acceptance: 'node --test' })])
1392
+ const h = mkRpcHandlers(board)
1393
+ const got = await h['get-tasks']({})
1394
+ assert.equal(got.tasks.length, 1)
1395
+ assert.equal(got.tasks[0].lintWarnings, undefined) // 不补造字段,undefined 不炸
1396
+ })
1397
+
1398
+ test('起草 lint 接线:四入口 draftLint 落卡 + 详情 ⚠️ 行 + README 双份(源码级断言)', () => {
1399
+ const host = hostSrc()
1400
+ assert.equal((host.match(/t\.lintWarnings = draftLint\(t, draftLintIo\(sid, d\.ownerCwd\)/g) || []).length, 4) // task_create/create-task/task_update/update-task 四入口
1401
+ assert.equal((host.match(/function draftLintIo\(/g) || []).length, 1) // IO 注入 helper 单一出处
1402
+ const coreSrc = readFileSync(new URL('../lib/core.mjs', import.meta.url), 'utf8')
1403
+ assert.match(coreSrc, /export function draftLint\(t, io\)/)
1404
+ assert.match(coreSrc, /export function ambiguousTouch\(touches, io\)/)
1405
+ assert.match(coreSrc, /export function touchDirPrefix\(p\)/)
1406
+ assert.match(coreSrc, /TREE_GLOB_LINT = 'touches 粒度过粗,几乎锁整仓'/)
1407
+ assert.match(coreSrc, /AMBIGUOUS_PATH_LINT = '路径双仓库歧义,建议加仓库前缀'/)
1408
+ assert.match(coreSrc, /FULL_NO_ACCEPTANCE_LINT = 'full 管线建议带验收命令'/)
1409
+ assert.match(coreSrc, /TITLE_NO_VERB_LINT = '标题缺动作动词/)
1410
+ const detail = readFileSync(new URL('../lib/client/task-detail.js', import.meta.url), 'utf8')
1411
+ assert.match(detail, /task\.lintWarnings\.map\(function \(w, i\)/) // 详情 ⚠️ 行小块
1412
+ const pkgReadme = readFileSync(new URL('../README.md', import.meta.url), 'utf8')
1413
+ const rootReadme = readFileSync(new URL('../../../README.md', import.meta.url), 'utf8')
1414
+ assert.equal((pkgReadme.match(/lintWarnings\[\]/g) || []).length, 2) // 包 README 双处(调研门禁 + 三档通用)
1415
+ assert.equal((rootReadme.match(/lintWarnings\[\]/g) || []).length, 2) // 根 README 同步双处
1416
+ })
1417
+
1091
1418
  test('epicPrecheck: 字段有无 + 路径存在性(exists 注入),direct/归档子任务不参与', () => {
1092
1419
  const kids = [
1093
1420
  mkTask({ id: 'k1', title: '无材料', parentId: 'ep', context: { files: [], notes: '' } }),
@@ -1392,6 +1719,79 @@ test('buildVerifierPrompt: 含「立单缺调研」驳回归因条款', () => {
1392
1719
  assert.match(p, /驳回热点统计/)
1393
1720
  })
1394
1721
 
1722
+ // ===== Verifier 验收员加餐(task-muy3gm03):固定 persona + 文件纪律 + toolFilter 收窄 =====
1723
+ test('Verifier 加餐①:VERIFIER_PERSONA 常量——验收员四要点 + 无 {{ 连写括号(严格插值护栏)', () => {
1724
+ const ps = core.VERIFIER_PERSONA
1725
+ assert.equal(typeof ps, 'string')
1726
+ assert.match(ps, /独立验收员/) // 人设定位
1727
+ assert.match(ps, /独立判断/) // ① 独立判断
1728
+ assert.match(ps, /不轻信/) // ① 不轻信 Worker 汇报
1729
+ assert.match(ps, /实证为准/) // ② 以验收脚本实证为准
1730
+ assert.match(ps, /诚实分级/) // ③ 诚实分级(自测指南三档)
1731
+ assert.match(ps, /双读者/) // ④ 结论给主窗口与用户双读者
1732
+ // 宿主对 persona 做严格 {{…}} 插值(同 deployment persona 模板语义)——文本不得含连写 {
1733
+ assert.ok(!/\{\{/.test(ps), 'persona 文本不得含 {{(严格插值会抛异常)')
1734
+ })
1735
+
1736
+ test('Verifier 加餐②:文件纪律条款在 verifier prompt;worker prompt 不含(parity)', () => {
1737
+ const t = mkTask({ id: 'tx', status: 'verifying' })
1738
+ const pv = core.buildVerifierPrompt(t, '')
1739
+ assert.match(pv, /文件纪律/) // 条款标题
1740
+ assert.match(pv, /可以写必要的临时\/测试文件/) // 允许写临时/测试文件
1741
+ assert.match(pv, /_scratch/) // 临时文件落点
1742
+ assert.match(pv, /验收结束后自行清理/) // 验收结束清理
1743
+ assert.match(pv, /禁止改动工程代码与文档/) // 工程代码/文档只读
1744
+ assert.match(pv, /touches 审计兜底/) // 事后兜底威慑写明
1745
+ // 条款在结论契约之前(纪律先行)
1746
+ assert.ok(pv.indexOf('文件纪律') < pv.indexOf('结论契约'), '文件纪律段先于结论契约')
1747
+ // worker prompt 逐字 parity:不含任何文件纪律/加餐条款
1748
+ const pw = core.buildWorkerPrompt(mkTask({ id: 'tx', status: 'pending' }), '')
1749
+ assert.doesNotMatch(pw, /文件纪律/)
1750
+ assert.doesNotMatch(pw, /touches 审计/)
1751
+ })
1752
+
1753
+ test('Verifier 加餐③:toolFilter 保守收窄——只 deny 联网检索,无 allow 白名单(注释锁定保守理由)', () => {
1754
+ // 白名单断言:收窄为 deny 形态,只砍 web_fetch/web_search(验收不需联网)
1755
+ assert.deepEqual(core.VERIFIER_TOOL_FILTER, { deny: ['web_fetch', 'web_search'] })
1756
+ assert.equal(core.VERIFIER_TOOL_FILTER.allow, undefined, '不设 allow 白名单(拿不准的宁可不收窄)')
1757
+ // 注释存在性断言:core.mjs 写明保守理由(其余工具保留的逐类口径)
1758
+ const coreSrc = readFileSync(new URL('../lib/core.mjs', import.meta.url), 'utf8')
1759
+ assert.match(coreSrc, /toolFilter 收窄评估结论(保守口径)/)
1760
+ assert.match(coreSrc, /拿不准的宁可不收窄/)
1761
+ })
1762
+
1763
+ test('Verifier 加餐④:真派发接线——verifier spawn 挂 persona/toolFilter,worker spawn 逐字 parity', async () => {
1764
+ // 真跑 poolCycle:一张 verifying 卡(派 verifier)+ 一张 pending 卡(派 worker,continuable 路径)
1765
+ const tv = mkTask({ id: 'v1', status: 'verifying' })
1766
+ const tw = mkTask({ id: 'w1', status: 'pending' })
1767
+ const h = mkHookDispatch(mkBoard([tv, tw]))
1768
+ await h.dispatch.poolCycle(FULL_SID)
1769
+ const v = h.spawned.find((s) => s.label === 'verifier:v1')
1770
+ assert.ok(v, 'verifier 按真实派发路径 spawn(一次性 start)')
1771
+ assert.equal(v.persona, core.VERIFIER_PERSONA, 'verifier spawn 挂固定验收员 persona')
1772
+ assert.deepEqual(v.toolFilter, { deny: ['web_fetch', 'web_search'] }, 'verifier spawn 挂收窄 toolFilter')
1773
+ const w = h.spawnedContinuable.find((s) => s.label === 'worker:w1')
1774
+ assert.ok(w, 'worker 按真实派发路径 spawn(continuable)')
1775
+ assert.equal(w.persona, undefined, 'worker spawn 不挂 persona(逐字 parity)')
1776
+ assert.equal(w.toolFilter, undefined, 'worker spawn 不挂 toolFilter(逐字 parity)')
1777
+ // hook run 也不挂(hook 只吃 buildHookPrompt 薄框架,不加验收员人设)
1778
+ assert.ok(h.spawned.every((s) => s.label.indexOf('hook') !== 0 || s.persona === undefined), 'hook spawn 不挂加餐')
1779
+ })
1780
+
1781
+ test('Verifier 加餐⑤:源码级锁定——加餐只挂 verifier 分支 + 失败回退剥外挂参数', () => {
1782
+ const dsp = readFileSync(new URL('../lib/dispatch.mjs', import.meta.url), 'utf8')
1783
+ // ① 挂载点只有一处,且被 role === 'verifier' 守门(worker/hook 不可能挂上)
1784
+ assert.match(dsp, /if \(role === 'verifier'\) \{ req\.persona = VERIFIER_PERSONA; req\.toolFilter = VERIFIER_TOOL_FILTER \}/)
1785
+ assert.equal((dsp.match(/req\.persona = VERIFIER_PERSONA/g) || []).length, 1)
1786
+ assert.equal((dsp.match(/req\.toolFilter = VERIFIER_TOOL_FILTER/g) || []).length, 1)
1787
+ // ② 常量从 core 解构导入(单一事实源)
1788
+ assert.match(dsp, /VERIFIER_PERSONA, VERIFIER_TOOL_FILTER/)
1789
+ // ③ 回退兜底:spawn 失败剥光外挂参数(persona/toolFilter/agentOptions)裸请求重试
1790
+ assert.match(dsp, /delete req\.agentOptions; delete req\.persona; delete req\.toolFilter/)
1791
+ // ④ 注释写明 continuable 面不支持 persona 的实证结论(只挂一次性面的理由)
1792
+ assert.match(dsp, /continuable 面(ContinuableCreateRequest 仅 sessionId\/parent\/signal)/)
1793
+ })
1794
+
1395
1795
  test('调研遵循接线:双通道 attachContextSuggestions / 驳回 hint 双挂 / Verifier 归因条款(源码级断言)', () => {
1396
1796
  const src = hostSrc()
1397
1797
  assert.equal((src.match(/attachContextSuggestions\(/g) || []).length, 2) // task_create 工具 + create-task RPC
@@ -1707,7 +2107,7 @@ test('Token 区统计范围接线:get-tasks 接 range 透传 + fetchTasks 带
1707
2107
  assert.match(cli, /今日与「近 7 天」为固定口径,不随范围变化/)
1708
2108
  })
1709
2109
 
1710
- // ===== Token 区三连修(task-muwq9u04):范围过滤数据源 / 选范围即渲染 / 主数字口径统一 =====
2110
+ // ===== Token 区三连修(task-muwq9u04):范围过滤数据源 / 选范围即渲染 / 主数字口径(task-muxnqunk 已翻转为总量主显)=====
1711
2111
  test('Token 区①:范围过滤读**任务级 t.runs[]**(run 级留账真实位置),不再误读 usage.runs 计数(源码级断言)', () => {
1712
2112
  const usageSrc = readFileSync(new URL('../lib/usage.mjs', import.meta.url), 'utf8')
1713
2113
  // 数据源必须取自 t.runs(数组,条目带 at/model/usage);t.usage.runs 只是「结算次数」计数(number)
@@ -1735,24 +2135,25 @@ test('Token 区②:usageSummary 变化纳入 notify 触发(选范围即渲
1735
2135
  assert.equal(/state\.usageSummary !== prevUsage/.test(cli), false)
1736
2136
  })
1737
2137
 
1738
- test('Token 区③:Top8 与模型分布主数字取有效消耗(合计进 title),caption 写明口径统一(源码级断言)', () => {
2138
+ test('Token 区③:Top8 与模型分布主数字取总量(有效进 title),caption 写明口径翻转(源码级断言)', () => {
1739
2139
  const cli = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
1740
- // 模型分布:主数字取 host 的 byModelEff(有效分摊),缺字段退化合计并标 ~
1741
- assert.match(cli, /var ee = \(u\.byModelEff && typeof u\.byModelEff\[m\] === 'number'\) \? u\.byModelEff\[m\] : null/)
1742
- assert.match(cli, /eff: \(ee === null \? tt : ee\), approx: ee === null/)
1743
- assert.match(cli, /value: m\.eff, max: maxM/)
1744
- assert.match(cli, /含缓存读合计 ' \+ String\(m\.total\) \+ ' tokens'/)
1745
- // Top8:主数字取 topTasks[].effective(老卡无分量才退化合计)
2140
+ // 口径翻转(task-muxnqunk,用户 2026-10-07 裁决「算总的吧」,推翻 task-muwq9u04 有效主显):
2141
+ // 主数字一律总量(含缓存读),有效消耗退悬浮 title
2142
+ // 模型分布:主数字取 host 的 byModel(总量),byModelEff(有效分摊)退 title(缺字段标「有效不可知」)
2143
+ assert.match(cli, /var tt = u\.byModel\[m\] \|\| 0/)
2144
+ assert.match(cli, /value: m\.total, max: maxM/)
2145
+ assert.match(cli, /:总量 ' \+ String\(m\.total\) \+ ' tokens(含缓存读)/)
2146
+ // Top8:主数字取 topTasks[].total,effective 退 title
1746
2147
  assert.match(cli, /var ee = \(typeof x\.effective === 'number'\) \? x\.effective : null/)
1747
- assert.match(cli, /eff: \(ee === null \? x\.total : ee\)/)
1748
- assert.match(cli, /value: x\.eff, max: maxT/)
1749
- assert.match(cli, /含缓存读合计 ' \+ String\(x\.total\)/)
1750
- // 排序按显示口径(有效)降序:条形长度与行序一致
1751
- assert.match(cli, /\}\)\.sort\(function \(a, b\) \{ return b\.eff - a\.eff \}\)/)
1752
- // 标题与 caption 写明口径统一(主数字均为有效消耗)
1753
- assert.match(cli, /'按模型分布(有效消耗)'/)
1754
- assert.match(cli, /'任务消耗 Top 8(有效消耗)'/)
1755
- assert.match(cli, /主数字(模型分布 \/ Top 8)与「今日」「近 7 天」均为有效消耗口径/)
2148
+ assert.match(cli, /value: x\.total, max: maxT/)
2149
+ assert.match(cli, /(总量 ' \+ String\(x\.total\) \+ ' tokens(含缓存读)/)
2150
+ // 排序按显示口径(总量)降序:条形长度与行序一致
2151
+ assert.match(cli, /\}\)\.sort\(function \(a, b\) \{ return b\.total - a\.total \}\)/)
2152
+ // 标题与 caption 写明口径翻转(主数字均为总量,有效见悬浮)
2153
+ assert.match(cli, /'按模型分布(总量)'/)
2154
+ assert.match(cli, /'任务消耗 Top 8(总量)'/)
2155
+ assert.match(cli, /主数字(今日 \/ 近 7 天 \/ 模型分布 \/ Top 8 \/ 主窗口行)均为总量口径(含缓存读)/)
2156
+ assert.match(cli, /有效消耗(input\+output\+cacheWrite,不含缓存读)见悬浮 title/)
1756
2157
  })
1757
2158
 
1758
2159
  test('Token 区③:byModelEff 与累计有效自洽(模型有效分摊不出「模型合计 > 累计有效」的矛盾)', () => {
@@ -1766,11 +2167,12 @@ test('Token 区③:byModelEff 与累计有效自洽(模型有效分摊不出
1766
2167
  assert.equal(s.byModel['m-1'], 930)
1767
2168
  })
1768
2169
 
1769
- test('README 双份同步记录 Token 口径(有效消耗 / 近 7 天 / 缓存读单列 / 派发口径边界)', () => {
2170
+ test('README 双份同步记录 Token 口径(总量主显 / 近 7 天 / 有效退悬浮 / 派发口径边界)', () => {
1770
2171
  const pkg = readFileSync(new URL('../README.md', import.meta.url), 'utf8')
1771
2172
  const root = readFileSync(new URL('../../../README.md', import.meta.url), 'utf8')
1772
2173
  assert.equal(pkg, root) // 两份 README 必须字节一致(npm run sync-readme 的约束)
1773
- for (const s of ['今日有效消耗', '近 7 天', '有效消耗 = 输入 + 输出 + 缓存写(不含缓存读)', '不含主窗口对话', 'run 级留账', '统计范围', '主数字口径统一', 't.runs[]']) {
2174
+ // 口径翻转(task-muxnqunk):主数字均为总量口径(含缓存读),有效消耗退悬浮
2175
+ for (const s of ['今日总量', '近 7 天', '有效消耗 = 输入 + 输出 + 缓存写(不含缓存读)', '不含主窗口对话', 'run 级留账', '统计范围', '主数字均为总量口径', 't.runs[]']) {
1774
2176
  assert.ok(pkg.includes(s), 'README 应记录口径:' + s)
1775
2177
  }
1776
2178
  })
@@ -1838,7 +2240,7 @@ test('Token 消耗接线:settleRun 结算累加 + 按模型小计 + get-tasks
1838
2240
  assert.match(cli, /'⛁ ' \+ fmtTokens\(t\.usage\.total\)/) // 进行中/已完成卡片显示本任务累计
1839
2241
  })
1840
2242
 
1841
- test('Token 日账接线:dispatch 记 byDay 双指标(本地日)+ 仪表盘有效消耗口径(源码级断言)', () => {
2243
+ test('Token 日账接线:dispatch 记 byDay 双指标(本地日)+ 仪表盘总量口径(源码级断言)', () => {
1842
2244
  const host = hostSrc()
1843
2245
  const cli = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
1844
2246
  assert.match(host, /cell\.t = \(Number\(cell\.t\) \|\| 0\) \+ u\.total/) // 结算时记一笔日账总量
@@ -1854,26 +2256,24 @@ test('Token 日账接线:dispatch 记 byDay 双指标(本地日)+ 仪表
1854
2256
  assert.equal(/toISOString/.test(dkBody[0]), false) // UTC 会让晚间消耗落到次日
1855
2257
  assert.match(host, /if \(!t\.usage\.byDay\) t\.usage\.byDay = \{\}/) // 老任务就地补日账(不改老字段形态)
1856
2258
  assert.match(host, /lc\.t \+= u\.total; lc\.e = null; s\.byDay\[lk\] = lc/) // 存量兜底归 updatedAt 本地日(只有总量,e 不可知)
1857
- // 仪表盘:大数字=今日有效消耗 + 「含缓存读共 X」小字 + 累计三分量 + 近 7 天有效值 + 口径 caption
2259
+ // 仪表盘:大数字=今日总量(含缓存读)+「其中有效 X」小字 + 累计三分量 + 近 7 天总量柱 + 口径 caption
2260
+ // (口径翻转 task-muxnqunk:主显总量,有效退悬浮/次要位;旧口径灰柱规则退役——见翻转测试负断言)
1858
2261
  assert.match(cli, /function lastNDays\(n\)/)
1859
2262
  assert.match(cli, /var todayCell = dayOf\(byDay\[todayKey\]\)/) // 今日缺省退化(无日账不误报)
1860
- assert.match(cli, /'tokens(今日有效'/) // 大数字口径 = 有效消耗
1861
- assert.match(cli, /'含缓存读共 '/) // 有效 vs 总量的差额单列
2263
+ assert.match(cli, /'tokens(今日总量,含缓存读)'/) // 大数字口径 = 总量(含缓存读)
2264
+ assert.match(cli, /'其中有效 '/) // 有效消耗退居次要小字(对照总量)
1862
2265
  assert.match(cli, /'累计(本看板) 有效 '/) // 累计三分量:有效
1863
2266
  assert.match(cli, /' · 缓存读 '/) // 累计三分量:缓存读
1864
2267
  assert.match(cli, /' · 合计 '/) // 累计三分量:合计
1865
- assert.match(cli, /'近 7 天(有效消耗)'/)
1866
- assert.match(cli, /k\.slice\(5\) \+ ':有效 ' \+ String\(v\)/) // 条形 title:MM-DD:有效 N tok
1867
- assert.match(cli, /含缓存读共 ' \+ String\(c\.t\) \+ ' tok'/) // 条形 title 补总量对照
1868
- assert.match(cli, /口径:累计与 Top 8 仅看板派发的 Worker\/Verifier run 消耗/) // 口径边界明示(累计/Top8 仅 run 口径)
2268
+ assert.match(cli, /'近 7 天(总量,含缓存读)'/)
2269
+ assert.match(cli, /k\.slice\(5\) \+ ':总量 ' \+ String\(v\)/) // 条形 title:MM-DD:总量 N tok
2270
+ assert.match(cli, /' \/ 有效 ' \+ String\(c\.e\) \+ ' tok'/) // 条形 title 补有效对照
2271
+ assert.match(cli, /口径:累计与 Top 8 仅看板派发的 Worker\/Verifier run(= 一次执行)消耗/) // 口径边界明示(累计/Top8 仅 run 口径)
1869
2272
  assert.match(cli, /主窗口行=本会话对话消耗,与看板派发口径并列不混入/) // 主窗口行并列不混入(task-muwsol23 起单列展示)
1870
- assert.match(cli, /主数字(模型分布 \/ Top 8)与「今日」「近 7 天」均为有效消耗口径/) // 主数字口径统一(task-muwq9u04)
1871
- assert.match(cli, /其中缓存读 ' \+ String\(x\.cacheRead \|\| 0\)/) // Top8 title 补 缓存读 拆分(合计/有效进 title)
1872
- assert.match(cli, /background: legacy \? C\.border : \(isToday \? C\.brand : C\.nested\)/) // 旧口径日灰柱、今天高亮 brand、其余浅底
2273
+ assert.match(cli, /主数字(今日 \/ 近 7 天 \/ 模型分布 \/ Top 8 \/ 主窗口行)均为总量口径(含缓存读)/) // 主数字口径翻转(task-muxnqunk)
2274
+ assert.match(cli, /其中缓存读 ' \+ String\(x\.cacheRead \|\| 0\)/) // Top8 title 补 缓存读 拆分(有效进 title)
2275
+ assert.match(cli, /background: isToday \? C\.brand : C\.nested/) // 今天高亮 brand、其余浅底(灰柱规则已退役)
1873
2276
  assert.match(cli, /hasDayData \? React\.createElement/) // 7 天全空不渲染该区
1874
- // e 不可知的旧口径日:不拿总量冒充有效——今日「—」/ 柱形矮灰柱 + tooltip 标明(task-muwsnyqv ③,
1875
- // 旧「标 ~ 近似」分支已除,见三连修测试的反向断言)
1876
- assert.match(cli, /旧口径数据(仅总量/)
1877
2277
  })
1878
2278
 
1879
2279
  // ===== Token 口径三连修(task-muwsnyqv):byDay 闭区间 / byDayFull 固定口径 / 旧口径不冒充 =====
@@ -1934,26 +2334,33 @@ test('Token 三连修②:byDayFull 未过滤全量与 byDay 并存(今日/
1934
2334
  assert.match(cli, /var byDay = \(u\.byDayFull && typeof u\.byDayFull === 'object'\) \? u\.byDayFull : \(\(u\.byDay && typeof u\.byDay === 'object'\) \? u\.byDay : \{\}\)/)
1935
2335
  })
1936
2336
 
1937
- test('Token 三连修③:e=null 旧口径日不拿 t 冒充有效(今日「—」/ 灰柱 + tooltip,源码级断言)', () => {
2337
+ // ===== Token 口径翻转(task-muxnqunk,用户 2026-10-07 裁决「算总的吧」):总量主显 / 有效退悬浮 / 灰柱退役 =====
2338
+ // 推翻 task-muwq9u04 的「有效消耗主显」:主数字一律总量 t(含缓存读),有效消耗 e 退居 title/次要位;
2339
+ // task-muwsnyqv ③ 的旧口径灰柱规则顺势退役(t 恒有值,e===null 的日子现在就是正常柱)。
2340
+ // 不动的:模型表现区与质量趋势区保持有效口径(效率指标语义),报告导出分列口径不动。
2341
+ test('Token 口径翻转:总量主显(含缓存读)+ 有效退 title + 旧口径灰柱规则退役(源码级断言)', () => {
1938
2342
  const cli = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
1939
- // 柱形:e===null 的日子走 legacy 灰柱分支,柱高不再吃 t 兜底
1940
- assert.match(cli, /var legacy = c\.e === null && c\.t > 0/)
1941
- assert.match(cli, /var v = legacy \? 0 : \(c\.e \|\| 0\)/)
1942
- assert.match(cli, /':旧口径数据(仅总量 '/) // 柱形 tooltip 标旧口径
1943
- assert.match(cli, /灰柱 = 旧口径数据(仅总量)/) // 有近 7 天旧口径日时表头图例
1944
- // maxDay 只按有效值归一(t 不参与,含缓存读的巨柱压不平其他天)
1945
- assert.match(cli, /if \(dc\.e !== null && dc\.e > maxDay\) maxDay = dc\.e/)
1946
- // 今日大数字:旧口径日显示「—」+ title 说明,不再 ~t 冒充
1947
- assert.match(cli, /var todayLegacy = todayCell\.e === null && todayCell\.t > 0/)
1948
- assert.match(cli, /todayLegacy \? '—' : fmtTokens\(todayEff\)/)
1949
- assert.match(cli, /今日有效消耗不可知:该日账为旧口径数据(仅总量/)
1950
- // 反向断言:旧的 t 兜底冒充分支已除(柱形与今日两处都不再有 e===null → 用 t 的路径)
1951
- assert.equal(/c\.e === null \? c\.t : c\.e/.test(cli), false)
1952
- assert.equal(/todayApprox \? todayCell\.t : todayCell\.e/.test(cli), false)
2343
+ // 近 7 天柱形:按总量归一与画柱(t 恒有值,e===null 的旧口径日就是正常柱)
2344
+ assert.match(cli, /if \(dc\.t > maxDay\) maxDay = dc\.t/) // maxDay 按总量归一
2345
+ assert.match(cli, /var v = c\.t \|\| 0/) // 柱高取总量
2346
+ assert.match(cli, /background: isToday \? C\.brand : C\.nested/) // 今天高亮 brand,其余浅底(无灰柱分支)
2347
+ // 今日大数字:显总量 todayCell.t(byDayFull 口径不变),有效退 title / 次要小字
2348
+ assert.match(cli, /fmtTokens\(todayCell\.t\)/)
2349
+ assert.match(cli, /'tokens(今日总量,含缓存读)'/)
2350
+ // caption 新文案:主数字均为总量口径(含缓存读);有效消耗见悬浮
2351
+ assert.match(cli, /主数字(今日 \/ 近 7 天 \/ 模型分布 \/ Top 8 \/ 主窗口行)均为总量口径(含缓存读)/)
2352
+ assert.match(cli, /有效消耗(input\+output\+cacheWrite,不含缓存读)见悬浮 title/)
2353
+ // 负断言:旧口径灰柱规则(task-muwsnyqv ③)顺势退役——legacy 分支 / 灰柱图例 / 今日「—」防护全删
2354
+ assert.equal(/var legacy = /.test(cli), false)
2355
+ assert.equal(/var todayLegacy/.test(cli), false)
2356
+ assert.equal(/hasLegacy/.test(cli), false)
2357
+ assert.equal(/灰柱 = 旧口径数据/.test(cli), false)
2358
+ assert.equal(/旧口径数据(仅总量/.test(cli), false)
2359
+ assert.equal(/今日有效消耗不可知/.test(cli), false)
1953
2360
  assert.equal(/(近似:老日账只有总量)/.test(cli), false)
1954
- // README 双份记录老数据处理口径
2361
+ // README 双份记录口径翻转
1955
2362
  const pkg = readFileSync(new URL('../README.md', import.meta.url), 'utf8')
1956
- assert.ok(pkg.includes('旧口径'), 'README 应记录旧口径日(e=null)不拿总量冒充有效的处理')
2363
+ assert.ok(pkg.includes('主数字均为总量口径'), 'README 应记录 Token 区口径翻转(总量主显)')
1957
2364
  })
1958
2365
 
1959
2366
  // ===== 调研 ROI 行 token 有效口径(task-muupnnq5)=====
@@ -2130,7 +2537,7 @@ test('学习飞轮接线:两处触发点 + push-lesson 开关拦截 + prompt/
2130
2537
  test('架构健康接线:get-tasks 返回 healthHints + 仪表盘 HealthHints 区缺省兼容渲染', () => {
2131
2538
  const host = hostSrc()
2132
2539
  const cli = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
2133
- assert.match(host, /d\.healthHints = computeHealthHints\(d\.tasks\)/) // host 每次请求现算返回(零存储)
2540
+ assert.match(host, /d\.healthHints = __rh\.hints\.concat\(computeHealthHints\(d\.tasks\)\)/) // host 每次请求现算返回(零存储);运行时 hint(task-muxhrkbg)拼在静态信号前面
2134
2541
  assert.match(cli, /function HealthHints\(\)/) // 仪表盘提示区组件
2135
2542
  assert.match(cli, /React\.createElement\(HealthHints\)/) // 挂进 Dashboard(统计卡行下方、Token 消耗区上方)
2136
2543
  assert.match(cli, /Array\.isArray\(d\.healthHints\)/) // 缺省兼容:老 host 无字段/非数组 → 按空处理
@@ -2267,8 +2674,8 @@ test('aggregateChildStats: 归档口径注释写明僵尸卡近似 + 旧「归
2267
2674
  // 防回归:聚合循环里不得再按 archived 跳过
2268
2675
  assert.doesNotMatch(coreSrc, /aggregateChildStats[\s\S]{0,600}?status === 'archived'\) continue/)
2269
2676
  assert.match(coreSrc, /if \(!t \|\| !isb\(t\)\) continue/)
2270
- // 与父卡自动收口口径(isChildSettled,archived 不算)刻意分离,注释里写明区别
2271
- assert.match(coreSrc, /与 isChildSettled(父卡自动收口口径,archived 不算)/)
2677
+ // 与父卡自动收口口径(isChildSettled)现已同口径:archived 计入终态集,注释里写明(反馈 n-muw706h1uymy)
2678
+ assert.match(coreSrc, /与 isChildSettled(父卡自动收口口径)现已同口径/)
2272
2679
  // 僵尸放行本身的门禁注释仍在 rpc.mjs(口径来源不悬空)
2273
2680
  const rpcSrc = readFileSync(new URL('../lib/rpc.mjs', import.meta.url), 'utf8')
2274
2681
  assert.match(rpcSrc, /in-progress 且无活跃 run\s+→ 放行(parentKick 僵尸态出清/)
@@ -2301,6 +2708,51 @@ test('FamilySection: 子任务清单含归档(灰化 + 「已归档」徽章 +
2301
2708
  assert.match(built, /csDone/)
2302
2709
  })
2303
2710
 
2711
+ test('详情「运行」区:渲染活跃 run 子会话 id(childId)+ 标注「对应子代理列表同名条目」(源码级断言)', () => {
2712
+ const src = readFileSync(new URL('../lib/client/task-detail.js', import.meta.url), 'utf8')
2713
+ // 运行区门禁:仅 in-progress + claimedBy(有活跃 run)才渲染,claimedBy 即该 run 的子会话 id(childId)
2714
+ assert.match(src, /task\.status === 'in-progress' && task\.claimedBy \?/)
2715
+ assert.match(src, /'子会话 id: ' \+ task\.claimedBy/)
2716
+ assert.match(src, /对应子代理列表同名条目/)
2717
+ // 组装产物里也要有(npm pretest 已保证 build-client 先跑)
2718
+ const built = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
2719
+ assert.match(built, /'子会话 id: ' \+ task\.claimedBy/)
2720
+ assert.match(built, /对应子代理列表同名条目/)
2721
+ })
2722
+
2723
+ test('README 双份:运行区子会话 id 归属标注 + git 纪律红线各补一句且逐字一致', () => {
2724
+ const r1 = readFileSync(new URL('../../../README.md', import.meta.url), 'utf8')
2725
+ const r2 = readFileSync(new URL('../README.md', import.meta.url), 'utf8')
2726
+ assert.equal(r1, r2, 'README 双份必须逐字一致(npm run sync-readme)')
2727
+ assert.ok(r1.indexOf('对应子代理列表同名条目') >= 0, 'README 缺运行区子会话 id 归属标注说明')
2728
+ assert.ok(r1.indexOf('git 纪律红线') >= 0, 'README 缺 git 纪律红线说明')
2729
+ })
2730
+
2731
+ // ===== 归档 tab 数据源口径(反馈 n-muyg3e0m9z7i):归档 tab 空 vs 仪表盘 68 根修 =====
2732
+ // 旧 ArchiveView 数据源走独立 state.archived/fetchArchived:异步填库却未接入 TopPanel 的 notify 更新项,
2733
+ // 归档 tab 打开后停在「暂无归档任务」空态;仪表盘「已归档」计数来自 state.tasks(get-tasks 全量含归档)。
2734
+ // 修法:ArchiveView 直接读 state.tasks.filter(status==='archived'),与仪表盘同源全量 + 归档时间倒序 + 顶部注条数。
2735
+ test('ArchiveView: 归档数据源读 state.tasks 全量(对齐仪表盘「已归档」),不依赖 state.archived/fetchArchived(源码级断言)', () => {
2736
+ const src = readFileSync(new URL('../lib/client/board-list.js', import.meta.url), 'utf8')
2737
+ // 数据源必须与仪表盘同源:state.tasks.filter(status==='archived')(get-tasks 全量含归档)
2738
+ assert.match(src, /var archived = state\.tasks\.filter\(function \(t\) \{ return t\.status === 'archived' \}\)/)
2739
+ // 反向断言:不得再读独立 state.archived.filter(异步填库不触发重渲染 → tab 恒空)
2740
+ assert.doesNotMatch(src, /state\.archived\.filter/)
2741
+ // 不得再挂「空则 fetchArchived」的 useEffect(独立数据源已移除)
2742
+ assert.doesNotMatch(src, /useEffect\(function \(\) \{ if \(!state\.archived\.length\) fetchArchived\(\) \}/)
2743
+ // 不得有任何 7 日窗口 cutoff(老归档 >7 天被整批滤掉是原根因候选形态之一)
2744
+ assert.doesNotMatch(src, /cutoff/)
2745
+ assert.doesNotMatch(src, /7 \* 24 \* 60|7 \* 86400000|604800000/)
2746
+ // 顶部注明条数(与仪表盘「已归档」同口径)
2747
+ assert.match(src, /'共 ' \+ archived\.length \+ ' 条归档/)
2748
+ // 归档时间倒序:archivedAt 优先,回退 resolvedAt/createdAt
2749
+ assert.match(src, /function archTs\(t\) \{ return t\.archivedAt \|\| t\.resolvedAt \|\| t\.createdAt \|\| '' \}/)
2750
+ // 组装产物同步(pretest 已跑 build-client)
2751
+ const built = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
2752
+ assert.match(built, /var archived = state\.tasks\.filter\(function \(t\) \{ return t\.status === 'archived' \}\)/)
2753
+ assert.match(built, /'共 ' \+ archived\.length \+ ' 条归档/)
2754
+ })
2755
+
2304
2756
  // ===== 史诗 hooks=agent run(task-muuw4ov7):host 生命周期接线(pre 闸门 / post 收口 / 薄框架 prompt / 主窗口限定写入)=====
2305
2757
  // 设计定稿:hook 点 = 一次**真实 agent 运行**(不是声明式命令);点位可选(epic 未声明 hooks 则全链路零变化);
2306
2758
  // 薄框架 + agent 自行决策 + 歧义上报兜底;commit/push 不入任何默认形态;hooks 只许主窗口设置。
@@ -2574,11 +3026,11 @@ function mkHookDispatch(board, over) {
2574
3026
  list: () => ['mock'],
2575
3027
  getProvider: () => ({ inheritsParentContext: false }),
2576
3028
  start: async (name, req) => {
2577
- spawned.push({ name, label: req.label, text: req.prompt[0].text, parent: req.parent })
3029
+ spawned.push({ name, label: req.label, text: req.prompt[0].text, parent: req.parent, persona: req.persona, toolFilter: req.toolFilter })
2578
3030
  return { id: 'run-' + spawned.length, result: new Promise(function () {}), dispose: async function () {} }
2579
3031
  },
2580
3032
  startContinuable: async (spec) => {
2581
- spawnedContinuable.push({ name: spec.provider, label: spec.label, text: spec.request.prompt[0].text, parent: spec.request.parent })
3033
+ spawnedContinuable.push({ name: spec.provider, label: spec.label, text: spec.request.prompt[0].text, parent: spec.request.parent, persona: spec.request.persona, toolFilter: spec.request.toolFilter })
2582
3034
  return { childId: 'child-' + spawnedContinuable.length, messageId: 'msg-' + spawnedContinuable.length }
2583
3035
  },
2584
3036
  },
@@ -2928,7 +3380,7 @@ test('可续跑 Worker 接线(源码级):事件订阅走 ctx.effect 回收
2928
3380
  // ⑥ 板级开关三件套(cfg 兜底 / normalizeBoard 补缺省 / seed 初值 / set-board-config 白名单 / get-tasks 透出)
2929
3381
  assert.match(coreSrc, /workerContinuable: d\.workerContinuable !== false/)
2930
3382
  assert.match(coreSrc, /if \(typeof d\.workerContinuable !== 'boolean'\) d\.workerContinuable = true/)
2931
- assert.match(coreSrc, /epicSplit: true, workerContinuable: true, minWorkers: 1/)
3383
+ assert.match(coreSrc, /epicSplit: true, workerContinuable: true, verifyUserGuide: true, minWorkers: 1/)
2932
3384
  assert.match(rpcSrc, /else if \(args\.key === 'workerContinuable'\) d\.workerContinuable = !!args\.value/)
2933
3385
  assert.match(rpcSrc, /d\.workerContinuable = cfg\(d\)\.workerContinuable/)
2934
3386
  // ⑦ 手动终止/活动查询对 continuable rec 不炸(id 统一取 rec.id,dispose 分路)
@@ -3272,10 +3724,12 @@ test('史诗语义层接线:poolCycle 派发分支触发父卡流转 + get-tas
3272
3724
  // get-tasks 现算 childStats(零存储)
3273
3725
  assert.match(host, /d\.childStats = aggregateChildStats\(d\.tasks\)/)
3274
3726
  // 两模块都从 core 解构引入(接线不断);rpc.mjs 解构表尾部随调研门禁(task-mute6zpw)、调研遵循三件套(task-mutnj3a4)、
3275
- // tasksHash(task-mutrtwin)、hooks 浅校验(normalizeHooks/mergeHooks,本批 hooks=agent run)扩展;
3276
- // dispatch.mjs 解构表尾部追加 hook 族(buildHookPrompt/hookOn/hookSetState/gsb)与驳回包 helper(pushRejection,task-muvg15p5)
3277
- assert.match(host, /parentKickOnDispatch, LESSON_RECALL_HINT, buildHookPrompt, applyHookSettle, hookOn, hookSetState, gsb, pushRejection \} = core/)
3278
- assert.match(host, /boardHome, aggregateChildStats, createTaskWarnings, epicPrecheck, epicPrecheckNote, attachContextSuggestions, REJECT_REDISPATCH_HINT, tasksHash, normalizeHooks, mergeHooks, pushRejection \} = core/)
3727
+ // tasksHash(task-mutrtwin)、hooks 浅校验(normalizeHooks/mergeHooks,本批 hooks=agent run)、
3728
+ // 自测指南归一(normalizeUserTest,task-muxyyvg0)扩展;
3729
+ // dispatch.mjs 解构表尾部追加 hook 族(buildHookPrompt/hookOn/hookSetState/gsb)与驳回包 helper(pushRejection,task-muvg15p5)、
3730
+ // Verifier 验收员加餐常量(VERIFIER_PERSONA/VERIFIER_TOOL_FILTER,task-muy3gm03)
3731
+ assert.match(host, /parentKickOnDispatch, LESSON_RECALL_HINT, buildHookPrompt, applyHookSettle, hookOn, hookSetState, gsb, pushRejection, VERIFIER_PERSONA, VERIFIER_TOOL_FILTER \} = core/)
3732
+ assert.match(host, /boardHome, aggregateChildStats, createTaskWarnings, epicPrecheck, epicPrecheckNote, attachContextSuggestions, REJECT_REDISPATCH_HINT, tasksHash, normalizeHooks, mergeHooks, pushRejection, normalizeUserTest, draftLint \} = core/)
3279
3733
  // 既有「全子任务 resolved → 父 verifying」逻辑不动(checkParentAuto 仍在 verifyApply 链路;
3280
3734
  // 但其内部已委托共享 helper maybeAutoCloseParent——单一判定口径,core.mjs 不在 hostSrc 清单,单独读)
3281
3735
  const coreSrc = readFileSync(new URL('../lib/core.mjs', import.meta.url), 'utf8')
@@ -3633,6 +4087,74 @@ test('task_archive 工具:与 archive-task RPC 同一门禁口径(僵尸放
3633
4087
  assert.equal(r.ok, false); assert.match(r.error, /cannot archive/)
3634
4088
  })
3635
4089
 
4090
+ // ===== 反馈修复(task-mux3uvx3,反馈 n-muw706h1uymy):归档路径触发父卡收口 + task_resolve 守卫对齐 =====
4091
+ test('归档双通道触发父卡自动收口:archive-task RPC 与 task_archive 工具同一 archiveApply 口径', async () => {
4092
+ // RPC 通道:归档末子卡 → 父卡 in-progress → verifying
4093
+ const p = mkTask({ id: 'ep', status: 'in-progress' })
4094
+ const k1 = mkTask({ id: 'k1', parentId: 'ep', status: 'resolved' })
4095
+ const k2 = mkTask({ id: 'k2', parentId: 'ep', status: 'resolved' })
4096
+ const board = mkBoard([p, k1, k2])
4097
+ const h = mkRpcHandlers(board)
4098
+ const r = await h['archive-task']({ taskId: 'k2' })
4099
+ assert.equal(r.ok, true); assert.equal(r.parentUpdated, true)
4100
+ assert.equal(p.status, 'verifying')
4101
+ assert.equal(p.resolution, 'all subtasks resolved')
4102
+ // 工具通道同口径
4103
+ const p2 = mkTask({ id: 'ep2', status: 'in-progress' })
4104
+ const j1 = mkTask({ id: 'j1', parentId: 'ep2', status: 'resolved' })
4105
+ const j2 = mkTask({ id: 'j2', parentId: 'ep2', status: 'resolved' })
4106
+ const h2 = mkRpcHandlers(mkBoard([p2, j1, j2]))
4107
+ const r2 = await h2.__tools['task_archive'].execute({ taskId: 'j2' }, {})
4108
+ assert.equal(r2.ok, true); assert.equal(r2.parentUpdated, true); assert.equal(p2.status, 'verifying')
4109
+ // 未凑齐不收口(还剩在跑子卡):归档其中一张已 resolved 的,父卡不动
4110
+ const p3 = mkTask({ id: 'ep3', status: 'in-progress' })
4111
+ const m1 = mkTask({ id: 'm1', parentId: 'ep3', status: 'resolved' })
4112
+ const m2 = mkTask({ id: 'm2', parentId: 'ep3', status: 'in-progress' })
4113
+ const h3 = mkRpcHandlers(mkBoard([p3, m1, m2]), { hasActiveRun: () => true })
4114
+ const r3 = await h3['archive-task']({ taskId: 'm1' })
4115
+ assert.equal(r3.ok, true); assert.equal('parentUpdated' in r3, false); assert.equal(p3.status, 'in-progress')
4116
+ })
4117
+
4118
+ test('batch-op archive:整批落定后触发父卡收口(第三条归档路径同口径);未凑齐不收口', async () => {
4119
+ // 末两张子卡一次批量归档 → 父卡 verifying(旧路径归档后从不查父卡 → 同样卡死)
4120
+ const p = mkTask({ id: 'bp', status: 'in-progress' })
4121
+ const b1 = mkTask({ id: 'b1', parentId: 'bp', status: 'resolved' })
4122
+ const b2 = mkTask({ id: 'b2', parentId: 'bp', status: 'resolved' })
4123
+ const b3 = mkTask({ id: 'b3', parentId: 'bp', status: 'cancelled' })
4124
+ const h = mkRpcHandlers(mkBoard([p, b1, b2, b3]))
4125
+ const r = await h['batch-op']({ op: 'archive', ids: ['b1', 'b2', 'b3'] })
4126
+ assert.equal(r.ok, true); assert.equal(r.done, 3); assert.equal(r.parentsClosed, 1)
4127
+ assert.equal(p.status, 'verifying')
4128
+ // 未凑齐(还有在跑子卡)→ 不收口、不挂 parentsClosed 字段(返回体老契约不变)
4129
+ const p2 = mkTask({ id: 'bp2', status: 'in-progress' })
4130
+ const d1 = mkTask({ id: 'd1', parentId: 'bp2', status: 'resolved' })
4131
+ const d2 = mkTask({ id: 'd2', parentId: 'bp2', status: 'in-progress' })
4132
+ const h2 = mkRpcHandlers(mkBoard([p2, d1, d2]))
4133
+ const r2 = await h2['batch-op']({ op: 'archive', ids: ['d1'] })
4134
+ assert.equal(r2.ok, true); assert.equal('parentsClosed' in r2, false); assert.equal(p2.status, 'in-progress')
4135
+ // 不级联:批量只动显式选中的 id(与单卡 archiveApply 的级联刻意不同)
4136
+ assert.equal(d2.status, 'in-progress')
4137
+ })
4138
+
4139
+ test('task_resolve 工具:claimedBy=null 的 in-progress 卡放行主窗口(对齐 RPC 口径);他人认领照旧拒', async () => {
4140
+ // claimedBy=null(parentKick 僵尸 epic / 手动置 in-progress 的无人认领卡)→ 放行(工具已有主窗口门禁)
4141
+ const t1 = mkTask({ id: 'u1', status: 'in-progress', claimedBy: null })
4142
+ const r1 = await mkRpcHandlers(mkBoard([t1])).__tools['task_resolve'].execute({ taskId: 'u1', status: 'blocked' }, {})
4143
+ assert.equal(r1.ok, true); assert.equal(t1.status, 'blocked')
4144
+ // verifying + resolution 同样放行(父卡检查仍由 resolveApply 内部触发,行为不变)
4145
+ const t2 = mkTask({ id: 'u2', status: 'in-progress', claimedBy: null, pipeline: 'full' })
4146
+ const r2 = await mkRpcHandlers(mkBoard([t2])).__tools['task_resolve'].execute({ taskId: 'u2', status: 'verifying', resolution: '手动兜底' }, {})
4147
+ assert.equal(r2.ok, true); assert.equal(t2.status, 'verifying')
4148
+ // 他人认领 → 照旧拒(既有守卫场景不回归)
4149
+ const t3 = mkTask({ id: 'u3', status: 'in-progress', claimedBy: 'other' })
4150
+ const r3 = await mkRpcHandlers(mkBoard([t3])).__tools['task_resolve'].execute({ taskId: 'u3', status: 'verifying', resolution: 'x' }, {})
4151
+ assert.equal(r3.ok, false); assert.match(r3.error, /not claimed by you/); assert.equal(t3.status, 'in-progress')
4152
+ // 本人认领 → 放行
4153
+ const t4 = mkTask({ id: 'u4', status: 'in-progress', claimedBy: 'tester', pipeline: 'full' })
4154
+ const r4 = await mkRpcHandlers(mkBoard([t4])).__tools['task_resolve'].execute({ taskId: 'u4', status: 'verifying', resolution: 'x' }, {})
4155
+ assert.equal(r4.ok, true); assert.equal(t4.status, 'verifying')
4156
+ })
4157
+
3636
4158
  test('反馈修复接线断言:index.mjs 注入 hasActiveRun + get-tasks 附 tasksHash + kernel 短路分支(源码级)', () => {
3637
4159
  const host = hostSrc()
3638
4160
  // index.mjs:hasActiveRun 定义(runsFor 表有记录且 !settled 即活跃)并注入 createRpc deps
@@ -4056,7 +4578,7 @@ test('epicSplit 接线(源码级):缓存同步 / Team 引导段门禁 /
4056
4578
  // 配置层(core.mjs:cfg 兜底 / seed 初值 / normalizeBoard 老看板补齐)
4057
4579
  assert.match(coreSrc, /epicSplit: d\.epicSplit !== false/)
4058
4580
  assert.match(coreSrc, /if \(typeof d\.epicSplit !== 'boolean'\) d\.epicSplit = true/)
4059
- assert.match(coreSrc, /notifyDone: true, epicSplit: true, workerContinuable: true, minWorkers: 1/) // seed 缺省开(workerContinuable 随 task-muw5gnhv 插入 epicSplit 与 minWorkers 之间)
4581
+ assert.match(coreSrc, /notifyDone: true, epicSplit: true, workerContinuable: true, verifyUserGuide: true, minWorkers: 1/) // seed 缺省开(workerContinuable 随 task-muw5gnhv、verifyUserGuide 随 task-muxyyvg0 插入 epicSplit 与 minWorkers 之间)
4060
4582
  // 缓存链路:rt() 读盘同步 + set-board-config 当场回填(Team 提示词是同步组装,只能读缓存)
4061
4583
  assert.match(src, /epicSplitCache\[sid\] = nd\.epicSplit !== false/)
4062
4584
  assert.match(src, /function epicSplitOn\(sid\) \{ return epicSplitCache\[sid\] !== false \}/)
@@ -4507,7 +5029,7 @@ test('池配置开关乐观更新:每个开关先写 state+notify 再 rpc,
4507
5029
  assert.ok(m, '开关 onChange 必须是 setCfg(键, e.target.checked) 单调用,实测:' + h)
4508
5030
  keys.push(m[1])
4509
5031
  }
4510
- assert.deepEqual(keys.slice().sort(), ['epicSplit', 'feedbackEnabled', 'notifyDispatch', 'notifyDone'])
5032
+ assert.deepEqual(keys.slice().sort(), ['epicSplit', 'feedbackEnabled', 'notifyDispatch', 'notifyDone', 'verifyUserGuide'])
4511
5033
  // ② 乐观序:先写 state、再 notify、最后才 rpc(顺序颠倒 = 点击仍等往返)
4512
5034
  const setCfgMatch = src.match(/function setCfg\(key, next\) \{[\s\S]*?\n \}/)
4513
5035
  assert.ok(setCfgMatch, '应定义 setCfg')
@@ -4556,17 +5078,17 @@ test('池配置开关乐观更新:每个开关先写 state+notify 再 rpc,
4556
5078
 
4557
5079
  test('fetchTasks 设置开关权威纠偏:hash 不变时配置变化补 notify + hash 短路结构保持(源码级)', () => {
4558
5080
  const src = readFileSync(new URL('../lib/client/kernel.js', import.meta.url), 'utf8')
4559
- // ① 取值口径单点定义(缺字段/脏值=开,只有显式 false 才关),且四个开关字段确由它赋值
5081
+ // ① 取值口径单点定义(缺字段/脏值=开,只有显式 false 才关),且五个开关字段确由它赋值
4560
5082
  const knobSrc = src.match(/function cfgKnobOf\(src, key\) \{[^\n]*\n/)[0]
4561
5083
  const changedSrc = src.match(/function cfgKnobsChanged\(a, b\) \{[^\n]*\n/)[0]
4562
5084
  const cfgKnobOf = new Function(knobSrc + '\nreturn cfgKnobOf')()
4563
5085
  const cfgKnobsChanged = new Function(changedSrc + '\nreturn cfgKnobsChanged')()
4564
5086
  assert.equal(cfgKnobOf({}, 'notifyDispatch'), true) // 老 host 无字段 → 开
4565
5087
  assert.equal(cfgKnobOf({ notifyDispatch: false }, 'notifyDispatch'), false) // 只有显式 false 才关
4566
- const KNOBS = ['feedbackEnabled', 'notifyDispatch', 'notifyDone', 'epicSplit']
5088
+ const KNOBS = ['feedbackEnabled', 'notifyDispatch', 'notifyDone', 'epicSplit', 'verifyUserGuide']
4567
5089
  for (const k of KNOBS) assert.match(src, new RegExp("state\\." + k + " = cfgKnobOf\\(d, '" + k + "'\\)"))
4568
- // ② 真执行比较器:四个开关任一翻转都算变化(漏一个 → 该开关又要等下一次任意 notify)
4569
- const allOn = { feedbackEnabled: true, notifyDispatch: true, notifyDone: true, epicSplit: true }
5090
+ // ② 真执行比较器:五个开关任一翻转都算变化(漏一个 → 该开关又要等下一次任意 notify)
5091
+ const allOn = { feedbackEnabled: true, notifyDispatch: true, notifyDone: true, epicSplit: true, verifyUserGuide: true }
4570
5092
  assert.equal(cfgKnobsChanged(allOn, Object.assign({}, allOn)), false)
4571
5093
  for (const k of KNOBS) {
4572
5094
  assert.equal(cfgKnobsChanged(allOn, Object.assign({}, allOn, { [k]: false })), true, k + ' 翻转必须被检测到')
@@ -4782,9 +5304,11 @@ test('池冻结根修⑤(接线,源码级):幽灵回收在活跃度计
4782
5304
  const iCount = dsp.indexOf('if (rc0.role === \'worker\') activeW++')
4783
5305
  assert.ok(iReap > 0 && iCount > iReap, '幽灵回收必须先于活跃度计数')
4784
5306
  // ② 回收后重读快照(否则空闲快进的 wt(snap) 会把收尾结果覆盖回旧状态)
4785
- assert.match(dsp, /if \(reapedN > 0\) \{ try \{ snap = await rt\(sid\) \} catch \(_\) \{\} \}/)
5307
+ // (task-muxhrkbg 在同一块内追加了 reapNote 打点,块体由单行扩成多行——语义不变,断言随之放宽到块形状)
5308
+ assert.match(dsp, /if \(reapedN > 0\) \{\s*try \{ snap = await rt\(sid\) \} catch \(_\) \{\}/)
4786
5309
  // ③ 本轮主体包 try(整轮异常隔离),且刻意不设再入 latch
4787
- assert.match(dsp, /try \{ return await poolCycleBody\(sid, info, runs, snap\) \}/)
5310
+ // (task-muxhrkbg 在 try 块内追加 poolLastOkAt 心跳打点:主体正常返回才刷新——异常隔离语义由下面的 catch 断言继续锁死)
5311
+ assert.match(dsp, /try \{\s*var cycleRet = await poolCycleBody\(sid, info, runs, snap\)\s*poolHealthFor\(sid\)\.poolLastOkAt = Date\.now\(\)\s*return cycleRet\s*\}/)
4788
5312
  assert.match(dsp, /catch \(e\) \{ console\.error\('\[task-board\] poolCycle 本轮异常(已隔离,下一轮照常)/)
4789
5313
  assert.match(dsp, /刻意\*\*不设\*\*再入守卫/)
4790
5314
  // ④ 持锁段按阶段隔离(五段各自 try/catch,回调照样返回 d → 该写的池状态一定写下去)
@@ -5004,3 +5528,57 @@ test('高优干预④(源码级):continuable 投递点 = host-protocol 符
5004
5528
  // 回退链顺序:host 投递在 sendMessage 之前(能保留插件 source 的通道优先)
5005
5529
  assert.ok(rpcSrc.indexOf('await host.call(subagents, parent, childId') < rpcSrc.indexOf('await subagents.sendMessage(parent, childId'), 'host 投递必须先于 sendMessage 回退')
5006
5530
  })
5531
+
5532
+ // ===== 巡检三连修(巡检实证三条):草稿发布预警 / 优先级 chip 单一状态源 / 批量归档跳过原因 =====
5533
+ test('巡检①:草稿态「保存并重置」改「保存并发布」+ 发布预警 title(源码级 + 文案存在性)', () => {
5534
+ const src = readFileSync(new URL('../lib/client/task-detail.js', import.meta.url), 'utf8')
5535
+ // 草稿态按钮文案切换(含「发布」字样),非草稿仍「保存并重置」——单一 createElement 内条件三元
5536
+ assert.match(src, /task\.status === 'draft' \? ' 保存并发布' : ' 保存并重置'/)
5537
+ // 草稿态 title 发布预警文案存在(发布后可能被立即派发)
5538
+ assert.match(src, /'发布后按当前工作模式可能被立即派发'/)
5539
+ // 组装产物同步(pretest 已跑 build-client)
5540
+ const built = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
5541
+ assert.match(built, /保存并发布/)
5542
+ assert.match(built, /发布后按当前工作模式可能被立即派发/)
5543
+ })
5544
+
5545
+ test('巡检②:优先级 chip 高亮与「清除」显隐统一读全局 state.filterPrio(消灭本地 fp 副本)', () => {
5546
+ const src = readFileSync(new URL('../lib/client/board-list.js', import.meta.url), 'utf8')
5547
+ // chip 高亮读全局 state.filterPrio(单一事实源)
5548
+ assert.match(src, /var on = state\.filterPrio\.indexOf\(p\) >= 0/)
5549
+ // 「清除」显隐也读全局 state.filterPrio
5550
+ assert.match(src, /var hasFilter = q\.trim\(\) \|\| state\.filterPrio\.length > 0 \|\| ft/)
5551
+ // 消灭本地副本:不得再出现 fp 副本初始化 / setFp 同步
5552
+ assert.doesNotMatch(src, /useState\(state\.filterPrio\)/)
5553
+ assert.doesNotMatch(src, /setFp/)
5554
+ // 组装产物同步
5555
+ const built = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
5556
+ assert.match(built, /var on = state\.filterPrio\.indexOf\(p\) >= 0/)
5557
+ assert.doesNotMatch(built, /setFp/)
5558
+ })
5559
+
5560
+ test('巡检③:batch-op archive 跳过附 reasons(id → 原因)且 client 渲成「标题:原因」列表', async () => {
5561
+ // host 行为:非 resolved/cancelled 卡跳过时记录 'cannot archive' 原因(不再只 skip 无因)
5562
+ const a = mkTask({ id: 'tf-a', status: 'resolved' })
5563
+ const b = mkTask({ id: 'tf-b', status: 'in-progress' })
5564
+ const c = mkTask({ id: 'tf-c', status: 'pending' })
5565
+ const board = mkBoard([a, b, c])
5566
+ const h = mkRpcHandlers(board)
5567
+ const r = await h['batch-op']({ op: 'archive', ids: ['tf-a', 'tf-b', 'tf-c'] })
5568
+ assert.equal(r.ok, true)
5569
+ assert.equal(r.done, 1)
5570
+ assert.deepEqual(r.skipped, ['tf-b', 'tf-c'])
5571
+ assert.equal(r.reasons['tf-b'], 'cannot archive')
5572
+ assert.equal(r.reasons['tf-c'], 'cannot archive')
5573
+ assert.equal(a.status, 'archived')
5574
+ assert.equal(b.status, 'in-progress')
5575
+ // client 渲染:reasonRows 把 reasons 对象转「标题:原因」列表(逐条,不再只显示「跳过 N」无原因)
5576
+ const src = readFileSync(new URL('../lib/client/board-list.js', import.meta.url), 'utf8')
5577
+ assert.match(src, /function reasonRows\(r\) \{ var m = \(r && r\.reasons\) \|\| \{\}; return Object\.keys\(m\)\.map/)
5578
+ assert.match(src, /it\.title \+ ':' \+ it\.reason/)
5579
+ assert.match(src, /'跳过原因'/)
5580
+ // 组装产物同步
5581
+ const built = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
5582
+ assert.match(built, /it\.title \+ ':' \+ it\.reason/)
5583
+ assert.match(built, /'跳过原因'/)
5584
+ })