dsh-agent-board 1.7.4 → 1.7.5

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')
@@ -384,7 +593,7 @@ test('注入迁移(task-muvjs392):清单进首条 prompt,注入区块通
384
593
  assert.doesNotMatch(dsp, /ctx\.fs|fs\.readText|sliceLines|buildFileOutline/)
385
594
  // ③ 瘦身清单本体直接拼进 worker/verifier 两态 prompt(三态其余不变:hook 仍走 buildHookPrompt)
386
595
  assert.match(dsp, /buildWorkerPrompt\(t, pack, cfg\(dsnap\)\.feedbackEnabled\)/)
387
- assert.match(dsp, /buildVerifierPrompt\(t, pack\)/)
596
+ assert.match(dsp, /buildVerifierPrompt\(t, pack, cfg\(dsnap\)\.verifyUserGuide\)/)
388
597
  assert.match(dsp, /buildHookPrompt\(t, role === 'hook-pre' \? 'pre' : 'post', kids\)/)
389
598
  // ④ readContextPack = 组装清单(不再 await 读盘)——派发点与 rpc 预览点都直接调用
390
599
  assert.match(dsp, /function readContextPack\(t\) \{/)
@@ -1707,7 +1916,7 @@ test('Token 区统计范围接线:get-tasks 接 range 透传 + fetchTasks 带
1707
1916
  assert.match(cli, /今日与「近 7 天」为固定口径,不随范围变化/)
1708
1917
  })
1709
1918
 
1710
- // ===== Token 区三连修(task-muwq9u04):范围过滤数据源 / 选范围即渲染 / 主数字口径统一 =====
1919
+ // ===== Token 区三连修(task-muwq9u04):范围过滤数据源 / 选范围即渲染 / 主数字口径(task-muxnqunk 已翻转为总量主显)=====
1711
1920
  test('Token 区①:范围过滤读**任务级 t.runs[]**(run 级留账真实位置),不再误读 usage.runs 计数(源码级断言)', () => {
1712
1921
  const usageSrc = readFileSync(new URL('../lib/usage.mjs', import.meta.url), 'utf8')
1713
1922
  // 数据源必须取自 t.runs(数组,条目带 at/model/usage);t.usage.runs 只是「结算次数」计数(number)
@@ -1735,24 +1944,25 @@ test('Token 区②:usageSummary 变化纳入 notify 触发(选范围即渲
1735
1944
  assert.equal(/state\.usageSummary !== prevUsage/.test(cli), false)
1736
1945
  })
1737
1946
 
1738
- test('Token 区③:Top8 与模型分布主数字取有效消耗(合计进 title),caption 写明口径统一(源码级断言)', () => {
1947
+ test('Token 区③:Top8 与模型分布主数字取总量(有效进 title),caption 写明口径翻转(源码级断言)', () => {
1739
1948
  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(老卡无分量才退化合计)
1949
+ // 口径翻转(task-muxnqunk,用户 2026-10-07 裁决「算总的吧」,推翻 task-muwq9u04 有效主显):
1950
+ // 主数字一律总量(含缓存读),有效消耗退悬浮 title
1951
+ // 模型分布:主数字取 host 的 byModel(总量),byModelEff(有效分摊)退 title(缺字段标「有效不可知」)
1952
+ assert.match(cli, /var tt = u\.byModel\[m\] \|\| 0/)
1953
+ assert.match(cli, /value: m\.total, max: maxM/)
1954
+ assert.match(cli, /:总量 ' \+ String\(m\.total\) \+ ' tokens(含缓存读)/)
1955
+ // Top8:主数字取 topTasks[].total,effective 退 title
1746
1956
  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 天」均为有效消耗口径/)
1957
+ assert.match(cli, /value: x\.total, max: maxT/)
1958
+ assert.match(cli, /(总量 ' \+ String\(x\.total\) \+ ' tokens(含缓存读)/)
1959
+ // 排序按显示口径(总量)降序:条形长度与行序一致
1960
+ assert.match(cli, /\}\)\.sort\(function \(a, b\) \{ return b\.total - a\.total \}\)/)
1961
+ // 标题与 caption 写明口径翻转(主数字均为总量,有效见悬浮)
1962
+ assert.match(cli, /'按模型分布(总量)'/)
1963
+ assert.match(cli, /'任务消耗 Top 8(总量)'/)
1964
+ assert.match(cli, /主数字(今日 \/ 近 7 天 \/ 模型分布 \/ Top 8 \/ 主窗口行)均为总量口径(含缓存读)/)
1965
+ assert.match(cli, /有效消耗(input\+output\+cacheWrite,不含缓存读)见悬浮 title/)
1756
1966
  })
1757
1967
 
1758
1968
  test('Token 区③:byModelEff 与累计有效自洽(模型有效分摊不出「模型合计 > 累计有效」的矛盾)', () => {
@@ -1766,11 +1976,12 @@ test('Token 区③:byModelEff 与累计有效自洽(模型有效分摊不出
1766
1976
  assert.equal(s.byModel['m-1'], 930)
1767
1977
  })
1768
1978
 
1769
- test('README 双份同步记录 Token 口径(有效消耗 / 近 7 天 / 缓存读单列 / 派发口径边界)', () => {
1979
+ test('README 双份同步记录 Token 口径(总量主显 / 近 7 天 / 有效退悬浮 / 派发口径边界)', () => {
1770
1980
  const pkg = readFileSync(new URL('../README.md', import.meta.url), 'utf8')
1771
1981
  const root = readFileSync(new URL('../../../README.md', import.meta.url), 'utf8')
1772
1982
  assert.equal(pkg, root) // 两份 README 必须字节一致(npm run sync-readme 的约束)
1773
- for (const s of ['今日有效消耗', '近 7 天', '有效消耗 = 输入 + 输出 + 缓存写(不含缓存读)', '不含主窗口对话', 'run 级留账', '统计范围', '主数字口径统一', 't.runs[]']) {
1983
+ // 口径翻转(task-muxnqunk):主数字均为总量口径(含缓存读),有效消耗退悬浮
1984
+ for (const s of ['今日总量', '近 7 天', '有效消耗 = 输入 + 输出 + 缓存写(不含缓存读)', '不含主窗口对话', 'run 级留账', '统计范围', '主数字均为总量口径', 't.runs[]']) {
1774
1985
  assert.ok(pkg.includes(s), 'README 应记录口径:' + s)
1775
1986
  }
1776
1987
  })
@@ -1838,7 +2049,7 @@ test('Token 消耗接线:settleRun 结算累加 + 按模型小计 + get-tasks
1838
2049
  assert.match(cli, /'⛁ ' \+ fmtTokens\(t\.usage\.total\)/) // 进行中/已完成卡片显示本任务累计
1839
2050
  })
1840
2051
 
1841
- test('Token 日账接线:dispatch 记 byDay 双指标(本地日)+ 仪表盘有效消耗口径(源码级断言)', () => {
2052
+ test('Token 日账接线:dispatch 记 byDay 双指标(本地日)+ 仪表盘总量口径(源码级断言)', () => {
1842
2053
  const host = hostSrc()
1843
2054
  const cli = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
1844
2055
  assert.match(host, /cell\.t = \(Number\(cell\.t\) \|\| 0\) \+ u\.total/) // 结算时记一笔日账总量
@@ -1854,26 +2065,24 @@ test('Token 日账接线:dispatch 记 byDay 双指标(本地日)+ 仪表
1854
2065
  assert.equal(/toISOString/.test(dkBody[0]), false) // UTC 会让晚间消耗落到次日
1855
2066
  assert.match(host, /if \(!t\.usage\.byDay\) t\.usage\.byDay = \{\}/) // 老任务就地补日账(不改老字段形态)
1856
2067
  assert.match(host, /lc\.t \+= u\.total; lc\.e = null; s\.byDay\[lk\] = lc/) // 存量兜底归 updatedAt 本地日(只有总量,e 不可知)
1857
- // 仪表盘:大数字=今日有效消耗 + 「含缓存读共 X」小字 + 累计三分量 + 近 7 天有效值 + 口径 caption
2068
+ // 仪表盘:大数字=今日总量(含缓存读)+「其中有效 X」小字 + 累计三分量 + 近 7 天总量柱 + 口径 caption
2069
+ // (口径翻转 task-muxnqunk:主显总量,有效退悬浮/次要位;旧口径灰柱规则退役——见翻转测试负断言)
1858
2070
  assert.match(cli, /function lastNDays\(n\)/)
1859
2071
  assert.match(cli, /var todayCell = dayOf\(byDay\[todayKey\]\)/) // 今日缺省退化(无日账不误报)
1860
- assert.match(cli, /'tokens(今日有效'/) // 大数字口径 = 有效消耗
1861
- assert.match(cli, /'含缓存读共 '/) // 有效 vs 总量的差额单列
2072
+ assert.match(cli, /'tokens(今日总量,含缓存读)'/) // 大数字口径 = 总量(含缓存读)
2073
+ assert.match(cli, /'其中有效 '/) // 有效消耗退居次要小字(对照总量)
1862
2074
  assert.match(cli, /'累计(本看板) 有效 '/) // 累计三分量:有效
1863
2075
  assert.match(cli, /' · 缓存读 '/) // 累计三分量:缓存读
1864
2076
  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 补总量对照
2077
+ assert.match(cli, /'近 7 天(总量,含缓存读)'/)
2078
+ assert.match(cli, /k\.slice\(5\) \+ ':总量 ' \+ String\(v\)/) // 条形 title:MM-DD:总量 N tok
2079
+ assert.match(cli, /' \/ 有效 ' \+ String\(c\.e\) \+ ' tok'/) // 条形 title 补有效对照
1868
2080
  assert.match(cli, /口径:累计与 Top 8 仅看板派发的 Worker\/Verifier run 消耗/) // 口径边界明示(累计/Top8 仅 run 口径)
1869
2081
  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、其余浅底
2082
+ assert.match(cli, /主数字(今日 \/ 近 7 天 \/ 模型分布 \/ Top 8 \/ 主窗口行)均为总量口径(含缓存读)/) // 主数字口径翻转(task-muxnqunk)
2083
+ assert.match(cli, /其中缓存读 ' \+ String\(x\.cacheRead \|\| 0\)/) // Top8 title 补 缓存读 拆分(有效进 title)
2084
+ assert.match(cli, /background: isToday \? C\.brand : C\.nested/) // 今天高亮 brand、其余浅底(灰柱规则已退役)
1873
2085
  assert.match(cli, /hasDayData \? React\.createElement/) // 7 天全空不渲染该区
1874
- // e 不可知的旧口径日:不拿总量冒充有效——今日「—」/ 柱形矮灰柱 + tooltip 标明(task-muwsnyqv ③,
1875
- // 旧「标 ~ 近似」分支已除,见三连修测试的反向断言)
1876
- assert.match(cli, /旧口径数据(仅总量/)
1877
2086
  })
1878
2087
 
1879
2088
  // ===== Token 口径三连修(task-muwsnyqv):byDay 闭区间 / byDayFull 固定口径 / 旧口径不冒充 =====
@@ -1934,26 +2143,33 @@ test('Token 三连修②:byDayFull 未过滤全量与 byDay 并存(今日/
1934
2143
  assert.match(cli, /var byDay = \(u\.byDayFull && typeof u\.byDayFull === 'object'\) \? u\.byDayFull : \(\(u\.byDay && typeof u\.byDay === 'object'\) \? u\.byDay : \{\}\)/)
1935
2144
  })
1936
2145
 
1937
- test('Token 三连修③:e=null 旧口径日不拿 t 冒充有效(今日「—」/ 灰柱 + tooltip,源码级断言)', () => {
2146
+ // ===== Token 口径翻转(task-muxnqunk,用户 2026-10-07 裁决「算总的吧」):总量主显 / 有效退悬浮 / 灰柱退役 =====
2147
+ // 推翻 task-muwq9u04 的「有效消耗主显」:主数字一律总量 t(含缓存读),有效消耗 e 退居 title/次要位;
2148
+ // task-muwsnyqv ③ 的旧口径灰柱规则顺势退役(t 恒有值,e===null 的日子现在就是正常柱)。
2149
+ // 不动的:模型表现区与质量趋势区保持有效口径(效率指标语义),报告导出分列口径不动。
2150
+ test('Token 口径翻转:总量主显(含缓存读)+ 有效退 title + 旧口径灰柱规则退役(源码级断言)', () => {
1938
2151
  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)
2152
+ // 近 7 天柱形:按总量归一与画柱(t 恒有值,e===null 的旧口径日就是正常柱)
2153
+ assert.match(cli, /if \(dc\.t > maxDay\) maxDay = dc\.t/) // maxDay 按总量归一
2154
+ assert.match(cli, /var v = c\.t \|\| 0/) // 柱高取总量
2155
+ assert.match(cli, /background: isToday \? C\.brand : C\.nested/) // 今天高亮 brand,其余浅底(无灰柱分支)
2156
+ // 今日大数字:显总量 todayCell.t(byDayFull 口径不变),有效退 title / 次要小字
2157
+ assert.match(cli, /fmtTokens\(todayCell\.t\)/)
2158
+ assert.match(cli, /'tokens(今日总量,含缓存读)'/)
2159
+ // caption 新文案:主数字均为总量口径(含缓存读);有效消耗见悬浮
2160
+ assert.match(cli, /主数字(今日 \/ 近 7 天 \/ 模型分布 \/ Top 8 \/ 主窗口行)均为总量口径(含缓存读)/)
2161
+ assert.match(cli, /有效消耗(input\+output\+cacheWrite,不含缓存读)见悬浮 title/)
2162
+ // 负断言:旧口径灰柱规则(task-muwsnyqv ③)顺势退役——legacy 分支 / 灰柱图例 / 今日「—」防护全删
2163
+ assert.equal(/var legacy = /.test(cli), false)
2164
+ assert.equal(/var todayLegacy/.test(cli), false)
2165
+ assert.equal(/hasLegacy/.test(cli), false)
2166
+ assert.equal(/灰柱 = 旧口径数据/.test(cli), false)
2167
+ assert.equal(/旧口径数据(仅总量/.test(cli), false)
2168
+ assert.equal(/今日有效消耗不可知/.test(cli), false)
1953
2169
  assert.equal(/(近似:老日账只有总量)/.test(cli), false)
1954
- // README 双份记录老数据处理口径
2170
+ // README 双份记录口径翻转
1955
2171
  const pkg = readFileSync(new URL('../README.md', import.meta.url), 'utf8')
1956
- assert.ok(pkg.includes('旧口径'), 'README 应记录旧口径日(e=null)不拿总量冒充有效的处理')
2172
+ assert.ok(pkg.includes('主数字均为总量口径'), 'README 应记录 Token 区口径翻转(总量主显)')
1957
2173
  })
1958
2174
 
1959
2175
  // ===== 调研 ROI 行 token 有效口径(task-muupnnq5)=====
@@ -2130,7 +2346,7 @@ test('学习飞轮接线:两处触发点 + push-lesson 开关拦截 + prompt/
2130
2346
  test('架构健康接线:get-tasks 返回 healthHints + 仪表盘 HealthHints 区缺省兼容渲染', () => {
2131
2347
  const host = hostSrc()
2132
2348
  const cli = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
2133
- assert.match(host, /d\.healthHints = computeHealthHints\(d\.tasks\)/) // host 每次请求现算返回(零存储)
2349
+ assert.match(host, /d\.healthHints = __rh\.hints\.concat\(computeHealthHints\(d\.tasks\)\)/) // host 每次请求现算返回(零存储);运行时 hint(task-muxhrkbg)拼在静态信号前面
2134
2350
  assert.match(cli, /function HealthHints\(\)/) // 仪表盘提示区组件
2135
2351
  assert.match(cli, /React\.createElement\(HealthHints\)/) // 挂进 Dashboard(统计卡行下方、Token 消耗区上方)
2136
2352
  assert.match(cli, /Array\.isArray\(d\.healthHints\)/) // 缺省兼容:老 host 无字段/非数组 → 按空处理
@@ -2267,8 +2483,8 @@ test('aggregateChildStats: 归档口径注释写明僵尸卡近似 + 旧「归
2267
2483
  // 防回归:聚合循环里不得再按 archived 跳过
2268
2484
  assert.doesNotMatch(coreSrc, /aggregateChildStats[\s\S]{0,600}?status === 'archived'\) continue/)
2269
2485
  assert.match(coreSrc, /if \(!t \|\| !isb\(t\)\) continue/)
2270
- // 与父卡自动收口口径(isChildSettled,archived 不算)刻意分离,注释里写明区别
2271
- assert.match(coreSrc, /与 isChildSettled(父卡自动收口口径,archived 不算)/)
2486
+ // 与父卡自动收口口径(isChildSettled)现已同口径:archived 计入终态集,注释里写明(反馈 n-muw706h1uymy)
2487
+ assert.match(coreSrc, /与 isChildSettled(父卡自动收口口径)现已同口径/)
2272
2488
  // 僵尸放行本身的门禁注释仍在 rpc.mjs(口径来源不悬空)
2273
2489
  const rpcSrc = readFileSync(new URL('../lib/rpc.mjs', import.meta.url), 'utf8')
2274
2490
  assert.match(rpcSrc, /in-progress 且无活跃 run\s+→ 放行(parentKick 僵尸态出清/)
@@ -2928,7 +3144,7 @@ test('可续跑 Worker 接线(源码级):事件订阅走 ctx.effect 回收
2928
3144
  // ⑥ 板级开关三件套(cfg 兜底 / normalizeBoard 补缺省 / seed 初值 / set-board-config 白名单 / get-tasks 透出)
2929
3145
  assert.match(coreSrc, /workerContinuable: d\.workerContinuable !== false/)
2930
3146
  assert.match(coreSrc, /if \(typeof d\.workerContinuable !== 'boolean'\) d\.workerContinuable = true/)
2931
- assert.match(coreSrc, /epicSplit: true, workerContinuable: true, minWorkers: 1/)
3147
+ assert.match(coreSrc, /epicSplit: true, workerContinuable: true, verifyUserGuide: true, minWorkers: 1/)
2932
3148
  assert.match(rpcSrc, /else if \(args\.key === 'workerContinuable'\) d\.workerContinuable = !!args\.value/)
2933
3149
  assert.match(rpcSrc, /d\.workerContinuable = cfg\(d\)\.workerContinuable/)
2934
3150
  // ⑦ 手动终止/活动查询对 continuable rec 不炸(id 统一取 rec.id,dispose 分路)
@@ -3272,10 +3488,11 @@ test('史诗语义层接线:poolCycle 派发分支触发父卡流转 + get-tas
3272
3488
  // get-tasks 现算 childStats(零存储)
3273
3489
  assert.match(host, /d\.childStats = aggregateChildStats\(d\.tasks\)/)
3274
3490
  // 两模块都从 core 解构引入(接线不断);rpc.mjs 解构表尾部随调研门禁(task-mute6zpw)、调研遵循三件套(task-mutnj3a4)、
3275
- // tasksHash(task-mutrtwin)、hooks 浅校验(normalizeHooks/mergeHooks,本批 hooks=agent run)扩展;
3491
+ // tasksHash(task-mutrtwin)、hooks 浅校验(normalizeHooks/mergeHooks,本批 hooks=agent run)、
3492
+ // 自测指南归一(normalizeUserTest,task-muxyyvg0)扩展;
3276
3493
  // dispatch.mjs 解构表尾部追加 hook 族(buildHookPrompt/hookOn/hookSetState/gsb)与驳回包 helper(pushRejection,task-muvg15p5)
3277
3494
  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/)
3495
+ assert.match(host, /boardHome, aggregateChildStats, createTaskWarnings, epicPrecheck, epicPrecheckNote, attachContextSuggestions, REJECT_REDISPATCH_HINT, tasksHash, normalizeHooks, mergeHooks, pushRejection, normalizeUserTest \} = core/)
3279
3496
  // 既有「全子任务 resolved → 父 verifying」逻辑不动(checkParentAuto 仍在 verifyApply 链路;
3280
3497
  // 但其内部已委托共享 helper maybeAutoCloseParent——单一判定口径,core.mjs 不在 hostSrc 清单,单独读)
3281
3498
  const coreSrc = readFileSync(new URL('../lib/core.mjs', import.meta.url), 'utf8')
@@ -3633,6 +3850,74 @@ test('task_archive 工具:与 archive-task RPC 同一门禁口径(僵尸放
3633
3850
  assert.equal(r.ok, false); assert.match(r.error, /cannot archive/)
3634
3851
  })
3635
3852
 
3853
+ // ===== 反馈修复(task-mux3uvx3,反馈 n-muw706h1uymy):归档路径触发父卡收口 + task_resolve 守卫对齐 =====
3854
+ test('归档双通道触发父卡自动收口:archive-task RPC 与 task_archive 工具同一 archiveApply 口径', async () => {
3855
+ // RPC 通道:归档末子卡 → 父卡 in-progress → verifying
3856
+ const p = mkTask({ id: 'ep', status: 'in-progress' })
3857
+ const k1 = mkTask({ id: 'k1', parentId: 'ep', status: 'resolved' })
3858
+ const k2 = mkTask({ id: 'k2', parentId: 'ep', status: 'resolved' })
3859
+ const board = mkBoard([p, k1, k2])
3860
+ const h = mkRpcHandlers(board)
3861
+ const r = await h['archive-task']({ taskId: 'k2' })
3862
+ assert.equal(r.ok, true); assert.equal(r.parentUpdated, true)
3863
+ assert.equal(p.status, 'verifying')
3864
+ assert.equal(p.resolution, 'all subtasks resolved')
3865
+ // 工具通道同口径
3866
+ const p2 = mkTask({ id: 'ep2', status: 'in-progress' })
3867
+ const j1 = mkTask({ id: 'j1', parentId: 'ep2', status: 'resolved' })
3868
+ const j2 = mkTask({ id: 'j2', parentId: 'ep2', status: 'resolved' })
3869
+ const h2 = mkRpcHandlers(mkBoard([p2, j1, j2]))
3870
+ const r2 = await h2.__tools['task_archive'].execute({ taskId: 'j2' }, {})
3871
+ assert.equal(r2.ok, true); assert.equal(r2.parentUpdated, true); assert.equal(p2.status, 'verifying')
3872
+ // 未凑齐不收口(还剩在跑子卡):归档其中一张已 resolved 的,父卡不动
3873
+ const p3 = mkTask({ id: 'ep3', status: 'in-progress' })
3874
+ const m1 = mkTask({ id: 'm1', parentId: 'ep3', status: 'resolved' })
3875
+ const m2 = mkTask({ id: 'm2', parentId: 'ep3', status: 'in-progress' })
3876
+ const h3 = mkRpcHandlers(mkBoard([p3, m1, m2]), { hasActiveRun: () => true })
3877
+ const r3 = await h3['archive-task']({ taskId: 'm1' })
3878
+ assert.equal(r3.ok, true); assert.equal('parentUpdated' in r3, false); assert.equal(p3.status, 'in-progress')
3879
+ })
3880
+
3881
+ test('batch-op archive:整批落定后触发父卡收口(第三条归档路径同口径);未凑齐不收口', async () => {
3882
+ // 末两张子卡一次批量归档 → 父卡 verifying(旧路径归档后从不查父卡 → 同样卡死)
3883
+ const p = mkTask({ id: 'bp', status: 'in-progress' })
3884
+ const b1 = mkTask({ id: 'b1', parentId: 'bp', status: 'resolved' })
3885
+ const b2 = mkTask({ id: 'b2', parentId: 'bp', status: 'resolved' })
3886
+ const b3 = mkTask({ id: 'b3', parentId: 'bp', status: 'cancelled' })
3887
+ const h = mkRpcHandlers(mkBoard([p, b1, b2, b3]))
3888
+ const r = await h['batch-op']({ op: 'archive', ids: ['b1', 'b2', 'b3'] })
3889
+ assert.equal(r.ok, true); assert.equal(r.done, 3); assert.equal(r.parentsClosed, 1)
3890
+ assert.equal(p.status, 'verifying')
3891
+ // 未凑齐(还有在跑子卡)→ 不收口、不挂 parentsClosed 字段(返回体老契约不变)
3892
+ const p2 = mkTask({ id: 'bp2', status: 'in-progress' })
3893
+ const d1 = mkTask({ id: 'd1', parentId: 'bp2', status: 'resolved' })
3894
+ const d2 = mkTask({ id: 'd2', parentId: 'bp2', status: 'in-progress' })
3895
+ const h2 = mkRpcHandlers(mkBoard([p2, d1, d2]))
3896
+ const r2 = await h2['batch-op']({ op: 'archive', ids: ['d1'] })
3897
+ assert.equal(r2.ok, true); assert.equal('parentsClosed' in r2, false); assert.equal(p2.status, 'in-progress')
3898
+ // 不级联:批量只动显式选中的 id(与单卡 archiveApply 的级联刻意不同)
3899
+ assert.equal(d2.status, 'in-progress')
3900
+ })
3901
+
3902
+ test('task_resolve 工具:claimedBy=null 的 in-progress 卡放行主窗口(对齐 RPC 口径);他人认领照旧拒', async () => {
3903
+ // claimedBy=null(parentKick 僵尸 epic / 手动置 in-progress 的无人认领卡)→ 放行(工具已有主窗口门禁)
3904
+ const t1 = mkTask({ id: 'u1', status: 'in-progress', claimedBy: null })
3905
+ const r1 = await mkRpcHandlers(mkBoard([t1])).__tools['task_resolve'].execute({ taskId: 'u1', status: 'blocked' }, {})
3906
+ assert.equal(r1.ok, true); assert.equal(t1.status, 'blocked')
3907
+ // verifying + resolution 同样放行(父卡检查仍由 resolveApply 内部触发,行为不变)
3908
+ const t2 = mkTask({ id: 'u2', status: 'in-progress', claimedBy: null, pipeline: 'full' })
3909
+ const r2 = await mkRpcHandlers(mkBoard([t2])).__tools['task_resolve'].execute({ taskId: 'u2', status: 'verifying', resolution: '手动兜底' }, {})
3910
+ assert.equal(r2.ok, true); assert.equal(t2.status, 'verifying')
3911
+ // 他人认领 → 照旧拒(既有守卫场景不回归)
3912
+ const t3 = mkTask({ id: 'u3', status: 'in-progress', claimedBy: 'other' })
3913
+ const r3 = await mkRpcHandlers(mkBoard([t3])).__tools['task_resolve'].execute({ taskId: 'u3', status: 'verifying', resolution: 'x' }, {})
3914
+ assert.equal(r3.ok, false); assert.match(r3.error, /not claimed by you/); assert.equal(t3.status, 'in-progress')
3915
+ // 本人认领 → 放行
3916
+ const t4 = mkTask({ id: 'u4', status: 'in-progress', claimedBy: 'tester', pipeline: 'full' })
3917
+ const r4 = await mkRpcHandlers(mkBoard([t4])).__tools['task_resolve'].execute({ taskId: 'u4', status: 'verifying', resolution: 'x' }, {})
3918
+ assert.equal(r4.ok, true); assert.equal(t4.status, 'verifying')
3919
+ })
3920
+
3636
3921
  test('反馈修复接线断言:index.mjs 注入 hasActiveRun + get-tasks 附 tasksHash + kernel 短路分支(源码级)', () => {
3637
3922
  const host = hostSrc()
3638
3923
  // index.mjs:hasActiveRun 定义(runsFor 表有记录且 !settled 即活跃)并注入 createRpc deps
@@ -4056,7 +4341,7 @@ test('epicSplit 接线(源码级):缓存同步 / Team 引导段门禁 /
4056
4341
  // 配置层(core.mjs:cfg 兜底 / seed 初值 / normalizeBoard 老看板补齐)
4057
4342
  assert.match(coreSrc, /epicSplit: d\.epicSplit !== false/)
4058
4343
  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 之间)
4344
+ assert.match(coreSrc, /notifyDone: true, epicSplit: true, workerContinuable: true, verifyUserGuide: true, minWorkers: 1/) // seed 缺省开(workerContinuable 随 task-muw5gnhv、verifyUserGuide 随 task-muxyyvg0 插入 epicSplit 与 minWorkers 之间)
4060
4345
  // 缓存链路:rt() 读盘同步 + set-board-config 当场回填(Team 提示词是同步组装,只能读缓存)
4061
4346
  assert.match(src, /epicSplitCache\[sid\] = nd\.epicSplit !== false/)
4062
4347
  assert.match(src, /function epicSplitOn\(sid\) \{ return epicSplitCache\[sid\] !== false \}/)
@@ -4507,7 +4792,7 @@ test('池配置开关乐观更新:每个开关先写 state+notify 再 rpc,
4507
4792
  assert.ok(m, '开关 onChange 必须是 setCfg(键, e.target.checked) 单调用,实测:' + h)
4508
4793
  keys.push(m[1])
4509
4794
  }
4510
- assert.deepEqual(keys.slice().sort(), ['epicSplit', 'feedbackEnabled', 'notifyDispatch', 'notifyDone'])
4795
+ assert.deepEqual(keys.slice().sort(), ['epicSplit', 'feedbackEnabled', 'notifyDispatch', 'notifyDone', 'verifyUserGuide'])
4511
4796
  // ② 乐观序:先写 state、再 notify、最后才 rpc(顺序颠倒 = 点击仍等往返)
4512
4797
  const setCfgMatch = src.match(/function setCfg\(key, next\) \{[\s\S]*?\n \}/)
4513
4798
  assert.ok(setCfgMatch, '应定义 setCfg')
@@ -4556,17 +4841,17 @@ test('池配置开关乐观更新:每个开关先写 state+notify 再 rpc,
4556
4841
 
4557
4842
  test('fetchTasks 设置开关权威纠偏:hash 不变时配置变化补 notify + hash 短路结构保持(源码级)', () => {
4558
4843
  const src = readFileSync(new URL('../lib/client/kernel.js', import.meta.url), 'utf8')
4559
- // ① 取值口径单点定义(缺字段/脏值=开,只有显式 false 才关),且四个开关字段确由它赋值
4844
+ // ① 取值口径单点定义(缺字段/脏值=开,只有显式 false 才关),且五个开关字段确由它赋值
4560
4845
  const knobSrc = src.match(/function cfgKnobOf\(src, key\) \{[^\n]*\n/)[0]
4561
4846
  const changedSrc = src.match(/function cfgKnobsChanged\(a, b\) \{[^\n]*\n/)[0]
4562
4847
  const cfgKnobOf = new Function(knobSrc + '\nreturn cfgKnobOf')()
4563
4848
  const cfgKnobsChanged = new Function(changedSrc + '\nreturn cfgKnobsChanged')()
4564
4849
  assert.equal(cfgKnobOf({}, 'notifyDispatch'), true) // 老 host 无字段 → 开
4565
4850
  assert.equal(cfgKnobOf({ notifyDispatch: false }, 'notifyDispatch'), false) // 只有显式 false 才关
4566
- const KNOBS = ['feedbackEnabled', 'notifyDispatch', 'notifyDone', 'epicSplit']
4851
+ const KNOBS = ['feedbackEnabled', 'notifyDispatch', 'notifyDone', 'epicSplit', 'verifyUserGuide']
4567
4852
  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 }
4853
+ // ② 真执行比较器:五个开关任一翻转都算变化(漏一个 → 该开关又要等下一次任意 notify)
4854
+ const allOn = { feedbackEnabled: true, notifyDispatch: true, notifyDone: true, epicSplit: true, verifyUserGuide: true }
4570
4855
  assert.equal(cfgKnobsChanged(allOn, Object.assign({}, allOn)), false)
4571
4856
  for (const k of KNOBS) {
4572
4857
  assert.equal(cfgKnobsChanged(allOn, Object.assign({}, allOn, { [k]: false })), true, k + ' 翻转必须被检测到')
@@ -4782,9 +5067,11 @@ test('池冻结根修⑤(接线,源码级):幽灵回收在活跃度计
4782
5067
  const iCount = dsp.indexOf('if (rc0.role === \'worker\') activeW++')
4783
5068
  assert.ok(iReap > 0 && iCount > iReap, '幽灵回收必须先于活跃度计数')
4784
5069
  // ② 回收后重读快照(否则空闲快进的 wt(snap) 会把收尾结果覆盖回旧状态)
4785
- assert.match(dsp, /if \(reapedN > 0\) \{ try \{ snap = await rt\(sid\) \} catch \(_\) \{\} \}/)
5070
+ // (task-muxhrkbg 在同一块内追加了 reapNote 打点,块体由单行扩成多行——语义不变,断言随之放宽到块形状)
5071
+ assert.match(dsp, /if \(reapedN > 0\) \{\s*try \{ snap = await rt\(sid\) \} catch \(_\) \{\}/)
4786
5072
  // ③ 本轮主体包 try(整轮异常隔离),且刻意不设再入 latch
4787
- assert.match(dsp, /try \{ return await poolCycleBody\(sid, info, runs, snap\) \}/)
5073
+ // (task-muxhrkbg 在 try 块内追加 poolLastOkAt 心跳打点:主体正常返回才刷新——异常隔离语义由下面的 catch 断言继续锁死)
5074
+ assert.match(dsp, /try \{\s*var cycleRet = await poolCycleBody\(sid, info, runs, snap\)\s*poolHealthFor\(sid\)\.poolLastOkAt = Date\.now\(\)\s*return cycleRet\s*\}/)
4788
5075
  assert.match(dsp, /catch \(e\) \{ console\.error\('\[task-board\] poolCycle 本轮异常(已隔离,下一轮照常)/)
4789
5076
  assert.match(dsp, /刻意\*\*不设\*\*再入守卫/)
4790
5077
  // ④ 持锁段按阶段隔离(五段各自 try/catch,回调照样返回 d → 该写的池状态一定写下去)