dsh-agent-board 1.7.6 → 1.7.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-agent-board",
3
3
  "description": "DeepSeek Harness 智能看板:任务卡片 + 一次性 Worker/Verifier 派发 + 依赖调度 + 管线分档 + Team 模式(歧义上报主窗口裁决)",
4
- "version": "1.7.6",
4
+ "version": "1.7.8",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
7
7
  "scripts": {
@@ -0,0 +1,43 @@
1
+ // dsh-agent-board — 归档卡点开详情(用户 2026-10-09 指令)client 侧单测(test/archive-detail.test.mjs)
2
+ // client 无渲染 harness(react 不在包依赖,渲染由 dsh web 客户端运行时注入)——与历卡同一纪律:
3
+ // 全部走源码级断言(board-list.js / kernel.js / task-detail.js 模块源 + client.js 重组装产物)。
4
+ // 断言:
5
+ // ① 归档行可点击进详情:ArchiveView 行挂 onClick → state.detailId = t.id(复用 detailId 机制,与看板卡同路径);
6
+ // 恢复/历史会话按钮 stopPropagation——点「恢复待办」不误开详情
7
+ // ② 归档 tab 分支尊重 detailId:kernel.js view==='archive' 分支 detailId 优先渲染 DetailView
8
+ // ③ 已阅链路对归档卡生效:mark-reviewed 触发条件无 status 门禁(读 detailId,archived 同样落账)
9
+ // ④ 产物重组装:关键接线标记出现在 lib/client.js
10
+ import { test } from 'node:test'
11
+ import assert from 'node:assert/strict'
12
+ import { readFileSync } from 'node:fs'
13
+
14
+ const bl = readFileSync(new URL('../lib/client/board-list.js', import.meta.url), 'utf8')
15
+ const kernel = readFileSync(new URL('../lib/client/kernel.js', import.meta.url), 'utf8')
16
+ const td = readFileSync(new URL('../lib/client/task-detail.js', import.meta.url), 'utf8')
17
+ const built = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
18
+
19
+ // ===== ① 归档行可点击进详情 + 行内按钮不冒泡 =====
20
+ test('① 归档行可点击进详情(onClick 接线 state.detailId)+ 恢复/历史会话按钮不冒泡', () => {
21
+ assert.match(bl, /onClick: function \(\) \{ state\.detailId = t\.id; notify\(\) \}/)
22
+ assert.match(bl, /onClick: function \(e\) \{ e\.stopPropagation\(\); restore\(t\.id\) \}/)
23
+ assert.match(bl, /onClick: function \(e\) \{ e\.stopPropagation\(\); if \(uiWorkspaceSvc\) uiWorkspaceSvc\.openSession\(r\.id\) \}/)
24
+ })
25
+
26
+ // ===== ② 归档 tab 分支尊重 detailId:复用 DetailView =====
27
+ test('② 归档 tab 分支尊重 detailId:复用 DetailView(与看板卡同路径)', () => {
28
+ assert.match(kernel, /view === 'archive'\) \{ content = detailId \? React\.createElement\(DetailView\) : React\.createElement\(ArchiveView\) \}/)
29
+ })
30
+
31
+ // ===== ③ 已阅链路对归档卡生效:mark-reviewed 无 status 门禁 =====
32
+ test('③ 已阅链路对归档卡生效:mark-reviewed 触发无 status 门禁(读 detailId)', () => {
33
+ assert.match(td, /var t = getTask\(state\.detailId\); if \(t && t\.reviewHint && t\.reviewHint\.score > 0 && !t\.reviewedAt\) \{ rpc\('mark-reviewed'/)
34
+ // reviewPending 单一事实源含 archived(徽章随 fetchTasks 消失的口径)
35
+ assert.match(kernel, /t\.status === 'resolved' \|\| t\.status === 'archived'/)
36
+ })
37
+
38
+ // ===== ④ 产物重组装 =====
39
+ test('④ 产物重组装:三处接线标记出现在 lib/client.js', () => {
40
+ for (const m of ['state.detailId = t.id; notify()', 'detailId ? React.createElement(DetailView) : React.createElement(ArchiveView)', 'e.stopPropagation(); restore(t.id)']) {
41
+ assert.ok(built.indexOf(m) >= 0, 'client.js 缺标记:' + m + '(需 npm run build-client 重组装)')
42
+ }
43
+ })
@@ -1018,6 +1018,15 @@ test('README 双份同步记录 touches 锁随工作态口径(状态流转到
1018
1018
  assert.ok(!pkg.includes('锁持到归档'), 'README 不应再记录「锁持到归档」旧口径')
1019
1019
  })
1020
1020
 
1021
+ test('README 定位段:agent 团队持久台账与治理层关键词存在(台账/治理层/审计台/流水线归机器)+ 双份逐字一致', () => {
1022
+ const pkg = readFileSync(new URL('../README.md', import.meta.url), 'utf8')
1023
+ const root = readFileSync(new URL('../../../README.md', import.meta.url), 'utf8')
1024
+ assert.equal(pkg, root) // 两份 README 必须字节一致(npm run sync-readme 的约束)
1025
+ for (const s of ['agent 团队的持久台账与治理层', '台账', '治理层', '审计台', '流水线归机器,审计台归人', '验收独立复跑', '适合谁']) {
1026
+ assert.ok(root.includes(s), 'README 定位段应含关键词:' + s)
1027
+ }
1028
+ })
1029
+
1021
1030
  // ===== 派发决策(touches 拦截)=====
1022
1031
  test('pickDispatch: touches 与活动任务冲突 → 不进 pendings,记入 blockedTouches', () => {
1023
1032
  const tasks = [
@@ -2135,6 +2144,18 @@ test('Token 区②:usageSummary 变化纳入 notify 触发(选范围即渲
2135
2144
  assert.equal(/state\.usageSummary !== prevUsage/.test(cli), false)
2136
2145
  })
2137
2146
 
2147
+ test('setUsage 订阅锁(task-mv10lvud 遗留,task-mv1pqy27 补锁):TopPanel update() 内 setUsage(state.usageSummary) 订阅存在(kernel.js 源 + client.js 产物各一条)', () => {
2148
+ const kernel = readFileSync(new URL('../lib/client/kernel.js', import.meta.url), 'utf8')
2149
+ const built = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
2150
+ // 订阅声明:usageSummary 换新对象即触发 Token 区重渲染
2151
+ assert.match(kernel, /var _usg = useState\(state\.usageSummary\), setUsage = _usg\[1\]/)
2152
+ assert.match(built, /var _usg = useState\(state\.usageSummary\), setUsage = _usg\[1\]/)
2153
+ // 锁定「订阅存在于 update() 监听器内」:setUsage(state.usageSummary) 落在 update() 函数体、
2154
+ // 且该 update 被 listeners.push 注册(而非游离在组件渲染路径上)
2155
+ assert.match(kernel, /function update\(\) \{[\s\S]*?setUsage\(state\.usageSummary\)[\s\S]*?\}; listeners\.push\(update\)/)
2156
+ assert.match(built, /function update\(\) \{[\s\S]*?setUsage\(state\.usageSummary\)[\s\S]*?\}; listeners\.push\(update\)/)
2157
+ })
2158
+
2138
2159
  test('Token 区③:Top8 与模型分布主数字取总量(有效进 title),caption 写明口径翻转(源码级断言)', () => {
2139
2160
  const cli = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
2140
2161
  // 口径翻转(task-muxnqunk,用户 2026-10-07 裁决「算总的吧」,推翻 task-muwq9u04 有效主显):
@@ -2734,8 +2755,8 @@ test('README 双份:运行区子会话 id 归属标注 + git 纪律红线各
2734
2755
  // 修法:ArchiveView 直接读 state.tasks.filter(status==='archived'),与仪表盘同源全量 + 归档时间倒序 + 顶部注条数。
2735
2756
  test('ArchiveView: 归档数据源读 state.tasks 全量(对齐仪表盘「已归档」),不依赖 state.archived/fetchArchived(源码级断言)', () => {
2736
2757
  const src = readFileSync(new URL('../lib/client/board-list.js', import.meta.url), 'utf8')
2737
- // 数据源必须与仪表盘同源:state.tasks.filter(status==='archived')(get-tasks 全量含归档)
2738
- assert.match(src, /var archived = state\.tasks\.filter\(function \(t\) \{ return t\.status === 'archived' \}\)/)
2758
+ // 数据源必须与仪表盘同源:state.tasks.filter(status==='archived' || status==='cancelled')(get-tasks 全量含归档;取消通道卡2:cancelled 归入本 tab 可见可恢复)
2759
+ assert.match(src, /var archived = state\.tasks\.filter\(function \(t\) \{ return t\.status === 'archived' \|\| t\.status === 'cancelled' \}\)/)
2739
2760
  // 反向断言:不得再读独立 state.archived.filter(异步填库不触发重渲染 → tab 恒空)
2740
2761
  assert.doesNotMatch(src, /state\.archived\.filter/)
2741
2762
  // 不得再挂「空则 fetchArchived」的 useEffect(独立数据源已移除)
@@ -2743,14 +2764,18 @@ test('ArchiveView: 归档数据源读 state.tasks 全量(对齐仪表盘「已
2743
2764
  // 不得有任何 7 日窗口 cutoff(老归档 >7 天被整批滤掉是原根因候选形态之一)
2744
2765
  assert.doesNotMatch(src, /cutoff/)
2745
2766
  assert.doesNotMatch(src, /7 \* 24 \* 60|7 \* 86400000|604800000/)
2746
- // 顶部注明条数(与仪表盘「已归档」同口径)
2747
- assert.match(src, /'共 ' \+ archived\.length \+ ' 条归档/)
2748
- // 归档时间倒序:archivedAt 优先,回退 resolvedAt/createdAt
2749
- assert.match(src, /function archTs\(t\) \{ return t\.archivedAt \|\| t\.resolvedAt \|\| t\.createdAt \|\| '' \}/)
2767
+ // 顶部注明条数:共 N 条;含已取消时补「(含已取消 X 条)」括号段,取消为 0 时省略(task-mv1pqy27 文案口径修正)
2768
+ assert.match(src, /var cancelledCount = archived\.filter\(function \(t\) \{ return t\.status === 'cancelled' \}\)\.length/)
2769
+ assert.match(src, /'共 ' \+ archived\.length \+ ' 条' \+ \(cancelledCount > 0 \? '(含已取消 ' \+ cancelledCount \+ ' 条)' : ''\)/)
2770
+ // 归档/取消时间倒序:archivedAt 优先,回退 cancelledAt/resolvedAt/createdAt
2771
+ assert.match(src, /function archTs\(t\) \{ return t\.archivedAt \|\| t\.cancelledAt \|\| t\.resolvedAt \|\| t\.createdAt \|\| '' \}/)
2772
+ // 取消卡可见标记:行内「已取消」chip + 时间戳回退 cancelledAt
2773
+ assert.match(src, /t\.status === 'cancelled' \? React\.createElement\('span', \{ title: '已取消(可恢复待办)'/)
2774
+ assert.match(src, /\(t\.archivedAt \|\| t\.cancelledAt\) \? ago\(t\.archivedAt \|\| t\.cancelledAt\)/)
2750
2775
  // 组装产物同步(pretest 已跑 build-client)
2751
2776
  const built = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
2752
- assert.match(built, /var archived = state\.tasks\.filter\(function \(t\) \{ return t\.status === 'archived' \}\)/)
2753
- assert.match(built, /'共 ' \+ archived\.length \+ ' 条归档/)
2777
+ assert.match(built, /var archived = state\.tasks\.filter\(function \(t\) \{ return t\.status === 'archived' \|\| t\.status === 'cancelled' \}\)/)
2778
+ assert.match(built, /'共 ' \+ archived\.length \+ ' 条' \+ \(cancelledCount > 0 \? '(含已取消 ' \+ cancelledCount \+ ' 条)' : ''\)/)
2754
2779
  })
2755
2780
 
2756
2781
  // ===== 史诗 hooks=agent run(task-muuw4ov7):host 生命周期接线(pre 闸门 / post 收口 / 薄框架 prompt / 主窗口限定写入)=====
@@ -3656,22 +3681,28 @@ test('可续跑 Worker⑫(卡2 续跑基线):续跑轮没产出 → 残留
3656
3681
  } finally { fs.rmSync(tmp, { recursive: true, force: true }) }
3657
3682
  })
3658
3683
 
3659
- test('可续跑 Worker⑬(卡2 手动终止臂):终止 continuable run → interrupt 留存(零 dispose),任务回 pending 且刻意不留续跑资格', async () => {
3684
+ test('可续跑 Worker⑬(卡2 手动终止臂):终止 continuable run → interrupt 留存(零 dispose),任务回 pending 且关 run 结局 terminated(不留续跑资格)', async () => {
3660
3685
  const parent = { id: FULL_SID }
3661
3686
  const calls = { interrupt: [] }
3687
+ const closed = []
3662
3688
  const t = mkTask({ id: 'mt1', title: '手动终止卡', status: 'in-progress', claimedBy: 'child-live', runs: [{ role: 'worker', id: 'child-live', at: '2026-01-01T00:00:00.000Z', model: '', outcome: 'running', hardMin: 120, continuable: true }] })
3663
3689
  const board = mkBoard([t])
3664
3690
  // 活跃 rec:continuable 形态(无 run,只能 interrupt)
3665
3691
  const runs = { mt1: { id: 'child-live', childId: 'child-live', continuable: true, ran: true, run: null, role: 'worker', taskId: 'mt1', startedAt: Date.now(), model: '', settled: false } }
3666
- const h = mkRpcHandlers(board, { rootForSession: () => parent, runsFor: () => runs }, { subagents: { interrupt: (id, auth) => calls.interrupt.push({ id: id, auth: auth }) } })
3692
+ const h = mkRpcHandlers(board, {
3693
+ rootForSession: () => parent, runsFor: () => runs,
3694
+ // 手动终止 run 关账(task-mv1pqy27):与生产 dispatch.closeRunHistory 同口径,只关 outcome==='running' 的条目
3695
+ closeRunHistory: async (sid, taskId, runId, outcome) => { closed.push({ taskId: taskId, runId: runId, outcome: outcome }); var tt = board.tasks.find(function (x) { return x.id === taskId }); if (tt && Array.isArray(tt.runs)) { var rr = tt.runs[tt.runs.length - 1]; if (rr && rr.id === runId && rr.outcome === 'running') { rr.outcome = outcome; rr.endedAt = '2026-01-01T00:00:01Z' } } }
3696
+ }, { subagents: { interrupt: (id, auth) => calls.interrupt.push({ id: id, auth: auth }) } })
3667
3697
  const r = await h['terminate-agent']({ taskId: 'mt1' })
3668
3698
  assert.equal(r.ok, true); assert.equal(r.terminated, 'worker:mt1')
3669
3699
  // 终止 = interrupt 留存(不是 dispose):authority 形状同硬超时臂
3670
3700
  assert.deepEqual(calls.interrupt, [{ id: 'child-live', auth: { kind: 'ancestor', agent: parent } }])
3671
3701
  assert.equal(runs.mt1, undefined) // 活跃表摘除(结算通道随之关闭,不会被 idle 事件误结算)
3672
3702
  assert.equal(t.status, 'pending'); assert.equal(t.claimedBy, null) // 既有语义:重新排队
3673
- // 刻意不留续跑资格:结局停在 running → resumeTarget 不认(重派走 fresh spawn,不唤醒刚被终止的子会话)
3674
- assert.equal(t.runs[t.runs.length - 1].outcome, 'running')
3703
+ // 关 run 结局 terminated(与状态无关,task-mv1pqy27):terminated 不满足 resumeTarget 续跑判据 → 仍不留续跑资格
3704
+ assert.deepEqual(closed, [{ taskId: 'mt1', runId: 'child-live', outcome: 'terminated' }])
3705
+ assert.equal(t.runs[t.runs.length - 1].outcome, 'terminated')
3675
3706
  })
3676
3707
 
3677
3708
  test('hooks 接线(源码级):spawnOneShot 三态 prompt + settleRun 分派 + pre/post 占用与 spawn 失败回收', () => {
@@ -4087,6 +4118,114 @@ test('task_archive 工具:与 archive-task RPC 同一门禁口径(僵尸放
4087
4118
  assert.equal(r.ok, false); assert.match(r.error, /cannot archive/)
4088
4119
  })
4089
4120
 
4121
+ // ===== 取消通道(task_cancel 工具 + cancel-task RPC,反馈 n-mv0zbqk...)=====
4122
+ test('cancel-task RPC: draft/pending/blocked 三态可取消,reason 落历史', async () => {
4123
+ const d1 = mkTask({ id: 'd1', status: 'draft' })
4124
+ const p1 = mkTask({ id: 'p1', status: 'pending' })
4125
+ const b1 = mkTask({ id: 'b1', status: 'blocked', resolution: '卡住了' })
4126
+ const board = mkBoard([d1, p1, b1])
4127
+ const h = mkRpcHandlers(board)
4128
+ assert.equal((await h['cancel-task']({ taskId: 'd1', reason: '误建' })).ok, true)
4129
+ assert.equal((await h['cancel-task']({ taskId: 'p1' })).ok, true)
4130
+ assert.equal((await h['cancel-task']({ taskId: 'b1', reason: '放弃' })).ok, true)
4131
+ assert.equal(d1.status, 'cancelled'); assert.ok(d1.cancelledAt)
4132
+ assert.equal(p1.status, 'cancelled'); assert.ok(p1.cancelledAt)
4133
+ assert.equal(b1.status, 'cancelled'); assert.equal(b1.resolution, null) // 阻塞残留 resolution 清空
4134
+ // reason 落历史(actor + note)
4135
+ assert.ok(d1.history.some(function (x) { return x.to === 'cancelled' && x.actor === 'tester' && /取消: 误建/.test(x.note) }))
4136
+ assert.ok(p1.history.some(function (x) { return x.to === 'cancelled' && x.actor === 'tester' && /取消/.test(x.note) }))
4137
+ assert.ok(b1.history.some(function (x) { return x.to === 'cancelled' && /取消: 放弃/.test(x.note) }))
4138
+ })
4139
+
4140
+ test('cancel-task RPC: in-progress(真实 run)/verifying/resolved 拒(terminate/archive 语义)', async () => {
4141
+ const ip = mkTask({ id: 'ip', status: 'in-progress', claimedBy: 'run-x' })
4142
+ const vf = mkTask({ id: 'vf', status: 'verifying' })
4143
+ const rs = mkTask({ id: 'rs', status: 'resolved' })
4144
+ const board = mkBoard([ip, vf, rs])
4145
+ const h = mkRpcHandlers(board)
4146
+ let r = await h['cancel-task']({ taskId: 'ip' })
4147
+ assert.equal(r.ok, false); assert.match(r.error, /terminate-agent/); assert.equal(ip.status, 'in-progress')
4148
+ r = await h['cancel-task']({ taskId: 'vf' })
4149
+ assert.equal(r.ok, false); assert.match(r.error, /terminate-agent/); assert.equal(vf.status, 'verifying')
4150
+ r = await h['cancel-task']({ taskId: 'rs' })
4151
+ assert.equal(r.ok, false); assert.match(r.error, /归档/); assert.equal(rs.status, 'resolved')
4152
+ })
4153
+
4154
+ test('cancel-task RPC: spawn-pending 边缘回退(清认领位,不留死占位)', async () => {
4155
+ const sp = mkTask({ id: 'sp', status: 'in-progress', claimedBy: 'spawn-pending', claimedAt: '2026-01-01T00:00:00Z' })
4156
+ const board = mkBoard([sp])
4157
+ const h = mkRpcHandlers(board)
4158
+ const r = await h['cancel-task']({ taskId: 'sp', reason: 'spawn 在途作废' })
4159
+ assert.equal(r.ok, true)
4160
+ assert.equal(sp.status, 'cancelled')
4161
+ assert.equal(sp.claimedBy, null); assert.equal(sp.claimedAt, null) // 占位回退(与 spawn 失败同口径)
4162
+ assert.ok(sp.history.some(function (x) { return x.from === 'in-progress' && x.to === 'cancelled' }))
4163
+ })
4164
+
4165
+ test('task_cancel 工具:与 cancel-task RPC 同一门禁口径(放行/拒绝/幂等)', async () => {
4166
+ const t = mkTask({ id: 'x1', status: 'pending' })
4167
+ let h = mkRpcHandlers(mkBoard([t]))
4168
+ let r = await h.__tools['task_cancel'].execute({ taskId: 'x1', reason: '走工具通道' }, {})
4169
+ assert.equal(r.ok, true); assert.equal(t.status, 'cancelled')
4170
+ // 幂等:再次取消已取消卡 → alreadyCancelled
4171
+ r = await h.__tools['task_cancel'].execute({ taskId: 'x1' }, {})
4172
+ assert.equal(r.ok, true); assert.equal(r.alreadyCancelled, true)
4173
+ // 执行中拒绝(工具通道同门禁)
4174
+ const t2 = mkTask({ id: 'x2', status: 'in-progress', claimedBy: 'run-y' })
4175
+ h = mkRpcHandlers(mkBoard([t2]))
4176
+ r = await h.__tools['task_cancel'].execute({ taskId: 'x2' }, {})
4177
+ assert.equal(r.ok, false); assert.match(r.error, /terminate-agent/)
4178
+ })
4179
+
4180
+ test('restore 清 cancelledAt(task-mv1pqy27):已取消卡 resetToPending 恢复待办不背取消时间戳(行为级,RPC + 工具双通道)', async () => {
4181
+ // RPC 通道(归档 tab「↩ 恢复待办」走 update-task resetToPending)
4182
+ const c = mkTask({ id: 'rc1', status: 'cancelled', cancelledAt: '2026-01-01T00:00:00Z' })
4183
+ const h = mkRpcHandlers(mkBoard([c]))
4184
+ const r = await h['update-task']({ taskId: 'rc1', resetToPending: true })
4185
+ assert.equal(r.ok, true); assert.equal(c.status, 'pending')
4186
+ assert.equal('cancelledAt' in c, false) // delete 语义:字段消失,不背取消时间戳
4187
+ // 工具通道(task_update resetToPending)同口径
4188
+ const c2 = mkTask({ id: 'rc2', status: 'cancelled', cancelledAt: '2026-01-01T00:00:00Z' })
4189
+ const h2 = mkRpcHandlers(mkBoard([c2]))
4190
+ const r2 = await h2.__tools['task_update'].execute({ taskId: 'rc2', resetToPending: true }, {})
4191
+ assert.equal(r2.ok, true); assert.equal(c2.status, 'pending')
4192
+ assert.equal('cancelledAt' in c2, false)
4193
+ })
4194
+
4195
+ test('terminate 认领位清理与状态无关 + 关 run 结局 terminated(源码级,task-mv1pqy27 死结窗口根修)', () => {
4196
+ const rpc = readFileSync(new URL('../lib/rpc.mjs', import.meta.url), 'utf8')
4197
+ // 认领位清理按命中的 run 角色(rec.role)而非卡状态分支(resolved/archived/cancelled 态也清)
4198
+ assert.match(rpc, /if \(rec\) \{\s*if \(rec\.role === 'verifier'\) \{ t\.verifierRun = null; delete t\.verifierRunAt \}/)
4199
+ assert.match(rpc, /else \{ t\.claimedBy = null; t\.claimedAt = null \}/)
4200
+ // 关 run 结局 outcome:'terminated'(与状态无关,凡 terminate 命中该卡的 run 就关账)
4201
+ assert.match(rpc, /closeRunHistory\(sid, taskId, String\(rec\.id\), 'terminated'\)/)
4202
+ // stale 兜底仍保留:无 rec 的 verifying 卡仍按状态清 verifierRun(rec 不存在时 rec.role 无从判定)
4203
+ assert.match(rpc, /else if \(t\.status === 'verifying'\) \{ t\.verifierRun = null; delete t\.verifierRunAt; ah\(t, 'verifying'/)
4204
+ // 接线:dispatch 导出 closeRunHistory + index 注入 rpc(未接线则 doTerminate 走 no-op 兜底,关账失效)
4205
+ const dsp = readFileSync(new URL('../lib/dispatch.mjs', import.meta.url), 'utf8')
4206
+ const idx = readFileSync(new URL('../index.mjs', import.meta.url), 'utf8')
4207
+ assert.match(dsp, /closeRunHistory: closeRunHistory/)
4208
+ assert.match(idx, /closeRunHistory: dispatch\.closeRunHistory/)
4209
+ })
4210
+
4211
+ test('task_cancel GUI 按钮门禁:canCancel 三态 + 详情页按钮接线 + confirm 预警 + 产物重组装', () => {
4212
+ const ksrc = readFileSync(new URL('../lib/client/kernel.js', import.meta.url), 'utf8')
4213
+ const tdsrc = readFileSync(new URL('../lib/client/task-detail.js', import.meta.url), 'utf8')
4214
+ const built = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
4215
+ // canCancel 谓词与 canDelete 同三态口径(draft/pending/blocked)
4216
+ assert.match(ksrc, /function canCancel\(t\) \{ return !!t && \(t\.status === 'draft' \|\| t\.status === 'pending' \|\| t\.status === 'blocked'\) \}/)
4217
+ // confirm 预警 + cancel-task RPC 调用
4218
+ assert.ok(ksrc.indexOf('取消后不再派发,可在归档恢复。确认取消') >= 0, 'kernel.js 缺 confirm 预警')
4219
+ assert.ok(ksrc.indexOf("rpc('cancel-task'") >= 0, 'kernel.js 缺 cancel-task 调用')
4220
+ // 详情页按钮门禁:canCancel(task) 才渲染取消按钮
4221
+ assert.ok(tdsrc.indexOf('canCancel(task) ? React.createElement') >= 0, 'task-detail.js 缺 canCancel 门禁')
4222
+ assert.ok(tdsrc.indexOf('cancelTask(task.id, task.title, setActionMsg)') >= 0, 'task-detail.js 缺取消按钮接线')
4223
+ // 产物重组装:关键接线标记出现在 lib/client.js
4224
+ for (const m of ['function canCancel(t)', "rpc('cancel-task'", '取消后不再派发,可在归档恢复', 'canCancel(task) ? React.createElement']) {
4225
+ assert.ok(built.indexOf(m) >= 0, 'client.js 缺标记:' + m)
4226
+ }
4227
+ })
4228
+
4090
4229
  // ===== 反馈修复(task-mux3uvx3,反馈 n-muw706h1uymy):归档路径触发父卡收口 + task_resolve 守卫对齐 =====
4091
4230
  test('归档双通道触发父卡自动收口:archive-task RPC 与 task_archive 工具同一 archiveApply 口径', async () => {
4092
4231
  // RPC 通道:归档末子卡 → 父卡 in-progress → verifying
@@ -4345,13 +4484,15 @@ test('isRoot 蝶变防抖(源码级):计数器 + 连续 3 次阈值 + ever
4345
4484
  assert.equal((cli.match(/applyIsRoot\(/g) || []).length, 2) // 定义 1 + 轮询调用 1
4346
4485
  })
4347
4486
 
4348
- test('isRoot 蝶变防抖:host 侧注释指回客户端防抖,且 host 行为未改(仍现算 isRoot)', () => {
4487
+ test('isRoot 持久语义:host 侧改读 parentSession(根=无父),防抖仍指回客户端', () => {
4349
4488
  const rpcSrc = readFileSync(new URL('../lib/rpc.mjs', import.meta.url), 'utf8')
4350
- // 指针注释:说明瞬态假 false + 防抖落在客户端 kernel.js applyIsRoot
4351
- assert.match(rpcSrc, /host 不改行为(保持"现算真相"),防抖在客户端:kernel\.js applyIsRoot/)
4489
+ // isRoot 持久判定入口:根会话=无父,不再读活跃 roots() 集
4490
+ assert.match(rpcSrc, /async function isRootPersistent\(sid\)/)
4491
+ assert.match(rpcSrc, /snap\.header\) return !snap\.header\.parentSession/)
4492
+ assert.match(rpcSrc, /d\.isRoot = await isRootPersistent\(sid\)/)
4493
+ // 指针注释:防抖仍在客户端 kernel.js applyIsRoot,曾确认 true 需连续 3 次 false 才收
4494
+ assert.match(rpcSrc, /防抖仍在客户端:kernel\.js applyIsRoot/)
4352
4495
  assert.match(rpcSrc, /曾确认 true 的会话需连续 3 次/)
4353
- // 行为未改:isRoot 仍按 agents.roots() 现算(不缓存、不防抖、不落盘)
4354
- assert.match(rpcSrc, /var __roots = __ag\.roots\(\)[\s\S]{0,200}d\.isRoot = __rids\.indexOf\(sid\) >= 0/)
4355
4496
  })
4356
4497
 
4357
4498
  // ===== 史诗 hooks UI(task-muuw56yf):卡片相位徽章 + 详情页 hooks 编辑区 =====
@@ -5582,3 +5723,49 @@ test('巡检③:batch-op archive 跳过附 reasons(id → 原因)且 clien
5582
5723
  assert.match(built, /it\.title \+ ':' \+ it\.reason/)
5583
5724
  assert.match(built, /'跳过原因'/)
5584
5725
  })
5726
+
5727
+ // ===== 巡检三连②(详情编辑 / 表单标签 / 恢复预警):优先级+标签可编辑 / 标签进 create 载荷 / 恢复 confirm 门禁 =====
5728
+ test('巡检三连②-①:详情页优先级四档下拉 + 标签编辑走 update-task(含 tags 通道落卡 trim/去空)', async () => {
5729
+ const src = readFileSync(new URL('../lib/client/task-detail.js', import.meta.url), 'utf8')
5730
+ // 优先级静态 chip → 四档下拉(value 读 task.priority,onChange 落 update-task priority 通道)
5731
+ assert.match(src, /value: task\.priority \|\| 'medium', onChange: function \(e\) \{ rpc\('update-task', \{ taskId: task\.id, priority: e\.target\.value \}\)\.then\(fetchTasks\)/)
5732
+ assert.match(src, /\['critical', 'high', 'medium', 'low'\]\.map/)
5733
+ // 标签编辑:TagsEditor 逗号分隔 input + update-task tags 通道
5734
+ assert.match(src, /function TagsEditor\(props\)/)
5735
+ assert.match(src, /rpc\('update-task', \{ taskId: task\.id, tags: tags \}\)/)
5736
+ // 行为级:update-task RPC tags 通道落卡 + trim/去空(详情页标签编辑的落点)
5737
+ const board = mkBoard([mkTask({ id: 't1', tags: [] })])
5738
+ const h = mkRpcHandlers(board)
5739
+ const r = await h['update-task']({ taskId: 't1', tags: ['bug', ' epic ', '', '待验收'] })
5740
+ assert.equal(r.ok, true)
5741
+ assert.deepEqual(board.tasks[0].tags, ['bug', 'epic', '待验收'])
5742
+ // 组装产物同步
5743
+ const built = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
5744
+ assert.match(built, /function TagsEditor\(props\)/)
5745
+ assert.match(built, /value: task\.priority \|\| 'medium', onChange: function \(e\) \{ rpc\('update-task', \{ taskId: task\.id, priority: e\.target\.value \}\)\.then\(fetchTasks\)/)
5746
+ })
5747
+
5748
+ test('巡检三连②-②:新建表单「标签」字段进 create-task 载荷(逗号分隔 trim+去空)', () => {
5749
+ const src = readFileSync(new URL('../lib/client/board-list.js', import.meta.url), 'utf8')
5750
+ // 标签输入字段 + 逗号分隔解析(trim + 去空)
5751
+ assert.match(src, /var tags = tagsRaw\.split\(/)
5752
+ assert.match(src, /'标签(逗号分隔,可空)'/)
5753
+ // create-task 载荷带 tags 字段(与 touches/dependsOn 同级)
5754
+ assert.match(src, /tags: tags, dependsOn: deps/)
5755
+ // 组装产物同步
5756
+ const built = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
5757
+ assert.match(built, /tags: tags, dependsOn: deps/)
5758
+ })
5759
+
5760
+ test('巡检三连②-③:归档「恢复待办」confirm 门禁(list 不弹 / auto+team 弹)+ title 预警', () => {
5761
+ const src = readFileSync(new URL('../lib/client/board-list.js', import.meta.url), 'utf8')
5762
+ // 工作模式派生(与 fetchTasks 同口径);list 不弹,auto/team 走 confirm 门禁
5763
+ assert.match(src, /var wm = state\.workMode \|\| \(state\.teamMode \? 'team' : \(state\.boardMode === 'auto' \? 'auto' : 'list'\)\)/)
5764
+ assert.match(src, /if \(wm !== 'list'\)/)
5765
+ assert.match(src, /window\.confirm\('恢复待办后将按当前工作模式立即重新派发,消耗一轮 Worker\+Verifier token。继续/)
5766
+ // 按钮 title 预警(非 list 模式),list 模式仅「恢复为待办」
5767
+ assert.match(src, /title: wm === 'list' \? '恢复为待办' : '恢复为待办(将按当前工作模式立即重新派发,消耗一轮 Worker\+Verifier token)'/)
5768
+ // 组装产物同步
5769
+ const built = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
5770
+ assert.match(built, /window\.confirm\('恢复待办后将按当前工作模式立即重新派发,消耗一轮 Worker\+Verifier token。继续/)
5771
+ })
@@ -207,3 +207,28 @@ test('冒烟链路:apply(mockCtx) → 建卡 → 时钟推进 → continuable
207
207
  assert.equal(t2.runs[1].resume, true, '第二条标 resume:true')
208
208
  } finally { await env.cleanup() }
209
209
  })
210
+
211
+ // ===== isRoot 持久语义(banner 入口瞬态/持久消失修复,task-muzys43o)=====
212
+ // 根因:host get-tasks 的 isRoot 旧实现读活跃 roots() 集——roots() 只含活体会话,
213
+ // 休眠根会话(有板有卡但无活 agent)恒 false → banner「智能看板」按钮分钟级消失;
214
+ // 且生成开始/结束 agents 树重建有瞬态窗口假 false。修法:isRoot 改读持久 parentSession
215
+ // (根会话=无父),休眠根也算 root;子代理会话(parentSession 指向父)仍 false。
216
+ test('isRoot 持久语义:休眠根会话仍 root(parentSession 缺省),子代理会话仍 false', async () => {
217
+ var env = createMockCtx()
218
+ try {
219
+ plugin.apply(env.ctx)
220
+ // 造一块「有板有卡」的根会话板(证明休眠前确有板卡)
221
+ var created = await env.rpc('create-task', { sessionId: env.sid, id: 't-isroot-1', title: '休眠根会话卡', pipeline: 'work' })
222
+ assert.equal(created.ok, true)
223
+ // 模拟休眠:活跃 roots 清空(无活 agent),但持久表仍记 parentSession 缺省(根)
224
+ env.setRoots([])
225
+ var r = await env.rpc('get-tasks', { sessionId: env.sid })
226
+ assert.equal(r.isRoot, true, '① 休眠根会话 isRoot=true(持久 parentSession 缺省,不依赖活跃 roots 集)')
227
+ assert.equal(r.tasks.length, 1, '休眠不丢板卡(rt 读盘仍在)')
228
+ // ② 子代理会话:持久表记 parentSession → false(保护语义不变)
229
+ var childSid = 'session-child-aaaa-bbbb'
230
+ env.persistedHeaders[childSid] = { id: childSid, parentSession: env.sid }
231
+ var rc = await env.rpc('get-tasks', { sessionId: childSid })
232
+ assert.equal(rc.isRoot, false, '② 子代理会话 isRoot=false(parentSession 指向父)')
233
+ } finally { await env.cleanup() }
234
+ })
@@ -9,7 +9,8 @@
9
9
  // · subagents.start(provider, req) → 一次性 run(result promise 由测试驱动落定/拒绝)
10
10
  // · subagents.startContinuable({...}) → { childId, messageId }(持久子会话,turn 结算走 emitStatus)
11
11
  // · subagents.sendMessage / interrupt / listChildren → 记录器 + 可剧本化
12
- // · agents.currentInitiator()/roots()/list()/isOwnedBy() → 按测试剧本返回(缺省单 root,actor=root)
12
+ // · agents.currentInitiator()/roots()/list()/get()/isOwnedBy() → 按测试剧本返回(缺省单 root,actor=root;env.setRoots 可清空模拟休眠)
13
+ // · ctx.get('sessionPersistence') → 持久会话表 stat(id)(env.persistedHeaders 可写 parentSession 模拟子会话/根会话)
13
14
  // · tools.register / webServer.register → 捕获工具表与 RPC 路由(env.rpc 走真 readBody/handler 路径)
14
15
  // · ctx.get('systemPrompt'|'llm') → 记录器(注册了什么段/查了什么模型都留痕)
15
16
  // · ctx.effect / ctx.on('agent/status') → 真实执行 + disposer 收口 / 事件捕获与重放
@@ -48,6 +49,8 @@ import { Readable } from 'node:stream'
48
49
  * @property {Object} spSections systemPrompt 注册段记录器
49
50
  * @property {Object} llm llm 记录器(listProviders/listModels 调用留痕)
50
51
  * @property {string} actor currentInitiator 返回值(缺省=root sid;测试可改写模拟子代理身份)
52
+ * @property {(list:Object[])=>void} setRoots 改写活跃 root 集(传 [] 模拟休眠根会话/重建窗口)
53
+ * @property {Object} persistedHeaders 持久会话表 sid → { id, parentSession }(写 parentSession 模拟子会话;缺省根会话 parentSession=undefined)
51
54
  * @property {()=>Object} board 读当前看板文件 JSON(不存在返回 null)
52
55
  * @property {(id:string)=>Object|undefined} task 看板里按 id 取任务
53
56
  * @property {(fn:Function, label?:string, timeoutMs?:number)=>Promise<any>} waitFor 真实时间轮询直到条件为真(默认 5s 上限)
@@ -188,10 +191,14 @@ export function createMockCtx(opts) {
188
191
  // whenIdle 刻意不实现:flushReceipts 走「无空闲门控 → 立即投递」分支(45s 窗口到点即投,可断言)
189
192
  }
190
193
  var env // 前向引用(currentInitiator 读 env.actor)
194
+ var rootsList = [root] // 活跃 root 集(测试可 env.setRoots([]) 清空模拟「休眠根会话」)
195
+ var persistedHeaders = {} // 持久会话表 sid → SessionHeader(测试可写 parentSession 模拟子会话/根会话)
196
+ persistedHeaders[sid] = { id: sid, parentSession: undefined }
191
197
  var agents = {
192
198
  currentInitiator: function () { return { id: env.actor } },
193
- roots: function () { return [root] },
194
- list: function () { return [root] },
199
+ roots: function () { return rootsList },
200
+ list: function () { return rootsList.slice() },
201
+ get: function (id) { for (var i = 0; i < rootsList.length; i++) { if (String(rootsList[i].id) === String(id)) return rootsList[i] } return undefined },
195
202
  isOwnedBy: function () { return false },
196
203
  }
197
204
 
@@ -228,6 +235,11 @@ export function createMockCtx(opts) {
228
235
  listModels: async function (p) { llmCalls.listModels.push(p); return [] },
229
236
  }
230
237
  }
238
+ if (name === 'sessionPersistence') {
239
+ return {
240
+ stat: async function (id) { var h = persistedHeaders[id]; return h ? { header: h } : undefined },
241
+ }
242
+ }
231
243
  return undefined
232
244
  },
233
245
  effect: function (fn) {
@@ -260,6 +272,8 @@ export function createMockCtx(opts) {
260
272
  spSections: spSections,
261
273
  llm: llmCalls,
262
274
  actor: sid,
275
+ setRoots: function (list) { rootsList = list || [] }, // 清空/改写活跃 root 集(模拟休眠/重建窗口)
276
+ persistedHeaders: persistedHeaders, // 持久会话表(sid → { id, parentSession });写 parentSession 模拟子会话
263
277
 
264
278
  // 以真 RPC 语义调用捕获的 webServer 路由(POST /dsh-agent-board → readBody → handlers[method])
265
279
  rpc: async function (method, args) {
@@ -0,0 +1,170 @@
1
+ // dsh-agent-board — 风险队列「待你过目」+ 审视摘要区单测(test/review-hints.test.mjs,task-muzikj1y)
2
+ // 覆盖(对应任务断言①~⑥ + 接线):
3
+ // ① 四硬信号各命中/不命中(computeReviewHints:驳回/歧义/核心 touches/full 无 acceptance)
4
+ // ② 软信号平时不计 / 质量异动告警激活期间计入(diff>300 行 + 续跑≥2 次,联动加严)
5
+ // ③ reviewedAt 幂等落账(mark-reviewed RPC 只写一次)+ get-tasks 现算透出 reviewHint 零落库
6
+ // ④⑤ 客户端接线(徽章/chip/审视摘要区四块+来源徽标/digest 未过目)源码级 + 产物重组装
7
+ // ⑥ README 双份逐字一致 + 各补「待你过目」说明
8
+ import { test } from 'node:test'
9
+ import assert from 'node:assert/strict'
10
+ import { readFileSync } from 'node:fs'
11
+ import * as core from '../lib/core.mjs'
12
+ import { createMockCtx } from './helpers/mock-ctx.mjs'
13
+ import * as plugin from '../index.mjs'
14
+
15
+ // 纯函数测试的任务形态:默认「干净卡」(无驳回/无歧义/无核心 touches/有 acceptance)→ score 0
16
+ function mkTask(over) {
17
+ return Object.assign({ id: 't1', title: 'T', status: 'resolved', pipeline: 'full', acceptance: 'npm test', touches: [], messages: [], runs: [], rejectCount: 0 }, over || {})
18
+ }
19
+
20
+ // ===== ① 四硬信号各命中/不命中 =====
21
+ test('① 四硬信号各命中/不命中(computeReviewHints)', () => {
22
+ // 干净卡 → score 0
23
+ assert.deepEqual(core.computeReviewHints([mkTask()]), { t1: { score: 0, reasons: [] } })
24
+ // 硬① 被驳回过:rejectCount>0(canonical 口径)
25
+ var h1 = core.computeReviewHints([mkTask({ id: 'a', rejectCount: 1 })])
26
+ assert.equal(h1.a.score, 1); assert.match(h1.a.reasons[0], /驳回/)
27
+ // 硬① 备选:verification.verdict='rejected'
28
+ assert.equal(core.computeReviewHints([mkTask({ id: 'a', verification: { verdict: 'rejected' } })]).a.score, 1)
29
+ // 硬① 备选:messages kind='rejection'
30
+ assert.equal(core.computeReviewHints([mkTask({ id: 'a', messages: [{ kind: 'rejection', text: 'x' }] })]).a.score, 1)
31
+ // 硬① 备选:runs 含 rejected 结局(老留档兼容,字面「runs 含 rejected」口径)
32
+ assert.equal(core.computeReviewHints([mkTask({ id: 'a', runs: [{ outcome: 'rejected' }] })]).a.score, 1)
33
+ // 硬① 不命中:零驳回
34
+ assert.equal(core.computeReviewHints([mkTask({ id: 'a', rejectCount: 0, verification: { verdict: 'approved' } })]).a.score, 0)
35
+ // 硬② 上报过歧义:messages kind='arbitration'
36
+ var h2 = core.computeReviewHints([mkTask({ id: 'a', messages: [{ kind: 'arbitration', text: 'x' }] })])
37
+ assert.equal(h2.a.score, 1); assert.match(h2.a.reasons[0], /歧义/)
38
+ // 硬② 不命中:无 arbitration 包
39
+ assert.equal(core.computeReviewHints([mkTask({ id: 'a', messages: [{ kind: 'progress', text: 'x' }] })]).a.score, 0)
40
+ // 硬③ touches 碰核心文件:三种核心文件 basename 各命中
41
+ assert.equal(core.computeReviewHints([mkTask({ id: 'a', touches: ['lib/dispatch.mjs'] })]).a.score, 1)
42
+ assert.equal(core.computeReviewHints([mkTask({ id: 'a', touches: ['lib/rpc.mjs'] })]).a.score, 1)
43
+ assert.equal(core.computeReviewHints([mkTask({ id: 'a', touches: ['core.mjs'] })]).a.score, 1)
44
+ assert.match(core.computeReviewHints([mkTask({ id: 'a', touches: ['./lib/core.mjs'] })]).a.reasons[0], /核心文件/)
45
+ // 硬③ 不命中:非核心文件 / glob
46
+ assert.equal(core.computeReviewHints([mkTask({ id: 'a', touches: ['src/a.mjs'] })]).a.score, 0)
47
+ assert.equal(core.computeReviewHints([mkTask({ id: 'a', touches: ['*.mjs'] })]).a.score, 0)
48
+ // 硬④ full 无 acceptance
49
+ var h4 = core.computeReviewHints([mkTask({ id: 'a', acceptance: '' })])
50
+ assert.equal(h4.a.score, 1); assert.match(h4.a.reasons[0], /验收脚本/)
51
+ // 硬④ 不命中:有 acceptance / work 档 / 空白 acceptance 但有内容(trim 后非空不算空)
52
+ assert.equal(core.computeReviewHints([mkTask({ id: 'a', acceptance: 'node --test' })]).a.score, 0)
53
+ assert.equal(core.computeReviewHints([mkTask({ id: 'a', acceptance: '', pipeline: 'work' })]).a.score, 0)
54
+ })
55
+
56
+ // ===== ② 软信号:平时不计 / 告警态计入(联动加严)=====
57
+ test('② 软信号:质量异动告警未激活不计入 / 激活期间计入(diff>300 行 + 续跑≥2 次)', () => {
58
+ var diff = ' src/a.mjs | 200 +++++++\n 1 file changed, 350 insertions(+), 50 deletions(-)'
59
+ var t = mkTask({ id: 'a', deliverable: { diff: diff }, runs: [{ role: 'worker', resume: true }, { role: 'worker', resume: true }] })
60
+ // 平时(告警未激活):软信号不计入 → score 0
61
+ assert.equal(core.computeReviewHints([t], {}).a.score, 0)
62
+ assert.equal(core.computeReviewHints([t], { qualityAlertActive: false }).a.score, 0)
63
+ // 告警激活:diff(400 行)>300 + 续跑 2 次≥2 → 两条软信号全计入
64
+ var h = core.computeReviewHints([t], { qualityAlertActive: true })
65
+ assert.equal(h.a.score, 2)
66
+ assert.match(h.a.reasons[0], /diff 超 300 行/)
67
+ assert.match(h.a.reasons[1], /续跑 ≥2 次/)
68
+ // 阈值边界:恰好 300 行不亮(>300 才亮);续跑 1 次不亮
69
+ var diffEdge = ' 1 file changed, 250 insertions(+), 50 deletions(-)' // 300 行
70
+ assert.equal(core.computeReviewHints([mkTask({ id: 'a', deliverable: { diff: diffEdge } })], { qualityAlertActive: true }).a.score, 0)
71
+ assert.equal(core.computeReviewHints([mkTask({ id: 'a', runs: [{ resume: true }] })], { qualityAlertActive: true }).a.score, 0)
72
+ // 硬软叠加:硬④ + 软⑤ + 软⑥ → score 3
73
+ var tMix = mkTask({ id: 'a', acceptance: '', deliverable: { diff: diff }, runs: [{ resume: true }, { resume: true }] })
74
+ var hMix = core.computeReviewHints([tMix], { qualityAlertActive: true })
75
+ assert.equal(hMix.a.score, 3)
76
+ })
77
+
78
+ // ===== ③ reviewedAt 幂等落账 + get-tasks 现算透出 reviewHint(真 harness)=====
79
+ test('③ mark-reviewed 幂等落账(只写一次)+ get-tasks 现算透出 reviewHint 零落库', async () => {
80
+ var env = createMockCtx()
81
+ try {
82
+ plugin.apply(env.ctx)
83
+ await env.rpc('create-task', { sessionId: env.sid, id: 'task-rv', title: 'R', description: 'd', pipeline: 'full', acceptance: 'npm test' })
84
+ assert.equal(env.task('task-rv').reviewedAt, undefined, '初始无 reviewedAt')
85
+ // 第一次落账
86
+ var r1 = await env.rpc('mark-reviewed', { sessionId: env.sid, taskId: 'task-rv' })
87
+ assert.equal(r1.ok, true)
88
+ var at1 = env.task('task-rv').reviewedAt
89
+ assert.ok(at1, '第一次落账 reviewedAt')
90
+ // 幂等:第二次不重写
91
+ var r2 = await env.rpc('mark-reviewed', { sessionId: env.sid, taskId: 'task-rv' })
92
+ assert.equal(r2.ok, true)
93
+ assert.equal(env.task('task-rv').reviewedAt, at1, '第二次调用不重写 reviewedAt')
94
+ // 未找到任务
95
+ var r3 = await env.rpc('mark-reviewed', { sessionId: env.sid, taskId: 'nope' })
96
+ assert.equal(r3.ok, false)
97
+ // get-tasks 透出 reviewHint(现算,不落盘)
98
+ await env.rpc('create-task', { sessionId: env.sid, id: 'task-gh', title: 'G', description: 'd', pipeline: 'full', acceptance: 'npm test' })
99
+ env.patchBoard(function (d) {
100
+ var t = d.tasks.find(function (x) { return x.id === 'task-gh' })
101
+ t.rejectCount = 1
102
+ t.touches = ['lib/core.mjs']
103
+ t.acceptance = ''
104
+ })
105
+ var d = await env.rpc('get-tasks', { sessionId: env.sid })
106
+ var gh = d.tasks.find(function (x) { return x.id === 'task-gh' })
107
+ assert.ok(gh.reviewHint, 'get-tasks 透出 reviewHint')
108
+ assert.equal(gh.reviewHint.score, 3, '驳回 + 核心文件 + 无 acceptance = 3 条硬信号')
109
+ assert.equal(gh.reviewHint.reasons.length, 3)
110
+ // 零落库:board 文件不存 reviewHint(现算字段不进盘)
111
+ var raw = env.board().tasks.find(function (x) { return x.id === 'task-gh' })
112
+ assert.equal(raw.reviewHint, undefined, 'reviewHint 现算零落库(board 文件不写 reviewHint)')
113
+ } finally { await env.cleanup() }
114
+ })
115
+
116
+ // ===== ④⑤⑥ 客户端接线 + 产物重组装 + README 双份(源码级)=====
117
+ test('④⑤ 客户端接线:徽章/chip/审视摘要区四块+来源徽标/digest(源码级 + 产物重组装)', () => {
118
+ const ksrc = readFileSync(new URL('../lib/client/kernel.js', import.meta.url), 'utf8')
119
+ const bl = readFileSync(new URL('../lib/client/board-list.js', import.meta.url), 'utf8')
120
+ const td = readFileSync(new URL('../lib/client/task-detail.js', import.meta.url), 'utf8')
121
+ const dash = readFileSync(new URL('../lib/client/dashboard.js', import.meta.url), 'utf8')
122
+ const built = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
123
+ // kernel:单一事实源谓词 + 过滤接线 + state 默认字段
124
+ assert.match(ksrc, /function reviewPending\(t\)/)
125
+ assert.match(ksrc, /state\.filterReview && !reviewPending\(t\)/)
126
+ assert.match(ksrc, /filterReview: false/)
127
+ // board-list:chip「待你过目」+ 「建议过目」徽章
128
+ assert.ok(bl.indexOf('待你过目') >= 0, 'FilterBar 缺「待你过目」chip')
129
+ assert.ok(bl.indexOf('👁 建议过目') >= 0, '缺「建议过目」徽章')
130
+ assert.match(bl, /reviewPending\(t\) \?/)
131
+ // task-detail:审视摘要区四块 + 来源徽标 + mark-reviewed 触发
132
+ assert.ok(td.indexOf('tskb-review-summary') >= 0, '缺审视摘要区容器 id')
133
+ assert.ok(td.indexOf('① 为什么在这') >= 0, '缺四问①')
134
+ assert.ok(td.indexOf('② 改了什么') >= 0, '缺四问②')
135
+ assert.ok(td.indexOf('③ 机器怎么验的') >= 0, '缺四问③')
136
+ assert.ok(td.indexOf('④ 怎么亲自确认') >= 0, '缺四问④')
137
+ assert.ok(td.indexOf('Worker 汇报') >= 0, '缺「Worker 汇报」来源徽标')
138
+ assert.ok(td.indexOf('Verifier 实证') >= 0, '缺「Verifier 实证」来源徽标')
139
+ assert.ok(td.indexOf('系统记录') >= 0, '缺「系统记录」来源徽标')
140
+ assert.match(td, /rpc\('mark-reviewed'/)
141
+ // dashboard:digest 未过目计数 + buildReport 行
142
+ assert.match(dash, /reviewPending\(t\)/)
143
+ assert.ok(dash.indexOf('- 未过目:') >= 0, '报告缺「未过目」行')
144
+ // 产物重组装:关键标记逐一出现在 lib/client.js
145
+ for (const m of ['待你过目', '👁 建议过目', 'tskb-review-summary', '- 未过目:']) {
146
+ assert.ok(built.indexOf(m) >= 0, 'client.js 缺标记:' + m + '(需 npm run build-client 重组装)')
147
+ }
148
+ })
149
+
150
+ test('接线:core 纯函数 + rpc get-tasks 现算 + mark-reviewed(源码级)', () => {
151
+ const rpc = readFileSync(new URL('../lib/rpc.mjs', import.meta.url), 'utf8')
152
+ const coreSrc = readFileSync(new URL('../lib/core.mjs', import.meta.url), 'utf8')
153
+ assert.match(coreSrc, /export function computeReviewHints\(tasks, opts\)/)
154
+ assert.match(coreSrc, /export var REVIEW_CORE_FILES = \['dispatch\.mjs', 'rpc\.mjs', 'core\.mjs'\]/)
155
+ assert.match(rpc, /core\.computeReviewHints\(d\.tasks, \{ qualityAlertActive: __qch\.length > 0 \}\)/)
156
+ assert.match(rpc, /handle\('mark-reviewed'/)
157
+ assert.match(rpc, /if \(!t\.reviewedAt\) t\.reviewedAt = new Date\(\)\.toISOString\(\)/)
158
+ // tasksHash 序列化口径纳入 reviewedAt + reviewHint(已阅/软信号联动才能重渲染)
159
+ assert.match(coreSrc, /t\.reviewedAt,/)
160
+ assert.match(coreSrc, /t\.reviewHint \? String\(t\.reviewHint\.score\)/)
161
+ })
162
+
163
+ test('⑥ README 双份:逐字一致 + 各补「待你过目」说明', () => {
164
+ const r1 = readFileSync(new URL('../../../README.md', import.meta.url), 'utf8')
165
+ const r2 = readFileSync(new URL('../README.md', import.meta.url), 'utf8')
166
+ assert.equal(r1, r2, 'README 双份必须逐字一致(npm run sync-readme)')
167
+ assert.ok(r1.indexOf('待你过目') >= 0, 'README 缺「待你过目」说明')
168
+ assert.ok(r1.indexOf('审视摘要') >= 0, 'README 缺「审视摘要」说明')
169
+ assert.ok(r1.indexOf('reviewHint') >= 0, 'README 缺 reviewHint 说明')
170
+ })