cs-bots 0.8.0 → 0.9.1

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/src/api.js CHANGED
@@ -865,6 +865,16 @@ function buildTaskAnchorText(task, notes = [], longrun = null) {
865
865
  lines.push(...describeMandate(mandate))
866
866
  }
867
867
 
868
+ // ── 产出物要求(2026-11-12 t-40 需求③)─────────────────────────────────
869
+ // Boss:「成果,任务的产出物在此,每轮会话的都应该有 md 产出物」。
870
+ // 只靠 `complete` 强制 artifacts 不够 —— 那是**收尾时**才校验,
871
+ // 执行者中途几轮可以什么都不留下,等到最后一次性补一份,过程全丢。
872
+ // 所以把这条要求写进**每次唤醒**都会看到的锚文本里,每轮结束都留一份 md。
873
+ lines.push('成果(t-40 需求③):本轮要有可点开的 md 产出物,不要只写进对话里 ——')
874
+ lines.push(` · 每轮结束都把结论写成 md 文件,再用 bot_task_update(action='complete', artifacts=[...]) 登记;`)
875
+ lines.push(' · 中途轮次同样要留文件(过程记录也算成果),别等收尾才一次性补;')
876
+ lines.push(' · 没写文件就当没交付 —— 对话里的内容别人打不开、也追溯不了。')
877
+
868
878
  lines.push('补充说明(按时间,全部有效,不得忽略):')
869
879
  for (const note of notes) {
870
880
  lines.push(`- [${formatAnchorTime(note?.at)}] ${note?.text ?? ''}`)
@@ -1779,6 +1789,63 @@ async function ensureTeamForBot(services, botSessionId, by) {
1779
1789
  }
1780
1790
  }
1781
1791
 
1792
+ /**
1793
+ * 给团队确保一个议事厅并返回它(Boss 2026-11-12 需求:长任务自动建房)。
1794
+ *
1795
+ * ## 为什么要它
1796
+ * `ensureTeamForBot()` 只建**团队**,不建**房间**。于是长任务建完后:
1797
+ * - 团队有了,但成员没有地方说话;
1798
+ * - 结果只能靠人挨个去问,没有一处「这个长任务进展/结论」的公共出口。
1799
+ * Boss 原话:「以后每个长任务都自动创建房间,结果传递给房间群主」。
1800
+ *
1801
+ * ## 语义
1802
+ * - 团队已有房间 ⇒ 直接复用(幂等,不重复建)
1803
+ * - 没有 ⇒ 建 `${team.name} · 议事厅`,成员 = 负责人 + 团队现有成员 + **委托人**
1804
+ * - `adminId = team.leadId`(群主 = 负责人),与建团队时 `adminPolicy:'lead-only'` 一致
1805
+ * - 负责人已在成员里 ⇒ `mode:'admin'`,让他能改名/归档
1806
+ *
1807
+ * ## 失败不阻断
1808
+ * 任务和团队已经建好了,房间没建上只是少一个沟通出口 —— 返回 `{reason}` 让调用方
1809
+ * 如实带回给页面,**绝不静默**。
1810
+ *
1811
+ * @returns {Promise<{room?: object, created?: boolean, reason?: string}>}
1812
+ */
1813
+ async function ensureRoomForTeam(services, team, by) {
1814
+ const rooms = services.room
1815
+ if (!team || !rooms || typeof rooms.create !== 'function') return { reason: '没有可用的房间服务或团队' }
1816
+ try {
1817
+ if (team.roomId) {
1818
+ const existing = rooms.get?.(team.roomId)
1819
+ if (existing) return { room: existing, created: false }
1820
+ // 团队记着 roomId 但房间已不存在(被归档/删除)⇒ 下面重建,并在结果里说明
1821
+ }
1822
+
1823
+ // 成员:负责人(群主)+ 团队现有成员 + 委托人(谁派的活谁要能看到结果)
1824
+ const leadId = team.leadId
1825
+ const memberList = [leadId, ...(Array.isArray(team.members) ? team.members : []), by]
1826
+ .filter((v) => typeof v === 'string' && v !== '')
1827
+ const members = [...new Set(memberList)]
1828
+
1829
+ const room = rooms.create({
1830
+ name: `${team.name} · 议事厅`,
1831
+ members,
1832
+ // 负责人是群主 ⇒ admin 模式(他能改名/归档);他一定在 members 里(上面首位)
1833
+ mode: 'admin',
1834
+ adminId: leadId,
1835
+ purpose: `长任务「${team.name}」的议事厅:负责人主持,进展与结论回传到此`,
1836
+ byId: by ?? 'ui',
1837
+ })
1838
+
1839
+ // 把房间记到团队上(幂等;已有 roomId 的情况在这里被覆盖为新房)
1840
+ if (typeof services.teams?.attachRoom === 'function') {
1841
+ services.teams.attachRoom({ id: team.id, roomId: room.id, by: by ?? 'ui' })
1842
+ }
1843
+ return { room, created: true }
1844
+ } catch (error) {
1845
+ return { reason: `自动建房间失败:${error?.message ?? error}` }
1846
+ }
1847
+ }
1848
+
1782
1849
  /**
1783
1850
  * 投递能力预检(2026-11-09 修复「任务指派后卡在排队中、失败无声」)。
1784
1851
  *
@@ -2198,10 +2265,29 @@ export function installApi(ctx, { warn } = {}) {
2198
2265
  }
2199
2266
  mountPhaseWrite(getServices(ctx))
2200
2267
 
2268
+ // ── t-40 需求②:把「给任务开议事厅」挂到 services(供 bot_task_room 工具调用)──
2269
+ // 与 HTTP `POST /tasks/room` 共用同一个实现,避免「页面能开、Bot 开不了」的漂移。
2270
+ const openTaskRoom = async (services, taskId, by) => {
2271
+ const task = await withService(services, 'board', (b) => b.get(taskId))
2272
+ if (!task) return { ok: false, reason: `任务 ${taskId} 不存在` }
2273
+ const who = task.assignee ?? task.ownerId ?? by
2274
+ const got = await ensureTeamForBot(services, who, by)
2275
+ if (!got?.team) return { ok: false, reason: '这个任务没有可用团队(没有负责人?)——先指派负责人再开议事厅' }
2276
+ const r = await ensureRoomForTeam(services, got.team, by)
2277
+ if (!r?.room) return { ok: false, reason: r?.reason ?? '建房失败' }
2278
+ return { ok: true, roomId: r.room.id, name: r.room.name, created: r.created === true, teamId: got.team.id }
2279
+ }
2280
+ const mountTaskRoom = (services) => {
2281
+ if (!services || typeof services !== 'object') return
2282
+ services.openTaskRoom = (taskId, by) => openTaskRoom(services, taskId, by)
2283
+ }
2284
+ mountTaskRoom(getServices(ctx))
2285
+
2201
2286
  const handler = async (request, response) => {
2202
2287
  const services = getServices(ctx)
2203
2288
  // 幂等重挂:services 若在 installApi 之后才注册,这里补上(写同一个 key)
2204
2289
  mountPhaseWrite(services)
2290
+ mountTaskRoom(services)
2205
2291
  // 鉴权先于一切业务逻辑(连 URL 解析都不做,避免攻击面)
2206
2292
  const verdict = fence.judgeHttp(request)
2207
2293
  if (!verdict.ok) {
@@ -2271,7 +2357,8 @@ export function installApi(ctx, { warn } = {}) {
2271
2357
  const source = qp(url, 'source')
2272
2358
  const sort = qp(url, 'sort')
2273
2359
  const byId = qp(url, 'byId')
2274
- const opts = { status: status ?? undefined, attention: attention || undefined, priority: priority ?? undefined, source: source ?? undefined, sort: sort ?? undefined, byId }
2360
+ const channel = qp(url, 'channel')
2361
+ const opts = { status: status ?? undefined, attention: attention || undefined, priority: priority ?? undefined, source: source ?? undefined, channel: channel ?? undefined, sort: sort ?? undefined, byId }
2275
2362
  const tasks = await withService(services, 'board', (b) =>
2276
2363
  onlyReady ? b.ready() : b.list(opts),
2277
2364
  )
@@ -2325,6 +2412,29 @@ export function installApi(ctx, { warn } = {}) {
2325
2412
  autoTeam = await ensureTeamForBot(services, rest.assignee, rest.byId ?? 'ui')
2326
2413
  }
2327
2414
 
2415
+ // ── 长任务自动建房间(Boss 2026-11-12 需求)────────────────────────
2416
+ // 原话:「以后每个长任务都自动创建房间,结果传递给房间群主」。
2417
+ // 为什么只给长任务建:普通任务一两天就结束,建房只会留下大量死房间;
2418
+ // 长任务跨多次唤醒、要多个阶段交付,需要一个**公共出口**让群主看到进展与结论。
2419
+ // 复用 autoTeam 的团队(不另建),团队没有就现场补一个。
2420
+ let autoRoom
2421
+ if (wantsLongrun && rest.autoRoom !== false) {
2422
+ const by = rest.byId ?? 'ui'
2423
+ let team = autoTeam?.team
2424
+ if (!team) {
2425
+ autoTeam = await ensureTeamForBot(services, rest.assignee ?? by, by)
2426
+ team = autoTeam?.team
2427
+ }
2428
+ if (team) {
2429
+ const got = await ensureRoomForTeam(services, team, by)
2430
+ autoRoom = got.room
2431
+ ? { roomId: got.room.id, name: got.room.name, created: got.created }
2432
+ : { reason: got.reason }
2433
+ } else {
2434
+ autoRoom = { reason: autoTeam?.reason ?? '没有团队可用于建房' }
2435
+ }
2436
+ }
2437
+
2328
2438
  // 阶段清单落在 task-meta.jsonl(与 evolve 同一份存储、同一形状)⇒
2329
2439
  // `buildTaskAnchorText` 自动带上,第一次唤醒就可见,不需要再转。
2330
2440
  if (wantsLongrun) {
@@ -2338,7 +2448,9 @@ export function installApi(ctx, { warn } = {}) {
2338
2448
  })
2339
2449
  sendJson(response, 200, {
2340
2450
  ok: true, task, phases: normalizedPhases, longrun: true, autoTeam,
2341
- note: '已按长任务创建;此后每次唤醒都会带上阶段清单',
2451
+ ...(autoRoom ? { autoRoom } : {}),
2452
+ note: '已按长任务创建;此后每次唤醒都会带上阶段清单'
2453
+ + (autoRoom?.roomId ? `,进展与结论请回传到议事厅 ${autoRoom.roomId}` : ''),
2342
2454
  })
2343
2455
  return
2344
2456
  }
@@ -2554,6 +2666,42 @@ export function installApi(ctx, { warn } = {}) {
2554
2666
  return
2555
2667
  }
2556
2668
 
2669
+ // ── 任务开议事厅(t-40 需求②)────────────────────────────────────────
2670
+ // Boss:「只有长任务默认建房间,其他短任务默认不建房间,防止房间爆炸,
2671
+ // 在任务页有『开议事厅』按钮,用户需要的时候再建」
2672
+ // 长任务在 /tasks 创建时已自动建;短任务走这个端点按需建,幂等(已有就复用)。
2673
+ if (request.method === 'POST' && path === '/tasks/room') {
2674
+ const body = (await readBody(request)) ?? {}
2675
+ const by = body.byId ?? 'ui'
2676
+ const taskId = String(body.id ?? '').trim()
2677
+ if (!taskId) { sendJson(response, 400, { ok: false, reason: 'id 必填' }); return }
2678
+ // 与 bot_task_room 工具共用 openTaskRoom 一份实现(不写两套)
2679
+ const r = await openTaskRoom(services, taskId, by)
2680
+ if (!r.ok) {
2681
+ sendJson(response, r.reason?.includes('不存在') ? 404 : 409, { ok: false, reason: r.reason })
2682
+ return
2683
+ }
2684
+ sendJson(response, 200, {
2685
+ ...r,
2686
+ note: r.created === true ? '议事厅已建好' : '该任务已有议事厅,直接复用',
2687
+ })
2688
+ return
2689
+ }
2690
+
2691
+ // 清理已退役 Bot(t-40):retire 只是标记,这里把记录真正移出注册表。
2692
+ // 只允许清 retired;不碰 cwd、不杀会话。
2693
+ if (request.method === 'POST' && path === '/roster/purge') {
2694
+ const body = (await readBody(request)) ?? {}
2695
+ const result = await withService(services, 'roster', (r) => r.purge({
2696
+ ids: body.ids,
2697
+ allRetired: body.allRetired,
2698
+ reason: body.reason,
2699
+ byId: body.byId ?? 'ui',
2700
+ }))
2701
+ sendJson(response, 200, { ok: true, ...result })
2702
+ return
2703
+ }
2704
+
2557
2705
  // ── 房间 ──────────────────────────────────────────────────────────────
2558
2706
  if (request.method === 'GET' && path === '/rooms') {
2559
2707
  // includeArchived=1 时连归档房一起返回(超级管理员的"回收站"视图)
@@ -2608,11 +2756,21 @@ export function installApi(ctx, { warn } = {}) {
2608
2756
  // - 房间设置 `allowBotAutoPost` 为 false 时跳过(给用户关开关的权利);
2609
2757
  // - dedupKey 用消息 ID,天然幂等,同一消息不会重复唤醒;
2610
2758
  // - dispatcher 缺失时降级为「只落消息不唤醒」,不抛错。
2759
+ //
2760
+ // ⚠️ 修复(2026-11-12 t-40-B):目标计算改走 room 服务的 `computeWakeTargets`。
2761
+ // 此前这里是裸的 `members.filter((m) => m !== skipSelf)`,**既不看
2762
+ // `mentionOnly`、也不排除人类 id** —— 实测 6 个房间全部设了
2763
+ // `mentionOnly: true`,却一次都没生效;8 成员房间一条消息 = 8 次模型调用,
2764
+ // 打给 `ui/user/person` 的那几次必然 agent-not-live。
2765
+ // 现在与 `room.relay()` 共用同一份四档规则(见 room.js computeWakeTargets)。
2611
2766
  const room = await withService(services, 'room', (r) => r.get(message.roomId))
2612
2767
  const senderSession = body.from ?? 'ui'
2613
- const members = room?.members ?? []
2614
- const skipSelf = (senderSession !== 'ui' && senderSession)
2615
- const toWake = members.filter((m) => m !== skipSelf)
2768
+ const decided = await withService(services, 'room', (r) =>
2769
+ r.computeWakeTargets(
2770
+ { members: room?.members ?? [], settings: room?.settings },
2771
+ { from: senderSession, text: message.text, mentions: message.mentions },
2772
+ ))
2773
+ const toWake = decided?.targets ?? []
2616
2774
  const wakeByTarget = new Map()
2617
2775
  if (toWake.length > 0 && room?.settings?.allowBotAutoPost !== false) {
2618
2776
  for (const target of toWake) {
@@ -2675,7 +2833,30 @@ export function installApi(ctx, { warn } = {}) {
2675
2833
  }
2676
2834
  }
2677
2835
 
2678
- sendJson(response, 200, { ok: true, message, wakes: toWake.length, revives, fanout })
2836
+ // ── t-39 第2批 C:发言同步给 DSH 原生 team 的 Lead(Boss 体验 item 5)──
2837
+ //
2838
+ // Boss 在 cs-bots 议事厅说话时,原生 team 的 Lead 感知不到 —— 两套团队模型
2839
+ // 是分离的。这一步把消息也投递给绑定的原生 Lead(root session 路径,合法)。
2840
+ //
2841
+ // 诚实原则:同步失败**不报** ok:false(房间消息本身成功了),而是
2842
+ // `leadSync.synced:false` + `reason`,页面据此显示「同步失败:<原因>」。
2843
+ // 绝不装成功 —— 那比不显示更糟。
2844
+ let leadSync
2845
+ try {
2846
+ leadSync = await withService(services, 'teams', (t) =>
2847
+ t.syncToNativeLead({
2848
+ roomId: message.roomId,
2849
+ messageId: message.id,
2850
+ text: message.text,
2851
+ by: body.from ?? 'ui',
2852
+ dispatcher: services.dispatcher,
2853
+ }))
2854
+ } catch (error) {
2855
+ // 同步是附加动作:异常也必须变成可见的 synced:false,而不是 500
2856
+ leadSync = { ok: true, synced: false, reason: 'SYNC_ERROR', detail: String(error?.message ?? error) }
2857
+ }
2858
+
2859
+ sendJson(response, 200, { ok: true, message, wakes: toWake.length, revives, fanout, leadSync })
2679
2860
  return
2680
2861
  }
2681
2862
 
@@ -5000,6 +5181,82 @@ export function installApi(ctx, { warn } = {}) {
5000
5181
  sendJson(response, 200, { ok: true, team: out })
5001
5182
  return
5002
5183
  }
5184
+ // ── 跨团队路由(t-39 第2批 A · HTTP 出口)───────────────────────────
5185
+ //
5186
+ // 为什么必须有这一条:页面上的「📤 发协作请求」按钮(client.js)就是打这个
5187
+ // 路径的。只有 MCP 工具 `bot_team_route` 而没有 HTTP 出口 ⇒ Boss 点按钮 404。
5188
+ //
5189
+ // 复用 `teams.deliverRoute()` —— 与工具层**同一套序列编排**(决定 → 投递 →
5190
+ // 留痕)。绝不在这里重写一遍,否则两条路的行为会漂。
5191
+ if (request.method === 'POST' && path === '/teams/route') {
5192
+ const body = (await readBody(request)) ?? {}
5193
+ const out = await withService(services, 'teams', (t) =>
5194
+ t.deliverRoute({
5195
+ ...body,
5196
+ by: body.by ?? OWNER,
5197
+ // dispatcher 从装配层取出后注入:TeamsService 不反向依赖 dispatcher
5198
+ dispatcher: services.dispatcher,
5199
+ })).catch(markTeamError)
5200
+ // refused 是**正常的业务结论**,不是服务器错误 ⇒ 200 带 decision:'refused'。
5201
+ // 用 4xx 会让前端把它当异常处理,丢掉 reason 的可读性(DecisionKind 语义)。
5202
+ sendJson(response, 200, out)
5203
+ return
5204
+ }
5205
+
5206
+ // ── 团队唤醒成本统计(t-39 第2批 D · HTTP 出口)─────────────────────
5207
+ //
5208
+ // 页面团队卡片要显示「今日唤醒 N 次」。数据源是 dispatcher 的 wakes.jsonl
5209
+ // 按 teamId 聚合(`byTeam`)。不传 id ⇒ 返回全部团队的横向对比。
5210
+ //
5211
+ // `today` = 今日 0 点(本地时区)之后;`total` = 该团队历史累计。
5212
+ if (request.method === 'GET' && path === '/teams/wake-stats') {
5213
+ const id = qp(url, 'id')
5214
+ const dispatcher = services.dispatcher
5215
+ if (!dispatcher || typeof dispatcher.byTeam !== 'function') {
5216
+ sendJson(response, 503, { ok: false, reason: 'DISPATCHER_UNAVAILABLE' })
5217
+ return
5218
+ }
5219
+ const since = qn(url, 'since')
5220
+ const nowMs = Date.now()
5221
+ const midnight = since ?? new Date(nowMs).setHours(0, 0, 0, 0)
5222
+ const todayAgg = dispatcher.byTeam({ since: midnight })
5223
+ const allAgg = dispatcher.byTeam({})
5224
+
5225
+ const pick = (agg) => {
5226
+ if (!id) {
5227
+ return {
5228
+ teams: agg.teams,
5229
+ unassigned: agg.unassigned,
5230
+ total: agg.total,
5231
+ }
5232
+ }
5233
+ const hit = agg.teams.find((t) => t.teamId === id)
5234
+ return hit ?? { teamId: id, count: 0, sent: 0, skipped: 0, failed: 0, pending: 0, targets: 0, targetsList: [] }
5235
+ }
5236
+ const today = pick(todayAgg)
5237
+ const all = pick(allAgg)
5238
+ if (id) {
5239
+ sendJson(response, 200, {
5240
+ ok: true,
5241
+ id,
5242
+ today: today.count ?? 0,
5243
+ total: all.count ?? 0,
5244
+ byResult: {
5245
+ sent: today.sent ?? 0, skipped: today.skipped ?? 0,
5246
+ failed: today.failed ?? 0, pending: today.pending ?? 0,
5247
+ },
5248
+ byResultAllTime: {
5249
+ sent: all.sent ?? 0, skipped: all.skipped ?? 0,
5250
+ failed: all.failed ?? 0, pending: all.pending ?? 0,
5251
+ },
5252
+ since: midnight,
5253
+ })
5254
+ return
5255
+ }
5256
+ sendJson(response, 200, { ok: true, since: midnight, today, all })
5257
+ return
5258
+ }
5259
+
5003
5260
  // 管理策略(2026-11-09「管理员权限边界从页面控制」):CAS 改团队的 adminPolicy。
5004
5261
  if (request.method === 'POST' && path === '/teams/policy') {
5005
5262
  const body = (await readBody(request)) ?? {}
package/src/board.js CHANGED
@@ -87,6 +87,22 @@ const STATUSES = ['pending', 'in_progress', 'completed', 'blocked', 'deleted']
87
87
  const ACCEPT_RESULTS = ['pass', 'reject']
88
88
  /** 租约缺省 TTL(ms)。 */
89
89
  const DEFAULT_LEASE_MS = 300_000
90
+ /**
91
+ * board 动作 → `src/metrics.js` 的 METRIC_KINDS 映射(bots04 欠账2)。
92
+ *
93
+ * 为什么需要这张表:board 的动作名(`claim`/`complete`/`accept`…)与 metrics 的
94
+ * 指标种类(`task_claim`/`task_complete`/`accept_pass`…)**不是同名**,而且不是一对一
95
+ * (`accept` 要按 result 拆成 pass/reject)。硬编码在 commit 里会变成一长串 if。
96
+ *
97
+ * 没有映射的动作(`create`/`edit`/`release`/`reopen`/`delete`)故意不记:
98
+ * metrics 的 METRIC_KINDS 里没有对应种类,**新增种类要同步改 metrics.js 与它的测试**,
99
+ * 不能在这里偷偷塞一个不认识的 kind(metrics.record 会抛错的)。
100
+ */
101
+ const ACTION_TO_METRIC = Object.freeze({
102
+ claim: 'task_claim',
103
+ complete: 'task_complete',
104
+ block: 'task_blocked',
105
+ })
90
106
  /** 复杂度档位(契约 §6 冻结;路由侧 `src/routing/complexity.js` 用同一套字面量)。 */
91
107
  const COMPLEXITY_TIERS = ['T0', 'T1', 'T2', 'T3']
92
108
  /**
@@ -346,6 +362,66 @@ function normalizeRequiresPaid(value, where) {
346
362
  }
347
363
 
348
364
  /** labels 归一:必须是字符串数组(可选;undefined ⇒ 不写字段)。 */
365
+ /**
366
+ * 任务来源渠道(2026-11-12 t-40 需求①,「后续可以接其他任务来源」)。
367
+ *
368
+ * 为什么与 `source` 分成两个字段:
369
+ * - `source` 回答「**谁**下的令」:`human`(人) / `ai`(智能体)——二分的归属问题;
370
+ * - `channel` 回答「**从哪个入口**下的令」:UI 手工建 / 会话里产出 / 外部渠道接入……
371
+ * 两者正交:将来接「外部工单系统」时,它既是 `source:'external'` 也可能是人发起。
372
+ * 合成一个字段的话,每加一个渠道都要改 `source` 的判定,早晚把二分搞脏。
373
+ *
374
+ * 未知 channel 一律拒绝(`BOARD_BAD_CHANNEL`)而不是静默落一个拼错的值 ——
375
+ * 拼错的值会永久留在事件流里,之后所有按渠道统计都是错的。
376
+ */
377
+ const TASK_CHANNELS = Object.freeze(['user', 'session', 'external'])
378
+
379
+ /** 归一 `source`:显式给了就只接受 human/ai;没给就按 byId 推断(人是 ui/user/person)。 */
380
+ function normalizeSource(value, byId, where) {
381
+ if (value === undefined || value === null || value === '') {
382
+ return isHumanId(byId) ? 'human' : 'ai'
383
+ }
384
+ if (value !== 'human' && value !== 'ai') {
385
+ const error = new Error(
386
+ `board.${where}:source 只能是 'human' 或 'ai',收到 ${JSON.stringify(value)}`,
387
+ )
388
+ error.status = 400
389
+ error.code = 'BOARD_BAD_SOURCE'
390
+ throw error
391
+ }
392
+ return value
393
+ }
394
+
395
+ /**
396
+ * 归一 `channel`:没给就按来源推断 —— 人 → `user`,智能体 → `session`。
397
+ * `external` 留给「后续增加的其他渠道」显式声明(目前没有生产方,但字段与校验已就位)。
398
+ */
399
+ function normalizeChannel(value, source, byId, where) {
400
+ if (value === undefined || value === null || value === '') {
401
+ return isHumanId(byId) || source === 'human' ? 'user' : 'session'
402
+ }
403
+ if (!TASK_CHANNELS.includes(value)) {
404
+ const error = new Error(
405
+ `board.${where}:channel 只能是 ${TASK_CHANNELS.join(' | ')},收到 ${JSON.stringify(value)}`,
406
+ )
407
+ error.status = 400
408
+ error.code = 'BOARD_BAD_CHANNEL'
409
+ throw error
410
+ }
411
+ return value
412
+ }
413
+
414
+ /**
415
+ * 人类身份(与 room/teams 的 HUMAN_IDS 同口径)。
416
+ * 需求⑤:`ui`/`person` 都是历史写法,规范 id 统一为 `user`;这里仍全认(读侧兼容)。
417
+ */
418
+ function isHumanId(id) {
419
+ return id === 'ui' || id === 'user' || id === 'person'
420
+ }
421
+
422
+ /** 规范用户 id(写侧一律用它)。 */
423
+ const CANONICAL_USER = 'user'
424
+
349
425
  function normalizeLabels(value, where) {
350
426
  if (value === undefined || value === null) return undefined
351
427
  if (!Array.isArray(value)) {
@@ -620,6 +696,31 @@ export function createBoard(ctx, config = {}) {
620
696
  : (Number.isFinite(config.wipLimitPerBot) && config.wipLimitPerBot >= 1 ? config.wipLimitPerBot : 2)
621
697
  const requireTriageForReady = settings.requireTriageForReady === true || config.requireTriageForReady === true
622
698
 
699
+ /**
700
+ * 指标旁路(bots04 欠账2)。
701
+ *
702
+ * 为什么是「注入」而不是从 ctx 取:服务注册表在 `src/registry.js` 用的是
703
+ * `WeakMap<ctx, services>`,且 `index.js` 里 **board 先于 metrics 装配的顺序不保证**。
704
+ * 用注入(`config.metrics`)能拿到**装配完成后的实例**,也避免 board→registry 的循环依赖。
705
+ *
706
+ * 取不到就不记(返回 false),绝不抛 —— 指标缺失是可接受的退化,
707
+ * 业务动作失败是不可接受的。
708
+ */
709
+ // ⚠️ 为什么支持函数:`index.js` 里 **board 先于 metrics 装配**(board 在 L130,
710
+ // metrics 在 L276)。若只接受实例,board 装配时 metrics 还是 undefined,
711
+ // 埋点会永久静默失效 —— 那正是「256 条全是 turn_end」这类 bug 的成因。
712
+ // 传函数 ⇒ 每次记录时现取,装配顺序就不再影响正确性。
713
+ const metricsSink = config && config.metrics !== undefined ? config.metrics : null
714
+ function recordMetric(kind, taskId, by) {
715
+ let sink = metricsSink
716
+ if (typeof sink === 'function') {
717
+ try { sink = sink() } catch { sink = null }
718
+ }
719
+ if (!sink || typeof sink.record !== 'function') return false
720
+ sink.record({ kind, taskId: taskId ?? null, by: by ?? null })
721
+ return true
722
+ }
723
+
623
724
  const jsonlPath = statePath(stateDir, 'board.jsonl')
624
725
  const snapPath = statePath(stateDir, 'board.snap.json')
625
726
 
@@ -696,6 +797,20 @@ export function createBoard(ctx, config = {}) {
696
797
  }
697
798
  tasks.set(taskId, clone(after))
698
799
  events.push(event)
800
+ // ── 指标埋点(bots04 欠账2)─────────────────────────────────────────
801
+ // 为什么埋在 commit 而不是各个动作里:commit 是**所有**状态变更的唯一出口,
802
+ // 埋在这里才不会漏(create/update/claim/complete/accept 全过它)。
803
+ //
804
+ // 为什么必须**服务端**埋:此前 256 条 metrics 全是 `turn_end` —— 因为
805
+ // 只有 `index.js` 在回合结束自动记,其余 11 种 kind 只靠「谁主动调
806
+ // bot_metrics_record」才记,而没有任何业务代码主动调。PDCA 页面因此只看得见
807
+ // 「回合结束」一种指标,看不到验收通过率、任务认领率。
808
+ //
809
+ // 纪律:埋点失败**绝不能**影响业务动作 —— 主流程已经落盘成功,指标是旁路。
810
+ try {
811
+ const metricsAction = ACTION_TO_METRIC[action]
812
+ if (metricsAction) recordMetric(metricsAction, taskId, byId)
813
+ } catch { /* 指标是旁路,绝不影响主流程 */ }
699
814
  if (after.sourceSeq) {
700
815
  // 幂等键在这里登记(而不是在 create 开头),保证「校验/落盘失败不污染键」
701
816
  idem.check(after.sourceSeq)
@@ -822,6 +937,9 @@ export function createBoard(ctx, config = {}) {
822
937
  tasks.set(ev.taskId, t)
823
938
  events.push(clone(ev))
824
939
  idCounter = Math.max(idCounter, idNumber(ev.taskId))
940
+ // 老数据没有 channel(本字段 2026-11-12 才有):回放时按 source 补默认值,
941
+ // 保证「读侧永远非空」,而不是让 UI 到处判空。
942
+ if (t && t.channel === undefined) t.channel = t.source === 'human' ? 'user' : 'session'
825
943
  if (t.sourceSeq) {
826
944
  sourceSeqIndex.set(t.sourceSeq, ev.taskId)
827
945
  idem.check(t.sourceSeq)
@@ -947,7 +1065,7 @@ export function createBoard(ctx, config = {}) {
947
1065
  }
948
1066
 
949
1067
  function list(options = {}) {
950
- const { status, attention, priority, dueAt, source, sort, triaged } = options ?? {}
1068
+ const { status, attention, priority, dueAt, source, channel, sort, triaged } = options ?? {}
951
1069
  if (status !== undefined && status !== null) {
952
1070
  if (!STATUSES.includes(status)) {
953
1071
  throw new BoardError(
@@ -977,6 +1095,10 @@ export function createBoard(ctx, config = {}) {
977
1095
  if (source !== undefined && source !== null) {
978
1096
  if (t.source !== source) continue
979
1097
  }
1098
+ // 需求①:按渠道过滤(user / session / external)——「后续接其他渠道」时无需改这里
1099
+ if (channel !== undefined && channel !== null) {
1100
+ if (t.channel !== channel) continue
1101
+ }
980
1102
  if (triaged !== undefined && triaged !== null) {
981
1103
  const want = triaged === true || triaged === '1' || triaged === 'true'
982
1104
  if ((t.triaged === true) !== want) continue
@@ -1031,15 +1153,17 @@ export function createBoard(ctx, config = {}) {
1031
1153
  function create(input = {}) {
1032
1154
  const {
1033
1155
  subject, description, blockedBy, writeScopes, priority, dueAt, byId, sourceSeq, now,
1034
- expectedComplexity, requiresPaid, source,
1156
+ expectedComplexity, requiresPaid, source, channel,
1035
1157
  labels, milestone, cycle, estimate, assignee, reviewer, progress, triaged,
1036
1158
  mandate,
1037
1159
  } = input ?? {}
1038
1160
  const by = requireString(byId, 'byId', 'create')
1039
- // 来源判定:显式传了 source 就采信;否则按 byId 推断
1040
- // ('ui'/'user' → 人;其他(会话 ID/Bot ID)→ 智能体)
1041
- const src = source === 'human' || source === 'ai' ? source
1042
- : (by === 'ui' || by === 'user' ? 'human' : 'ai')
1161
+ // 来源判定(2026-11-12 t-40 需求①):
1162
+ // 显式传了 source 就按白名单采信;否则**按 byId 推断**('ui'/'user'/'person' → 人)。
1163
+ // 新增 `channel` 字段承载「具体渠道」,source 仍是二分的「人/智能体」——
1164
+ // 因为「谁下的令」与「从哪个入口下的令」是两个正交问题,混成一个字段必然打补丁。
1165
+ const src = normalizeSource(source, by, 'create')
1166
+ const chan = normalizeChannel(channel, src, by, 'create')
1043
1167
  const subj = requireString(subject, 'subject', 'create')
1044
1168
  const desc = normalizeDescription(description, 'create')
1045
1169
  const deps = stringList(blockedBy, 'blockedBy', 'create')
@@ -1061,6 +1185,7 @@ export function createBoard(ctx, config = {}) {
1061
1185
  const reviewerNorm = normalizeOptionalString(reviewer, 'reviewer', 'create')
1062
1186
  const progressNorm = normalizeProgress(progress, 'create')
1063
1187
  const triagedNorm = normalizeTriaged(triaged, 'create')
1188
+ // 渠道归一(需求①:为「其他渠道」预留位置)
1064
1189
  // Mandate 授权留痕(可选;不给就是「未记录依据」,不伪造一个)
1065
1190
  const mandateNorm = normalizeMandate(mandate, 'create')
1066
1191
 
@@ -1097,7 +1222,7 @@ export function createBoard(ctx, config = {}) {
1097
1222
  /** @type {Record<string, unknown>} */
1098
1223
  const task = {
1099
1224
  id: taskId,
1100
- rev: 1, source: src,
1225
+ rev: 1, source: src, channel: chan,
1101
1226
  subject: subj,
1102
1227
  description: desc,
1103
1228
  status: 'pending',
@@ -1759,10 +1884,25 @@ export function createBoard(ctx, config = {}) {
1759
1884
  next.rev = current.rev + 1
1760
1885
  next.status = 'pending'
1761
1886
  delete next.lease
1762
- delete next.ownerId
1887
+ // ⚠️ **不删 ownerId**(2026-11-12 t-40-A 修复,Boss 原话「负责人不用切换,避免内容丢失」)。
1888
+ // 旧行为是 `delete next.ownerId` —— 租约一过期就把「谁负责这个任务」这条事实抹掉,
1889
+ // 任务打回 pending 后任何 Bot 都能领走,长任务的上下文归属就此断裂。
1890
+ // 租约(lease)本来就该失效,但它失效**不代表负责人不再负责**:
1891
+ // - `lease` 是「谁现在正在动手」,过期即失效,必须清(否则任务永远不可被重新领取);
1892
+ // - `assignee` 是「这个活归谁管」,**与租约生命周期无关**,不该被回收动作改写。
1893
+ // 保留 ownerId 后,被回收的任务在板上显示「待领(原负责人 X)」,重派也仍可追溯。
1894
+ const keeper = current.ownerId ?? current.assignee
1895
+ if (typeof keeper === 'string' && keeper !== '') next.ownerId = keeper
1896
+ else delete next.ownerId
1897
+ // 回收原因留档:让人一眼看出「这条不是被释放的,是过期被回收的」
1898
+ next.lastLeaseExpiredAt = at
1899
+ next.lastLeaseExpiredOwner = current.lease.ownerId ?? null
1763
1900
  commit(current.id, 'lease_expired', current.lease.ownerId ?? null, at, before, next)
1764
1901
  recycled.push(current.id)
1765
- info(`[cs-bots/board] 租约回收:${current.id} 由 ${current.lease.ownerId} 于 ${new Date(exp).toISOString()} 过期 → pending`)
1902
+ info(
1903
+ `[cs-bots/board] 租约回收:${current.id} 由 ${current.lease.ownerId} 于 ${new Date(exp).toISOString()} 过期`
1904
+ + ` → pending(负责人保留为 ${next.ownerId ?? '无'})`,
1905
+ )
1766
1906
  }
1767
1907
  return recycled
1768
1908
  }
@@ -1863,6 +2003,12 @@ export function createBoard(ctx, config = {}) {
1863
2003
  delete next.latestReport // 旧的收尾结论一并作废(重做后会重新产生)
1864
2004
  delete next.accepted // 驳回即取消「已验收」,与 acceptance.result='reject' 一致
1865
2005
  }
2006
+ // 验收埋点(bots04 欠账2):accept 的指标要按 result 拆成 pass/reject,
2007
+ // 不能用 ACTION_TO_METRIC(那里是动作名→kind 的一对一映射)。
2008
+ // 放在 commit **之前**:commit 成功后已经落盘,埋点是旁路,先记不影响主结果。
2009
+ try {
2010
+ recordMetric(result === 'pass' ? 'accept_pass' : 'accept_reject', taskId, actor)
2011
+ } catch { /* 指标是旁路,绝不影响主流程 */ }
1866
2012
  return commit(taskId, 'accept', actor, at, before, next)
1867
2013
  }
1868
2014