dsh-agent-board 1.7.0 → 1.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,9 +10,13 @@ import zlib from 'node:zlib'
10
10
  import * as core from '../lib/core.mjs'
11
11
  // 粒度治理(软闸门)住在 lib/policy.mjs(策略层,纯函数)、usage 聚合住在 lib/usage.mjs,
12
12
  // 均由 index.mjs 薄壳 re-export(对外契约不变),这里仍从 index.mjs 导入直接断言
13
- import { suggestSplitOf, withSplitHint, SUGGEST_SPLIT_TEXT, TASK_SIZE_CONTRACT, TEAM_SPLIT_RULE } from '../index.mjs'
14
- import { aggregateUsageSummary, readRunUsage, findRunLog } from '../index.mjs'
13
+ import { suggestSplitOf, withSplitHint, SUGGEST_SPLIT_TEXT, TASK_SIZE_CONTRACT, TEAM_SPLIT_RULE, splitRuleOf } from '../index.mjs'
14
+ import { aggregateUsageSummary, readRunUsage, findRunLog, effectiveTokens, taskEffectiveTokens } from '../index.mjs'
15
15
  import { createRpc } from '../lib/rpc.mjs'
16
+ // no-root 刷屏根治(task-muuf0o7a)专项:会话层幻影板防线 + 派发层 root 闸门直调断言
17
+ import { createSession, isFullSessionId } from '../lib/session.mjs'
18
+ import { createDispatch } from '../lib/dispatch.mjs'
19
+ import { createNotify } from '../lib/notify.mjs'
16
20
  // host 端源码拼接(Phase 2 模块化后:薄壳 index.mjs + lib/*.mjs 领域模块),供源码级接线断言
17
21
  const HOST_SOURCES = ['../index.mjs', '../lib/policy.mjs', '../lib/usage.mjs', '../lib/session.mjs', '../lib/store.mjs', '../lib/notify.mjs', '../lib/dispatch.mjs', '../lib/rpc.mjs']
18
22
  function hostSrc() { return HOST_SOURCES.map(function (f) { return readFileSync(new URL(f, import.meta.url), 'utf8') }).join('\n') }
@@ -76,6 +80,82 @@ test('子任务全 resolved → 父任务自动 verifying', () => {
76
80
  assert.equal(r.parentUpdated, true); assert.equal(p.status, 'verifying')
77
81
  })
78
82
 
83
+ // ===== 史诗自动收口补漏(task-muuoduri):work 管线子任务也要收口 =====
84
+ // 实证 bug:resolveApply 先把非 full 管线的 status 由 verifying 改写成 resolved,
85
+ // 旧的父卡检查绑在 `status === 'verifying'` 分支上 → work 档子任务(无 Verifier)永不触发父卡收口,
86
+ // epic 永久卡 in-progress。修法:判定抽成共享 helper maybeAutoCloseParent,resolveApply 落定后无条件调用。
87
+ test('史诗收口①:work 管线子任务 resolve → 父卡自动转 verifying(核心回归)', () => {
88
+ const p = mkTask({ id: 'epic', status: 'in-progress' }) // epic 父卡(pipeline=direct,不下池)
89
+ const c = mkTask({ id: 'c1', parentId: 'epic', status: 'in-progress', pipeline: 'work' })
90
+ const d = mkBoard([p, c])
91
+ const r = core.resolveApply(d, c, 'w', 'verifying', 'done')
92
+ assert.equal(c.status, 'resolved') // work 档:resolve 即落 resolved(无 Verifier)
93
+ assert.equal(r.parentUpdated, true) // 父卡收口被触发(旧实现此处为 undefined → 永久卡死)
94
+ assert.equal(p.status, 'verifying')
95
+ assert.equal(p.resolution, 'all subtasks resolved')
96
+ assert.equal(p.history[p.history.length - 1].actor, 'system')
97
+ })
98
+
99
+ test('史诗收口②:full 档子任务 verify approved 收口仍然成立(既有防回归)', () => {
100
+ const p = mkTask({ id: 'epic', status: 'in-progress' })
101
+ const c = mkTask({ id: 'c1', parentId: 'epic', status: 'verifying', pipeline: 'full' })
102
+ const d = mkBoard([p, c])
103
+ const r = core.verifyApply(d, c, 'v', 'approved', 'ok')
104
+ assert.equal(r.parentUpdated, true)
105
+ assert.equal(p.status, 'verifying')
106
+ })
107
+
108
+ test('史诗收口③:cancelled 子任务计入终态完成集 → 父卡也能收口', () => {
109
+ const p = mkTask({ id: 'epic', status: 'in-progress' })
110
+ const c1 = mkTask({ id: 'c1', parentId: 'epic', status: 'resolved' })
111
+ const c2 = mkTask({ id: 'c2', parentId: 'epic', status: 'cancelled' })
112
+ const c3 = mkTask({ id: 'c3', parentId: 'epic', status: 'in-progress', pipeline: 'work' })
113
+ const d = mkBoard([p, c1, c2, c3])
114
+ assert.equal(core.isChildSettled(c1), true)
115
+ assert.equal(core.isChildSettled(c2), true) // 人主动取消 = 该子任务范围已了结
116
+ assert.equal(core.isChildSettled(c3), false)
117
+ assert.equal(core.isChildSettled(mkTask({ status: 'archived' })), false) // 归档不算(收尾动作,不反向推动)
118
+ const r = core.resolveApply(d, c3, 'w', 'verifying', 'done')
119
+ assert.equal(r.parentUpdated, true)
120
+ assert.equal(p.status, 'verifying')
121
+ })
122
+
123
+ test('史诗收口④:仍有 pending/in-progress 子任务 → 不收口', () => {
124
+ const p = mkTask({ id: 'epic', status: 'in-progress' })
125
+ const c1 = mkTask({ id: 'c1', parentId: 'epic', status: 'in-progress', pipeline: 'work' })
126
+ const c2 = mkTask({ id: 'c2', parentId: 'epic', status: 'pending' })
127
+ const d = mkBoard([p, c1, c2])
128
+ const r = core.resolveApply(d, c1, 'w', 'verifying', 'done')
129
+ assert.equal(c1.status, 'resolved')
130
+ assert.equal('parentUpdated' in r, false)
131
+ assert.equal(p.status, 'in-progress') // 未被误收口
132
+ })
133
+
134
+ test('史诗收口⑤:父卡已 verifying/resolved → 幂等不重复触发(无副作用)', () => {
135
+ const c = mkTask({ id: 'c1', parentId: 'epic', status: 'in-progress', pipeline: 'work' })
136
+ const pv = mkTask({ id: 'epic', status: 'verifying' })
137
+ const dv = mkBoard([pv, c])
138
+ assert.equal(core.maybeAutoCloseParent(dv, c), null)
139
+ assert.equal(pv.history.length, 0) // 无 history 追加 = 零副作用
140
+ const pr = mkTask({ id: 'epic', status: 'resolved' })
141
+ const dr = mkBoard([pr, c])
142
+ assert.equal(core.maybeAutoCloseParent(dr, c), null)
143
+ assert.equal(pr.history.length, 0)
144
+ // 无 parentId(顶层任务)与父卡不存在 → 一律 null
145
+ assert.equal(core.maybeAutoCloseParent(mkBoard([mkTask({ id: 'lonely' })]), mkTask({ id: 'lonely' })), null)
146
+ assert.equal(core.maybeAutoCloseParent(mkBoard([c]), c), null) // 父卡不在板上(悬空引用)
147
+ })
148
+
149
+ test('史诗收口⑥:checkParentAuto 兼容名委托共享 helper(单一判定口径)', () => {
150
+ const p = mkTask({ id: 'epic', status: 'in-progress' })
151
+ const c = mkTask({ id: 'c1', parentId: 'epic', status: 'resolved' })
152
+ const d = mkBoard([p, c])
153
+ const got = core.checkParentAuto(d, c)
154
+ assert.equal(got, p)
155
+ assert.equal(p.status, 'verifying')
156
+ assert.equal(p.resolution, 'all subtasks resolved') // 口径文案统一(旧 resolveApply 分支的 'all subtasks done' 已退役)
157
+ })
158
+
79
159
  // ===== 依赖 =====
80
160
  test('validateDeps: 自引用/不存在/成环 全拒绝', () => {
81
161
  const a = mkTask({ id: 'a' }); const b = mkTask({ id: 'b', dependsOn: ['a'] })
@@ -498,21 +578,88 @@ test('touchesConflict: 与锁持有者逐条比对,返回冲突持有者 id',
498
578
  assert.deepEqual(core.touchesConflict({ id: 'h1', touches: ['src/a.js'] }, holds), []) // 不和自己冲突
499
579
  })
500
580
 
501
- test('holdsFiles: 仅 in-progress + claimedBy + 有 touches 持有文件锁', () => {
581
+ test('holdsFiles: in-progress(claimedBy) / verifying / resolved 三态持有文件锁,其余不持有', () => {
502
582
  const tasks = [
503
583
  mkTask({ id: 'live', status: 'in-progress', claimedBy: 'run-1', touches: ['src/**'] }),
504
584
  mkTask({ id: 'notouch', status: 'in-progress', claimedBy: 'run-2' }),
505
585
  mkTask({ id: 'noclaim', status: 'in-progress' }),
506
586
  mkTask({ id: 'pending', status: 'pending', touches: ['src/a.js'] }),
587
+ // task-muv7c8ja:verifying/resolved 都持锁(锁随卡的生命周期走,归档才真释放)
507
588
  mkTask({ id: 'verifying', status: 'verifying', touches: ['src/b.js'] }),
589
+ // resolved 卡不查 claimedBy(resolveApply 转 resolved 前 worker 已停笔,claimedBy 可能已清/保留)
590
+ mkTask({ id: 'resolved', status: 'resolved', touches: ['src/c.js'] }),
508
591
  mkTask({ id: 'empty', status: 'in-progress', claimedBy: 'run-3', touches: [] }),
509
592
  ]
510
593
  const holds = core.holdsFiles(mkBoard(tasks))
511
- assert.deepEqual(holds.map(h => h.id), ['live'])
594
+ assert.deepEqual(holds.map(h => h.id), ['live', 'verifying', 'resolved'])
512
595
  assert.deepEqual(holds[0].touches, ['src/**'])
513
596
  assert.deepEqual(core.holdsFiles(null), [])
514
597
  })
515
598
 
599
+ // ===== touches 锁延长至归档(task-muv7c8ja / 反馈 n-muupqg81u575)四类断言 =====
600
+ test('touches 持锁①:resolved 未归档 + 同 touches 候选 → 候选不派发(验收后-提交前窗口期不放锁)', () => {
601
+ const holder = mkTask({ id: 'holder', status: 'resolved', touches: ['src/**'] })
602
+ const cand = mkTask({ id: 'cand', touches: ['src/a.js'], createdAt: '2026-01-02' })
603
+ const r = core.pickDispatch(mkBoard([holder, cand]), 5, 0, null)
604
+ assert.deepEqual(r.pendings, []) // 锁未放:候选一张都不派
605
+ assert.deepEqual(r.blockedTouches, [{ id: 'cand', conflicts: ['holder'] }]) // 滞留原因对 UI 可见(等的是 resolved 卡)
606
+ // 不依赖候选自身状态:resolved 持锁只与 touches 声明有关
607
+ assert.deepEqual(core.holdsFiles(mkBoard([holder])).map(h => h.id), ['holder'])
608
+ })
609
+
610
+ test('touches 持锁②:holder 归档后 → 候选下一轮自动放行(归档是唯一真释放点)', () => {
611
+ const holder = mkTask({ id: 'holder', status: 'resolved', touches: ['src/**'] })
612
+ const cand = mkTask({ id: 'cand', touches: ['src/a.js'], createdAt: '2026-01-02' })
613
+ assert.deepEqual(core.pickDispatch(mkBoard([holder, cand]), 5, 0, null).pendings, []) // 归档前:拦住
614
+ holder.status = 'archived' // 主窗口提交后的归档动作
615
+ const r = core.pickDispatch(mkBoard([holder, cand]), 5, 0, null)
616
+ assert.deepEqual(r.pendings.map(t => t.id), ['cand'])
617
+ assert.deepEqual(r.blockedTouches, [])
618
+ })
619
+
620
+ test('touches 持锁③:cancelled 立即放锁(放弃语义=不再产出,不堵同批文件)', () => {
621
+ const holder = mkTask({ id: 'holder', status: 'in-progress', claimedBy: 'run-1', touches: ['src/**'] })
622
+ const cand = mkTask({ id: 'cand', touches: ['src/a.js'], createdAt: '2026-01-02' })
623
+ assert.deepEqual(core.pickDispatch(mkBoard([holder, cand]), 5, 0, null).blockedTouches, [{ id: 'cand', conflicts: ['holder'] }])
624
+ holder.status = 'cancelled'
625
+ assert.deepEqual(core.holdsFiles(mkBoard([holder])), []) // 取消即放锁(不等归档)
626
+ const r = core.pickDispatch(mkBoard([holder, cand]), 5, 0, null)
627
+ assert.deepEqual(r.pendings.map(t => t.id), ['cand'])
628
+ assert.deepEqual(r.blockedTouches, [])
629
+ })
630
+
631
+ test('touches 持锁④:verifying 持锁拦候选,但 Verifier 派发(verifs)照常不受影响', () => {
632
+ const vt = mkTask({ id: 'vt', status: 'verifying', pipeline: 'full', touches: ['src/**'] })
633
+ const cand = mkTask({ id: 'cand', touches: ['src/a.js'], createdAt: '2026-01-02' })
634
+ const r = core.pickDispatch(mkBoard([vt, cand]), 5, 5, null)
635
+ assert.deepEqual(r.blockedTouches, [{ id: 'cand', conflicts: ['vt'] }]) // verifying 仍持锁
636
+ assert.deepEqual(r.verifs.map(t => t.id), ['vt']) // 只读的 Verifier 不被 touches 拦
637
+ // 驳回会回 in-progress 继续改同一批文件 → 锁在 verifying 期间不能断
638
+ assert.deepEqual(core.holdsFiles(mkBoard([vt])).map(h => h.id), ['vt'])
639
+ })
640
+
641
+ test('touches 锁生命周期接线(源码级):holdsFiles 三态口径 + 不再有 resolved 放锁调用点', () => {
642
+ const src = readFileSync(new URL('../lib/core.mjs', import.meta.url), 'utf8')
643
+ // ① 三态持锁口径写在 holdsFiles 里(verifying 与 resolved 都持,in-progress 仍需 claimedBy)
644
+ assert.match(src, /var holds = t\.status === 'verifying' \|\| t\.status === 'resolved' \|\| \(t\.status === 'in-progress' && !!t\.claimedBy\)/)
645
+ // ② 锁不再随 in-progress→verifying 释放(旧注释口径已退役)
646
+ assert.doesNotMatch(src, /verifying 不持有/)
647
+ // ③ cancelled/archived 明确不在持锁集合里
648
+ assert.doesNotMatch(src, /t\.status === 'cancelled' && !!t\.claimedBy/)
649
+ // ④ 派发层不再有「resolved 时放锁」的动作调用(旧实现 releaseTouchesOnly 已不存在)
650
+ const dsp = readFileSync(new URL('../lib/dispatch.mjs', import.meta.url), 'utf8')
651
+ assert.doesNotMatch(dsp, /releaseTouchesOnly/)
652
+ })
653
+
654
+ test('README 双份同步记录 touches 锁持到归档口径(锁生命周期 = 卡生命周期)', () => {
655
+ const pkg = readFileSync(new URL('../README.md', import.meta.url), 'utf8')
656
+ const root = readFileSync(new URL('../../../README.md', import.meta.url), 'utf8')
657
+ assert.equal(pkg, root) // 两份 README 必须字节一致(npm run sync-readme 的约束)
658
+ for (const s of ['锁持到归档', 'verifying', 'resolved', 'cancelled']) {
659
+ assert.ok(pkg.includes(s), 'README 应记录 touches 锁口径:' + s)
660
+ }
661
+ })
662
+
516
663
  // ===== 派发决策(touches 拦截)=====
517
664
  test('pickDispatch: touches 与活动任务冲突 → 不进 pendings,记入 blockedTouches', () => {
518
665
  const tasks = [
@@ -526,15 +673,15 @@ test('pickDispatch: touches 与活动任务冲突 → 不进 pendings,记入 b
526
673
  assert.deepEqual(r.blockedTouches, [{ id: 'clash', conflicts: ['holder'] }])
527
674
  })
528
675
 
529
- test('pickDispatch: verifying 不持有文件锁(Worker 已停笔),verifier 派发(verifs)不受 touches 影响', () => {
676
+ test('pickDispatch: verifying 持有文件锁(锁未断),但 verifier 派发(verifs)不受 touches 影响', () => {
530
677
  const tasks = [
531
- // verifying 的任务即使声明了 touches 也不持有锁
678
+ // task-muv7c8ja 后:verifying 仍持锁(驳回会回 in-progress 继续改同一批文件,验收后-提交前窗口期也属本卡)
532
679
  mkTask({ id: 'vt', status: 'verifying', pipeline: 'full', claimedBy: 'run-1', touches: ['src/**'] }),
533
680
  mkTask({ id: 'w1', touches: ['src/a.js'] }),
534
681
  ]
535
682
  const r = core.pickDispatch(mkBoard(tasks), 5, 5, null)
536
- assert.deepEqual(r.pendings.map(t => t.id), ['w1']) // 未被 verifying 任务拦住
537
- assert.deepEqual(r.blockedTouches, [])
683
+ assert.deepEqual(r.pendings, []) // 被 verifying 任务拦住,本轮不派
684
+ assert.deepEqual(r.blockedTouches, [{ id: 'w1', conflicts: ['vt'] }])
538
685
  assert.deepEqual(r.verifs.map(t => t.id), ['vt']) // Verifier 照常派(只读,不参与排他)
539
686
  // 反向:pending 任务声明 touches 也不影响 verifs 派发
540
687
  const tasks2 = [mkTask({ id: 'w2', touches: ['src/**'] }), mkTask({ id: 'v2', status: 'verifying', pipeline: 'full' })]
@@ -562,20 +709,25 @@ test('pickDispatch: frozen/dependsOn/escalation 优先级不变(touches 拦截
562
709
  const tasks = [
563
710
  mkTask({ id: 'holder', status: 'in-progress', claimedBy: 'run-1', touches: ['src/**'] }),
564
711
  mkTask({ id: 'fz', frozen: true, touches: ['src/a.js'] }), // 冻结 → 既有语义直接排除,不记 blockedTouches
565
- mkTask({ id: 'dep', dependsOn: ['holder'], touches: ['src/a.js'] }), // 依赖未满足 → 既有语义排除
712
+ // dep 的 touches 走 lib/:只验「依赖未满足被既有语义排除」,不与 clash 抢同一把锁
713
+ // (holder 归档后 dep 的依赖即满足、会被派发并拿下 src/a.js,抢锁会掩盖本条想验的语义)
714
+ mkTask({ id: 'dep', dependsOn: ['holder'], touches: ['lib/a.js'] }),
566
715
  mkTask({ id: 'esc', escalation: { question: 'q' }, touches: ['src/a.js'] }),// 待裁决 → 排除
567
716
  mkTask({ id: 'clash', touches: ['src/a.js'] }), // 唯一被 touches 拦下的
568
717
  ]
569
718
  const r = core.pickDispatch(mkBoard(tasks), 5, 0, null)
570
719
  assert.deepEqual(r.pendings, [])
571
720
  assert.deepEqual(r.blockedTouches, [{ id: 'clash', conflicts: ['holder'] }])
572
- // 锁释放(holder 结算/归档)后,被拦任务下一轮自动恢复可派发
573
- mkBoard(tasks).tasks[0].status = 'verifying'
574
- const r2 = core.pickDispatch(mkBoard(tasks), 5, 0, null)
575
- assert.ok(r2.pendings.map(t => t.id).indexOf('clash') >= 0)
576
- assert.deepEqual(r2.blockedTouches, [])
721
+ // 锁真释放(holder 归档;task-muv7c8ja 后 verifying/resolved 都还持锁)后,被拦任务下一轮自动恢复可派发
722
+ const b2 = mkBoard(tasks)
723
+ b2.tasks[0].status = 'verifying'
724
+ const r2 = core.pickDispatch(b2, 5, 0, null)
725
+ assert.deepEqual(r2.blockedTouches, [{ id: 'clash', conflicts: ['holder'] }]) // verifying 仍持锁
726
+ b2.tasks[0].status = 'archived'
727
+ const r3 = core.pickDispatch(b2, 5, 0, null)
728
+ assert.ok(r3.pendings.map(t => t.id).indexOf('clash') >= 0)
729
+ assert.deepEqual(r3.blockedTouches, [])
577
730
  })
578
-
579
731
  test('normalizeBoard: touches 脏值(非数组)收敛为空数组,缺字段任务照常', () => {
580
732
  const legacy = { tasks: [{ id: 'a', touches: 'src/a.js' }, { id: 'b' }, { id: 'c', touches: ['src/c.js'] }] }
581
733
  const d = core.normalizeBoard(legacy)
@@ -697,8 +849,9 @@ test('粒度治理接线:工具描述/Team 提示词/双出口返回体均已
697
849
  assert.match(TEAM_SPLIT_RULE, /parentId=父卡 id/)
698
850
  assert.match(TEAM_SPLIT_RULE, /checkParentAuto/)
699
851
  assert.ok(src.includes("' + TASK_SIZE_CONTRACT")) // task_create 工具描述已拼接契约
700
- assert.ok(src.includes("' + TEAM_SPLIT_RULE")) // teamSection 已拼接第 6 条
701
- assert.equal((src.match(/withSplitHint\(\{ ok: true, task: t \}, t\)/g) || []).length, 2) // task_create 工具 + create-task RPC
852
+ // Team 提示词第 6 条改经 epicSplit 门禁出口注入(task-muuxcj6y):缺省 true 时逐字回到开关前形态
853
+ assert.ok(src.includes('splitRuleOf(epicSplitOn(String(agent.id)))'))
854
+ assert.equal((src.match(/withSplitHint\(\{ ok: true, task: t \}, t, cfg\(d\)\.epicSplit\)/g) || []).length, 2) // task_create 工具 + create-task RPC
702
855
  assert.match(src, /全量\|整体\|系统级\|全面\|重构\|所有模块\|整个/) // 史诗特征词表在位
703
856
  assert.match(src, /SPLIT_DESC_LIMIT = 500/) // 500 字符阈值在位
704
857
  })
@@ -706,12 +859,16 @@ test('粒度治理接线:工具描述/Team 提示词/双出口返回体均已
706
859
  // ===== create-task 空 description 软警告(E 卡)=====
707
860
  // 轻量 RPC 直调 harness:mock ctx(tools/effect/webServer)+ deps 最小面,直接调 handlers['create-task']
708
861
  // extra 可覆盖默认 deps(如 epic 预检用例注入 pushSysNote 捕获 / sessionCwd 解析根)
862
+ // 幻影板防线(task-muuf0o7a)落地后,create-task 对不完整短 id 直接报错——mock 默认 sid
863
+ // 必须是完整形态(session-xxxx-xxxx-...),否则全量 create-task 用例会被防线拦住。
864
+ const FULL_SID = 'session-test-0000-0000-000000000000'
709
865
  function mkRpcHandlers(board, extra) {
710
- const state = { handlers: {}, teamModeCache: {}, feedbackCache: {} }
711
- const ctx = { tools: { register() {} }, effect() {}, webServer: { register() { return () => {} } } }
866
+ const state = { handlers: {}, teamModeCache: {}, feedbackCache: {}, epicSplitCache: {} }
867
+ const tools = {} // 工具通道捕获:双通道接线测试经 __tools['task_create'].execute(...) 直调
868
+ const ctx = { tools: { register(t) { tools[t.name] = t } }, effect() {}, webServer: { register() { return () => {} } } }
712
869
  const deps = Object.assign({
713
870
  getActorId: () => 'tester', resolveRoot: (x) => x,
714
- toolSessionId: () => 's1', rpcSessionId: () => 's1',
871
+ toolSessionId: () => FULL_SID, rpcSessionId: () => FULL_SID,
715
872
  rootForSession: () => null, deriveWorkMode: () => 'solo', runsFor: () => [],
716
873
  rt: async () => board, mutateLocked: (sid, fn) => fn(board),
717
874
  maybeNotify: () => {}, notifyTaskDone: () => {},
@@ -719,6 +876,7 @@ function mkRpcHandlers(board, extra) {
719
876
  pushSysNote: () => {}, sessionCwd: () => '',
720
877
  }, extra || {})
721
878
  createRpc(ctx, state, deps)
879
+ state.handlers.__tools = tools
722
880
  return state.handlers
723
881
  }
724
882
  const EMPTY_DESC_WARNING = '任务描述为空——Worker 只能凭标题猜需求,建议补一句目标/约束'
@@ -857,6 +1015,255 @@ test('update-task RPC: 发布 epic(有子任务缺调研注入)→ pushSysNo
857
1015
  assert.equal(r3.ok, false); assert.match(r3.error, /not a draft/); assert.equal(notes2.length, 0)
858
1016
  })
859
1017
 
1018
+ // ===== 调研遵循·host 三件套(task-mutnj3a4):touches→suggestedContextFiles 桥接 + Verifier 归因 + 驳回 hint =====
1019
+
1020
+ test('suggestContextFiles: glob 跳过 / 锚点剥离 / 存在性过滤 / 去重 / 上限 20 全矩阵', () => {
1021
+ // 非数组 / 空条目 / 非字符串 → 空
1022
+ assert.deepEqual(core.suggestContextFiles(null, () => true), [])
1023
+ assert.deepEqual(core.suggestContextFiles(['', ' ', 42], () => true), [])
1024
+ // glob 条目(* ? [ ] { } 任一)跳过;具体文件保留
1025
+ assert.deepEqual(core.suggestContextFiles(['src/**', '*.mjs', 'a?.js', 'x/[ab].js', 'x/{a,b}.js', 'lib/a.mjs'], () => true), ['lib/a.mjs'])
1026
+ // 锚点 :L 段剥掉后再查存在性,返回剥锚后的相对原样
1027
+ const seen = []
1028
+ const r = core.suggestContextFiles(['lib/a.mjs:L10-L20'], (p) => { seen.push(p); return true })
1029
+ assert.deepEqual(r, ['lib/a.mjs']); assert.deepEqual(seen, ['lib/a.mjs'])
1030
+ // 存在性过滤:不存在的剔除
1031
+ assert.deepEqual(core.suggestContextFiles(['a.mjs', 'b.mjs'], (p) => p === 'b.mjs'), ['b.mjs'])
1032
+ // exists 抛异常视为不存在(宁缺勿滥)
1033
+ assert.deepEqual(core.suggestContextFiles(['a.mjs'], () => { throw new Error('x') }), [])
1034
+ // exists 缺省 → 退化为只做 glob/锚点过滤(与 epicPrecheck 同口径)
1035
+ assert.deepEqual(core.suggestContextFiles(['a.mjs', 'src/**']), ['a.mjs'])
1036
+ // 去重(按剥锚后原串)+ 顺序保持
1037
+ assert.deepEqual(core.suggestContextFiles(['a.mjs', 'a.mjs:L5', 'b.mjs', 'a.mjs'], () => true), ['a.mjs', 'b.mjs'])
1038
+ // 上限 20(与 contextFiles 上限一致)
1039
+ const many = []
1040
+ for (let i = 0; i < 25; i++) many.push('f' + i + '.mjs')
1041
+ assert.equal(core.suggestContextFiles(many, () => true).length, 20)
1042
+ })
1043
+
1044
+ test('attachContextSuggestions: 触发②且有建议 → warning 补尾 + 挂字段;否则形态不变', () => {
1045
+ // 触发:warnings 含无调研警告且 touches 有可建议文件
1046
+ const out = { ok: true }
1047
+ const warns = core.createTaskWarnings(mkTask({ description: 'd', touches: ['lib/a.mjs'] }))
1048
+ const suggested = core.attachContextSuggestions(out, warns, ['lib/a.mjs'], () => true)
1049
+ assert.deepEqual(suggested, ['lib/a.mjs'])
1050
+ assert.deepEqual(out.suggestedContextFiles, ['lib/a.mjs'])
1051
+ assert.match(warns[0], /可直接 task_update contextFiles 补上/)
1052
+ // 不触发①:无「未附调研上下文」警告(已有调研上下文)→ null,out 不挂字段
1053
+ const out2 = { ok: true }
1054
+ const warns2 = core.createTaskWarnings(mkTask({ description: 'd', touches: ['lib/a.mjs'], context: { files: [], notes: 'n' } }))
1055
+ assert.equal(core.attachContextSuggestions(out2, warns2, ['lib/a.mjs'], () => true), null)
1056
+ assert.equal('suggestedContextFiles' in out2, false)
1057
+ // 不触发②:touches 全 glob → null,warning 原文不变(不补尾巴)
1058
+ const out3 = { ok: true }
1059
+ const warns3 = core.createTaskWarnings(mkTask({ description: 'd', touches: ['src/**'] }))
1060
+ const before = warns3.join(';')
1061
+ assert.equal(core.attachContextSuggestions(out3, warns3, ['src/**'], () => true), null)
1062
+ assert.equal('suggestedContextFiles' in out3, false)
1063
+ assert.equal(warns3.join(';'), before)
1064
+ })
1065
+
1066
+ test('create-task 双通道:touches 指向真实文件且无调研上下文 → 挂 suggestedContextFiles + warning 补尾', async () => {
1067
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'board-suggest-'))
1068
+ fs.writeFileSync(path.join(tmp, 'real.mjs'), '// x')
1069
+ const extra = { sessionCwd: () => tmp }
1070
+ // RPC 通道:ghost.mjs 不存在、src/** 是 glob → 均剔除,只剩 real.mjs
1071
+ const board = mkBoard([])
1072
+ const r = await mkRpcHandlers(board, extra)['create-task']({ title: 'T', description: 'd', touches: ['real.mjs', 'ghost.mjs', 'src/**'] })
1073
+ assert.equal(r.ok, true)
1074
+ assert.deepEqual(r.suggestedContextFiles, ['real.mjs'])
1075
+ assert.match(r.warning, /未附调研上下文/); assert.match(r.warning, /可直接 task_update contextFiles 补上/)
1076
+ // 工具通道(同口径)
1077
+ const board2 = mkBoard([])
1078
+ const tools = mkRpcHandlers(board2, extra).__tools
1079
+ const r2 = await tools['task_create'].execute({ title: 'T', description: 'd', touches: ['real.mjs'] }, {})
1080
+ assert.equal(r2.ok, true)
1081
+ assert.deepEqual(r2.suggestedContextFiles, ['real.mjs'])
1082
+ assert.match(r2.warning, /可直接 task_update contextFiles 补上/)
1083
+ })
1084
+
1085
+ test('create-task:touches 全 glob 或已有调研上下文 → 不挂 suggestedContextFiles(返回体形态不变)', async () => {
1086
+ const board = mkBoard([])
1087
+ const r = await mkRpcHandlers(board)['create-task']({ title: 'T', description: 'd', touches: ['src/**'] })
1088
+ assert.equal(r.ok, true); assert.match(r.warning, /未附调研上下文/)
1089
+ assert.equal('suggestedContextFiles' in r, false)
1090
+ assert.ok(!/可直接 task_update/.test(r.warning)) // 无建议时尾巴也不补
1091
+ const board2 = mkBoard([])
1092
+ const r2 = await mkRpcHandlers(board2)['create-task']({ title: 'T', description: 'd', touches: ['lib/a.mjs'], contextNotes: '已调研' })
1093
+ assert.equal(r2.ok, true); assert.equal('warning' in r2, false); assert.equal('suggestedContextFiles' in r2, false)
1094
+ })
1095
+
1096
+ test('verify 驳回 hint:verify-task RPC + task_verify 工具 rejected 附重派提示,approved 不附', async () => {
1097
+ // RPC 通道 rejected → 附 hint,驳回语义不变(回 in-progress)
1098
+ const board = mkBoard([mkTask({ id: 'v1', status: 'verifying' })])
1099
+ const r = await mkRpcHandlers(board)['verify-task']({ taskId: 'v1', verdict: 'rejected', comment: '跑偏了' })
1100
+ assert.equal(r.ok, true); assert.match(r.hint, /驳回原因写进 description/); assert.match(r.hint, /新 Worker 没有上一轮记忆/)
1101
+ assert.equal(board.tasks[0].status, 'in-progress')
1102
+ // RPC 通道 approved → 无 hint 字段
1103
+ const board2 = mkBoard([mkTask({ id: 'v2', status: 'verifying' })])
1104
+ const r2 = await mkRpcHandlers(board2)['verify-task']({ taskId: 'v2', verdict: 'approved' })
1105
+ assert.equal(r2.ok, true); assert.equal('hint' in r2, false)
1106
+ // 工具通道 rejected → 同口径附 hint
1107
+ const board3 = mkBoard([mkTask({ id: 'v3', status: 'verifying' })])
1108
+ const r3 = await mkRpcHandlers(board3).__tools['task_verify'].execute({ taskId: 'v3', verdict: 'rejected', comment: '缺测试' }, {})
1109
+ assert.equal(r3.ok, true); assert.match(r3.hint, /驳回原因写进 description/)
1110
+ // 工具通道 approved → 无 hint
1111
+ const board4 = mkBoard([mkTask({ id: 'v4', status: 'verifying' })])
1112
+ const r4 = await mkRpcHandlers(board4).__tools['task_verify'].execute({ taskId: 'v4', verdict: 'approved' }, {})
1113
+ assert.equal(r4.ok, true); assert.equal('hint' in r4, false)
1114
+ })
1115
+
1116
+ // ===== 驳回信息全量带回(task-muvg15p5):三条驳回路径统一落 kind='rejection' 完整包 =====
1117
+ // 事故链:驳回信息此前只走 history(histNotes 每条截 300 字)——board_verdict 工具通道的 checks
1118
+ // (逐条核对证据)整段丢弃、手动驳回 comment 常空且不写 t.verification → 重派 Worker 只看到一句
1119
+ // 被截断的「rejected」,拿不到可执行的返工细节。修法:驳回包落 t.messages,经 buildMessages 全量
1120
+ // 注入 Worker prompt(每条 2000 字),history 保持不动(审计轨)。
1121
+ const REJ_CHECKS = '## 核对项\n- 断言①未落地(lib/core.mjs:100 无 pushRejection 调用)\n- 断言②端到端未锁(test/core.test.mjs 缺 prompt 含 checks 断言)'
1122
+ // 文本通道端到端 harness:真跑 poolCycle 派 Verifier run,run.result 由用例手工 resolve → 真走 settleVerifier
1123
+ function mkVerifierSettleDispatch(board) {
1124
+ var runs = {}
1125
+ var resolveRun = null
1126
+ var boardRef = { b: board }
1127
+ var ctx = {
1128
+ fs: {}, effect: function () {}, get: function () { return null }, timer: null,
1129
+ subagents: {
1130
+ list: function () { return ['mock'] },
1131
+ getProvider: function () { return { inheritsParentContext: false } },
1132
+ start: async function () { return { id: 'run-v1', result: new Promise(function (res) { resolveRun = res }), dispose: async function () {} } },
1133
+ },
1134
+ }
1135
+ var state = { knownSessions: {}, dispatchedEver: {}, badModels: {}, packByChild: {}, pendingPacks: [], teamModeCache: {}, activeRuns: {} }
1136
+ var dispatch = createDispatch(ctx, state, {
1137
+ rt: async function () { return boardRef.b }, wt: async function () {}, mutateLocked: async function (sid, fn) { return fn(boardRef.b) },
1138
+ kickCycle: function () {}, rootForSession: function () { return { id: FULL_SID } }, sessionCwd: function () { return '' },
1139
+ withTimeout: function (p) { return p }, runsFor: function () { return runs }, feedbackOn: function () { return true }, epicSplitOn: function () { return true },
1140
+ pushSysNote: function () {}, maybeNotify: function () {}, notifyTaskDone: function () {},
1141
+ })
1142
+ return { dispatch: dispatch, settle: function (text) { resolveRun({ output: [{ type: 'text', text: text }], stopReason: 'completed' }) } }
1143
+ }
1144
+ async function waitRej(t, ms) {
1145
+ var t0 = Date.now()
1146
+ while (Date.now() - t0 < (ms || 2000)) {
1147
+ if (t.messages.some(function (m) { return m && m.kind === 'rejection' })) return true
1148
+ await new Promise(function (r) { setTimeout(r, 5) })
1149
+ }
1150
+ return t.messages.some(function (m) { return m && m.kind === 'rejection' })
1151
+ }
1152
+
1153
+ test('驳回信息全量带回①:board_verdict 工具通道 → messages 落完整驳回包(checks 全文不再丢)', async () => {
1154
+ const board = mkBoard([mkTask({ id: 'r1', status: 'verifying' })])
1155
+ const r = await mkRpcHandlers(board).__tools['board_verdict'].execute({ taskId: 'r1', verdict: 'rejected', summary: '单测未过', checks: REJ_CHECKS }, {})
1156
+ assert.equal(r.ok, true)
1157
+ const t = board.tasks[0]
1158
+ const rej = t.messages.filter(m => m.kind === 'rejection')
1159
+ assert.equal(rej.length, 1)
1160
+ assert.match(rej[0].text, /单测未过/)
1161
+ assert.ok(rej[0].text.includes('断言①未落地(lib/core.mjs:100 无 pushRejection 调用)')) // checks 全文在位(工具通道此前整段丢弃)
1162
+ assert.equal(rej[0].by, 'tester')
1163
+ assert.equal(rej[0].at, t.verification.at) // 与验收结论同一时间戳(判重口径)
1164
+ // 端到端:重派 Worker prompt 真的带上了 checks(「带回」的最终判据)
1165
+ const p = core.buildWorkerPrompt(t, '', false)
1166
+ assert.match(p, /\[rejection\]/)
1167
+ assert.ok(p.includes('断言②端到端未锁(test/core.test.mjs 缺 prompt 含 checks 断言)'))
1168
+ })
1169
+
1170
+ test('驳回信息全量带回②:Verifier 文本结算路径 → 同构驳回包,prompt 含 checks(端到端真 settle)', async () => {
1171
+ const t = mkTask({ id: 'v1', status: 'verifying' })
1172
+ const h = mkVerifierSettleDispatch(mkBoard([t]))
1173
+ await h.dispatch.poolCycle(FULL_SID)
1174
+ assert.equal(t.status, 'verifying'); assert.ok(t.verifierRun) // 已派 Verifier(占位已换真实 run id)
1175
+ h.settle('REJECTED: 交付物与验收脚本不符\n\n## 测试概要\nnpm test 未跑通\n\n' + REJ_CHECKS)
1176
+ assert.equal(await waitRej(t), true)
1177
+ const rej = t.messages.filter(m => m.kind === 'rejection')
1178
+ assert.equal(rej.length, 1)
1179
+ assert.match(rej[0].text, /npm test 未跑通/)
1180
+ assert.ok(rej[0].text.includes('断言①未落地'))
1181
+ assert.equal(rej[0].by, 'run-v1')
1182
+ assert.equal(t.verification.verdict, 'rejected')
1183
+ assert.equal(t.status, 'pending') // 驳回重派(rejectCount=1 < 3)
1184
+ const p = core.buildWorkerPrompt(t, '', false)
1185
+ assert.ok(p.includes('断言②端到端未锁(test/core.test.mjs 缺 prompt 含 checks 断言)'))
1186
+ })
1187
+
1188
+ test('驳回信息全量带回③:手动驳回双通道 → messages 落包 + t.verification 补写(空 comment 有兜底)', async () => {
1189
+ // RPC 通道 + comment 缺省(人工/GUI 常留空):兜底文案也要是「可执行的下一步」,不能是空消息
1190
+ const board = mkBoard([mkTask({ id: 'm1', status: 'verifying' })])
1191
+ await mkRpcHandlers(board)['verify-task']({ taskId: 'm1', verdict: 'rejected' })
1192
+ const t = board.tasks[0]
1193
+ assert.equal(t.verification.verdict, 'rejected'); assert.equal(t.verification.checks, '')
1194
+ assert.equal(t.verification.by, 'tester'); assert.ok(t.verification.at)
1195
+ const rej = t.messages.filter(m => m.kind === 'rejection')
1196
+ assert.equal(rej.length, 1)
1197
+ assert.match(rej[0].text, /驳回方未填写原因,请先自查交付物与验收脚本差距/)
1198
+ assert.match(core.buildWorkerPrompt(t, '', false), /驳回方未填写原因/) // 端到端:空原因也带回一句可执行兜底
1199
+ // 工具通道带 comment → 同构落包 + 结论字段口径与自动路径一致
1200
+ const board2 = mkBoard([mkTask({ id: 'm2', status: 'verifying' })])
1201
+ await mkRpcHandlers(board2).__tools['task_verify'].execute({ taskId: 'm2', verdict: 'rejected', comment: '缺边界断言' }, {})
1202
+ const t2 = board2.tasks[0]
1203
+ assert.equal(t2.verification.summary, '缺边界断言'); assert.equal(t2.verification.checks, '')
1204
+ const rej2 = t2.messages.filter(m => m.kind === 'rejection')
1205
+ assert.equal(rej2.length, 1)
1206
+ assert.match(rej2[0].text, /验收驳回 · 缺边界断言/)
1207
+ })
1208
+
1209
+ test('驳回信息全量带回④:approved 路径零 rejection 消息(双通道 + 文本结算,防误落)', async () => {
1210
+ const board = mkBoard([mkTask({ id: 'a1', status: 'verifying' })])
1211
+ await mkRpcHandlers(board).__tools['board_verdict'].execute({ taskId: 'a1', verdict: 'approved', summary: 'OK', checks: '## 核对项\n- 全过' }, {})
1212
+ assert.equal(board.tasks[0].status, 'resolved')
1213
+ assert.equal(board.tasks[0].messages.filter(m => m.kind === 'rejection').length, 0)
1214
+ const board2 = mkBoard([mkTask({ id: 'a2', status: 'verifying' })])
1215
+ await mkRpcHandlers(board2)['verify-task']({ taskId: 'a2', verdict: 'approved', comment: 'OK' })
1216
+ assert.equal(board2.tasks[0].messages.filter(m => m.kind === 'rejection').length, 0)
1217
+ const t = mkTask({ id: 'a3', status: 'verifying' })
1218
+ const h = mkVerifierSettleDispatch(mkBoard([t]))
1219
+ await h.dispatch.poolCycle(FULL_SID)
1220
+ h.settle('APPROVED: 全部通过\n\n## 测试概要\n真跑通过')
1221
+ await new Promise(function (r) { setTimeout(r, 20) })
1222
+ assert.equal(t.status, 'resolved')
1223
+ assert.equal(t.messages.filter(m => m.kind === 'rejection').length, 0)
1224
+ })
1225
+
1226
+ test('pushRejection: 同事件判重(同时间戳/同前缀不双推)+ 空原因兜底文案', () => {
1227
+ const t = { messages: [] }
1228
+ assert.equal(core.pushRejection(t, '概要', '核对项全文', 'T1', 'v'), true)
1229
+ assert.equal(core.pushRejection(t, '概要', '核对项全文', 'T1', 'v'), false) // 同时间戳
1230
+ assert.equal(core.pushRejection(t, '概要', '核对项全文', 'T2', 'v'), false) // 异时间戳但正文前缀相同(同一驳回)
1231
+ assert.equal(core.pushRejection(t, '另一件事', '', 'T3', 'v'), true) // 不同事件照常落
1232
+ assert.equal(t.messages.length, 2)
1233
+ assert.equal(t.messages[0].kind, 'rejection'); assert.equal(t.messages[0].by, 'v')
1234
+ assert.equal(core.rejectionText('', ''), '(驳回方未填写原因,请先自查交付物与验收脚本差距)')
1235
+ assert.equal(core.rejectionText('概要', '核对'), '验收驳回 · 概要\n\n核对项:\n核对')
1236
+ })
1237
+
1238
+ test('驳回全量带回接线(源码级):三条驳回路径都调 pushRejection + messages 通道在位', () => {
1239
+ const rpc = readFileSync(new URL('../lib/rpc.mjs', import.meta.url), 'utf8')
1240
+ const dsp = readFileSync(new URL('../lib/dispatch.mjs', import.meta.url), 'utf8')
1241
+ const coreSrc = readFileSync(new URL('../lib/core.mjs', import.meta.url), 'utf8')
1242
+ assert.equal((rpc.match(/pushRejection\(t, /g) || []).length, 3) // board_verdict 工具 + verify-task RPC + task_verify 工具
1243
+ assert.match(dsp, /pushRejection\(t, vsecs\.verifySummary, vsecs\.checks, t\.verification\.at, String\(rec\.run\.id\)\)/)
1244
+ assert.equal((hostSrc().match(/pushRejection\(t, /g) || []).length, 4) // 三条路径 + Verifier 文本结算
1245
+ assert.match(coreSrc, /export function pushRejection/)
1246
+ assert.match(coreSrc, /kind: 'rejection'/)
1247
+ // buildMessages 现成通道在位(驳回包靠它进 prompt,不新开通道)
1248
+ assert.match(coreSrc, /export function buildMessages/)
1249
+ assert.match(coreSrc, /if \(msgs\) p \+= '\\n\\n该任务的详细消息/)
1250
+ })
1251
+
1252
+ test('buildVerifierPrompt: 含「立单缺调研」驳回归因条款', () => {
1253
+ const p = core.buildVerifierPrompt(mkTask({ id: 'tx', status: 'verifying' }), '')
1254
+ assert.match(p, /立单缺调研/)
1255
+ assert.match(p, /驳回热点统计/)
1256
+ })
1257
+
1258
+ test('调研遵循接线:双通道 attachContextSuggestions / 驳回 hint 双挂 / Verifier 归因条款(源码级断言)', () => {
1259
+ const src = hostSrc()
1260
+ assert.equal((src.match(/attachContextSuggestions\(/g) || []).length, 2) // task_create 工具 + create-task RPC
1261
+ assert.equal((src.match(/\.hint = REJECT_REDISPATCH_HINT/g) || []).length, 2) // task_verify 工具 + verify-task RPC
1262
+ const coreSrc = readFileSync(new URL('../lib/core.mjs', import.meta.url), 'utf8')
1263
+ assert.match(coreSrc, /立单缺调研/) // Verifier prompt 归因条款在位
1264
+ assert.match(coreSrc, /export function suggestContextFiles/)
1265
+ })
1266
+
860
1267
  test('调研门禁接线:双通道 warning 调用 / epic 预检投递 / 读包失败落卡(源码级轻量断言)', () => {
861
1268
  const src = hostSrc()
862
1269
  assert.equal((src.match(/createTaskWarnings\(t\)/g) || []).length, 2) // task_create 工具 + create-task RPC
@@ -872,7 +1279,8 @@ test('aggregateUsageSummary: 空任务/无 usage 任务 → 全零 + 空 Top', (
872
1279
  const s = aggregateUsageSummary([mkTask({ id: 'a' }), mkTask({ id: 'b', usage: null })])
873
1280
  assert.equal(s.total, 0); assert.equal(s.input, 0); assert.equal(s.output, 0); assert.equal(s.cacheRead, 0)
874
1281
  assert.deepEqual(s.byModel, {}); assert.deepEqual(s.topTasks, [])
875
- assert.deepEqual(aggregateUsageSummary(undefined), { total: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, byModel: {}, topTasks: [] })
1282
+ assert.deepEqual(s.byDay, {}) // 日账缺省空对象(老看板/无 usage 不炸)
1283
+ assert.deepEqual(aggregateUsageSummary(undefined), { total: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, effective: 0, byModel: {}, byDay: {}, topTasks: [] })
876
1284
  })
877
1285
 
878
1286
  test('aggregateUsageSummary: 总量/输入输出缓存拆分累加 + 按模型小计合并', () => {
@@ -882,6 +1290,49 @@ test('aggregateUsageSummary: 总量/输入输出缓存拆分累加 + 按模型
882
1290
  assert.equal(s.total, 1133); assert.equal(s.input, 105); assert.equal(s.output, 25)
883
1291
  assert.equal(s.cacheRead, 1000); assert.equal(s.cacheWrite, 3)
884
1292
  assert.deepEqual(s.byModel, { 'deepseek-flash': 1013, 'glm-5': 120 })
1293
+ assert.deepEqual(s.byDay, {}) // 无 byDay 且无 updatedAt → 不归任何日(宁可漏不错)
1294
+ })
1295
+
1296
+ // 测试侧独立复刻本地日 key(与实现同口径但各自独立写,防「实现自证」)
1297
+ const lk = (iso) => { const d = new Date(iso); const p2 = (n) => (n < 10 ? '0' : '') + n; return d.getFullYear() + '-' + p2(d.getMonth() + 1) + '-' + p2(d.getDate()) }
1298
+ // 用本地时间构造再转 ISO:任意时区下本地日都是 2026-10-02,断言与宿主时区无关
1299
+ const localIso = (y, mo, d, h, mi) => new Date(y, mo - 1, d, h, mi).toISOString()
1300
+
1301
+ test('aggregateUsageSummary: byDay 日账逐日合并(同日累加 / 跨日分桶)', () => {
1302
+ const a = mkTask({ id: 'a', usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 100, runs: 2, models: {}, updatedAt: localIso(2026, 10, 2, 10, 0), byDay: { '2026-10-01': { t: 60, e: 60 }, '2026-10-02': { t: 40, e: 40 } } } })
1303
+ const b = mkTask({ id: 'b', usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 30, runs: 1, models: {}, updatedAt: localIso(2026, 10, 2, 11, 0), byDay: { '2026-10-02': { t: 30, e: 30 } } } })
1304
+ const s = aggregateUsageSummary([a, b])
1305
+ assert.deepEqual(s.byDay, { '2026-10-01': { t: 60, e: 60 }, '2026-10-02': { t: 70, e: 70 } }) // 同日 t/e 分别累加、跨日分桶
1306
+ assert.equal(s.total, 130) // 总量口径不变
1307
+ assert.deepEqual(a.usage.byDay, { '2026-10-01': { t: 60, e: 60 }, '2026-10-02': { t: 40, e: 40 } }) // 入参对象未被就地改写(纯函数)
1308
+ })
1309
+
1310
+ test('aggregateUsageSummary: 存量任务无 byDay → 整笔归 updatedAt 的本地日(近似口径)', () => {
1311
+ const legacy = mkTask({ id: 'old', usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 500, runs: 1, models: {}, updatedAt: localIso(2026, 10, 2, 10, 30) } })
1312
+ const fresh = mkTask({ id: 'new', usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 20, runs: 1, models: {}, updatedAt: localIso(2026, 10, 3, 9, 0), byDay: { '2026-10-03': { t: 20, e: 20 } } } })
1313
+ const s = aggregateUsageSummary([legacy, fresh])
1314
+ assert.deepEqual(s.byDay, { '2026-10-02': { t: 500, e: null }, '2026-10-03': { t: 20, e: 20 } }) // 老任务兜底只有总量(e 不可知=null)+ 新任务日账双指标
1315
+ assert.equal(lk(localIso(2026, 10, 2, 10, 30)), '2026-10-02') // 本地日口径自检(防 toISOString 的 UTC 错位)
1316
+ })
1317
+
1318
+ test('aggregateUsageSummary: 本地 00:30 的 updatedAt 落在本地日(用 UTC 位移可区分的时刻钉死口径)', () => {
1319
+ // 东八区:本地 2026-10-02 00:30 → UTC 2026-10-01T16:30Z;若实现用 toISOString().slice(0,10)
1320
+ // 就会记成 10-01(跨日错位)。本条在 UTC+8 宿主上必然抓得住这种写法。
1321
+ const iso = localIso(2026, 10, 2, 0, 30)
1322
+ const s = aggregateUsageSummary([mkTask({ id: 'midnight', usage: { total: 42, models: {}, updatedAt: iso } })])
1323
+ assert.deepEqual(Object.keys(s.byDay), ['2026-10-02'])
1324
+ assert.deepEqual(s.byDay['2026-10-02'], { t: 42, e: null }) // 兜底路径只有总量:e 不可知置 null,不冒充有效值
1325
+ })
1326
+
1327
+ test('aggregateUsageSummary: 无 updatedAt / 坏 updatedAt → 不归任何日,byDay 空对象不炸', () => {
1328
+ const s = aggregateUsageSummary([
1329
+ mkTask({ id: 'x', usage: { total: 7, models: {} } }),
1330
+ mkTask({ id: 'y', usage: { total: 9, models: {}, updatedAt: 'not-a-date' } }),
1331
+ mkTask({ id: 'z', usage: { total: 11, models: {}, byDay: null } })
1332
+ ])
1333
+ assert.deepEqual(s.byDay, {})
1334
+ assert.equal(s.total, 27)
1335
+ assert.equal(s.topTasks.length, 3) // 总量/Top 不受日账缺失影响
885
1336
  })
886
1337
 
887
1338
  test('aggregateUsageSummary: Top 任务按总量降序、最多 8 条、带 runs 计数', () => {
@@ -893,6 +1344,86 @@ test('aggregateUsageSummary: Top 任务按总量降序、最多 8 条、带 runs
893
1344
  assert.equal(s.topTasks[0].id, 't11'); assert.equal(s.topTasks[0].title, 'T11'); assert.equal(s.topTasks[0].runs, 11)
894
1345
  })
895
1346
 
1347
+ // ===== Token 口径对齐真实消耗:有效/缓存读分离 + run 级留账 =====
1348
+ test('aggregateUsageSummary: 有效消耗合计 = 输入+输出+缓存写(缓存读不计入)', () => {
1349
+ // 实证场景:本板累计 10.3M 里缓存读 9.7M(94%),有效消耗仅 605K——大数字虚高 17 倍。
1350
+ const u1 = { input: 100, output: 20, cacheRead: 1000, cacheWrite: 5, total: 1125, runs: 2, models: {} }
1351
+ const u2 = { input: 5, output: 5, cacheRead: 0, cacheWrite: 3, total: 13, runs: 1, models: {} }
1352
+ const s = aggregateUsageSummary([mkTask({ id: 'a', usage: u1 }), mkTask({ id: 'b', usage: u2 })])
1353
+ assert.equal(s.effective, 138) // (100+20+5) + (5+5+3)
1354
+ assert.equal(s.cacheRead, 1000)
1355
+ assert.equal(s.total, 1138)
1356
+ assert.equal(s.effective + s.cacheRead, s.total) // 三分量自洽:有效 + 缓存读 = 合计
1357
+ // 脏字段/缺字段不把 NaN 带进看板(NaN 会让工具输出的 lossless-JSON 校验拒整条结果)
1358
+ const s2 = aggregateUsageSummary([mkTask({ id: 'z', usage: { total: 9, models: {} } })])
1359
+ assert.equal(s2.effective, 0)
1360
+ // Top8 条目带 有效 / 缓存读 拆分(仪表盘 title 直接用)
1361
+ const t0 = aggregateUsageSummary([mkTask({ id: 'a', usage: u1 })]).topTasks[0]
1362
+ assert.equal(t0.effective, 125); assert.equal(t0.cacheRead, 1000)
1363
+ })
1364
+
1365
+ test('aggregateUsageSummary: byDay 双指标记账({t,e} 逐日合并)+ 有效合计', () => {
1366
+ const a = mkTask({ id: 'a', usage: { input: 10, output: 5, cacheRead: 900, cacheWrite: 5, total: 920, runs: 2, models: {}, updatedAt: localIso(2026, 10, 2, 10, 0), byDay: { '2026-10-01': { t: 600, e: 60 }, '2026-10-02': { t: 320, e: 20 } } } })
1367
+ const b = mkTask({ id: 'b', usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 1, total: 30, runs: 1, models: {}, updatedAt: localIso(2026, 10, 2, 11, 0), byDay: { '2026-10-02': { t: 30, e: 3 } } } })
1368
+ const s = aggregateUsageSummary([a, b])
1369
+ assert.deepEqual(s.byDay, { '2026-10-01': { t: 600, e: 60 }, '2026-10-02': { t: 350, e: 23 } }) // 同日 t/e 各自累加、跨日分桶
1370
+ assert.equal(s.effective, 23) // (10+5+5) + (1+1+1)
1371
+ assert.equal(s.total, 950)
1372
+ assert.deepEqual(a.usage.byDay, { '2026-10-01': { t: 600, e: 60 }, '2026-10-02': { t: 320, e: 20 } }) // 纯函数:入参不被就地改写
1373
+ })
1374
+
1375
+ test('aggregateUsageSummary: 老 number 形态 byDay 兼容(总量保留、有效值置 null 不猜)', () => {
1376
+ // 本轮之前的日账是裸 number(只有总量、没有逐 run 拆分)→ e 不可知必须 null,
1377
+ // 既不能让「近 7 天」整段消失(t 保留),也不能伪造一个有效值冒充。
1378
+ const legacy = mkTask({ id: 'old', usage: { input: 10, output: 5, cacheRead: 900, cacheWrite: 5, total: 920, runs: 1, models: {}, byDay: { '2026-10-01': 600 } } })
1379
+ const fresh = mkTask({ id: 'new', usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 1, total: 30, runs: 1, models: {}, byDay: { '2026-10-02': { t: 30, e: 3 } } } })
1380
+ const s = aggregateUsageSummary([legacy, fresh])
1381
+ assert.deepEqual(s.byDay, { '2026-10-01': { t: 600, e: null }, '2026-10-02': { t: 30, e: 3 } })
1382
+ assert.equal(s.byDay['2026-10-01'].e, null) // 不可知就是 null(不是 0、也不是 t)
1383
+ // 同一天既有老形态又有新形态 → 合并后 e 仍不可知(总量照常累加,不假装算得出)
1384
+ const mixOld = mkTask({ id: 'm1', usage: { total: 100, models: {}, byDay: { '2026-10-03': 40 } } })
1385
+ const mixNew = mkTask({ id: 'm2', usage: { input: 3, output: 2, cacheRead: 50, cacheWrite: 0, total: 55, models: {}, byDay: { '2026-10-03': { t: 55, e: 5 } } } })
1386
+ assert.deepEqual(aggregateUsageSummary([mixOld, mixNew]).byDay['2026-10-03'], { t: 95, e: null })
1387
+ // 逐项合计仍按各任务 usage 精确算(不受日账形态影响)
1388
+ assert.equal(aggregateUsageSummary([legacy, fresh]).effective, 23) // (10+5+5)+(1+1+1)
1389
+ })
1390
+
1391
+ test('aggregateUsageSummary: effective 字段与 effectiveTokens 导出一致(口径单点定义)', () => {
1392
+ assert.equal(effectiveTokens({ input: 3, output: 4, cacheRead: 99, cacheWrite: 5 }), 12)
1393
+ assert.equal(effectiveTokens({ cacheRead: 99 }), 0) // 只有缓存读 → 有效 0(缓存读不算真实消耗)
1394
+ assert.equal(effectiveTokens(null), 0) // 空值不炸
1395
+ assert.equal(effectiveTokens({ input: '3', output: 4 }), 7) // 字符串数字按数字算
1396
+ const s = aggregateUsageSummary([mkTask({ id: 'a', usage: { input: 3, output: 4, cacheRead: 99, cacheWrite: 5, total: 111, models: {} } })])
1397
+ assert.equal(s.effective, effectiveTokens({ input: 3, output: 4, cacheRead: 99, cacheWrite: 5 }))
1398
+ })
1399
+
1400
+ test('README 双份同步记录 Token 口径(有效消耗 / 近 7 天 / 缓存读单列 / 派发口径边界)', () => {
1401
+ const pkg = readFileSync(new URL('../README.md', import.meta.url), 'utf8')
1402
+ const root = readFileSync(new URL('../../../README.md', import.meta.url), 'utf8')
1403
+ assert.equal(pkg, root) // 两份 README 必须字节一致(npm run sync-readme 的约束)
1404
+ for (const s of ['今日有效消耗', '近 7 天', '有效消耗 = 输入 + 输出 + 缓存写(不含缓存读)', '不含主窗口对话', 'run 级留账']) {
1405
+ assert.ok(pkg.includes(s), 'README 应记录口径:' + s)
1406
+ }
1407
+ })
1408
+
1409
+ test('Token run 级留账:accumulateRunUsage 把本次用量写回对应 t.runs 条目(源码级断言)', () => {
1410
+ const dsp = readFileSync(new URL('../lib/dispatch.mjs', import.meta.url), 'utf8')
1411
+ // 五分量原样写回 runs 条目:任何维度(按天/按模型/按阶段)都能从 runs 精确重建,不必回头猜
1412
+ assert.match(dsp, /if \(Array\.isArray\(t\.runs\)\)/)
1413
+ assert.match(dsp, /if \(String\(t\.runs\[ri\] && t\.runs\[ri\]\.id\) === String\(rec\.run\.id\)\)/)
1414
+ assert.match(dsp, /t\.runs\[ri\]\.usage = \{ input: u\.input \|\| 0, output: u\.output \|\| 0, cacheRead: u\.cacheRead \|\| 0, cacheWrite: u\.cacheWrite \|\| 0, total: u\.total \|\| 0 \}/)
1415
+ assert.match(dsp, /break/) // 命中即停(同一 runId 只记一次)
1416
+ // 倒序查找 + 找不到就跳过:runs 条目由 recordRunHistory 先行写入、closeRunHistory 更新结局,正常必存在
1417
+ assert.match(dsp, /for \(var ri = t\.runs\.length - 1; ri >= 0; ri--\)/)
1418
+ // 有效消耗口径单点定义(host 侧),byDay 双指标记账与它同源
1419
+ assert.match(dsp, /function effectiveOf\(u\) \{/)
1420
+ assert.match(dsp, /return \(u\.input \|\| 0\) \+ \(u\.output \|\| 0\) \+ \(u\.cacheWrite \|\| 0\)/)
1421
+ assert.match(dsp, /var eff = effectiveOf\(u\)/)
1422
+ // byDay 双指标:总 t / 有效 e 各自累加;老 number 形态脏值就地收敛成对象(不把老字段形态写坏)
1423
+ assert.match(dsp, /if \(!cell \|\| typeof cell !== 'object'\) cell = \{ t: Number\(cell\) \|\| 0, e: 0 \}/)
1424
+ assert.match(dsp, /t\.usage\.byDay\[dk\] = cell/)
1425
+ })
1426
+
896
1427
  test('readRunUsage: run 不存在 / 日志目录不可读 → null(优雅降级,不抛错)', () => {
897
1428
  assert.equal(readRunUsage('no-such-run-' + Date.now()), null)
898
1429
  assert.equal(readRunUsage(''), null)
@@ -938,6 +1469,118 @@ test('Token 消耗接线:settleRun 结算累加 + 按模型小计 + get-tasks
938
1469
  assert.match(cli, /'⛁ ' \+ fmtTokens\(t\.usage\.total\)/) // 进行中/已完成卡片显示本任务累计
939
1470
  })
940
1471
 
1472
+ test('Token 日账接线:dispatch 记 byDay 双指标(本地日)+ 仪表盘有效消耗口径(源码级断言)', () => {
1473
+ const host = hostSrc()
1474
+ const cli = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
1475
+ assert.match(host, /cell\.t = \(Number\(cell\.t\) \|\| 0\) \+ u\.total/) // 结算时记一笔日账总量
1476
+ assert.match(host, /cell\.e = \(Number\(cell\.e\) \|\| 0\) \+ eff/) // 同一笔记一笔有效消耗
1477
+ assert.match(host, /var dk = localDayKey\(\)/) // 键取结算时刻本地日
1478
+ // 本地日口径:localDayKey 必须用本地 getters 拼(含 getFullYear/getMonth/getDate),不得用 toISOString
1479
+ const dispatchSrc = readFileSync(new URL('../lib/dispatch.mjs', import.meta.url), 'utf8')
1480
+ const dkBody = dispatchSrc.match(/function localDayKey\(d\) \{[\s\S]{0,300}?\n \}/)
1481
+ assert.ok(dkBody, 'dispatch.mjs 应定义 localDayKey')
1482
+ assert.match(dkBody[0], /getFullYear\(\)/)
1483
+ assert.match(dkBody[0], /getMonth\(\) \+ 1/)
1484
+ assert.match(dkBody[0], /getDate\(\)/)
1485
+ assert.equal(/toISOString/.test(dkBody[0]), false) // UTC 会让晚间消耗落到次日
1486
+ assert.match(host, /if \(!t\.usage\.byDay\) t\.usage\.byDay = \{\}/) // 老任务就地补日账(不改老字段形态)
1487
+ assert.match(host, /lc\.t \+= u\.total; lc\.e = null; s\.byDay\[lk\] = lc/) // 存量兜底归 updatedAt 本地日(只有总量,e 不可知)
1488
+ // 仪表盘:大数字=今日有效消耗 + 「含缓存读共 X」小字 + 累计三分量 + 近 7 天有效值 + 口径 caption
1489
+ assert.match(cli, /function lastNDays\(n\)/)
1490
+ assert.match(cli, /var todayCell = dayOf\(byDay\[todayKey\]\)/) // 今日缺省退化(无日账不误报)
1491
+ assert.match(cli, /'tokens(今日有效'/) // 大数字口径 = 有效消耗
1492
+ assert.match(cli, /'含缓存读共 '/) // 有效 vs 总量的差额单列
1493
+ assert.match(cli, /'累计(本看板) 有效 '/) // 累计三分量:有效
1494
+ assert.match(cli, /' · 缓存读 '/) // 累计三分量:缓存读
1495
+ assert.match(cli, /' · 合计 '/) // 累计三分量:合计
1496
+ assert.match(cli, /'近 7 天(有效消耗)'/)
1497
+ assert.match(cli, /k\.slice\(5\) \+ ':有效 ' \+ String\(v\)/) // 条形 title:MM-DD:有效 N tok
1498
+ assert.match(cli, /含缓存读共 ' \+ String\(c\.t\) \+ ' tok'/) // 条形 title 补总量对照
1499
+ assert.match(cli, /口径:仅看板派发的 Worker\/Verifier run 消耗,不含主窗口对话/) // 口径边界明示(不含主窗口)
1500
+ assert.match(cli, /\/ 缓存读 ' \+ String\(x\.cacheRead \|\| 0\)/) // Top8 title 补 有效 / 缓存读 拆分
1501
+ assert.match(cli, /background: isToday \? C\.brand : C\.nested/) // 今天高亮 brand、其余浅底
1502
+ assert.match(cli, /hasDayData \? React\.createElement/) // 7 天全空不渲染该区
1503
+ assert.match(cli, /'(近似:老日账只有总量)'/) // e 不可知 → 标 ~ 近似,不冒充有效值
1504
+ })
1505
+
1506
+ // ===== 调研 ROI 行 token 有效口径(task-muupnnq5)=====
1507
+ // 客户端 bundle 无模块系统(只注入 React),故 ROI 判定在 dashboard.js 内联为 roiTokenOf;
1508
+ // 这里把该函数原文切出来执行,直接断言真行为而不是断言注释。
1509
+ function dashboardFnOf(name) {
1510
+ const src = readFileSync(new URL('../lib/client/dashboard.js', import.meta.url), 'utf8')
1511
+ const start = src.indexOf('function ' + name + '(')
1512
+ assert.ok(start >= 0, 'lib/client/dashboard.js 应定义 ' + name)
1513
+ const end = src.indexOf('\n function ', start + 1)
1514
+ return src.slice(start, end > start ? end : undefined)
1515
+ }
1516
+ function evalDashboardFn(name, deps) {
1517
+ const body = dashboardFnOf(name) + '\n' + (deps || []).map(dashboardFnOf).join('\n')
1518
+ return new Function(body + '\nreturn ' + name)()
1519
+ }
1520
+ // 造一张「已领取→已完成」的 ROI 候选卡(usage 由调用方给)
1521
+ function mkRoiTask(i, hasRes, usage) {
1522
+ const base = Date.parse('2026-01-01T00:00:00Z')
1523
+ return mkTask({
1524
+ id: 'roi' + i,
1525
+ status: 'resolved',
1526
+ createdAt: new Date(base + i * 60000).toISOString(),
1527
+ claimedAt: new Date(base + i * 60000 + 60000).toISOString(),
1528
+ resolvedAt: new Date(base + i * 60000 + 60000 + 3600000).toISOString(), // 执行恒为 1h
1529
+ context: hasRes ? { files: ['docs/a.md'] } : {},
1530
+ usage: usage || null,
1531
+ })
1532
+ }
1533
+
1534
+ test('ROI token 口径:有分量字段取有效消耗(不含缓存读),与 total 明确不同(源码级实执)', () => {
1535
+ const roiTokenOf = evalDashboardFn('roiTokenOf')
1536
+ // 有效 = 输入 1000 + 输出 500 + 缓存写 200 = 1700;含缓存读的 total 竟是 91700(缓存读撑高 ~54 倍)
1537
+ assert.deepEqual(roiTokenOf({ input: 1000, output: 500, cacheWrite: 200, cacheRead: 90000, total: 91700 }), { tok: 1700, fallback: false })
1538
+ // 分量只有一项也算:缓存写单独存在时有效值就是它(不等于含缓存读的 total)
1539
+ assert.deepEqual(roiTokenOf({ cacheWrite: 300, cacheRead: 90000, total: 90300 }), { tok: 300, fallback: false })
1540
+ // 全 0 分量 + 有 total:退化为含缓存读的合计并标 fallback(老/存量结算卡的近似口径)
1541
+ assert.deepEqual(roiTokenOf({ input: 0, output: 0, cacheWrite: 0, cacheRead: 90000, total: 90000 }), { tok: 90000, fallback: true })
1542
+ assert.deepEqual(roiTokenOf({ total: 5000 }), { tok: 5000, fallback: true })
1543
+ // 无任何结算记录 → null(不参与均值,不拉低样本);脏值/缺 usage 同样降级
1544
+ assert.deepEqual(roiTokenOf({ input: 0, total: 0 }), { tok: null, fallback: false })
1545
+ assert.deepEqual(roiTokenOf(null), { tok: null, fallback: false })
1546
+ assert.deepEqual(roiTokenOf({ input: 'x', total: -5 }), { tok: null, fallback: false })
1547
+ })
1548
+
1549
+ test('ROI 分组均值:computeStats 用有效消耗取均值,token 口径与 Token 区一致(源码级实执)', () => {
1550
+ const computeStats = evalDashboardFn('computeStats', ['roiTokenOf'])
1551
+ // 有调研 2 张:(1000+0+200)=1200 与 (300+100+0)=400 → 均值 800(若退回旧口径 total,均值会是 45850)
1552
+ const tasks = [
1553
+ mkRoiTask(0, true, { input: 1000, output: 0, cacheWrite: 200, cacheRead: 90000, total: 91200 }),
1554
+ mkRoiTask(1, true, { input: 300, output: 100, cacheWrite: 0, cacheRead: 0, total: 400 }),
1555
+ // 无调研 2 张:一张只有 total(老卡 → 兜底 + fallback 计数),一张无 usage(不参与均值)
1556
+ mkRoiTask(2, false, { total: 5000 }),
1557
+ mkRoiTask(3, false, null),
1558
+ ]
1559
+ const st = computeStats(tasks)
1560
+ assert.ok(st.roi, '两组各 ≥1 且总样本 ≥4 时应产出 roi')
1561
+ assert.equal(st.roi.yes.n, 2)
1562
+ assert.equal(st.roi.yes.tok, 800)
1563
+ assert.equal(st.roi.yes.tokN, 2)
1564
+ assert.equal(st.roi.yes.tokFallbackN, 0)
1565
+ assert.equal(st.roi.no.n, 2)
1566
+ assert.equal(st.roi.no.tok, 5000) // 无 usage 的卡被跳过:均值就是那张老卡的 total
1567
+ assert.equal(st.roi.no.tokN, 1)
1568
+ assert.equal(st.roi.no.tokFallbackN, 1) // 老卡兜底要能被 UI 标 ~(ResearchRoiRow 读该字段)
1569
+ assert.equal(st.roi.no.execMs, 3600000)
1570
+ })
1571
+
1572
+ test('ROI 口径注释/接线:client.js 产物与 host 端判定同源(源码级断言)', () => {
1573
+ const cli = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
1574
+ assert.match(cli, /var tk = roiTokenOf\(t\.usage\)/) // ROI 行不再直接读 t.usage.total
1575
+ assert.equal(/var tok = \(t\.usage && typeof t\.usage\.total === 'number'/.test(cli), false)
1576
+ assert.match(cli, /tokN: toks\.length, tokFallbackN: fb/) // 分组均值带样本数与兜底张数
1577
+ assert.match(cli, /口径 = 有效消耗(输入\+输出\+缓存写,不含缓存读)/) // title 标注口径来源
1578
+ // host 侧同款判定(单卡口径的规范实现,client 内联以免引入模块系统)
1579
+ assert.deepEqual(taskEffectiveTokens({ input: 10, output: 5, cacheWrite: 2, cacheRead: 900, total: 917 }), { tok: 17, fallback: false, total: 917 })
1580
+ assert.deepEqual(taskEffectiveTokens({ total: 917 }), { tok: 917, fallback: true, total: 917 })
1581
+ assert.deepEqual(taskEffectiveTokens(null), { tok: null, fallback: false, total: null })
1582
+ })
1583
+
941
1584
  // ===== 学习飞轮 v1:候选教训信号 + feedbackEnabled 开关 + 软召回 =====
942
1585
  test('cfg: feedbackEnabled 默认开(缺字段/脏值都算开),只有显式 false 才关', () => {
943
1586
  assert.equal(core.cfg({}).feedbackEnabled, true)
@@ -999,7 +1642,7 @@ test('学习飞轮接线:两处触发点 + push-lesson 开关拦截 + prompt/
999
1642
  // 候选教训两处触发:Verifier 驳回(文本通道 + 工具通道 + GUI RPC)与主窗口仲裁结论
1000
1643
  assert.match(host, /pushRejectLesson\(d, t, vsecs\.verifySummary \|\| trimmed, t\.verification\.at\)/)
1001
1644
  assert.match(host, /if \(!approved\) pushRejectLesson\(d, t, \(args\.summary \|\| ''\)/)
1002
- assert.match(host, /if \(args\.verdict === 'rejected'\) pushRejectLesson\(d, t, args\.comment\)/)
1645
+ assert.match(host, /if \(args\.verdict === 'rejected'\) \{ t\.verification = \{ verdict: 'rejected'[^}]*\}; pushRejection\(t, args\.comment, '', t\.verification\.at, actor\); pushRejectLesson\(d, t, args\.comment\); r\.hint = REJECT_REDISPATCH_HINT \}/) // verify-task RPC:驳回包落 messages + 候选教训 + 驳回重派 hint 同分支挂载(task-mutnj3a4 / task-muvg15p5)
1003
1646
  assert.match(host, /pushArbitrationLesson\(d, t, escQ, answer \|\| '', arbAt\)/)
1004
1647
  // 生成前一律过 feedbackEnabled 总开关(关掉 = 不生成、不推)
1005
1648
  assert.equal((host.match(/if \(!cfg\(d\)\.feedbackEnabled\) return false/g) || []).length, 2)
@@ -1095,7 +1738,7 @@ test('aggregateChildStats: 空板 / 无父子关系 → 空对象(不出键)
1095
1738
  assert.deepEqual(core.aggregateChildStats(d), {})
1096
1739
  })
1097
1740
 
1098
- test('aggregateChildStats: 聚合口径 total/resolved/active/activeTitle', () => {
1741
+ test('aggregateChildStats: 聚合口径 total/settled/active/activeTitle', () => {
1099
1742
  const kids = [
1100
1743
  mkTask({ id: 'c1', parentId: 'epic', status: 'resolved' }),
1101
1744
  mkTask({ id: 'c2', parentId: 'epic', status: 'in-progress', title: '进行中甲' }),
@@ -1105,29 +1748,505 @@ test('aggregateChildStats: 聚合口径 total/resolved/active/activeTitle', () =
1105
1748
  mkTask({ id: 'c6', parentId: 'epic', status: 'blocked' }),
1106
1749
  ]
1107
1750
  const s = core.aggregateChildStats(kids).epic
1108
- assert.equal(s.total, 6) // 非归档子任务全计
1109
- assert.equal(s.resolved, 1) // 仅 resolved(verifying 不算完成)
1751
+ assert.equal(s.total, 6) // 子任务全计(含归档,本例无归档)
1752
+ assert.equal(s.settled, 1) // 仅 resolved(verifying 不算了结)
1753
+ assert.equal(s.resolved, 1) // 兼容别名同值
1110
1754
  assert.equal(s.active, 2) // in-progress 数
1111
1755
  assert.equal(s.activeTitle, '进行中甲') // 第一个 in-progress 子任务标题(看板顺序)
1112
1756
  })
1113
1757
 
1114
- test('aggregateChildStats: 归档子任务不计入;全归档 → 不出键;多父卡各自成键', () => {
1758
+ // 反馈 task-muupgfot(史诗进度归档消失):归档子任务必须计入 total + settled,否则归档一张子卡
1759
+ // 会让进度分母缩水(0/10 → 0/9)、分子永不前进,且全归档父卡整个从卡片上消失。
1760
+ test('aggregateChildStats: 归档子任务计入 total+settled(10 卡归 2 → 2/10,不再退化 0/8)', () => {
1761
+ const ten = []
1762
+ for (let i = 1; i <= 10; i++) ten.push(mkTask({ id: 'k' + i, parentId: 'epic', status: i <= 2 ? 'archived' : 'pending' }))
1763
+ const s10 = core.aggregateChildStats(ten).epic
1764
+ assert.equal(s10.total, 10) // 归档不减分母(旧口径被 continue 跳过 → 8)
1765
+ assert.equal(s10.settled, 2) // 归档计入分子(旧口径 → 0)
1766
+ assert.equal(s10.resolved, 2) // 兼容别名与 settled 同值(老前端/老断言按 resolved 读仍成立)
1767
+ assert.equal(s10.active, 0)
1768
+
1769
+ // 全归档父卡仍出键(史诗收尾后卡片徽章不凭空消失,留档可查)
1770
+ const allArch = [mkTask({ id: 'z1', parentId: 'e2', status: 'archived' }), mkTask({ id: 'z2', parentId: 'e2', status: 'archived' })]
1771
+ assert.deepEqual(core.aggregateChildStats(allArch).e2, { total: 2, settled: 2, resolved: 2, active: 0, activeTitle: '' })
1772
+
1773
+ // 多父卡各自成键 + 混合状态(cancelled 也属了结集:人已显式放弃该子任务范围)
1115
1774
  const tasks = [
1116
- // 父卡 p1:1 resolved + 1 archived(归档不计)+ 1 in-progress
1117
1775
  mkTask({ id: 'a1', parentId: 'p1', status: 'resolved' }),
1118
1776
  mkTask({ id: 'a2', parentId: 'p1', status: 'archived' }),
1119
1777
  mkTask({ id: 'a3', parentId: 'p1', status: 'in-progress', title: 'p1活跃' }),
1120
- // 父卡 p2:全部 archived → 不出键
1121
1778
  mkTask({ id: 'b1', parentId: 'p2', status: 'archived' }),
1122
- mkTask({ id: 'b2', parentId: 'p2', status: 'archived' }),
1123
- // 父卡 p3:无活跃子任务 → activeTitle 空串
1779
+ mkTask({ id: 'b2', parentId: 'p2', status: 'cancelled' }),
1124
1780
  mkTask({ id: 'd1', parentId: 'p3', status: 'pending' }),
1125
1781
  mkTask({ id: 'd2', parentId: 'p3', status: 'resolved' }),
1126
1782
  ]
1127
1783
  const stats = core.aggregateChildStats(tasks)
1128
- assert.deepEqual(Object.keys(stats).sort(), ['p1', 'p3']) // p2 全归档不出键;父卡自身不在 tasks 也照算(按键聚合)
1129
- assert.deepEqual(stats.p1, { total: 2, resolved: 1, active: 1, activeTitle: 'p1活跃' }) // archived 的 a2 不进 total
1130
- assert.deepEqual(stats.p3, { total: 2, resolved: 1, active: 0, activeTitle: '' })
1784
+ assert.deepEqual(Object.keys(stats).sort(), ['p1', 'p2', 'p3']) // 全归档的父卡也出键(不再「键自然消失」)
1785
+ assert.deepEqual(stats.p1, { total: 3, settled: 2, resolved: 2, active: 1, activeTitle: 'p1活跃' })
1786
+ assert.deepEqual(stats.p2, { total: 2, settled: 2, resolved: 2, active: 0, activeTitle: '' })
1787
+ assert.deepEqual(stats.p3, { total: 2, settled: 1, resolved: 1, active: 0, activeTitle: '' })
1788
+ })
1789
+
1790
+ test('aggregateChildStats: in-progress/verifying/pending 未了结不计 settled;archived 永不算 active', () => {
1791
+ const kids = [
1792
+ mkTask({ id: 'c1', parentId: 'epic', status: 'in-progress', title: '甲' }),
1793
+ mkTask({ id: 'c2', parentId: 'epic', status: 'verifying' }),
1794
+ mkTask({ id: 'c3', parentId: 'epic', status: 'blocked' }),
1795
+ mkTask({ id: 'c4', parentId: 'epic', status: 'pending' }),
1796
+ mkTask({ id: 'c5', parentId: 'epic', status: 'draft' }),
1797
+ mkTask({ id: 'c6', parentId: 'epic', status: 'archived' }),
1798
+ ]
1799
+ const s = core.aggregateChildStats(kids).epic
1800
+ assert.equal(s.total, 6)
1801
+ assert.equal(s.settled, 1) // 只有归档那张算了结;进行中/验证中/阻塞/待办/草稿都不算
1802
+ assert.equal(s.active, 1) // 归档不冒充在跑
1803
+ assert.equal(s.activeTitle, '甲')
1804
+ })
1805
+
1806
+ test('aggregateChildStats: 归档口径注释写明僵尸卡近似 + 旧「归档跳过」已消灭(源码级断言)', () => {
1807
+ const coreSrc = readFileSync(new URL('../lib/core.mjs', import.meta.url), 'utf8')
1808
+ // 近似口径必须写在注释里:v1.7.1 起 archive-task 放行「无活跃 run 的 in-progress 僵尸卡」,
1809
+ // 故 archived 不再严格等于「曾经完成」——但归档仍是人的显式了结,进度上计入是对的
1810
+ assert.match(coreSrc, /archive-task 放行「无活跃 run 的 in-progress 僵尸卡」/)
1811
+ assert.match(coreSrc, /进度只增不减/)
1812
+ // 分子口径单一出处(resolved | cancelled | archived)
1813
+ assert.match(coreSrc, /if \(t\.status === 'resolved' \|\| t\.status === 'cancelled' \|\| t\.status === 'archived'\) \{ s\.settled\+\+; s\.resolved\+\+ \}/)
1814
+ // 防回归:聚合循环里不得再按 archived 跳过
1815
+ assert.doesNotMatch(coreSrc, /aggregateChildStats[\s\S]{0,600}?status === 'archived'\) continue/)
1816
+ assert.match(coreSrc, /if \(!t \|\| !isb\(t\)\) continue/)
1817
+ // 与父卡自动收口口径(isChildSettled,archived 不算)刻意分离,注释里写明区别
1818
+ assert.match(coreSrc, /与 isChildSettled(父卡自动收口口径,archived 不算)/)
1819
+ // 僵尸放行本身的门禁注释仍在 rpc.mjs(口径来源不悬空)
1820
+ const rpcSrc = readFileSync(new URL('../lib/rpc.mjs', import.meta.url), 'utf8')
1821
+ assert.match(rpcSrc, /in-progress 且无活跃 run\s+→ 放行(parentKick 僵尸态出清/)
1822
+ })
1823
+
1824
+ test('FamilySection: 子任务清单含归档(灰化 + 「已归档」徽章 + 沉底,不排 archived)(源码级断言)', () => {
1825
+ const src = readFileSync(new URL('../lib/client/task-detail.js', import.meta.url), 'utf8')
1826
+ // 清单按 parentId 现算,**不再**附加 status !== 'archived' 过滤(反馈 task-muupgfot:归档后详情里子任务消失)
1827
+ assert.match(src, /var kids = state\.tasks\.filter\(function \(x\) \{ return x\.parentId === task\.id \}\)/)
1828
+ assert.doesNotMatch(src, /x\.parentId === task\.id && x\.status !== 'archived'/)
1829
+ // 沉底排序:已归档排后(稳定排序,未归档保持既有顺序)
1830
+ assert.match(src, /\.sort\(function \(a, b\) \{ return \(a\.status === 'archived' \? 1 : 0\) - \(b\.status === 'archived' \? 1 : 0\) \}\)/)
1831
+ // 行尾「已归档」徽章 + 灰化(降透明度 + 次要文字色),点击行为不变(仍可进详情看留档)
1832
+ assert.match(src, /'已归档'/)
1833
+ assert.match(src, /opacity: arch \? 0\.55 : 1/)
1834
+ assert.match(src, /color: arch \? C\.text2 : C\.brand/)
1835
+ assert.match(src, /onClick: function \(\) \{ jump\(x\.id\) \}/)
1836
+ // 标题行分子口径与卡片 📦 徽章同源(settled,含归档),不再用只数 resolved 的旧分子
1837
+ assert.match(src, /var settledN = kids\.filter\(function \(x\) \{ return x\.status === 'resolved' \|\| x\.status === 'cancelled' \|\| x\.status === 'archived' \}\)\.length/)
1838
+ assert.match(src, /'📦 子任务 · ' \+ settledN \+ '\/' \+ kids\.length \+ ' 已了结'/)
1839
+ // 卡片徽章/进度条读 settled(老宿主无该字段时回退 resolved,缺省兼容)
1840
+ const card = readFileSync(new URL('../lib/client/board-list.js', import.meta.url), 'utf8')
1841
+ assert.match(card, /var csDone = cs \? \(typeof cs\.settled === 'number' \? cs\.settled : cs\.resolved\) : 0/)
1842
+ assert.match(card, /'📦 史诗 · ' \+ csDone \+ '\/' \+ cs\.total/)
1843
+ assert.match(card, /Math\.round\(\(csDone \/ cs\.total\) \* 100\)/)
1844
+ assert.doesNotMatch(card, /cs\.resolved \+ '\/' \+ cs\.total/) // 旧读法已全部换成 csDone
1845
+ // 组装产物里也要有(require build-client 先跑;npm pretest 已保证)
1846
+ const built = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
1847
+ assert.match(built, /'已归档'/)
1848
+ assert.match(built, /csDone/)
1849
+ })
1850
+
1851
+ // ===== 史诗 hooks=agent run(task-muuw4ov7):host 生命周期接线(pre 闸门 / post 收口 / 薄框架 prompt / 主窗口限定写入)=====
1852
+ // 设计定稿:hook 点 = 一次**真实 agent 运行**(不是声明式命令);点位可选(epic 未声明 hooks 则全链路零变化);
1853
+ // 薄框架 + agent 自行决策 + 歧义上报兜底;commit/push 不入任何默认形态;hooks 只许主窗口设置。
1854
+ const HOOK_TASK = (over) => mkTask(Object.assign({ id: 'epic', title: '史诗甲', status: 'in-progress', hooks: { pre: { enabled: true, prompt: '准备环境', state: 'idle', runId: null } } }, over || {}))
1855
+
1856
+ test('normalizeHooks: 浅校验形状(enabled 缺省 true / prompt 必填 / state 枚举 / 未知键忽略 / null 清除)', () => {
1857
+ const ok = core.normalizeHooks({ pre: { prompt: '准备' } })
1858
+ assert.deepEqual(ok.hooks.pre, { enabled: true, prompt: '准备', state: 'idle', runId: null }) // enabled 缺省 true
1859
+ assert.equal(core.normalizeHooks({ pre: { prompt: 'p', enabled: false } }).hooks.pre.enabled, false)
1860
+ assert.equal(core.normalizeHooks(null).hooks, null) // null = 清除
1861
+ assert.equal(core.normalizeHooks({}).hooks.pre, undefined) // 空对象 = 什么都不声明
1862
+ assert.deepEqual(core.normalizeHooks({ pre: null }).hooks, { pre: null }) // 显式 null 原样穿出(撤点位的意图不能在归一化阶段被吃掉)
1863
+ // prompt 必填(薄框架契约本体由主窗口写,空 prompt 等于没契约 → 宁早报错)
1864
+ assert.match(core.normalizeHooks({ pre: { prompt: ' ' } }).error, /prompt 不能为空/)
1865
+ assert.match(core.normalizeHooks({ pre: { prompt: 123 } }).error, /prompt 必须是字符串/)
1866
+ // 形状与枚举
1867
+ assert.match(core.normalizeHooks('x').error, /必须是对象/)
1868
+ assert.match(core.normalizeHooks({ pre: 'x' }).error, /hooks\.pre 必须是对象/)
1869
+ assert.equal(core.normalizeHooks({ pre: { prompt: 'p', state: 'running' } }).hooks.pre.state, 'running') // 合法枚举放行
1870
+ assert.ok(core.normalizeHooks({ pre: { prompt: 'p', state: 'bogus' } }).error, 'state 必须是 ') // 非法枚举拒绝
1871
+ assert.match(String(core.normalizeHooks({ pre: { prompt: 'p', state: 'bogus' } }).error), /state 必须是/)
1872
+ assert.equal(core.normalizeHooks({ pre: { prompt: 'p', state: 'done', runId: 'r1' } }).hooks.pre.state, 'done') // 重启后人工恢复现场
1873
+ // 未知键忽略(前向兼容:老 host 存下的字段不炸)
1874
+ assert.deepEqual(Object.keys(core.normalizeHooks({ pre: { prompt: 'p', 未知: 1 } }).hooks.pre).sort(), ['enabled', 'prompt', 'runId', 'state'])
1875
+ // 超长 prompt 截断到 4000
1876
+ assert.equal(core.normalizeHooks({ pre: { prompt: 'x'.repeat(5000) } }).hooks.pre.prompt.length, 4000)
1877
+ })
1878
+
1879
+ test('mergeHooks: 浅合并(未提交点位保留 / { pre: null } 只撤 pre / null 全清)', () => {
1880
+ const prev = { pre: { enabled: true, prompt: 'A', state: 'done', runId: null }, post: { enabled: true, prompt: 'B', state: 'idle', runId: null } }
1881
+ const onlyPre = core.mergeHooks(prev, core.normalizeHooks({ pre: { prompt: 'A2' } }).hooks)
1882
+ assert.equal(onlyPre.post.prompt, 'B') // 未提交的点位保留原值(task_update 只想改 prompt 时不必重复整条)
1883
+ assert.equal(onlyPre.pre.prompt, 'A2')
1884
+ assert.equal(onlyPre.pre.state, 'idle') // 重新提交 pre = 重置该点位状态机(人改了契约就当重跑)
1885
+ const dropPre = core.mergeHooks(prev, { pre: null }) // { pre: null } 撤点位
1886
+ assert.equal(dropPre.pre, undefined); assert.equal(dropPre.post.prompt, 'B')
1887
+ assert.deepEqual(core.mergeHooks(prev, null), prev) // 语义同「不提交」
1888
+ })
1889
+
1890
+ test('preHookGate: 未声明/未启用/done 放行;idle/running/failed 拦下(串行闸门)', () => {
1891
+ assert.deepEqual(core.preHookGate(null), { pass: true, reason: 'none' }) // 老 epic 无 hooks → 放行
1892
+ assert.deepEqual(core.preHookGate(mkTask({})), { pass: true, reason: 'none' })
1893
+ assert.equal(core.preHookGate(mkTask({ hooks: { pre: { enabled: false, state: 'idle' } } })).pass, true) // 未启用 → 放行
1894
+ assert.deepEqual(core.preHookGate(HOOK_TASK({ hooks: { pre: { enabled: true, state: 'done' } } })), { pass: true, reason: 'done' })
1895
+ assert.deepEqual(core.preHookGate(HOOK_TASK()), { pass: false, reason: 'idle' }) // 待跑 → 本轮不派子任务
1896
+ assert.deepEqual(core.preHookGate(HOOK_TASK({ hooks: { pre: { enabled: true, state: 'running' } } })), { pass: false, reason: 'running' })
1897
+ assert.deepEqual(core.preHookGate(HOOK_TASK({ hooks: { pre: { enabled: true, state: 'failed' } } })), { pass: false, reason: 'failed' })
1898
+ assert.equal(core.hookOn(HOOK_TASK(), 'pre'), true)
1899
+ assert.equal(core.hookOn(HOOK_TASK(), 'post'), false) // 只声明了 pre
1900
+ })
1901
+
1902
+ test('pickDispatch: pre 闸门未 done → 该 epic 的子任务一张都不派;done 后放行(核心回归)', () => {
1903
+ const epi = HOOK_TASK() // pre=idle
1904
+ const c1 = mkTask({ id: 'c1', parentId: 'epic', status: 'pending' })
1905
+ const c2 = mkTask({ id: 'c2', parentId: 'epic', status: 'pending' })
1906
+ const other = mkTask({ id: 'solo', status: 'pending' }) // 无关卡片不受影响
1907
+ const d = mkBoard([epi, c1, c2, other])
1908
+ assert.deepEqual(core.pickDispatch(d, 5, 0, null).pendings.map(t => t.id), ['solo'])
1909
+ // idle → running(hook run 在跑):仍然一张都不派
1910
+ epi.hooks.pre.state = 'running'
1911
+ assert.deepEqual(core.pickDispatch(d, 5, 0, null).pendings.map(t => t.id), ['solo'])
1912
+ // failed(已 blocked 待人裁决):绝不自动放行
1913
+ epi.hooks.pre.state = 'failed'
1914
+ assert.deepEqual(core.pickDispatch(d, 5, 0, null).pendings.map(t => t.id), ['solo'])
1915
+ // done(前置准备完成):下轮起子任务正常派发
1916
+ epi.hooks.pre.state = 'done'
1917
+ assert.deepEqual(core.pickDispatch(d, 5, 0, null).pendings.map(t => t.id).sort(), ['c1', 'c2', 'solo'])
1918
+ })
1919
+
1920
+ test('pickDispatch: 老 epic(无 hooks)+ 只在 post 声明 → 与既有行为零变化', () => {
1921
+ const epi = mkTask({ id: 'epic', status: 'in-progress' }) // 无 hooks
1922
+ const c = mkTask({ id: 'c1', parentId: 'epic', status: 'pending' })
1923
+ assert.deepEqual(core.pickDispatch(mkBoard([epi, c]), 5, 0, null).pendings.map(t => t.id), ['c1'])
1924
+ const epi2 = mkTask({ id: 'e2', status: 'in-progress', hooks: { post: { enabled: true, prompt: '收口', state: 'idle' } } })
1925
+ const c2 = mkTask({ id: 'c2', parentId: 'e2', status: 'pending' })
1926
+ assert.deepEqual(core.pickDispatch(mkBoard([epi2, c2]), 5, 0, null).pendings.map(t => t.id), ['c2']) // post 不拦前置派发
1927
+ })
1928
+
1929
+ test('maybeAutoCloseParent 接线③:post 未收口 → 不转 verifying,只置 running + 待跑标记', () => {
1930
+ const epi = mkTask({ id: 'epic', status: 'in-progress', hooks: { post: { enabled: true, prompt: '收口', state: 'idle' } } })
1931
+ const c = mkTask({ id: 'c1', parentId: 'epic', status: 'resolved' })
1932
+ const d = mkBoard([epi, c])
1933
+ const p = core.maybeAutoCloseParent(d, c)
1934
+ assert.equal(p, epi)
1935
+ assert.equal(epi.status, 'in-progress') // 关键:延迟 verifying(不再直接收口)
1936
+ assert.equal(epi.hooks.post.pending, true) // 等 poolCycle 见标记 spawn hook-post
1937
+ assert.equal(epi.hooks.post.state, 'running')
1938
+ assert.equal(epi.resolution, undefined) // 未收口就绝不算已收口(旧实现此处会被写成 'all subtasks resolved')
1939
+ assert.match(epi.history[epi.history.length - 1].note, /post hook 收口未完成,延迟 verifying/)
1940
+ // 幂等:已在 post 收口途中重复调用仍不转 verifying
1941
+ assert.equal(core.maybeAutoCloseParent(d, c), epi)
1942
+ assert.equal(epi.status, 'in-progress')
1943
+ })
1944
+
1945
+ test('maybeAutoCloseParent 接线③:post 已 done → 正常转 verifying;failed → 回归安静态', () => {
1946
+ const mk = (state, pending) => {
1947
+ const e = mkTask({ id: 'epic', status: 'in-progress', hooks: { post: { enabled: true, prompt: '收口', state: state, pending: pending } } })
1948
+ const c = mkTask({ id: 'c1', parentId: 'epic', status: 'resolved' })
1949
+ return { e, d: mkBoard([e, c]), c }
1950
+ }
1951
+ const a = mk('done', undefined)
1952
+ assert.equal(core.maybeAutoCloseParent(a.d, a.c), a.e)
1953
+ assert.equal(a.e.status, 'verifying') // 收口完成 → 交人验收
1954
+ assert.equal(a.e.resolution, 'all subtasks resolved')
1955
+ const b = mk('failed', undefined)
1956
+ assert.equal(core.maybeAutoCloseParent(b.d, b.c), null) // 收口失败已 blocked 待人裁决,绝不自动收口
1957
+ assert.equal(b.e.status, 'in-progress')
1958
+ })
1959
+
1960
+ test('maybeAutoCloseParent: 老 epic(无 hooks)全链路零变化(防回归)', () => {
1961
+ const e = mkTask({ id: 'epic', status: 'in-progress' })
1962
+ const c = mkTask({ id: 'c1', parentId: 'epic', status: 'resolved' })
1963
+ const d = mkBoard([e, c])
1964
+ assert.equal(core.maybeAutoCloseParent(d, c), e)
1965
+ assert.equal(e.status, 'verifying') // 未声明 post → 维持现状直接收口
1966
+ assert.equal(e.hooks, undefined)
1967
+ })
1968
+
1969
+ test('applyHookSettle 接线②:pre 完成 → done(串行闸门打开)', () => {
1970
+ const epi = HOOK_TASK({ hooks: { pre: { enabled: true, prompt: '准备', state: 'running', runId: 'run-p', pending: false } } })
1971
+ const d = mkBoard([epi])
1972
+ const r = core.applyHookSettle(d, 'epic', 'pre', true, '准备完成', 'run-p', '')
1973
+ assert.equal(r.preDone, true)
1974
+ assert.equal(epi.hooks.pre.state, 'done')
1975
+ assert.equal(epi.hooks.pre.runId, null) // run 归零(结束即失效)
1976
+ assert.equal(epi.status, 'in-progress') // pre 不影响 epic 自身状态
1977
+ assert.match(epi.history[epi.history.length - 1].note, /hooks\.pre: running → done/)
1978
+ assert.equal(core.applyHookSettle(d, 'epic', 'pre', true, 'x', 'run-p', '').already, true) // 幂等
1979
+ })
1980
+
1981
+ test('applyHookSettle 接线②:pre 失败 → state=failed + epic blocked + 歧义上报(不自动重试)', () => {
1982
+ const epi = HOOK_TASK({ hooks: { pre: { enabled: true, prompt: '准备', state: 'running', runId: 'run-p' } } })
1983
+ const d = mkBoard([epi])
1984
+ const r = core.applyHookSettle(d, 'epic', 'pre', false, '', 'run-p', '模型不可用')
1985
+ assert.equal(r.blocked, true)
1986
+ assert.equal(epi.hooks.pre.state, 'failed')
1987
+ assert.equal(epi.status, 'blocked')
1988
+ assert.match(epi.escalation.question, /hooks\.pre(前置准备)失败:模型不可用/)
1989
+ assert.match(epi.escalation.question, /重试.*跳过.*放弃/s)
1990
+ assert.match(epi.lastError, /hooks\.pre run 失败/)
1991
+ assert.equal(epi.messages[epi.messages.length - 1].kind, 'escalation')
1992
+ // 已 blocked 后子任务仍被闸门拦住(failed 不放行)
1993
+ const c = mkTask({ id: 'c1', parentId: 'epic', status: 'pending' })
1994
+ d.tasks.push(c)
1995
+ assert.equal(core.pickDispatch(d, 5, 0, null).pendings.length, 0)
1996
+ // 幂等:已被裁决前重复结算不覆盖 escalation
1997
+ const esc0 = epi.escalation
1998
+ assert.equal(core.applyHookSettle(d, 'epic', 'pre', false, '', 'run-p', 'again').already, true)
1999
+ assert.equal(epi.escalation, esc0)
2000
+ })
2001
+
2002
+ test('applyHookSettle 接线②:post 完成 → post=done + epic 转 verifying(收口完成)', () => {
2003
+ const epi = HOOK_TASK({ status: 'in-progress', hooks: { post: { enabled: true, prompt: '收口', state: 'running', runId: 'run-q', pending: false } } })
2004
+ const d = mkBoard([epi])
2005
+ const r = core.applyHookSettle(d, 'epic', 'post', true, '已总结', 'run-q', '')
2006
+ assert.equal(r.closed, true)
2007
+ assert.equal(epi.hooks.post.state, 'done')
2008
+ assert.equal(epi.status, 'verifying') // 收口完成才交人验收
2009
+ assert.equal(epi.resolution, 'hooks.post settled')
2010
+ assert.match(epi.history[epi.history.length - 1].note, /post hook 收口完成/)
2011
+ })
2012
+
2013
+ test('applyHookSettle 接线②:post 失败 → failed + blocked + 歧义;点位被删则只收口', () => {
2014
+ const epi = HOOK_TASK({ hooks: { post: { enabled: true, prompt: '收口', state: 'running', runId: 'run-q' } } })
2015
+ const d = mkBoard([epi])
2016
+ assert.equal(core.applyHookSettle(d, 'epic', 'post', false, '', 'run-q', '超时').blocked, true)
2017
+ assert.equal(epi.hooks.post.state, 'failed'); assert.equal(epi.status, 'blocked')
2018
+ assert.match(epi.escalation.question, /hooks\.post(收口)失败:超时/)
2019
+ // hooks 被人删除 → 只收口(already),不凭空造状态
2020
+ const epi2 = mkTask({ id: 'e2', status: 'in-progress' })
2021
+ assert.equal(core.applyHookSettle(mkBoard([epi2]), 'e2', 'post', true, '', 'r', '').already, true)
2022
+ assert.equal(epi2.status, 'in-progress')
2023
+ // epic 不存在 → null(调用方静默跳过)
2024
+ assert.equal(core.applyHookSettle(mkBoard([]), 'ghost', 'pre', true, '', 'r', ''), null)
2025
+ })
2026
+
2027
+ test('buildHookPrompt: 薄框架模板(契约 + epic 上下文 + 子任务清单 + 歧义兜底)', () => {
2028
+ const epi = mkTask({
2029
+ id: 'epic', title: '史诗甲', description: '把 X 做完',
2030
+ hooks: { pre: { enabled: true, prompt: '先把依赖装好', state: 'idle' } },
2031
+ })
2032
+ epi.context = { instructions: '别碰 client' }
2033
+ const kids = [mkTask({ id: 'c1', title: '子一', status: 'pending', pipeline: 'full' }), mkTask({ id: 'c2', title: '子二', status: 'resolved' })]
2034
+ const p = core.buildHookPrompt(epi, 'pre', kids)
2035
+ assert.match(p, /hooks=pre run/) // 角色标识
2036
+ assert.match(p, /pre(前置准备闸门)/)
2037
+ assert.match(p, /epic · 史诗甲/)
2038
+ assert.match(p, /把 X 做完/) // epic 描述注入
2039
+ assert.match(p, /别碰 client/) // epic 指引注入
2040
+ assert.match(p, /先把依赖装好/) // 主窗口写的运行契约
2041
+ assert.match(p, /- \[pending\] c1 · 子一(管线 full)/) // 子任务标题/状态清单
2042
+ assert.match(p, /- \[resolved\] c2 · 子二/)
2043
+ assert.match(p, /让这批子任务具备开跑条件/) // 薄框架:只定边界,不排具体动作
2044
+ assert.match(p, /由你根据上下文判断/)
2045
+ assert.match(p, /board_report(kind=escalate, taskId=epic/) // 歧义兜底通道
2046
+ assert.match(p, /不要猜测、不要硬闯/)
2047
+ assert.match(p, /不要默认提交(git commit)或推送(git push)/) // 红线③:commit/push 不入默认形态
2048
+ assert.match(p, /完成即代表 pre 闸门放行/)
2049
+ // post 契约:收口语义 + 明确「自行决策并自负其责」,且不含 pre 的放行文案
2050
+ const q = core.buildHookPrompt(epi, 'post', kids)
2051
+ assert.match(q, /hooks=post run/)
2052
+ assert.match(q, /post(收口闸门)/)
2053
+ assert.match(q, /把这批已完成的工作收口/)
2054
+ assert.match(q, /自行决策并自负其责/)
2055
+ assert.match(q, /史诗将自动转入 verifying/)
2056
+ assert.doesNotMatch(q, /完成即代表 pre 闸门放行/)
2057
+ // 无子任务时也有可读清单占位(不产生半截列表)
2058
+ assert.match(core.buildHookPrompt(epi, 'pre', []), /(暂无子任务)/)
2059
+ })
2060
+
2061
+ test('hooks 只许主窗口设置:create-task/update-task RPC 与 task_create/task_update 双通道角色门禁', async () => {
2062
+ // ① RPC 通道:子代理(resolveRoot(actor) !== actor)带 hooks 一律拒绝
2063
+ const child = { resolveRoot: () => 'root-other' }
2064
+ const b1 = mkBoard([])
2065
+ const r1 = await mkRpcHandlers(b1, child)['create-task']({ title: 'T', description: 'd', hooks: { pre: { prompt: 'p' } } })
2066
+ assert.equal(r1.ok, false); assert.match(r1.error, /hooks 仅主窗口可设/); assert.equal(b1.tasks.length, 0)
2067
+ // 不带 hooks 时子代理路径行为不变(老契约:RPC 通道本就主窗口驱动)
2068
+ const r1b = await mkRpcHandlers(mkBoard([]), child)['create-task']({ title: 'T', description: 'd' })
2069
+ assert.equal(r1b.ok, true)
2070
+ // ② RPC 通道:主窗口(resolveRoot(actor) === actor)可设 + 形状校验错误原样返回
2071
+ const b2 = mkBoard([])
2072
+ const r2 = await mkRpcHandlers(b2)['create-task']({ title: 'T', description: 'd', hooks: { pre: { prompt: '准备' } } })
2073
+ assert.equal(r2.ok, true)
2074
+ assert.equal(b2.tasks[0].hooks.pre.prompt, '准备')
2075
+ assert.equal(b2.tasks[0].hooks.pre.enabled, true)
2076
+ const r2b = await mkRpcHandlers(mkBoard([]))['create-task']({ title: 'T', description: 'd', hooks: { pre: {} } })
2077
+ assert.equal(r2b.ok, false); assert.match(r2b.error, /prompt 不能为空/)
2078
+ // ③ RPC 通道:update-task 浅合并(只改 prompt 不动 post;{ pre: null } 撤点位)
2079
+ const b3 = mkBoard([mkTask({ id: 'e1', hooks: { pre: { enabled: true, prompt: 'A', state: 'idle' }, post: { enabled: true, prompt: 'B', state: 'idle' } } })])
2080
+ const r3 = await mkRpcHandlers(b3)['update-task']({ taskId: 'e1', hooks: { pre: { prompt: 'A2' } } })
2081
+ assert.equal(r3.ok, true)
2082
+ assert.equal(b3.tasks[0].hooks.pre.prompt, 'A2'); assert.equal(b3.tasks[0].hooks.post.prompt, 'B')
2083
+ const r3b = await mkRpcHandlers(b3)['update-task']({ taskId: 'e1', hooks: { pre: null } })
2084
+ assert.equal(r3b.ok, true); assert.equal(b3.tasks[0].hooks.pre, undefined); assert.equal(b3.tasks[0].hooks.post.prompt, 'B')
2085
+ // ④ 工具通道:schema 有 hooks 参数 + hooks 专属门禁(子代理报「hooks 仅主窗口可设」,非通用无权限文案)
2086
+ const h = mkRpcHandlers(mkBoard([]))
2087
+ const hChild = mkRpcHandlers(mkBoard([]), child) // 子代理身份(resolveRoot 指向别处)
2088
+ for (const name of ['task_create', 'task_update']) {
2089
+ const tool = h.__tools[name]
2090
+ assert.ok(tool, name + ' 工具已注册')
2091
+ assert.ok(tool.parameters.properties.hooks, name + ' schema 含 hooks 参数')
2092
+ // hooks 与 draft/publish 同级(顶层属性)——曾误插进 draft/publish 属性内部(`.properties.hooks` 为空即漏检)
2093
+ assert.equal(tool.parameters.properties.draft && tool.parameters.properties.draft.properties && tool.parameters.properties.draft.properties.hooks, undefined, name + ' hooks 未误插进 draft 属性内')
2094
+ // 子代理调用带 hooks → 专属文案拒绝(先于/独立于通用「仅主窗口可用」门禁,便于定位权限边界)
2095
+ const resChild = await hChild.__tools[name].execute({ taskId: 'x', title: 'T', hooks: { pre: { prompt: 'p' } } })
2096
+ assert.equal(resChild.ok, false)
2097
+ assert.match(resChild.error, /hooks 仅主窗口可设/)
2098
+ }
2099
+ // ⑤ 子代理(resolveRoot 指向别处)经 update-task 带 hooks → 同一文案拒绝,且不改卡
2100
+ const b4 = mkBoard([mkTask({ id: 'e2', hooks: { pre: { enabled: true, prompt: 'A', state: 'idle' } } })])
2101
+ const r4 = await mkRpcHandlers(b4, child)['update-task']({ taskId: 'e2', hooks: { pre: { prompt: 'X' } } })
2102
+ assert.equal(r4.ok, false); assert.match(r4.error, /hooks 仅主窗口可设/)
2103
+ assert.equal(b4.tasks[0].hooks.pre.prompt, 'A')
2104
+ assert.equal(b4.tasks[0].hooks.pre.state, 'idle') // 状态机不被越权写入扰动
2105
+ })
2106
+
2107
+ // ===== hooks 接线(host 侧):pre 闸门 spawn / post 补 spawn / hook run 结算分派(直调 + 源码级)=====
2108
+ // poolCycle 直调 harness(带 subagents):spawnOneShot 走真实路径,捕获 provider/label/prompt;
2109
+ // run.result 用永不落定的 Promise(本用例只验证「谁被 spawn 了、prompt 对不对」,不触发结算)。
2110
+ function mkHookDispatch(board, over) {
2111
+ const spawned = []
2112
+ const runs = {}
2113
+ const boardRef = { b: board }
2114
+ const ctx = {
2115
+ fs: {}, effect: function () {}, get: function () { return null },
2116
+ timer: null,
2117
+ subagents: {
2118
+ list: () => ['mock'],
2119
+ getProvider: () => ({ inheritsParentContext: false }),
2120
+ start: async (name, req) => {
2121
+ spawned.push({ name, label: req.label, text: req.prompt[0].text, parent: req.parent })
2122
+ return { id: 'run-' + spawned.length, result: new Promise(function () {}), dispose: async function () {} }
2123
+ },
2124
+ },
2125
+ }
2126
+ const state = { knownSessions: {}, dispatchedEver: {}, badModels: {}, packByChild: {}, pendingPacks: [], teamModeCache: {}, activeRuns: {} }
2127
+ const dispatch = createDispatch(ctx, state, Object.assign({
2128
+ rt: async () => boardRef.b,
2129
+ wt: async () => {},
2130
+ mutateLocked: async (sid, fn) => fn(boardRef.b),
2131
+ kickCycle: () => {},
2132
+ rootForSession: () => ({ id: FULL_SID }),
2133
+ sessionCwd: () => '', withTimeout: (p) => p, runsFor: () => runs, feedbackOn: () => true,
2134
+ pushSysNote: () => {}, maybeNotify: () => {}, notifyTaskDone: () => {},
2135
+ }, over || {}))
2136
+ return { dispatch, spawned, runs, board: boardRef }
2137
+ }
2138
+
2139
+ test('poolCycle 接线①:pre=idle → 不派子任务,改 spawn hook-pre run(prompt=薄框架+契约)', async () => {
2140
+ const epi = HOOK_TASK({ hooks: { pre: { enabled: true, prompt: '先把依赖装好', state: 'idle', runId: null } } })
2141
+ const child = mkTask({ id: 'c1', parentId: 'epic', status: 'pending' })
2142
+ // 第二个子任务挂在一张无 hooks 的 epic 上:它是「无关卡片」参照物,也顺带保证 epi 不被 claim
2143
+ // → epi 停在 in-progress(poolCycle 的空闲快进不会把整轮短路掉,hook 闸门分支真的被执行到)
2144
+ const epi2 = mkTask({ id: 'epic2', status: 'in-progress' })
2145
+ const child2 = mkTask({ id: 'z1', parentId: 'epic2', status: 'pending' })
2146
+ const board = mkBoard([epi, child, epi2, child2])
2147
+ const h = mkHookDispatch(board)
2148
+ await h.dispatch.poolCycle(FULL_SID)
2149
+ const labels = h.spawned.map(s => s.label).sort()
2150
+ assert.deepEqual(labels, ['hook-pre:epic', 'worker:z1']) // 只 spawn hook + 无关卡片照常派,无 c1 的 Worker
2151
+ assert.equal(child.status, 'pending') // 串行闸门:子任务原地待命
2152
+ assert.equal(child2.status, 'in-progress') // 无关卡片不受闸门影响
2153
+ assert.equal(epi.hooks.pre.state, 'running') // 状态机推进(写在卡上,重启可恢复)
2154
+ assert.equal(epi.hooks.pre.pending, undefined) // pre 不用内存占用标记:幂等靠 state='running' 本身
2155
+ const hp = h.spawned.find(s => s.label === 'hook-pre:epic')
2156
+ assert.match(hp.text, /hooks=pre run/)
2157
+ assert.match(hp.text, /先把依赖装好/) // 主窗口契约注入
2158
+ // 第二轮(hook 仍在跑)不重复 spawn
2159
+ await h.dispatch.poolCycle(FULL_SID)
2160
+ assert.equal(h.spawned.filter(s => s.label === 'hook-pre:epic').length, 1)
2161
+ })
2162
+
2163
+ test('poolCycle 接线①:pre=done → 子任务正常派发(闸门放行,不再 spawn hook)', async () => {
2164
+ const epi = HOOK_TASK({ hooks: { pre: { enabled: true, prompt: '准备', state: 'done', runId: null } } })
2165
+ const child = mkTask({ id: 'c1', parentId: 'epic', status: 'pending' })
2166
+ const h = mkHookDispatch(mkBoard([epi, child]))
2167
+ await h.dispatch.poolCycle(FULL_SID)
2168
+ assert.equal(h.spawned.length, 1)
2169
+ assert.equal(h.spawned[0].label, 'worker:c1') // 派的是 Worker
2170
+ assert.equal(child.status, 'in-progress')
2171
+ })
2172
+
2173
+ test('poolCycle 接线③:post 待跑标记 → 不转 verifying,spawn hook-post run 收口', async () => {
2174
+ const epi = mkTask({ id: 'epic', title: '史诗甲', status: 'in-progress', hooks: { post: { enabled: true, prompt: '做收口', state: 'idle', runId: null } } })
2175
+ const child = mkTask({ id: 'c1', parentId: 'epic', status: 'resolved' })
2176
+ const board = mkBoard([epi, child])
2177
+ // 先让 core 纯函数挂上待跑标记(真实触发路径:子任务了结时 maybeAutoCloseParent 被调用)
2178
+ core.maybeAutoCloseParent(board, child)
2179
+ assert.equal(epi.status, 'in-progress') // 延迟 verifying
2180
+ const h = mkHookDispatch(board)
2181
+ await h.dispatch.poolCycle(FULL_SID)
2182
+ assert.equal(h.spawned.length, 1)
2183
+ assert.equal(h.spawned[0].label, 'hook-post:epic')
2184
+ assert.equal(epi.status, 'in-progress') // spawn 后仍等 hook 结算才转 verifying
2185
+ assert.equal(epi.hooks.post.pending, false)
2186
+ assert.equal(epi.verifierRun, 'run-1') // 幂等占用位换成真实 run id
2187
+ await h.dispatch.poolCycle(FULL_SID) // 不重复 spawn
2188
+ assert.equal(h.spawned.length, 1)
2189
+ })
2190
+
2191
+ test('poolCycle 接线③:老 epic(无 hooks)→ 子任务了结仍直接 verifying,零 hook spawn(防回归)', async () => {
2192
+ const epi = mkTask({ id: 'epic', status: 'in-progress' })
2193
+ const child = mkTask({ id: 'c1', parentId: 'epic', status: 'resolved' })
2194
+ // 再来一对有 hooks 的 epic(idle pre):本轮真的会 spawn hook-pre,
2195
+ // 这样「hooked 场景 spawn ≥1、老场景 spawn 0」是对照组而非空跑(快进短路不会掩盖结论)
2196
+ const hooked = HOOK_TASK({ id: 'hooked', hooks: { pre: { enabled: true, prompt: '准备', state: 'idle' } } })
2197
+ const hchild = mkTask({ id: 'h1', parentId: 'hooked', status: 'pending' })
2198
+ const board = mkBoard([epi, child, hooked, hchild])
2199
+ core.maybeAutoCloseParent(board, child)
2200
+ const h = mkHookDispatch(board)
2201
+ await h.dispatch.poolCycle(FULL_SID)
2202
+ assert.equal(epi.status, 'verifying') // 老 epic 维持现状直接收口
2203
+ assert.equal(hooked.hooks.pre.state, 'running') // 对照组确实跑了 hook
2204
+ // 老 epic 收口后进 verifying 会照常派 Verifier(既有行为未变);对照组只有 hook-pre 一条 run
2205
+ assert.deepEqual(h.spawned.map(s => s.label).sort(), ['hook-pre:hooked', 'verifier:epic'])
2206
+ assert.equal(epi.hooks, undefined)
2207
+ })
2208
+
2209
+ test('hooks 接线(源码级):spawnOneShot 三态 prompt + settleRun 分派 + pre/post 占用与 spawn 失败回收', () => {
2210
+ const dsp = readFileSync(new URL('../lib/dispatch.mjs', import.meta.url), 'utf8')
2211
+ // spawnOneShot:hook 角色走 buildHookPrompt(phase 由 role 决定)
2212
+ assert.match(dsp, /if \(role === 'hook-pre' \|\| role === 'hook-post'\) \{/)
2213
+ assert.match(dsp, /buildHookPrompt\(t, role === 'hook-pre' \? 'pre' : 'post', kids\)/)
2214
+ // settleRun:hook 分支进 settleHook(照 worker/verifier 结算模式)
2215
+ assert.match(dsp, /else if \(rec\.role === 'hook-pre' \|\| rec\.role === 'hook-post'\) await settleHook\(sid, rec, output, failed, errText\)/)
2216
+ assert.match(dsp, /applyHookSettle\(d, rec\.taskId, phase, !\(failed \|\| esc\), output, runId, errText\)/)
2217
+ // pre 闸门占用(state=running 即幂等占用)与 post 补 spawn(pending 标记驱动,复用 verifierRun 幂等位)
2218
+ assert.match(dsp, /if \(hookOn\(t, 'pre'\) && t\.hooks\.pre\.state === 'idle' && !runs\[t\.id\]\) \{/)
2219
+ assert.match(dsp, /hookSetState\(t, 'pre', 'running', 'system', '派发前置 hook run'\)/)
2220
+ assert.match(dsp, /t\.hooks\.post\.pending && !t\.verifierRun && !runs\[t\.id\]/)
2221
+ // spawn 失败回收:pre 退回 idle 重试 / post 保留待跑标记
2222
+ assert.match(dsp, /sp\.role === 'hook-pre'/)
2223
+ assert.match(dsp, /t\.hooks\.pre\.state === 'running'\) \{ t\.hooks\.pre\.state = 'idle'/)
2224
+ assert.match(dsp, /sp\.role === 'hook-post'/)
2225
+ // 角色口径注释:hook run 计入 activeV(不挤占 Worker 并发位但参与空闲快进判定)
2226
+ assert.match(dsp, /hook run 不是 Worker,不该挤占 maxWorkers 并发位/)
2227
+ // core:post 延迟 verifying 注释与串行闸门说明在位(设计意图可追溯)
2228
+ const coreSrc = readFileSync(new URL('../lib/core.mjs', import.meta.url), 'utf8')
2229
+ assert.match(coreSrc, /post 延迟 verifying(hooks=agent run 接线③)/)
2230
+ assert.match(coreSrc, /串行闸门语义 = 「一个 epic 的子任务在 pre hook 完成之前一张都不派」/)
2231
+ assert.match(coreSrc, /commit\/push 不进任何默认形态/)
2232
+ })
2233
+
2234
+ test('hooks 主窗口限定(源码级):RPC 与工具双通道门禁 + 浅校验口径唯一在 core.normalizeHooks', () => {
2235
+ const rpc = readFileSync(new URL('../lib/rpc.mjs', import.meta.url), 'utf8')
2236
+ assert.match(rpc, /var HOOKS_MAIN_ONLY = 'hooks 仅主窗口可设(子代理无 hooks 权限)'/)
2237
+ assert.match(rpc, /function hooksDenied\(actor\) \{ return resolveRoot\(actor\) !== actor \}/)
2238
+ // create-task RPC:锁外角色门禁 + 锁内形状校验(与建卡同处一次持锁段)
2239
+ assert.match(rpc, /if \(args\.hooks !== undefined && hooksDenied\(actor\)\) return \{ ok: false, error: HOOKS_MAIN_ONLY \}/)
2240
+ assert.match(rpc, /var nHooks = null; if \(args\.hooks !== undefined\) \{ var hn = normalizeHooks\(args\.hooks\)/)
2241
+ // update-task RPC:同一门禁 + 浅合并
2242
+ assert.match(rpc, /if \(args\.hooks !== undefined && hooksDenied\(actor\)\) return \{ ok: false, error: HOOKS_MAIN_ONLY \}; var existsFn = existsInSession\(sid\)/)
2243
+ assert.match(rpc, /if \(args\.hooks !== undefined\) \{ var __he = applyHooks\(t, args\.hooks\)/)
2244
+ // 工具通道:两个工具的 hooks 专属门禁(在通用门禁之后,子代理拿到更准确的错误文案)
2245
+ assert.equal((rpc.match(/if \(args\.hooks !== undefined && resolveRoot\(__ra\) !== __ra\) return \{ ok: false, error: 'hooks 仅主窗口可设(子代理无 hooks 权限)' \}/g) || []).length, 2)
2246
+ // 形状校验口径唯一(normalizeHooks/mergeHooks 只在 core 定义,rpc 只解构引用)
2247
+ const coreSrc = readFileSync(new URL('../lib/core.mjs', import.meta.url), 'utf8')
2248
+ assert.match(coreSrc, /export function normalizeHooks\(input\)/)
2249
+ assert.match(coreSrc, /export function mergeHooks\(prev, next\)/)
1131
2250
  })
1132
2251
 
1133
2252
  test('史诗语义层接线:poolCycle 派发分支触发父卡流转 + get-tasks 返回 childStats(源码级断言)', () => {
@@ -1136,12 +2255,16 @@ test('史诗语义层接线:poolCycle 派发分支触发父卡流转 + get-tas
1136
2255
  assert.match(host, /claimApply\(d, t, 'spawn-pending', 'dispatch'\); if \(parentKickOnDispatch\(d, t\)\)/)
1137
2256
  // get-tasks 现算 childStats(零存储)
1138
2257
  assert.match(host, /d\.childStats = aggregateChildStats\(d\.tasks\)/)
1139
- // 两模块都从 core 解构引入(接线不断);rpc.mjs 解构表尾部随调研门禁(task-mute6zpw)扩展三个纯函数
1140
- assert.match(host, /parentKickOnDispatch, LESSON_RECALL_HINT \} = core/)
1141
- assert.match(host, /boardHome, aggregateChildStats, createTaskWarnings, epicPrecheck, epicPrecheckNote \} = core/)
1142
- // 既有「全子任务 resolved → 父 verifying」逻辑不动(checkParentAuto 仍在 verifyApply 链路;core.mjs 不在 hostSrc 清单,单独读)
2258
+ // 两模块都从 core 解构引入(接线不断);rpc.mjs 解构表尾部随调研门禁(task-mute6zpw)、调研遵循三件套(task-mutnj3a4)、
2259
+ // tasksHash(task-mutrtwin)、hooks 浅校验(normalizeHooks/mergeHooks,本批 hooks=agent run)扩展;
2260
+ // dispatch.mjs 解构表尾部追加 hook 族(buildHookPrompt/hookOn/hookSetState/gsb)与驳回包 helper(pushRejection,task-muvg15p5)
2261
+ assert.match(host, /parentKickOnDispatch, LESSON_RECALL_HINT, buildHookPrompt, applyHookSettle, hookOn, hookSetState, gsb, pushRejection \} = core/)
2262
+ assert.match(host, /boardHome, aggregateChildStats, createTaskWarnings, epicPrecheck, epicPrecheckNote, attachContextSuggestions, REJECT_REDISPATCH_HINT, tasksHash, normalizeHooks, mergeHooks, pushRejection \} = core/)
2263
+ // 既有「全子任务 resolved → 父 verifying」逻辑不动(checkParentAuto 仍在 verifyApply 链路;
2264
+ // 但其内部已委托共享 helper maybeAutoCloseParent——单一判定口径,core.mjs 不在 hostSrc 清单,单独读)
1143
2265
  const coreSrc = readFileSync(new URL('../lib/core.mjs', import.meta.url), 'utf8')
1144
2266
  assert.match(coreSrc, /if \(verdict === 'approved' && isb\(t\)\) \{ var p = checkParentAuto\(d, t\)/)
2267
+ assert.match(coreSrc, /export function checkParentAuto\(d, t\) \{ return maybeAutoCloseParent\(d, t\) \}/)
1145
2268
  })
1146
2269
 
1147
2270
  // ===== 防再发:notify 逻辑单一来源(lib/notify.mjs)=====
@@ -1149,7 +2272,7 @@ test('史诗语义层接线:poolCycle 派发分支触发父卡流转 + get-tas
1149
2272
  // 函数声明提升后被头部 var x = deps.x 赋值覆盖,是永不执行的死代码——改错地方不产生效果,挪赋值顺序即引爆)。
1150
2273
  // 此处断言六个函数的定义只存在于 notify.mjs,任何模块再长出副本即红。
1151
2274
  test('notify 单一来源:notifyTaskDone/flushReceipts/deliverEscalation 等函数定义只存在于 lib/notify.mjs', () => {
1152
- const names = ['deliverEscalation', 'notifyMainWindow', 'maybeNotify', 'notifyTaskDone', 'pushSysNote', 'flushReceipts']
2275
+ const names = ['deliverEscalation', 'notifyMainWindow', 'maybeNotify', 'notifyTaskDone', 'notifyDispatched', 'pushSysNote', 'flushReceipts']
1153
2276
  const others = ['../index.mjs', '../lib/policy.mjs', '../lib/usage.mjs', '../lib/session.mjs', '../lib/store.mjs', '../lib/dispatch.mjs', '../lib/rpc.mjs']
1154
2277
  const notifySrc = readFileSync(new URL('../lib/notify.mjs', import.meta.url), 'utf8')
1155
2278
  for (const name of names) {
@@ -1162,6 +2285,218 @@ test('notify 单一来源:notifyTaskDone/flushReceipts/deliverEscalation 等
1162
2285
  }
1163
2286
  })
1164
2287
 
2288
+ // ===== 派发即回执(task-muuoduri):notifyDispatched 入 45s 聚合队列「🚀 已派发」区 =====
2289
+ // createNotify 直调 harness:mock ctx.timer 捕获 45s 窗口回调(不自动触发 → 无 45s 真等待);
2290
+ // root.followup 捕获投递文本;rt 按 o.board 返回看板快照(投递前状态过滤读的就是它)。
2291
+ function mkNotify(options) {
2292
+ const o = options || {}
2293
+ const captured = []
2294
+ let board = o.board || { tasks: [] }
2295
+ const state = { escNotifyTimers: {}, receiptBuf: {}, receiptedKeys: {}, sysNotesBuf: {} }
2296
+ const ctx = { timer: { timeout: () => new Promise(() => {}) } } // 窗口回调不自动触发,测试按「满 5 条」同步冲刷
2297
+ const root = { followup: (m) => { captured.push(m) }, whenIdle: () => Promise.resolve() }
2298
+ const deps = {
2299
+ rt: async () => board,
2300
+ rootForSession: () => (o.noRoot ? null : root),
2301
+ withTimeout: (p) => p,
2302
+ isDispatched: (sid, id) => !!id,
2303
+ }
2304
+ const notify = createNotify(ctx, state, deps)
2305
+ return { notify, state, captured, setBoard: (b) => { board = b } }
2306
+ }
2307
+ function textOf(m) { return m.content.map(c => c.text).join('\n') }
2308
+ const DTASK = (over) => mkTask(Object.assign({ id: 'dt1', title: '派发中的卡', status: 'in-progress', claimedBy: 'run-1', claimedAt: '2026-01-02T00:00:00Z' }, over || {}))
2309
+ // 顶开窗口:再入 4 条完成回执触发「满 5 条」同步冲刷(真实生产冲刷路径,无 45s 真等待)
2310
+ function pushFour(h) { for (const i of [1, 2, 3, 4]) h.notify.notifyTaskDone('s1', mkTask({ id: 'k' + i, title: 'K' + i, status: 'resolved', claimedBy: 'r' + i, resolvedAt: 'T' + i }), 'resolved') }
2311
+
2312
+ test('派发回执①:notifyDispatched 入队(kind=dispatched + role)→ flush 出「🚀 已派发」区', async () => {
2313
+ const h = mkNotify({ board: { tasks: [DTASK()] } })
2314
+ h.notify.notifyDispatched('s1', DTASK(), 'worker')
2315
+ const buf = h.state.receiptBuf.s1
2316
+ assert.equal(buf.items.length, 1)
2317
+ assert.equal(buf.items[0].kind, 'dispatched')
2318
+ assert.equal(buf.items[0].role, 'worker')
2319
+ pushFour(h)
2320
+ await new Promise(r => setImmediate(r))
2321
+ assert.equal(h.captured.length, 1)
2322
+ const txt = textOf(h.captured[0])
2323
+ assert.match(txt, /🚀 已派发 1 个:/)
2324
+ assert.match(txt, /派发中的卡 \(dt1\) — Worker 执行中/)
2325
+ assert.equal(h.captured[0].source.kind, 'plugin:dsh-agent-board')
2326
+
2327
+ // verifier 角色文案
2328
+ const vt = mkTask({ id: 'v1', title: '验收中的卡', status: 'in-progress', claimedBy: 'w0', verifierRun: 'v-1' })
2329
+ const h2 = mkNotify({ board: { tasks: [vt] } })
2330
+ h2.notify.notifyDispatched('s1', vt, 'verifier')
2331
+ pushFour(h2)
2332
+ await new Promise(r => setImmediate(r))
2333
+ assert.match(textOf(h2.captured[0]), /验收中的卡 \(v1\) — Verifier 验收中/)
2334
+ })
2335
+
2336
+ test('派发回执②:flush 时任务已离场(非 in-progress)→ dispatched 项被丢弃', async () => {
2337
+ const h = mkNotify({ board: { tasks: [mkTask({ id: 'fast', title: '10 秒探针', status: 'resolved', claimedBy: 'run-1' })] } })
2338
+ h.notify.notifyDispatched('s1', mkTask({ id: 'fast', title: '10 秒探针', status: 'in-progress', claimedBy: 'run-1', claimedAt: 'T' }), 'worker')
2339
+ assert.equal(h.state.receiptBuf.s1.items.length, 1) // 入队照常(派发时刻确实派发了)
2340
+ pushFour(h)
2341
+ await new Promise(r => setImmediate(r))
2342
+ assert.equal(h.captured.length, 1)
2343
+ const txt = textOf(h.captured[0])
2344
+ assert.doesNotMatch(txt, /🚀 已派发/) // 已离场的派发回执被丢弃
2345
+ assert.doesNotMatch(txt, /10 秒探针/) // 不与完成回执重复刷屏
2346
+ assert.match(txt, /✅ 完成 4 个:/) // 完成回执(主通道)照常投递
2347
+ })
2348
+
2349
+ test('派发回执③:完成回执与派发回执同窗口 → 合并成一条消息', async () => {
2350
+ const t = mkTask({ id: 'm1', title: '合并卡', status: 'in-progress', claimedBy: 'run-9', claimedAt: 'T' })
2351
+ const h = mkNotify({ board: { tasks: [t] } })
2352
+ h.notify.notifyDispatched('s1', t, 'worker') // 派发时刻
2353
+ h.notify.notifyTaskDone('s1', Object.assign({}, t, { status: 'resolved', resolvedAt: 'T3' }), 'resolved') // 完成时刻(同一窗口)
2354
+ pushFour(h)
2355
+ await new Promise(r => setImmediate(r))
2356
+ assert.equal(h.captured.length, 1) // 一条消息,不是两条
2357
+ const txt = textOf(h.captured[0])
2358
+ assert.match(txt, /回执摘要(5 条)/)
2359
+ assert.match(txt, /🚀 已派发 1 个:/)
2360
+ assert.match(txt, /✅ 完成 4 个:/)
2361
+ })
2362
+
2363
+ test('派发回执④:deps 未注入 notifyDispatched(老 host)→ 静默跳过,不抛错', () => {
2364
+ const dsp = readFileSync(new URL('../lib/dispatch.mjs', import.meta.url), 'utf8')
2365
+ // 回执开关(task-muuwgcro)加在 deps 注入判断之前:关掉开关与老 host 未注入两种静默路径共用同一行
2366
+ assert.match(dsp, /if \(c\.notifyDispatch !== false && typeof notifyDispatched === 'function'\) notifyDispatched\(sid, sp\.t, sp\.role\)/)
2367
+ const idx = readFileSync(new URL('../index.mjs', import.meta.url), 'utf8')
2368
+ assert.match(idx, /notifyDispatched: notify\.notifyDispatched/) // index.mjs 接线在位
2369
+ // 行为面:deps 未提供 isDispatched 时 notifyDispatched 照常入队(老 host 兼容,零依赖完成回执判定)
2370
+ const h = mkNotify({ board: { tasks: [DTASK()] } })
2371
+ assert.doesNotThrow(() => h.notify.notifyDispatched('s1', DTASK(), 'worker'))
2372
+ assert.equal(h.state.receiptBuf.s1.items.length, 1)
2373
+ })
2374
+
2375
+ // ===== 回执开关进设置(task-muuwgcro):板级 notifyDispatch/notifyDone 双布尔(缺省 true)+ 两处闸门 =====
2376
+ // 用户指令:「回执可以做一个开关,放到设置里」。语义:设置区「通知」小节可分别关掉「派发回执」与
2377
+ // 「完成回执」;歧义裁决通知(notifyMainWindow)是裁决通道不是回执,不接入开关。
2378
+ test('回执开关①:cfg/normalizeBoard/seed 三处缺省均为 true(老板文件与新建板行为不变)', () => {
2379
+ // ① 空对象(最老形态):cfg 归一为 true,不是 undefined/假值——闸门读的是 !== false
2380
+ const c = core.cfg({})
2381
+ assert.equal(c.notifyDispatch, true)
2382
+ assert.equal(c.notifyDone, true)
2383
+ // ② 老看板文件(无该字段)→ normalizeBoard 补 true(读路径兜底,UI 勾选态确定)
2384
+ const old = core.normalizeBoard({ version: 11, tasks: [] })
2385
+ assert.equal(old.notifyDispatch, true)
2386
+ assert.equal(old.notifyDone, true)
2387
+ // ③ 显式 false 原样保留;非布尔脏值收敛回 true(与 feedbackEnabled 同一归一化口径)
2388
+ const off = core.normalizeBoard({ notifyDispatch: false, notifyDone: false })
2389
+ assert.equal(off.notifyDispatch, false)
2390
+ assert.equal(off.notifyDone, false)
2391
+ assert.equal(core.cfg({ notifyDispatch: false, notifyDone: false }).notifyDispatch, false)
2392
+ assert.equal(core.cfg({ notifyDispatch: false, notifyDone: false }).notifyDone, false)
2393
+ const dirty = core.normalizeBoard({ notifyDispatch: 'no', notifyDone: 0 })
2394
+ assert.equal(dirty.notifyDispatch, true)
2395
+ assert.equal(dirty.notifyDone, true)
2396
+ // ④ 新建板种子显式带两个 true(seed 与 cfg 口径一致)
2397
+ const seeded = core.seed('s1')
2398
+ assert.equal(seeded.notifyDispatch, true)
2399
+ assert.equal(seeded.notifyDone, true)
2400
+ })
2401
+
2402
+ test('回执开关②:notifyDispatch=false → spawn 成功也不入派发回执(真跑 poolCycle + 真 spawnOneShot)', async () => {
2403
+ const calls = []
2404
+ // 真 spawnOneShot 需要一个可用 provider:start 返回永不结算的 run(派发回执只看 spawn 成功,不看结局)
2405
+ const ctxOver = { subagents: { list: () => ['p1'], getProvider: () => ({ inheritsParentContext: false }), start: async () => ({ id: 'run-1', dispose() {}, result: new Promise(function () {}) }) } }
2406
+ function run(flag) {
2407
+ const board = Object.assign(mkBoard([mkTask({ id: 'dt1', title: '被派的卡' })]), { maxWorkers: 3, notifyDispatch: flag })
2408
+ return mkDispatch(board, {
2409
+ rootForSession: () => ({ id: FULL_SID }),
2410
+ notifyDispatched: (sid, t, role) => calls.push({ sid: sid, id: t.id, role: role }),
2411
+ }, ctxOver)
2412
+ }
2413
+ // 关:spawn 照常成功(占位 claim + 历史留档 + run id 回写,多次写盘),但派发回执一次都不入队
2414
+ const off = run(false)
2415
+ await off.dispatch.poolCycle(FULL_SID)
2416
+ assert.equal(calls.length, 0, 'notifyDispatch=false 时不得入派发回执')
2417
+ assert.ok(off.counters.writes > 1, '派发确实发生(不是因未派发才没回执)')
2418
+ // 开(缺字段 → cfg 默认 true):同一路径照常回执,证明闸门没误伤正常路径
2419
+ calls.length = 0
2420
+ const on = run(true)
2421
+ await on.dispatch.poolCycle(FULL_SID)
2422
+ assert.deepEqual(calls, [{ sid: FULL_SID, id: 'dt1', role: 'worker' }])
2423
+ })
2424
+
2425
+ test('回执开关③b:notifyDone=false → worker 三连败转 blocked 也不入完成回执(真跑结算路径)', async () => {
2426
+ const done = []
2427
+ // run 立即失败(Promise.reject)→ settleRun 走失败分支 → retryCount 2→3 → blocked(状态机不受开关影响)
2428
+ const ctxOver = { subagents: { list: () => ['p1'], getProvider: () => ({ inheritsParentContext: false }), start: async () => ({ id: 'run-1', dispose() {}, result: Promise.reject(new Error('boom')) }) } }
2429
+ async function run(flag) {
2430
+ done.length = 0
2431
+ const runs = {} // 稳定 runs 表:settleRun 靠 runsFor(sid)[taskId] === rec 认领本次 run
2432
+ const board = Object.assign(mkBoard([mkTask({ id: 'dt2', title: '会失败的卡', retryCount: 2 })]), { maxWorkers: 3, notifyDone: flag })
2433
+ const h = mkDispatch(board, {
2434
+ rootForSession: () => ({ id: FULL_SID }),
2435
+ runsFor: () => runs,
2436
+ notifyTaskDone: (sid, t, kind) => done.push({ id: t.id, kind: kind, status: t.status }),
2437
+ }, ctxOver)
2438
+ await h.dispatch.poolCycle(FULL_SID)
2439
+ for (let i = 0; i < 3; i++) await new Promise(r => setImmediate(r)) // 结算链(含 closeRunHistory/usage)全在微任务里
2440
+ return { h, board }
2441
+ }
2442
+ // 关:照常 blocked(卡该阻塞就阻塞),只是一条完成回执都不发
2443
+ const off = await run(false)
2444
+ assert.equal(off.board.tasks[0].status, 'blocked')
2445
+ assert.deepEqual(done, [])
2446
+ // 开:同一路径照常回执(证明闸门没误伤完成回执主通道)
2447
+ const on = await run(true)
2448
+ assert.equal(on.board.tasks[0].status, 'blocked')
2449
+ assert.deepEqual(done, [{ id: 'dt2', kind: 'blocked', status: 'blocked' }])
2450
+ })
2451
+
2452
+ test('回执开关③/④:完成/阻塞回执闸门在结算调用方;歧义裁决通道零开关(源码级断言)', () => {
2453
+ const dsp = readFileSync(new URL('../lib/dispatch.mjs', import.meta.url), 'utf8')
2454
+ const noti = readFileSync(new URL('../lib/notify.mjs', import.meta.url), 'utf8')
2455
+ // 完成回执:三个结算函数(worker/verifier/hook)都在同一次持锁回调里取 notifyDone(零额外读盘)
2456
+ assert.equal((dsp.match(/doneOn = cfg\(d\)\.notifyDone !== false/g) || []).length, 3)
2457
+ assert.equal((dsp.match(/if \(doneOn && /g) || []).length, 5) // worker 2 + verifier 2 + hook-closed 1
2458
+ assert.match(dsp, /if \(doneOn && result\.task && result\.task\.status === 'resolved'\) notifyTaskDone\(sid, result\.task, 'resolved'\)/)
2459
+ assert.match(dsp, /if \(doneOn && result\.task && result\.task\.status === 'blocked'\) notifyTaskDone\(sid, result\.task, 'blocked'\)/)
2460
+ assert.match(dsp, /if \(result\.blocked\) \{ maybeNotify\(sid, result\.task\); if \(doneOn\) notifyTaskDone\(sid, result\.task, 'blocked'\) \}/)
2461
+ // 闸门默认 true:回调未跑到也不漏报(宁可多报)
2462
+ assert.equal((dsp.match(/var doneOn = true/g) || []).length, 3)
2463
+ // ④ 歧义通道零开关:通知层根本读不到板级配置(cfg 未引入),去注释后的上报代码段不含两个开关名
2464
+ assert.doesNotMatch(noti, /cfg\(/)
2465
+ const escBlock = noti.slice(noti.indexOf('// 歧义上报通知'), noti.indexOf('// ===== 任务回执通知'))
2466
+ assert.ok(escBlock.length > 200, '上报段切片成功(锚点注释在位)')
2467
+ const escCode = escBlock.split('\n').filter(l => !/^\s*\/\//.test(l)).join('\n')
2468
+ assert.doesNotMatch(escCode, /notifyDispatch|notifyDone/)
2469
+ // 三个结算点的歧义通知调用(maybeNotify)都不带任何开关闸门
2470
+ assert.equal((dsp.match(/maybeNotify\(sid, result\.task\)/g) || []).length, 3)
2471
+ })
2472
+
2473
+ test('回执开关⑤:设置弹层「通知」小节两行开关 + set-board-config 读写通道(源码 + RPC 行为)', async () => {
2474
+ const cli = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
2475
+ // UI:小节标题 + 两行开关文案 + 各自 RPC 键(勾选态缺字段=开,与 host 缺省一致)
2476
+ assert.match(cli, /'通知'/)
2477
+ assert.match(cli, /'⚡ 派发回执(任务被 Worker\/Verifier 领走时播报)'/)
2478
+ assert.match(cli, /'✅ 完成回执(任务完成或阻塞时聚合播报)'/)
2479
+ assert.match(cli, /checked: props\.notifyDispatch !== false, onChange: function \(e\) \{ rpc\('set-board-config', \{ key: 'notifyDispatch', value: e\.target\.checked \}\)/)
2480
+ assert.match(cli, /checked: props\.notifyDone !== false, onChange: function \(e\) \{ rpc\('set-board-config', \{ key: 'notifyDone', value: e\.target\.checked \}\)/)
2481
+ assert.match(cli, /歧义裁决通知不受这两个开关影响/)
2482
+ // 透传链路:state 读取(老 host 缺字段=开)→ TopPanel useState → PoolCfgPopover props
2483
+ assert.match(cli, /state\.notifyDispatch = !\(d && d\.notifyDispatch === false\)/)
2484
+ assert.match(cli, /state\.notifyDone = !\(d && d\.notifyDone === false\)/)
2485
+ assert.match(cli, /useState\(state\.notifyDispatch\)/)
2486
+ assert.match(cli, /feedbackEnabled: fbEnabled, notifyDispatch: ndOn, notifyDone: nnOn/)
2487
+ // host 通道:set-board-config 白名单两键布尔原样存 + get-tasks 显式透出确定布尔值
2488
+ const board = mkBoard([])
2489
+ const h = mkRpcHandlers(board)
2490
+ await h['set-board-config']({ key: 'notifyDispatch', value: false })
2491
+ await h['set-board-config']({ key: 'notifyDone', value: false })
2492
+ assert.equal(board.notifyDispatch, false)
2493
+ assert.equal(board.notifyDone, false)
2494
+ await h['set-board-config']({ key: 'notifyDispatch', value: true })
2495
+ const got = await h['get-tasks']({})
2496
+ assert.equal(got.notifyDispatch, true)
2497
+ assert.equal(got.notifyDone, false)
2498
+ })
2499
+
1165
2500
  // ===== 调研门禁·UI:无调研徽章 + 详情注入清单 + 创建表单 warning 展示(源码级断言)=====
1166
2501
  test('调研门禁 UI 接线:卡片「⚠️ 无调研」徽章 + 详情「调研注入」清单 + 创建表单 warning 黄行(源码级断言)', () => {
1167
2502
  const cli = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
@@ -1177,3 +2512,536 @@ test('调研门禁 UI 接线:卡片「⚠️ 无调研」徽章 + 详情「调
1177
2512
  assert.match(cli, /if \(r && r\.warning\)/) // 创建表单消费 create-task 响应的 warning 字段
1178
2513
  assert.match(cli, /'⚠️ ' \+ warn/) // warning 原文黄色行展示(C.warn)
1179
2514
  })
2515
+
2516
+ // ===== 反馈修复(task-mutrtwin):僵尸 epic 归档放行 + get-tasks tasksHash =====
2517
+ // ① archive-task 对「无活跃 run 的 in-progress」放行(parentKick 僵尸态出清,反馈 n-mutma3mmwceq);
2518
+ // ② get-tasks 附 tasksHash(core 纯函数 djb2),kernel hash 相同跳渲染(反馈 n-mut9rzs2mkhg)。
2519
+
2520
+ test('tasksHash: 同输入同 hash(纯函数稳定;JSON 重解析的等价形态 hash 相同)', () => {
2521
+ const a = [mkTask({ id: 'a', title: '甲' }), mkTask({ id: 'b', status: 'in-progress' })]
2522
+ assert.equal(core.tasksHash(a), core.tasksHash(a))
2523
+ // 跨轮询 rt() 重新 JSON.parse 的同内容对象 → hash 相同(短路生效的前提)
2524
+ assert.equal(core.tasksHash(a), core.tasksHash(JSON.parse(JSON.stringify(a))))
2525
+ assert.equal(core.tasksHash([]), core.tasksHash([]))
2526
+ assert.equal(core.tasksHash(undefined), core.tasksHash(null)) // 非数组一律按空板兜底
2527
+ })
2528
+
2529
+ test('tasksHash: 关键字段变 → hash 变;非关键字段(history/messages/usage)变 → hash 不变', () => {
2530
+ const h0 = core.tasksHash([mkTask({ id: 'a' })])
2531
+ // 关键字段逐一变化(驱动列表/详情渲染的字段)
2532
+ assert.notEqual(core.tasksHash([mkTask({ id: 'a', status: 'in-progress' })]), h0)
2533
+ assert.notEqual(core.tasksHash([mkTask({ id: 'a', title: '改名' })]), h0)
2534
+ assert.notEqual(core.tasksHash([mkTask({ id: 'a', priority: 'high' })]), h0)
2535
+ assert.notEqual(core.tasksHash([mkTask({ id: 'a', lastError: 'boom' })]), h0)
2536
+ assert.notEqual(core.tasksHash([mkTask({ id: 'a', lastProgress: { text: '过半', at: 'x' } })]), h0)
2537
+ assert.notEqual(core.tasksHash([mkTask({ id: 'a', resolvedAt: '2026-01-02T00:00:00Z' })]), h0)
2538
+ assert.notEqual(core.tasksHash([mkTask({ id: 'a', frozen: true })]), h0)
2539
+ // escalation 必须入 hash:board_report escalate 只写 escalation/messages/history、
2540
+ // 不动其他任何字段——漏掉它「新歧义自动弹开面板」会被 hash 短路吞掉
2541
+ assert.notEqual(core.tasksHash([mkTask({ id: 'a', escalation: { question: '歧义', at: 'x', by: 'w' } })]), h0)
2542
+ // 非关键字段不动 hash(不驱动列表渲染,参与会让结算写账/进展消息引发空渲染抖动)
2543
+ const t1 = mkTask({ id: 'a' }); t1.history.push({ from: 'pending', to: 'in-progress', timestamp: 'x', actor: 'y', note: '' })
2544
+ assert.equal(core.tasksHash([t1]), h0)
2545
+ const t2 = mkTask({ id: 'a' }); t2.messages.push({ kind: 'progress', text: 'p', at: 'x', by: 'w' })
2546
+ assert.equal(core.tasksHash([t2]), h0)
2547
+ const t3 = mkTask({ id: 'a' }); t3.usage = { input: 1, output: 2, cacheRead: 3, cacheWrite: 4, total: 10, runs: 1, models: {} }
2548
+ assert.equal(core.tasksHash([t3]), h0)
2549
+ })
2550
+
2551
+ test('tasksHash: 顺序有关(数组序变化 → hash 变;看板按数组序渲染,口径写清)', () => {
2552
+ const a = mkTask({ id: 'a' }), b = mkTask({ id: 'b' })
2553
+ assert.notEqual(core.tasksHash([a, b]), core.tasksHash([b, a]))
2554
+ })
2555
+
2556
+ test('archive-task RPC: resolved/cancelled 放行(既有行为不破)', async () => {
2557
+ const board = mkBoard([mkTask({ id: 'r1', status: 'resolved' }), mkTask({ id: 'c1', status: 'cancelled' })])
2558
+ const h = mkRpcHandlers(board)
2559
+ assert.equal((await h['archive-task']({ taskId: 'r1' })).ok, true)
2560
+ assert.equal((await h['archive-task']({ taskId: 'c1' })).ok, true)
2561
+ assert.equal(board.tasks[0].status, 'archived')
2562
+ assert.equal(board.tasks[1].status, 'archived')
2563
+ })
2564
+
2565
+ test('archive-task RPC: in-progress 无活跃 run 放行(parentKick 僵尸 epic 出清)', async () => {
2566
+ // 僵尸态还原:direct epic 被 parentKickOnDispatch 推进到 in-progress,子任务已归档、无 run、claimedBy=null
2567
+ const zombie = mkTask({ id: 'z1', status: 'in-progress', claimedBy: null, pipeline: 'direct' })
2568
+ const board = mkBoard([zombie])
2569
+ const h = mkRpcHandlers(board, { hasActiveRun: () => false })
2570
+ const r = await h['archive-task']({ taskId: 'z1' })
2571
+ assert.equal(r.ok, true); assert.equal(zombie.status, 'archived'); assert.ok(zombie.archivedAt)
2572
+ })
2573
+
2574
+ test('archive-task RPC: in-progress 有活跃 run 拒绝;已 settled run 放行;未注入 hasActiveRun 按活跃兜底', async () => {
2575
+ // 有活跃 run → 拒绝(不能归在跑的任务)
2576
+ let t = mkTask({ id: 'a1', status: 'in-progress' })
2577
+ let h = mkRpcHandlers(mkBoard([t]), { hasActiveRun: () => true })
2578
+ let r = await h['archive-task']({ taskId: 'a1' })
2579
+ assert.equal(r.ok, false); assert.match(r.error, /cannot archive/); assert.equal(t.status, 'in-progress')
2580
+ // 有 run 记录但已 settled(finish→settleRun 过渡窗口,Worker 已结束)→ 放行
2581
+ // (hasActiveRun 的 !settled 口径由 index.mjs 实现,此处 mock 其结论)
2582
+ t = mkTask({ id: 'a2', status: 'in-progress' })
2583
+ h = mkRpcHandlers(mkBoard([t]), { hasActiveRun: () => false })
2584
+ r = await h['archive-task']({ taskId: 'a2' })
2585
+ assert.equal(r.ok, true); assert.equal(t.status, 'archived')
2586
+ // deps 未注入 hasActiveRun → 保守兜底视为有活跃 run → 拒绝(保持旧门禁行为,不误放在跑任务)
2587
+ t = mkTask({ id: 'a3', status: 'in-progress' })
2588
+ h = mkRpcHandlers(mkBoard([t]))
2589
+ r = await h['archive-task']({ taskId: 'a3' })
2590
+ assert.equal(r.ok, false); assert.match(r.error, /cannot archive/)
2591
+ // 其余状态(verifying 等)仍拒绝——放行面只扩到「无活跃 run 的 in-progress」
2592
+ t = mkTask({ id: 'a4', status: 'verifying' })
2593
+ h = mkRpcHandlers(mkBoard([t]), { hasActiveRun: () => false })
2594
+ r = await h['archive-task']({ taskId: 'a4' })
2595
+ assert.equal(r.ok, false); assert.match(r.error, /cannot archive/)
2596
+ })
2597
+
2598
+ test('task_archive 工具:与 archive-task RPC 同一门禁口径(僵尸放行/活跃拒绝)', async () => {
2599
+ let t = mkTask({ id: 'z2', status: 'in-progress', claimedBy: null })
2600
+ let h = mkRpcHandlers(mkBoard([t]), { hasActiveRun: () => false })
2601
+ let r = await h.__tools['task_archive'].execute({ taskId: 'z2' }, {})
2602
+ assert.equal(r.ok, true); assert.equal(t.status, 'archived')
2603
+ t = mkTask({ id: 'z3', status: 'in-progress' })
2604
+ h = mkRpcHandlers(mkBoard([t]), { hasActiveRun: () => true })
2605
+ r = await h.__tools['task_archive'].execute({ taskId: 'z3' }, {})
2606
+ assert.equal(r.ok, false); assert.match(r.error, /cannot archive/)
2607
+ })
2608
+
2609
+ test('反馈修复接线断言:index.mjs 注入 hasActiveRun + get-tasks 附 tasksHash + kernel 短路分支(源码级)', () => {
2610
+ const host = hostSrc()
2611
+ // index.mjs:hasActiveRun 定义(runsFor 表有记录且 !settled 即活跃)并注入 createRpc deps
2612
+ assert.match(host, /function hasActiveRun\(sid, taskId\) \{ var rec = session\.runsFor\(sid\)\[taskId\]; return !!\(rec && !rec\.settled\) \}/)
2613
+ assert.match(host, /hasActiveRun: hasActiveRun,/)
2614
+ // rpc.mjs:get-tasks 响应附 tasksHash(现算不落盘);archiveErr 门禁 = 定义1 + RPC1 + 工具1
2615
+ assert.match(host, /d\.tasksHash = tasksHash\(d\.tasks\)/)
2616
+ const rpcSrc = readFileSync(new URL('../lib/rpc.mjs', import.meta.url), 'utf8')
2617
+ assert.equal((rpcSrc.match(/archiveErr\(sid, t\)/g) || []).length, 3)
2618
+ // kernel:hash 短路分支存在(hash 相同跳过 tasks 赋值 + notify;老 host 无 hash 恒视为变化)
2619
+ const ksrc = readFileSync(new URL('../lib/client/kernel.js', import.meta.url), 'utf8')
2620
+ assert.match(ksrc, /var newHash = \(d && d\.tasksHash\) \|\| ''/)
2621
+ assert.match(ksrc, /var tasksChanged = !newHash \|\| newHash !== state\.tasksHash/)
2622
+ assert.match(ksrc, /if \(tasksChanged\) \{[\s\S]*?notify\(\)/)
2623
+ })
2624
+
2625
+ // ===== 无障碍两件套(task-mutrubay,反馈 n-mut9rzpyc7p1):卡片键盘可达 + 详情页「流转到」按钮组(源码级断言)=====
2626
+ test('无障碍接线:卡片 role/tabIndex/aria-label/onKeyDown/焦点框 + 详情页流转按钮组(源码级)', () => {
2627
+ const cli = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
2628
+ // ① 卡片键盘可达:button 角色 + Tab 序 + 读屏标签(标题+状态)+ Enter/Space 与点击共用同一激活函数 + 主题色焦点框
2629
+ assert.match(cli, /role: 'button', tabIndex: 0, 'aria-label': t\.title \+ '(' \+ \(statusLabels\[t\.status\] \|\| t\.status\) \+ ')'/)
2630
+ assert.match(cli, /onKeyDown: function \(e\) \{ if \(e\.key === 'Enter' \|\| e\.key === ' '\) \{ e\.preventDefault\(\); activate\(\) \} \}/)
2631
+ assert.match(cli, /onClick: activate,/) // 点击与键盘同一条激活路径(多选=切换选中,否则开详情)
2632
+ assert.match(cli, /outline: isCardFocus\(t\.id\) \? '2px solid ' \+ C\.brand : 'none'/) // 主题色焦点框(非浏览器默认蓝框)
2633
+ assert.match(cli, /function isCardFocus\(id\)/) // 焦点态走 state 现算(轮询重渲染不丢焦点框)
2634
+ // ② 详情页「流转到」按钮组:合法迁移表 + 组容器(读屏可报组名)+ 四状态按钮文案
2635
+ assert.match(cli, /var FLOW_DEF = \{/)
2636
+ assert.match(cli, /role: 'group', 'aria-label': '状态流转'/)
2637
+ assert.match(cli, /'⇄ 流转到:'/)
2638
+ assert.match(cli, /'▶ 开始处理'/); assert.match(cli, /'✅ 提交验收'/); assert.match(cli, /'⛔ 标记阻塞'/)
2639
+ assert.match(cli, /'↩ 重投待办'/); assert.match(cli, /'✔ 验收通过'/); assert.match(cli, /'↩ 驳回重投'/)
2640
+ // 迁移通道与拖拽 transition() 同 RPC 族(host 门禁不变);失败原因 ⚠️ 回显 actionMsg 行
2641
+ assert.match(cli, /function flowTo\(to\)/)
2642
+ assert.match(cli, /p = rpc\('claim-task', \{ taskId: task\.id \}\)/)
2643
+ assert.match(cli, /p = rpc\('update-task', \{ taskId: task\.id, resetToPending: true \}\)/)
2644
+ assert.match(cli, /p = rpc\('resolve-task', \{ taskId: task\.id, status: 'verifying', resolution: res \}\)/)
2645
+ assert.match(cli, /p = rpc\('verify-task', \{ taskId: task\.id, verdict: 'approved' \}\)/)
2646
+ assert.match(cli, /setActionMsg\('⚠️ 流转失败:' \+ \(r\.error \|\| '未知错误'\)\)/)
2647
+ })
2648
+
2649
+ // ===== no-root 刷屏根治(task-muuf0o7a):poolCycle root 闸门 + 短 id 幻影板防线 =====
2650
+ // 事故链:幻影板 tasks-cc24eb5c.json(裸短 id 建出)滞留 pending 卡 → poolCycle 每 15s
2651
+ // pickDispatch 成功→占位 claim→spawnOneShot 才发现无活 root→console.error→占位超时回收
2652
+ // →下轮再来,永久 spam。根治 = ①poolCycle 入口 root 闸门(无 root 不派发不写盘零日志)
2653
+ // + ②touchSession/create-task 拒绝不完整短 id(import- 前缀放行)。
2654
+
2655
+ test('isFullSessionId:裸短 id/空值/unknown 拒绝;完整 root id 与 import- 前缀放行', () => {
2656
+ assert.equal(isFullSessionId('cc24eb5c'), false) // 裸短 id(本次幻影板元凶)
2657
+ assert.equal(isFullSessionId(''), false)
2658
+ assert.equal(isFullSessionId(null), false)
2659
+ assert.equal(isFullSessionId(undefined), false)
2660
+ assert.equal(isFullSessionId('unknown'), false)
2661
+ assert.equal(isFullSessionId('abcdefghij-k'), false) // 长度 <20
2662
+ assert.equal(isFullSessionId('abcdefghijklmno-pqrst'), false) // ≥20 但只有一个 '-'
2663
+ assert.equal(isFullSessionId('-abcdefghij-klmnopqr'), false) // 首字符即 '-' 不算结构
2664
+ assert.equal(isFullSessionId('session-cc24eb5c-702c-4f7d-a1b2c3d4e5f6'), true)
2665
+ assert.equal(isFullSessionId('import-sess_df0837fa-1'), true) // import- 前缀板合法放行
2666
+ })
2667
+
2668
+ test('touchSession:不完整短 id 不注册进已知会话集合(幻影板不进心跳轮询)', () => {
2669
+ const known = {}
2670
+ const session = createSession({}, { knownSessions: known, feedbackCache: {}, activeRuns: {}, dispatchedEver: {} })
2671
+ session.touchSession('cc24eb5c') // 裸短 id → 拒绝
2672
+ session.touchSession('unknown') // 既有排除项不变
2673
+ session.touchSession(null)
2674
+ session.touchSession('session-cc24eb5c-702c-4f7d-a1b2c3d4e5f6') // 完整 id → 注册
2675
+ session.touchSession('import-sess_df0837fa-1') // import- 前缀 → 放行
2676
+ assert.deepEqual(Object.keys(known).sort(), ['import-sess_df0837fa-1', 'session-cc24eb5c-702c-4f7d-a1b2c3d4e5f6'].sort())
2677
+ })
2678
+
2679
+ // poolCycle 直调 harness:ctx 不给 timer(15s 心跳 IIFE 跳过),deps 全 mock,计数读/写/日志
2680
+ // ctxOver(第三参,可选):覆盖 ctx 面(如注入 subagents 让真 spawnOneShot 跑起来——回执开关行为测试用)
2681
+ function mkDispatch(board, over, ctxOver) {
2682
+ const counters = { reads: 0, writes: 0, errs: [] }
2683
+ const dispatch = createDispatch(
2684
+ // 创建期无条件触达:effect(卸载清理注册)/ get('systemPrompt' 引导段);不给 timer/agents/subagents
2685
+ Object.assign({ fs: {}, effect: function () {}, get: function () { return null } }, ctxOver || {}),
2686
+ { knownSessions: {}, dispatchedEver: {}, badModels: {}, packByChild: {}, pendingPacks: [], teamModeCache: {}, activeRuns: {} },
2687
+ Object.assign({
2688
+ rt: async () => { counters.reads++; return board },
2689
+ wt: async () => { counters.writes++ },
2690
+ mutateLocked: async (sid, fn) => { counters.writes++; return fn(board) },
2691
+ kickCycle: () => {},
2692
+ rootForSession: () => undefined, // 默认无活 root(幻影板/死会话场景)
2693
+ sessionCwd: () => '', withTimeout: (p) => p, runsFor: () => ({}), feedbackOn: () => true,
2694
+ pushSysNote: () => {}, maybeNotify: () => {}, notifyTaskDone: () => {},
2695
+ }, over || {}))
2696
+ return { dispatch, counters }
2697
+ }
2698
+
2699
+ test('poolCycle root 闸门:无活 root → 直接返回 undefined,不读盘/不写盘/零日志(根治 15s 刷屏)', async () => {
2700
+ const { dispatch, counters } = mkDispatch(mkBoard([mkTask()])) // 板上有 pending 卡也不进循环
2701
+ const origErr = console.error
2702
+ console.error = function () { counters.errs.push(Array.prototype.join.call(arguments, ' ')) }
2703
+ try {
2704
+ const r = await dispatch.poolCycle('cc24eb5c')
2705
+ assert.equal(r, undefined) // 调用方(kickCycle/15s 心跳)忽略返回值,签名兼容
2706
+ assert.equal(counters.reads, 0) // rt 都没跑:零读盘
2707
+ assert.equal(counters.writes, 0) // 零写盘
2708
+ assert.deepEqual(counters.errs, []) // 零日志
2709
+ } finally { console.error = origErr }
2710
+ })
2711
+
2712
+ test('poolCycle root 闸门:有活 root → 正常进循环(空板走空闲快进返回 snap,不写盘)', async () => {
2713
+ const board = mkBoard([])
2714
+ const { dispatch, counters } = mkDispatch(board, { rootForSession: () => ({ id: FULL_SID }) })
2715
+ const r = await dispatch.poolCycle(FULL_SID)
2716
+ assert.ok(r && Array.isArray(r.tasks)) // 空闲快进返回 snap(闸门未误伤正常路径)
2717
+ assert.equal(counters.reads, 1)
2718
+ assert.equal(counters.writes, 0) // 空板无残留 poolStatus/dispatchInfo → 快进不写盘
2719
+ })
2720
+
2721
+ test('create-task RPC:不完整短 id sessionId → 报错不建板;import- 前缀与正常路径放行', async () => {
2722
+ // rpcSessionId 覆写为「尊重显式 sessionId 入参」(生产行为:args.sessionId 优先于 actor 归一)
2723
+ const honorSid = { rpcSessionId: (args) => (args && args.sessionId) || FULL_SID }
2724
+ // ① 裸短 id → 拒绝且不落任务(防幻影板)
2725
+ const board = mkBoard([])
2726
+ const r = await mkRpcHandlers(board, honorSid)['create-task']({ title: 'T', description: 'd', sessionId: 'cc24eb5c' })
2727
+ assert.equal(r.ok, false)
2728
+ assert.match(r.error, /sessionId 不完整/)
2729
+ assert.equal(board.tasks.length, 0)
2730
+ // ② import- 前缀板 → 放行(导入会话是合法板,别误伤)
2731
+ const board2 = mkBoard([])
2732
+ const r2 = await mkRpcHandlers(board2, honorSid)['create-task']({ title: 'T', description: 'd', sessionId: 'import-sess_df0837fa-1' })
2733
+ assert.equal(r2.ok, true)
2734
+ assert.equal(board2.tasks.length, 1)
2735
+ // ③ 不传 sessionId(正常路径,归一为完整 root id)→ 放行,返回体形态不变
2736
+ const board3 = mkBoard([])
2737
+ const r3 = await mkRpcHandlers(board3, honorSid)['create-task']({ title: 'T', description: 'd' })
2738
+ assert.equal(r3.ok, true)
2739
+ assert.equal(board3.tasks.length, 1)
2740
+ })
2741
+
2742
+ test('no-root 刷屏根治接线断言(源码级):闸门/防线/兜底注释均在位', () => {
2743
+ const dsp = readFileSync(new URL('../lib/dispatch.mjs', import.meta.url), 'utf8')
2744
+ const ses = readFileSync(new URL('../lib/session.mjs', import.meta.url), 'utf8')
2745
+ const rpc = readFileSync(new URL('../lib/rpc.mjs', import.meta.url), 'utf8')
2746
+ // ① poolCycle 入口即 root 闸门(闸门必须在 rt 读盘之前——不读盘才谈得上零 IO)
2747
+ assert.match(dsp, /async function poolCycle\(sid\) \{[\s\S]{0,900}if \(!rootForSession\(sid\)\) return undefined[\s\S]{0,200}var snap = await rt\(sid\)/)
2748
+ // spawnOneShot 的 console.error 兜底保留 + 注释说明闸门在上游
2749
+ assert.match(dsp, /console\.error\('\[task-board\] no root agent for session ' \+ sid \+ ', skip spawn'\)/)
2750
+ assert.match(dsp, /poolCycle 入口已有 root 存活早闸门/)
2751
+ // ② touchSession 注册口接入 isFullSessionId;纯函数出口 + rpc.mjs 直引
2752
+ assert.match(ses, /export function isFullSessionId\(sid\)/)
2753
+ assert.match(ses, /sid !== 'unknown' && isFullSessionId\(sid\)\) knownSessions\[sid\]/)
2754
+ assert.match(rpc, /import \{ isFullSessionId \} from '\.\/session\.mjs'/)
2755
+ // create-task handler 校验在 mutateLocked 之前(拒绝时不建板)
2756
+ // 窗口放宽到 700:幻影板防线与 return mutateLocked 之间新增了 hooks 角色门禁(本批 hooks=agent run;
2757
+ // hooks 是高权限入口,只许主窗口设置),防线本身仍紧随 handler 开头
2758
+ assert.match(rpc, /handle\('create-task', async function \(args\) \{ var sid = rpcSessionId\(args\);[\s\S]{0,700}if \(!isFullSessionId\(sid\)\) return \{ ok: false, error: 'sessionId 不完整[\s\S]{0,300}return mutateLocked/)
2759
+ })
2760
+
2761
+ // ===== isRoot 蝶变防抖(task-muupr8ld,反馈 n-muuerxv9ijxs)=====
2762
+ // 根因:host get-tasks 现算 isRoot(agents.roots() 含本 sid),生成开始/结束瞬间 agents 树重建有瞬态窗口
2763
+ // 返回假 false;旧口径一次 false 就强收抽屉(state.open=false)→「看板在生成状态切换时突然消失」。
2764
+ // 修法:客户端防抖——曾确认 true 的会话需连续 3 次 false 才收;从未 true 的子代理会话即时收起;
2765
+ // 三处消费点(强收/按钮/面板)+ 活动心跳省流统一读 isRootStable;会话切换重置防抖态。
2766
+ test('isRoot 蝶变防抖(源码级):计数器 + 连续 3 次阈值 + everTrue 门锁 + 新会话即时收紧', () => {
2767
+ const ksrc = readFileSync(new URL('../lib/client/kernel.js', import.meta.url), 'utf8')
2768
+ const cli = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
2769
+ // ① 防抖态三字段入 state(everTrue/连续 false 计数/稳定值)
2770
+ assert.match(ksrc, /isRoot: true, isRootEverTrue: false, isRootFalseN: 0, isRootStable: true/)
2771
+ // ② 阈值常量 = 3(host 3s 心跳 ≈ 9s),注释写明为什么防抖/为什么子代理不防抖
2772
+ assert.match(ksrc, /var IS_ROOT_FALSE_LIMIT = 3/)
2773
+ assert.match(ksrc, /为什么防抖:host rpc\.mjs L97 的 isRoot 是每次 get-tasks 现算的/)
2774
+ assert.match(ksrc, /为什么子代理会话不防抖:everTrue=false 的会话(新打开的子代理)立即 isRootStable=false/)
2775
+ // ③ 防抖器本体:true → 清零+everTrue+stable;从未 true → 即时 false(门锁);everTrue 则累计到阈值才翻
2776
+ assert.match(ksrc, /function applyIsRoot\(rawIsRoot\) \{/)
2777
+ assert.match(ksrc, /if \(rawIsRoot\) \{[\s\S]{0,400}state\.isRootEverTrue = true/)
2778
+ assert.match(ksrc, /state\.isRootFalseN = 0[\s\S]{0,80}state\.isRootStable = true/)
2779
+ assert.match(ksrc, /else if \(!state\.isRootEverTrue\) \{[\s\S]{0,120}state\.isRootStable = false/)
2780
+ assert.match(ksrc, /state\.isRootFalseN\+\+[\s\S]{0,200}if \(state\.isRootFalseN >= IS_ROOT_FALSE_LIMIT\) state\.isRootStable = false/)
2781
+ // ④ 轮询只把原始值喂给防抖器(不再直写 state.isRoot)
2782
+ assert.match(ksrc, /applyIsRoot\(!d \|\| d\.isRoot !== false\)/)
2783
+ assert.doesNotMatch(ksrc, /state\.isRoot = !d \|\| d\.isRoot !== false/)
2784
+ // ⑤ 三处消费点 + 活动心跳省流统一读 isRootStable(不得再读单次 isRoot 原始值)
2785
+ assert.match(ksrc, /if \(!state\.isRootStable && state\.open\) \{ state\.open = false; state\.detailId = null \}/) // L303 强收
2786
+ assert.match(ksrc, /if \(!state\.sessionId \|\| !state\.isRootStable \|\| !state\.open\) return/) // 省流轮询
2787
+ assert.match(ksrc, /setIsOpen\(state\.open\); setIsRoot\(state\.isRootStable\)/) // 按钮
2788
+ assert.match(ksrc, /if \(!isRoot\) return null \/\/ 子代理会话不显示看板入口(读 isRootStable/)
2789
+ assert.match(ksrc, /if \(!state\.isRootStable\) return null \/\/ 子代理会话不渲染看板面板(读 isRootStable/)
2790
+ assert.doesNotMatch(ksrc, /state\.isRoot === false/) // 旧口径已根除(消费点不再看单次值)
2791
+ // ⑥ 会话切换重置防抖态(旧会话的"曾确认 true"不得漂到新会话)
2792
+ assert.match(ksrc, /state\.isRootEverTrue = false; state\.isRootFalseN = 0; state\.isRootStable = false; notify\(\); fetchTasks\(\); fetchChildren\(\)/)
2793
+ // ⑦ 产物体重组装:断言在源码与 lib/client.js 产物上同口径
2794
+ assert.match(cli, /var IS_ROOT_FALSE_LIMIT = 3/)
2795
+ assert.match(cli, /isRootEverTrue: false, isRootFalseN: 0, isRootStable: true/)
2796
+ assert.equal((cli.match(/applyIsRoot\(/g) || []).length, 2) // 定义 1 + 轮询调用 1
2797
+ })
2798
+
2799
+ test('isRoot 蝶变防抖:host 侧注释指回客户端防抖,且 host 行为未改(仍现算 isRoot)', () => {
2800
+ const rpcSrc = readFileSync(new URL('../lib/rpc.mjs', import.meta.url), 'utf8')
2801
+ // 指针注释:说明瞬态假 false + 防抖落在客户端 kernel.js applyIsRoot
2802
+ assert.match(rpcSrc, /host 不改行为(保持"现算真相"),防抖在客户端:kernel\.js applyIsRoot/)
2803
+ assert.match(rpcSrc, /曾确认 true 的会话需连续 3 次/)
2804
+ // 行为未改:isRoot 仍按 agents.roots() 现算(不缓存、不防抖、不落盘)
2805
+ assert.match(rpcSrc, /var __roots = __ag\.roots\(\)[\s\S]{0,200}d\.isRoot = __rids\.indexOf\(sid\) >= 0/)
2806
+ })
2807
+
2808
+ // ===== 史诗 hooks UI(task-muuw56yf):卡片相位徽章 + 详情页 hooks 编辑区 =====
2809
+ // 依赖卡 task-muuw4ov7 落地的真实字段契约(本文件上方 hooks 测试段即其口径):
2810
+ // epic.hooks = { pre: { enabled, prompt, state, runId, pending? }, post: 同构 };state ∈ idle|running|done|failed;
2811
+ // hook run 记在 epic.runs[](role 'hook-pre'/'hook-post',{ role, id, at, endedAt, outcome, model });
2812
+ // update-task RPC 的 hooks 字段浅合并,normalizeHooks 对缺省 state 归零成 idle(故 UI 必须透传 state/runId);
2813
+ // hooks 只许主窗口设置(UI 不做权限预判,失败原因原样回显)。
2814
+ test('hooks 卡片相位徽章(源码级):三态文案 + err 色 + 无 hooks 零渲染', () => {
2815
+ const card = readFileSync(new URL('../lib/client/board-list.js', import.meta.url), 'utf8')
2816
+ // 相位判定唯一出处:hookBadgeOf(读 t.hooks,未声明直接 null = 老卡零渲染)
2817
+ assert.match(card, /function hookBadgeOf\(t\) \{/)
2818
+ assert.match(card, /var h = t && t\.hooks\n if \(!h\) return null/)
2819
+ // 三个相位文案 + 徽章优先级:failed(pre|post)> pre running > post running > 不渲染
2820
+ assert.match(card, /'⚠️ hook 失败待裁决'/)
2821
+ assert.match(card, /'⏳ 前置准备中'/)
2822
+ assert.match(card, /'🧪 收尾中'/)
2823
+ assert.match(card, /if \(\(pre && pre\.state === 'failed'\) \|\| \(post && post\.state === 'failed'\)\) \{/)
2824
+ assert.match(card, /if \(pre && pre\.state === 'running'\) return \{ txt: '⏳ 前置准备中'/)
2825
+ assert.match(card, /if \(post && post\.state === 'running'\) return \{ txt: '🧪 收尾中'/)
2826
+ assert.match(card, /return null\n \}/) // 末尾兜底:idle / done 不占位
2827
+ // 只有「已声明且 enabled」的点位参与相位判定(停用的点位不该报 running/failed)
2828
+ assert.match(card, /var pre = \(h\.pre && h\.pre\.enabled\) \? h\.pre : null/)
2829
+ assert.match(card, /var post = \(h\.post && h\.post\.enabled\) \? h\.post : null/)
2830
+ // 失败徽章走 err 色(与既有 escalation 视觉同源)
2831
+ assert.match(card, /return \{ txt: '⚠️ hook 失败待裁决', color: C\.err,/)
2832
+ // 挂载点:与「📦 史诗 · x/y」并存(史诗卡限定,相位是附加过程态而非替代进度)
2833
+ assert.match(card, /epic \? hookPhaseBadge\(t\) : null,/)
2834
+ assert.match(card, /'📦 史诗 · ' \+ csDone \+ '\/' \+ cs\.total/)
2835
+ // 组装产物里也要有(require build-client 先跑;npm pretest 已保证)
2836
+ const built = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
2837
+ for (const s of ['⏳ 前置准备中', '🧪 收尾中', '⚠️ hook 失败待裁决']) assert.ok(built.includes(s), '产物应含相位文案:' + s)
2838
+ })
2839
+
2840
+ test('hooks 详情页编辑区(源码级):开关 + prompt + state/耗时 + 失败跳裁决 + run 跳会话 + 保存通道', () => {
2841
+ const src = readFileSync(new URL('../lib/client/task-detail.js', import.meta.url), 'utf8')
2842
+ // ① 挂载与门禁:史诗(有子任务)或已声明 hooks 才渲染,普通老卡整块不出现
2843
+ assert.match(src, /function HooksSection\(props\) \{/)
2844
+ assert.match(src, /var kids = state\.tasks\.filter\(function \(x\) \{ return x\.parentId === task\.id \}\)/)
2845
+ assert.match(src, /var declared = !!\(kHooks && \(kHooks\.pre \|\| kHooks\.post\)\)/)
2846
+ assert.match(src, /if \(kids\.length === 0 && !declared\) return null/)
2847
+ // key=task.id 挂载:切换任务重挂组件,编辑缓冲不串卡
2848
+ assert.match(src, /React\.createElement\(HooksSection, \{ key: task\.id, task: task \}\)/)
2849
+ // ② 两行同构:enabled 开关(原生 checkbox)+ prompt 文本框(薄框架语义写进 placeholder)
2850
+ assert.match(src, /phaseRow\('pre', '⏳ 前置(pre)'/)
2851
+ assert.match(src, /phaseRow\('post', '🧪 收尾(post)'/)
2852
+ assert.match(src, /type: 'checkbox', checked: !!b\.enabled, onChange: function \(e\) \{ setPhase\(ph, \{ enabled: e\.target\.checked \}\) \}/)
2853
+ assert.match(src, /placeholder: '补充指令(可选)——前置=让子任务具备开跑条件;后置=把这批活收口。具体动作由 hook agent 自行决策'/)
2854
+ // ③ state 展示四态 + 未启用 + 耗时(运行中=已跑、落定=净耗时)
2855
+ assert.match(src, /\{ idle: \{ label: off \? '未启用' : '待运行', color: C\.text2 \}, running: \{ label: '运行中', color: C\.brand \}, done: \{ label: '已完成', color: C\.ok \}, failed: \{ label: '失败', color: C\.err \} \}/)
2856
+ assert.match(src, /if \(st === 'running'\) dur = '已跑 ' \+ elapsedSince\(run\.at\)/)
2857
+ assert.match(src, /else if \(run\.endedAt\) dur = '耗时 ' \+ fmtDur\(/)
2858
+ // ④ 失败 → 「前往裁决」:同页锚点跳转(裁决区 id 固定,DetailView 已挂 id)
2859
+ assert.match(src, /m\.state === 'failed' \? React\.createElement\('button', \{ onClick: goArbitration/)
2860
+ assert.match(src, /'前往裁决'/)
2861
+ assert.match(src, /function goArbitration\(\) \{ var el = document\.getElementById\('tskb-escalation'\); if \(el && el\.scrollIntoView\) el\.scrollIntoView\(\{ block: 'center' \}\) \}/)
2862
+ assert.match(src, /React\.createElement\('div', \{ id: 'tskb-escalation'/)
2863
+ // ⑤ run 跳会话(照 verifierRun 跳转既有模式)+ post 的 run id 落在 verifierRun 位
2864
+ assert.match(src, /onClick: function \(\) \{ if \(uiWorkspaceSvc\) uiWorkspaceSvc\.openSession\(m\.run\.id\) \}/)
2865
+ assert.match(src, /var rid = runId \|\| \(phase === 'post' \? task\.verifierRun : null\)/)
2866
+ assert.match(src, /var role = 'hook-' \+ phase/)
2867
+ // ⑥ 保存通道:update-task RPC 的 hooks 字段(UI 不做权限判断,失败原因原样 ⚠️ 回显)
2868
+ assert.match(src, /rpc\('update-task', \{ taskId: task\.id, hooks: \{ pre: pre\.item, post: post\.item \} \}\)/)
2869
+ assert.match(src, /if \(r && r\.ok === false\) \{ setMsg\('⚠️ ' \+ \(r\.error \|\| '保存失败'\)\); return \}/)
2870
+ // ⓪ 防再发(2026-10-05 详情页崩板事故):组件首行必须完成 React hook 解构——bundler 只注入 React 本体,
2871
+ // 裸用 useState = 运行期 ReferenceError → shell 容错层卸载整个 shell.overlay(看板整体消失)
2872
+ assert.match(src, /function HooksSection\(props\) \{\s+var _R = React; var useState = _R\.useState/)
2873
+ // ⑦ 提交体三条口径:空 prompt=撤点位(null);state/runId/pending 原样透传(否则在跑的 hook 会被打回 idle);
2874
+ // 运行中的点位不清空(run 还在飞,撤声明会让结算落到空点位)——删除意图保留 + 回一行提示
2875
+ assert.match(src, /if \(!String\(b\.prompt \|\| ''\)\.trim\(\)\) \{/)
2876
+ assert.match(src, /if \(cur && cur\.state === 'running'\) return \{ item: keep\(cur\), warn: ph \+ ' 运行中:本次不清空该点位(run 还在跑),等它跑完再撤' \}/)
2877
+ assert.match(src, /return \{ item: null \}/)
2878
+ assert.match(src, /if \(cur\.state\) item\.state = cur\.state/)
2879
+ assert.match(src, /if \(cur\.runId\) item\.runId = cur\.runId/)
2880
+ assert.match(src, /if \(cur\.pending\) item\.pending = true/)
2881
+ assert.match(src, /setMsg\(warns\.length \? '⚠️ ' \+ warns\.join\(';'\) : '✅ 已保存 hooks 配置'\)/)
2882
+ // 组装产物里也要有
2883
+ const built = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
2884
+ for (const s of ['Hooks(可选点位)', '前往裁决', '保存 hooks']) assert.ok(built.includes(s), '产物应含:' + s)
2885
+ })
2886
+
2887
+ test('README 双份同步记录 hooks UI 口径(相位徽章 / 详情编辑区 / 自行决策 + 歧义兜底)', () => {
2888
+ const pkg = readFileSync(new URL('../README.md', import.meta.url), 'utf8')
2889
+ const root = readFileSync(new URL('../../../README.md', import.meta.url), 'utf8')
2890
+ assert.equal(pkg, root) // 两份 README 必须字节一致(npm run sync-readme 的约束)
2891
+ for (const s of ['可选 hooks', '串行闸门', '自行决策', '歧义上报', '⏳ 前置准备中', '🧪 收尾中', '⚠️ hook 失败待裁决', 'hooks 仅主窗口可设']) {
2892
+ assert.ok(pkg.includes(s), 'README 应记录 hooks UI 口径:' + s)
2893
+ }
2894
+ })
2895
+
2896
+ // ===== 史诗拆分总开关(epicSplit,板级;口径:只关「引导」不禁「机制」)=====
2897
+ // false 时两个生效点:① Team 提示词第 6 条 TEAM_SPLIT_RULE 整条不注入(policy.splitRuleOf)
2898
+ // ② create-task RPC / task_create 工具返回体不再附 suggestSplit(policy.withSplitHint 第三参)。
2899
+ // 机制面(显式 parentId 建子卡 / 史诗自动收口 / hooks 状态机)一律不看这个开关。
2900
+ test('cfg/seed/normalizeBoard: epicSplit 缺省开(缺字段/脏值都算开),只有显式 false 才关', () => {
2901
+ assert.equal(core.cfg({}).epicSplit, true)
2902
+ assert.equal(core.cfg({ epicSplit: true }).epicSplit, true)
2903
+ assert.equal(core.cfg({ epicSplit: false }).epicSplit, false)
2904
+ assert.equal(core.cfg({ epicSplit: 'no' }).epicSplit, true) // 脏值不关(默认开)
2905
+ assert.equal(core.cfg({ epicSplit: 0 }).epicSplit, true)
2906
+ assert.equal(core.seed('s1').epicSplit, true)
2907
+ assert.equal(core.normalizeBoard({ tasks: [] }).epicSplit, true) // 老看板无字段 → 默认开(行为零变化)
2908
+ assert.equal(core.normalizeBoard({ tasks: [], epicSplit: false }).epicSplit, false) // 显式 false 原样保留
2909
+ assert.equal(core.normalizeBoard({ tasks: [], epicSplit: 'x' }).epicSplit, true) // 脏值收敛成默认开
2910
+ })
2911
+
2912
+ test('splitRuleOf: 第 6 条注入出口——缺省/true 与开关落地前逐字相同,显式 false 整条不出现', () => {
2913
+ assert.equal(splitRuleOf(undefined), '\n' + TEAM_SPLIT_RULE) // 老调用方/缺省 → 零变化
2914
+ assert.equal(splitRuleOf(true), '\n' + TEAM_SPLIT_RULE)
2915
+ assert.equal(splitRuleOf(false), '')
2916
+ assert.doesNotMatch(splitRuleOf(false), /大任务必须拆分|parentId|checkParentAuto/)
2917
+ // 拼接形态:第 5 条末尾直接接出口——关掉时编号 1~5 连续、不留空行尾巴
2918
+ assert.ok(('5. 尾部。' + splitRuleOf(true)).endsWith('\n' + TEAM_SPLIT_RULE))
2919
+ assert.equal('5. 尾部。' + splitRuleOf(false), '5. 尾部。')
2920
+ })
2921
+
2922
+ test('epicSplit 行为①:Team 提示词第 6 条随门禁注入/消失(真跑一次 section 组装,不是源码断言)', () => {
2923
+ const SID = FULL_SID
2924
+ const sections = []
2925
+ const ctx = {
2926
+ fs: {},
2927
+ effect: function () { return function () {} },
2928
+ get: function (name) {
2929
+ if (name !== 'systemPrompt') return null
2930
+ return { section: function (cfg) { sections.push(cfg); return function () {} }, context: function () { return function () {} } }
2931
+ },
2932
+ }
2933
+ // 只给 createDispatch 创建期真正用到的 deps 面;epicSplitOn 就是要验的门禁读取器
2934
+ const mkDeps = (on) => ({
2935
+ rt: async () => mkBoard([]), wt: async () => {}, mutateLocked: async (sid, fn) => fn(mkBoard([])), kickCycle: () => {},
2936
+ rootForSession: () => undefined, sessionCwd: () => '', withTimeout: (p) => p, runsFor: () => ({}),
2937
+ feedbackOn: () => false, epicSplitOn: () => on,
2938
+ pushSysNote: () => {}, maybeNotify: () => {}, notifyTaskDone: () => {},
2939
+ })
2940
+ const mkState = () => { const s = { knownSessions: {}, dispatchedEver: {}, badModels: {}, packByChild: {}, pendingPacks: [], teamModeCache: {}, activeRuns: {} }; s.teamModeCache[SID] = true; return s }
2941
+ const textOf = (on) => {
2942
+ sections.length = 0
2943
+ createDispatch(ctx, mkState(), mkDeps(on))
2944
+ const sec = sections.find((s) => s.name === 'task-board:team-mode')
2945
+ assert.ok(sec, 'team-mode 引导段已注册')
2946
+ return sec.text({ agent: { id: SID } })
2947
+ }
2948
+ const on = textOf(true)
2949
+ assert.ok(on.includes(TEAM_SPLIT_RULE)); assert.match(on, /大任务必须拆分/); assert.match(on, /checkParentAuto/)
2950
+ const off = textOf(false)
2951
+ // 条款整条消失(连 parentId/checkParentAuto 都不再出现),基础 1~5 条引导一字不少
2952
+ assert.doesNotMatch(off, /大任务必须拆分|parentId=父卡 id|checkParentAuto|epic 父卡/)
2953
+ assert.match(off, /Team 模式已开启/); assert.match(off, /5\. Team 模式下 task_create 默认建为草稿/)
2954
+ assert.equal(on.replace(splitRuleOf(true), ''), off.replace(splitRuleOf(false), '')) // 除第 6 条外逐字相同
2955
+ })
2956
+
2957
+ test('withSplitHint: epicSplit=false 不附 suggestSplit(与未命中同形),缺省/true 零变化', () => {
2958
+ const big = mkTask({ title: '全量重写派发引擎' })
2959
+ const off = withSplitHint({ ok: true, task: {} }, big, false)
2960
+ assert.equal(off.suggestSplit, undefined)
2961
+ assert.deepEqual(Object.keys(off), ['ok', 'task']) // 返回体形态与「未命中」逐字同形
2962
+ assert.equal(withSplitHint({ ok: true, task: {} }, big, true).suggestSplit, SUGGEST_SPLIT_TEXT)
2963
+ assert.equal(withSplitHint({ ok: true, task: {} }, big).suggestSplit, SUGGEST_SPLIT_TEXT) // 两参老调用零变化
2964
+ assert.equal(withSplitHint({ ok: true, task: {} }, big, undefined).suggestSplit, SUGGEST_SPLIT_TEXT) // 缺省=开
2965
+ })
2966
+
2967
+ test('buildWorkerPrompt: Worker prompt 不含拆分条款(引导只在主窗口侧两处动态面,审计锁定)', () => {
2968
+ const t = mkTask({ title: '把整个引擎系统级重写', description: 'x'.repeat(600) })
2969
+ const p = core.buildWorkerPrompt(t, '', true)
2970
+ for (const s of ['建议粒度', '拆分', 'parentId', 'epic 卡', 'TASK_SIZE_CONTRACT', 'TEAM_SPLIT_RULE']) {
2971
+ assert.ok(!p.includes(s), 'Worker prompt 不该出现拆分引导:' + s)
2972
+ }
2973
+ // 源码级:buildWorkerPrompt 体内不引用拆分文案、也不引用门禁出口——Worker 侧没有可跳过的条款,
2974
+ // 所以 epicSplit 对 Worker prompt 是零影响(将来若往这里加拆分条款,必须同步接 epicSplit 门禁)
2975
+ const coreSrc = readFileSync(new URL('../lib/core.mjs', import.meta.url), 'utf8')
2976
+ const body = coreSrc.slice(coreSrc.indexOf('export function buildWorkerPrompt'), coreSrc.indexOf('export function buildVerifierPrompt'))
2977
+ assert.ok(body.length > 500, 'buildWorkerPrompt 切片成功')
2978
+ assert.doesNotMatch(body, /TASK_SIZE_CONTRACT|TEAM_SPLIT_RULE|splitRuleOf|建议粒度/)
2979
+ })
2980
+
2981
+ test('epicSplit 透出:get-tasks 返回确定布尔值(缺省 true / 显式 false),set-board-config 白名单落盘', async () => {
2982
+ const board = mkBoard([])
2983
+ const h = mkRpcHandlers(board)
2984
+ assert.equal((await h['get-tasks']({})).epicSplit, true) // 老看板缺字段 → true(零变化)
2985
+ await h['set-board-config']({ key: 'epicSplit', value: false })
2986
+ assert.equal(board.epicSplit, false)
2987
+ assert.equal((await h['get-tasks']({})).epicSplit, false)
2988
+ await h['set-board-config']({ key: 'epicSplit', value: true })
2989
+ assert.equal((await h['get-tasks']({})).epicSplit, true)
2990
+ })
2991
+
2992
+ test('epicSplit 行为:关掉后 create-task 不附 suggestSplit,但显式 parentId 建子卡 + 史诗自动收口照常(机制不禁)', async () => {
2993
+ const board = mkBoard([])
2994
+ const h = mkRpcHandlers(board)
2995
+ const big = { title: '全量重构整个派发引擎', description: '把整个看板的派发与结算链路系统级重写一遍,覆盖全部管线与状态机' }
2996
+ const on = await h['create-task'](Object.assign({ id: 'epic-on' }, big))
2997
+ assert.equal(on.ok, true); assert.equal(on.suggestSplit, SUGGEST_SPLIT_TEXT) // 缺省开:引导照旧
2998
+ assert.equal((await h['set-board-config']({ key: 'epicSplit', value: false })).ok, true)
2999
+ const r1 = await h['create-task'](Object.assign({ id: 'epic-off' }, big))
3000
+ assert.equal(r1.ok, true); assert.equal('suggestSplit' in r1, false) // 引导关掉:不再劝拆
3001
+ // 机制不禁①:显式 parentId 建子卡照常
3002
+ const child = await h['create-task']({ id: 'kid-1', title: '拆出来的一小块', description: '明确描述', parentId: 'epic-off' })
3003
+ assert.equal(child.ok, true); assert.equal(child.task.parentId, 'epic-off')
3004
+ // 机制不禁②:子任务全部了结 → 父卡照常自动转 verifying(checkParentAuto 不看开关)
3005
+ const ep = board.tasks.find((x) => x.id === 'epic-off'); ep.status = 'in-progress'
3006
+ const kid = board.tasks.find((x) => x.id === 'kid-1'); kid.status = 'resolved'
3007
+ assert.equal(core.checkParentAuto(board, kid), ep)
3008
+ assert.equal(ep.status, 'verifying')
3009
+ })
3010
+
3011
+ test('epicSplit 接线(源码级):缓存同步 / Team 引导段门禁 / 双出口第三参 / get-tasks 透出 / UI「功能」小节', () => {
3012
+ const src = hostSrc()
3013
+ const coreSrc = readFileSync(new URL('../lib/core.mjs', import.meta.url), 'utf8') // hostSrc 不含 core(纯逻辑核)
3014
+ const cli = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
3015
+ // 配置层(core.mjs:cfg 兜底 / seed 初值 / normalizeBoard 老看板补齐)
3016
+ assert.match(coreSrc, /epicSplit: d\.epicSplit !== false/)
3017
+ assert.match(coreSrc, /if \(typeof d\.epicSplit !== 'boolean'\) d\.epicSplit = true/)
3018
+ assert.match(coreSrc, /notifyDone: true, epicSplit: true, minWorkers: 1/) // seed 缺省开
3019
+ // 缓存链路:rt() 读盘同步 + set-board-config 当场回填(Team 提示词是同步组装,只能读缓存)
3020
+ assert.match(src, /epicSplitCache\[sid\] = nd\.epicSplit !== false/)
3021
+ assert.match(src, /function epicSplitOn\(sid\) \{ return epicSplitCache\[sid\] !== false \}/)
3022
+ assert.match(src, /epicSplitOn: session\.epicSplitOn/)
3023
+ assert.match(src, /var epicSplitOn = deps\.epicSplitOn/)
3024
+ assert.match(src, /else if \(args\.key === 'epicSplit'\) \{ d\.epicSplit = !!args\.value; epicSplitCache\[sid\] = d\.epicSplit \}/)
3025
+ // ① Team 提示词第 6 条经门禁出口注入
3026
+ assert.match(src, /splitRuleOf\(epicSplitOn\(String\(agent\.id\)\)\)/)
3027
+ // ② 双出口(RPC + 工具)都传第三参
3028
+ assert.equal((src.match(/withSplitHint\(\{ ok: true, task: t \}, t, cfg\(d\)\.epicSplit\)/g) || []).length, 2)
3029
+ assert.match(src, /d\.epicSplit = cfg\(d\)\.epicSplit/) // get-tasks 透出确定布尔值
3030
+ // ③ UI:入池配置弹层「功能」小节一行开关(勾选态缺字段=开)+ 状态透传链
3031
+ assert.match(cli, /'功能'/)
3032
+ assert.match(cli, /'🧩 史诗拆分:大任务引导拆为 epic \+ 子任务'/)
3033
+ assert.match(cli, /checked: props\.epicSplit !== false, onChange: function \(e\) \{ rpc\('set-board-config', \{ key: 'epicSplit', value: e\.target\.checked \}\)/)
3034
+ assert.match(cli, /关掉只停引导:显式 parentId 建子卡与史诗自动收口照常工作/)
3035
+ assert.match(cli, /state\.epicSplit = !\(d && d\.epicSplit === false\)/)
3036
+ assert.match(cli, /useState\(state\.epicSplit\)/)
3037
+ assert.match(cli, /epicSplit: esOn/)
3038
+ })
3039
+
3040
+ test('README 双份同步记录 epicSplit 开关口径(关引导不禁机制)', () => {
3041
+ const pkg = readFileSync(new URL('../README.md', import.meta.url), 'utf8')
3042
+ const root = readFileSync(new URL('../../../README.md', import.meta.url), 'utf8')
3043
+ assert.equal(pkg, root) // 两份 README 必须字节一致
3044
+ for (const s of ['`epicSplit`', '「**功能**」小节', '只停**引导**', '**机制不禁**', 'parentId']) {
3045
+ assert.ok(pkg.includes(s), 'README 应记录 epicSplit 口径:' + s)
3046
+ }
3047
+ })