cs-bots 0.9.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/CHANGELOG.md CHANGED
@@ -13,6 +13,10 @@
13
13
 
14
14
  ---
15
15
 
16
+ ## [0.9.1] - 2026-11-12
17
+
18
+ 任务来源渠道;任务开议事厅;每轮 md 产出物;用户身份归一;清理退役 Bot。
19
+
16
20
  ## [0.9.0] - 2026-11-11
17
21
 
18
22
  > 第 1 批 = 定向唤醒 / kind 字段 / attachRoom(止血)
@@ -1,6 +1,6 @@
1
1
  # cs-bots 架构总览(改造07 · 分层架构 + 改造08 · 人机分治)
2
2
 
3
- > 版本:v0.9.0(改造07 分层 + 改造08 需求1–5 + 改造09 Boss 体验/任务可管理性/升级接续)
3
+ > 版本:v0.9.1(改造07 分层 + 改造08 需求1–5 + 改造09 Boss 体验/任务可管理性/升级接续)
4
4
  > 日期:2026-11-10
5
5
  > 状态:改造09 进行中 —— 套件规模 1190 项,截至 2026-11-10 有 6 项失败待修(详见 `docs/CONTRACT-升级09-Boss体验.md` §10)。改完前**不要**把本文当作「全绿」依据。
6
6
 
@@ -50,7 +50,7 @@
50
50
  │ │
51
51
  │ ┌──────────────────────────────────────────────────────┐ │
52
52
  │ │ AI 智能体调用接口(机器友好、可审计) │ │
53
- │ │ 43 个 bot_* 工具 + HTTP API │ │
53
+ │ │ 45 个 bot_* 工具 + HTTP API │ │
54
54
  │ └──────────────────────────────────────────────────────┘ │
55
55
  └─────────────────────────────────────────────────────────────┘
56
56
  ```
@@ -141,7 +141,7 @@
141
141
  | 后端运行时 | Node.js ≥22.5,ESM,零第三方运行时依赖 |
142
142
  | HTTP API | 宿主 `webServer.register`(前缀路由 `/cs-bots/api`) |
143
143
  | 前端 | React 18,宿主模块加载器 `window.__ModuleLoader__.load` |
144
- | 模型工具 | 宿主 `tools.register`,43 个 `bot_*` 工具 |
144
+ | 模型工具 | 宿主 `tools.register`,45 个 `bot_*` 工具 |
145
145
  | 持久化 | JSONL 事件流 + 原子快照,SQLite 不引入 |
146
146
  | 安全 | 会话鉴权围栏 + 同源校验 + 受保护区 + CSRF 防护 |
147
147
 
@@ -212,7 +212,7 @@ src/subscription.js 任务订阅 + 房间扇出(改造07 新增)
212
212
  src/routing/ 复杂度路由
213
213
  src/store.js 存储原语(jsonl + 快照 + 幂等集 + 原子写)
214
214
  src/kernel.js 宿主桥(唯一可引用宿主包的文件)
215
- src/tools.js 43 个模型工具
215
+ src/tools.js 45 个模型工具
216
216
  src/index.js 装配入口
217
217
  ```
218
218
 
@@ -227,7 +227,7 @@ src/index.js 装配入口
227
227
  | 房间→任务 | 无连接 | Subscription 扇出 |
228
228
  | Bot 开工 | 无准备步骤 | prepare command |
229
229
  | 底层可见性 | 无(黑盒) | 穿透面板(7 区块) |
230
- | 工具数 | 38 个 | 41 个 | **43 个**(改造09 新增 `bot_task_phase`,bots04 新增 `bot_team_route`) |
230
+ | 工具数 | 38 个 | 41 个 | **45 个**(改造09 新增 `bot_task_phase`,bots04 新增 `bot_team_route`,t-40 新增 `bot_roster_purge`、`bot_task_room`) |
231
231
  | 测试数 | 949 项 | 1036 项 |
232
232
 
233
233
  ---
@@ -1,6 +1,6 @@
1
1
  # cs-bots 改造07 + 改造08(需求1–5)需求规格书
2
2
 
3
- > 版本:v0.9.0(改造07 分层 + 改造08 需求1–5 + 改造09 + bots04 大团队)
3
+ > 版本:v0.9.1(改造07 分层 + 改造08 需求1–5 + 改造09 + bots04 大团队)
4
4
  > 日期:2026-11-09
5
5
  > 状态:已实现
6
6
 
@@ -1,6 +1,6 @@
1
1
  # cs-bots 改造07 + 改造08(需求1–5)详细设计文档
2
2
 
3
- > 版本:v0.9.0(改造07 分层 + 改造08 需求1–5 + 改造09 + bots04 大团队)
3
+ > 版本:v0.9.1(改造07 分层 + 改造08 需求1–5 + 改造09 + bots04 大团队)
4
4
  > 日期:2026-11-09
5
5
  > 状态:已实现,1263 项测试全通过(`bash test/run-all.sh` exit 0)
6
6
 
@@ -1,6 +1,6 @@
1
1
  # cs-bots 改造07 + 改造08(需求1–5)用户操作手册
2
2
 
3
- > 版本:v0.9.0(改造07 分层 + 改造08 需求1–5 + 改造09 + bots04 大团队)
3
+ > 版本:v0.9.1(改造07 分层 + 改造08 需求1–5 + 改造09 + bots04 大团队)
4
4
  > 日期:2026-11-09
5
5
  > 适用对象:使用 cs-bots 协作平台的人(不是 AI)
6
6
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cs-bots",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "type": "module",
5
5
  "description": "跨团队桥 + 定向唤醒 + 升级可回滚。",
6
6
  "main": "src/index.js",
@@ -57,5 +57,13 @@
57
57
  "publishConfig": {
58
58
  "access": "public",
59
59
  "registry": "https://registry.npmjs.org"
60
+ },
61
+ "peerDependencies": {
62
+ "@deepseek-ai/dsh": ">=0.2.2-rc.1 <0.3.0"
63
+ },
64
+ "peerDependenciesMeta": {
65
+ "@deepseek-ai/dsh": {
66
+ "optional": true
67
+ }
60
68
  }
61
69
  }
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) {
package/src/board.js CHANGED
@@ -362,6 +362,66 @@ function normalizeRequiresPaid(value, where) {
362
362
  }
363
363
 
364
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
+
365
425
  function normalizeLabels(value, where) {
366
426
  if (value === undefined || value === null) return undefined
367
427
  if (!Array.isArray(value)) {
@@ -877,6 +937,9 @@ export function createBoard(ctx, config = {}) {
877
937
  tasks.set(ev.taskId, t)
878
938
  events.push(clone(ev))
879
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'
880
943
  if (t.sourceSeq) {
881
944
  sourceSeqIndex.set(t.sourceSeq, ev.taskId)
882
945
  idem.check(t.sourceSeq)
@@ -1002,7 +1065,7 @@ export function createBoard(ctx, config = {}) {
1002
1065
  }
1003
1066
 
1004
1067
  function list(options = {}) {
1005
- const { status, attention, priority, dueAt, source, sort, triaged } = options ?? {}
1068
+ const { status, attention, priority, dueAt, source, channel, sort, triaged } = options ?? {}
1006
1069
  if (status !== undefined && status !== null) {
1007
1070
  if (!STATUSES.includes(status)) {
1008
1071
  throw new BoardError(
@@ -1032,6 +1095,10 @@ export function createBoard(ctx, config = {}) {
1032
1095
  if (source !== undefined && source !== null) {
1033
1096
  if (t.source !== source) continue
1034
1097
  }
1098
+ // 需求①:按渠道过滤(user / session / external)——「后续接其他渠道」时无需改这里
1099
+ if (channel !== undefined && channel !== null) {
1100
+ if (t.channel !== channel) continue
1101
+ }
1035
1102
  if (triaged !== undefined && triaged !== null) {
1036
1103
  const want = triaged === true || triaged === '1' || triaged === 'true'
1037
1104
  if ((t.triaged === true) !== want) continue
@@ -1086,15 +1153,17 @@ export function createBoard(ctx, config = {}) {
1086
1153
  function create(input = {}) {
1087
1154
  const {
1088
1155
  subject, description, blockedBy, writeScopes, priority, dueAt, byId, sourceSeq, now,
1089
- expectedComplexity, requiresPaid, source,
1156
+ expectedComplexity, requiresPaid, source, channel,
1090
1157
  labels, milestone, cycle, estimate, assignee, reviewer, progress, triaged,
1091
1158
  mandate,
1092
1159
  } = input ?? {}
1093
1160
  const by = requireString(byId, 'byId', 'create')
1094
- // 来源判定:显式传了 source 就采信;否则按 byId 推断
1095
- // ('ui'/'user' → 人;其他(会话 ID/Bot ID)→ 智能体)
1096
- const src = source === 'human' || source === 'ai' ? source
1097
- : (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')
1098
1167
  const subj = requireString(subject, 'subject', 'create')
1099
1168
  const desc = normalizeDescription(description, 'create')
1100
1169
  const deps = stringList(blockedBy, 'blockedBy', 'create')
@@ -1116,6 +1185,7 @@ export function createBoard(ctx, config = {}) {
1116
1185
  const reviewerNorm = normalizeOptionalString(reviewer, 'reviewer', 'create')
1117
1186
  const progressNorm = normalizeProgress(progress, 'create')
1118
1187
  const triagedNorm = normalizeTriaged(triaged, 'create')
1188
+ // 渠道归一(需求①:为「其他渠道」预留位置)
1119
1189
  // Mandate 授权留痕(可选;不给就是「未记录依据」,不伪造一个)
1120
1190
  const mandateNorm = normalizeMandate(mandate, 'create')
1121
1191
 
@@ -1152,7 +1222,7 @@ export function createBoard(ctx, config = {}) {
1152
1222
  /** @type {Record<string, unknown>} */
1153
1223
  const task = {
1154
1224
  id: taskId,
1155
- rev: 1, source: src,
1225
+ rev: 1, source: src, channel: chan,
1156
1226
  subject: subj,
1157
1227
  description: desc,
1158
1228
  status: 'pending',
@@ -1814,10 +1884,25 @@ export function createBoard(ctx, config = {}) {
1814
1884
  next.rev = current.rev + 1
1815
1885
  next.status = 'pending'
1816
1886
  delete next.lease
1817
- 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
1818
1900
  commit(current.id, 'lease_expired', current.lease.ownerId ?? null, at, before, next)
1819
1901
  recycled.push(current.id)
1820
- 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
+ )
1821
1906
  }
1822
1907
  return recycled
1823
1908
  }
package/src/room.js CHANGED
@@ -162,10 +162,30 @@ const USER = 'user'
162
162
  * 缺省广播把 Boss 自己也算成唤醒目标,每次必失败一条。
163
163
  */
164
164
  const HUMAN_IDS = Object.freeze(['ui', 'user', 'person'])
165
- /** 是否为人类身份(房间转发一律跳过)。 */
165
+
166
+ /**
167
+ * **规范用户 id**(2026-11-12 t-40 需求⑤)。
168
+ *
169
+ * Boss:「以后要统一一下名称……人=用户=boss=user=我」。
170
+ * 历史数据里同一个人有三种写法(`ui` 来自页面层、`user` 来自 API 层、`person` 来自早期约定),
171
+ * 于是「这是谁」在每一处都要判三遍,迟早漏一处。
172
+ *
173
+ * 采用**读兼容 + 写统一**,而不是一刀切改写历史:
174
+ * - 读:`ui`/`person` 一律视为 `user`(`HUMAN_IDS` 仍然认它们,老数据不会失效);
175
+ * - 写:新写入一律用 `user`。
176
+ * 这样不破坏历史事件流(append-only,改历史等于伪造),又能让新数据只有一个写法。
177
+ */
178
+ const CANONICAL_USER = 'user'
179
+
180
+ /** 是否为人类身份(`ui`/`user`/`person` 都认 —— 读侧兼容)。 */
166
181
  function isHumanId(id) {
167
182
  return typeof id === 'string' && HUMAN_IDS.includes(id)
168
183
  }
184
+
185
+ /** 把任意人类写法归一成规范 id `user`;非人类身份原样返回。 */
186
+ function normalizeHumanId(id) {
187
+ return isHumanId(id) ? CANONICAL_USER : id
188
+ }
169
189
  /** 房间事件类型集合(审计流可枚举)。 */
170
190
  const EVENTS = [
171
191
  'room_created',
@@ -352,6 +372,64 @@ function collectMentions(message, members) {
352
372
  return out
353
373
  }
354
374
 
375
+ /**
376
+ * 计算「一条房间消息该唤醒谁」—— **唤醒风暴的唯一真相源**。
377
+ *
378
+ * 为什么抽成独立函数(2026-11-12 t-40-B 修复):
379
+ * 房间有**两条**唤醒路径,过去各写一份目标计算,于是必然漂移:
380
+ * - `room.relay()`(`POST /rooms/relay`)实现了四档规则;
381
+ * - `POST /rooms/post`(Bot 用 `bot_room_post`、Boss 用 UI **都走这条**)却是
382
+ * 裸的 `members.filter((m) => m !== skipSelf)` —— 不看 `mentionOnly`、
383
+ * 不排除人类 id。实测 6 个房间全设了 `mentionOnly:true`,**一次都没生效**;
384
+ * 8 成员房间 ⇒ 一条消息 8 次模型调用,其中打给人类 id 的必然失败。
385
+ *
386
+ * 所以这里只留**一份**规则,两个调用点都用它。改规则只改这里。
387
+ *
388
+ * 四档,**顺序即优先级**(与 relay 原注释一致):
389
+ * ① 显式 targets → 原样采信(剔除人类,并 warn:那是调用方的错)
390
+ * ② mentionOnly=true 且 @ 了某人 → 只打给被 @ 的成员(不含发送者/人类)
391
+ * ③ mentionOnly=true 但没 @ → **空**(只落消息不唤醒,合法结果,不是错误)
392
+ * ④ mentionOnly=false(显式关掉定向)→ 除发送者外的全部成员,**仍排除人类**
393
+ *
394
+ * @param {{ members?: string[], settings?: object, from?: string }} room 房间快照
395
+ * @param {{ from?: string, text?: string, mentions?: string[] }} message 消息
396
+ * @param {{ targets?: unknown, warn?: (m: string) => void }} [options]
397
+ * 显式目标(仅 relay 用);`warn` 注入调用方所在作用域的日志器
398
+ * (工厂内部有 `warn`,模块顶层没有 —— 这里显式注入,不靠隐式作用域)
399
+ * @returns {{ targets: string[], source: 'explicit'|'mention'|'mention-only-no-target'|'broadcast' }}
400
+ */
401
+ function computeWakeTargets(room, message, options = {}) {
402
+ const members = Array.isArray(room?.members) ? room.members : []
403
+ const settings = room?.settings ?? DEFAULT_SETTINGS
404
+ const mentionOnly = settings.mentionOnly !== false
405
+ // 发送者自己永远不唤醒(发消息的人不需要被自己的消息叫醒)
406
+ const sender = message?.from ?? room?.from
407
+ /** 唤醒排除表:人类一律不算唤醒目标(人没有会话,唤醒必然 agent-not-live)。 */
408
+ const wakeable = (id) => id !== sender && !isHumanId(id)
409
+
410
+ if (options?.targets !== undefined && options?.targets !== null) {
411
+ let list = stringList(options.targets, 'targets', 'relay')
412
+ const humans = list.filter((t) => isHumanId(t))
413
+ if (humans.length > 0) {
414
+ const say = typeof options.warn === 'function' ? options.warn : () => {}
415
+ say(`[cs-bots/room] 显式 targets 含人类身份 ${humans.join(',')} —— 已剔除(人不可被唤醒)`)
416
+ list = list.filter((t) => !isHumanId(t))
417
+ }
418
+ return { targets: list, source: 'explicit' }
419
+ }
420
+
421
+ if (mentionOnly) {
422
+ const mentioned = collectMentions(message, members)
423
+ const targets = mentioned.filter(wakeable)
424
+ return targets.length > 0
425
+ ? { targets, source: 'mention' }
426
+ : { targets: [], source: 'mention-only-no-target' }
427
+ }
428
+
429
+ // ④ 显式关掉定向才广播 —— 即便如此也不唤醒人类、不唤醒发送者自己
430
+ return { targets: members.filter(wakeable), source: 'broadcast' }
431
+ }
432
+
355
433
  /** 时间参数归一:缺省 now;支持 ms 数字与 Date。 */
356
434
  function normalizeNow(value, where) {
357
435
  if (value === undefined || value === null) return Date.now()
@@ -1966,42 +2044,29 @@ export function createRoom(ctx, config = {}) {
1966
2044
  let targetSource
1967
2045
  let noTargetReason
1968
2046
 
1969
- if (targets !== undefined && targets !== null) {
1970
- targetList = stringList(targets, 'targets', 'relay')
1971
- if (targetList.length === 0) {
1972
- throw new RoomError(
1973
- `room.relay:targets 是空数组 —— 拒绝空转发(静默无操作),请显式给出目标或省略该字段走缺省`,
1974
- 'ROOM_NO_TARGETS',
1975
- { roomId: room.id, messageId: mid },
1976
- )
1977
- }
1978
- targetSource = 'explicit'
1979
- // 显式 targets 可能混进人类 —— 剔除,但这是**调用方的错**,如实 warn 不静默
1980
- const humans = targetList.filter((t) => isHumanId(t))
1981
- if (humans.length > 0) {
1982
- warn(`[cs-bots/room] relay 显式 targets 含人类身份 ${humans.join(',')} —— 已剔除(人不可被唤醒)`)
1983
- targetList = targetList.filter((t) => !isHumanId(t))
1984
- }
1985
- } else if (mentionOnly) {
1986
- // 从消息里解析「被 @ 的人」:mentions 字段 + text 里的 `@id`(与 admission.isAddressed 同口径)
1987
- const mentioned = collectMentions(message, room.members)
1988
- targetList = mentioned.filter(wakeable)
1989
- targetSource = 'mention'
1990
- if (targetList.length === 0) {
1991
- targetSource = 'mention-only-no-target'
1992
- noTargetReason = 'mention-only-no-target'
1993
- }
1994
- } else {
1995
- targetList = room.members.filter(wakeable)
1996
- targetSource = 'broadcast'
1997
- if (targetList.length === 0) {
1998
- throw new RoomError(
1999
- `room.relay:房间 ${room.id} 除发送者(${message.from})外没有其他可唤醒成员,且未显式指定 targets`
2000
- + ' —— 拒绝空转发(静默无操作)',
2001
- 'ROOM_NO_TARGETS',
2002
- { roomId: room.id, messageId: mid, from: message.from },
2003
- )
2004
- }
2047
+ // 规则统一在 computeWakeTargets(t-40-B):与 `POST /rooms/post` 共用同一份,
2048
+ // 避免两条唤醒路径各写一套而再度漂移。
2049
+ const decided = computeWakeTargets(room, message, { targets, warn })
2050
+ targetList = decided.targets
2051
+ targetSource = decided.source
2052
+ if (targetSource === 'mention-only-no-target') noTargetReason = 'mention-only-no-target'
2053
+ // 显式 targets 给了空数组 ⇒ 调用方契约违反,抛错(computeWakeTargets 只管剔除人类)
2054
+ if (targetSource === 'explicit' && Array.isArray(targets) && targets.length === 0) {
2055
+ throw new RoomError(
2056
+ `room.relay:targets 是空数组 —— 拒绝空转发(静默无操作),请显式给出目标或省略该字段走缺省`,
2057
+ 'ROOM_NO_TARGETS',
2058
+ { roomId: room.id, messageId: mid },
2059
+ )
2060
+ }
2061
+ // 广播档但「除发送者外全是人类」⇒ 这是**配置错误**(没有 bot 可广播),抛错比静默诚实。
2062
+ // 注意:mention 档的空结果不算错(③「只记录不打扰」是合法结果),二者不能混。
2063
+ if (targetSource === 'broadcast' && targetList.length === 0) {
2064
+ throw new RoomError(
2065
+ `room.relay:房间 ${room.id} 除发送者(${message.from})外没有其他可唤醒成员,且未显式指定 targets`
2066
+ + ' —— 拒绝空转发(静默无操作)',
2067
+ 'ROOM_NO_TARGETS',
2068
+ { roomId: room.id, messageId: mid, from: message.from },
2069
+ )
2005
2070
  }
2006
2071
 
2007
2072
  // ── ③「没 @ 到谁」:合法空转发 —— 记事件、返回 relayed:0,**不抛错** ──────
@@ -2304,6 +2369,14 @@ export function createRoom(ctx, config = {}) {
2304
2369
  leave,
2305
2370
  post,
2306
2371
  relay,
2372
+ /**
2373
+ * 计算「这条消息该唤醒谁」(t-40-B)。
2374
+ * 暴露给 `POST /rooms/post` 复用,保证两条唤醒路径共用同一份规则;
2375
+ * 纯函数、不落盘、不唤醒,调用方自己决定要不要投递。
2376
+ * 绑定本实例的 `warn`,这样 HTTP 路径的告警与 relay 走同一个 logger。
2377
+ */
2378
+ computeWakeTargets: (room, message, options = {}) =>
2379
+ computeWakeTargets(room, message, { ...options, warn }),
2307
2380
  readBody,
2308
2381
  listMessages,
2309
2382
  // DESIGN-v2 ①:房间配置 / 归档 / 硬删 / 成员角色(签名见 DESIGN 契约,勿改名)
@@ -2338,5 +2411,6 @@ export const ROOM_DEFAULT_SETTINGS = Object.freeze({ ...DEFAULT_SETTINGS })
2338
2411
 
2339
2412
  /** 用户(人类)发送者 id(`from` 允许的特殊值)。 */
2340
2413
  export const ROOM_USER = USER
2414
+ export const ROOM_CANONICAL_USER = CANONICAL_USER
2341
2415
 
2342
2416
  export default createRoom
package/src/roster.js CHANGED
@@ -100,6 +100,7 @@ const ACTIONS = [
100
100
  'create',
101
101
  'rename',
102
102
  'retire',
103
+ 'purge',
103
104
  'set_cwd',
104
105
  'claim',
105
106
  'release',
@@ -517,6 +518,8 @@ export function createRoster(ctx, config = {}) {
517
518
  skipped += 1
518
519
  continue
519
520
  }
521
+ // purge 是终态:重放时把它从内存表移除,而不是复活成一条记录
522
+ if (ev.after.state === 'purged') { bots.delete(ev.botId); events.push(clone(ev)); continue }
520
523
  bots.set(ev.botId, clone(ev.after))
521
524
  events.push(clone(ev))
522
525
  }
@@ -694,7 +697,7 @@ export function createRoster(ctx, config = {}) {
694
697
  * @throws {RosterError} 缺字段 / 非法 cwd / 重名 / 会话创建失败 / 事件落盘失败
695
698
  */
696
699
  async function create(input = {}) {
697
- const { name, cwd, label, model, instructions, preset, permission, profile, prepareCommand, byId, now } = input ?? {}
700
+ const { name, cwd, label, model, instructions, preset, permission, profile, prepareCommand, byId, now, adoptSession } = input ?? {}
698
701
  const by = requireString(byId, 'byId', 'create')
699
702
  const displayName = requireString(name, 'name', 'create')
700
703
  const dir = assertBotCwd(cwd, 'create')
@@ -707,6 +710,28 @@ export function createRoster(ctx, config = {}) {
707
710
  // ── 唯一性(校验通过后才动会话,避免「建了会话才发现重名」的资源泄漏)──
708
711
  assertNameFree(displayName, undefined, 'create')
709
712
 
713
+ // ── adoptSession:把**已存在的会话**注册成 Bot(2026-11-11 Boss 需求)──
714
+ // 为什么需要:`create()` 原本只会**新建**会话(`bot-${randomUUID()}`),
715
+ // 无法接管一个已经在跑、已有上下文的会话。Boss 要把当前会话(带着全部上下文)
716
+ // 变成可在协作平台派活的 Bot ⇒ 会话 id 必须保持不变。
717
+ // 语义边界:
718
+ // - `adoptSession` 给出时**不建新会话**,注册表直接引用它;
719
+ // - 该 sessionId 不能已被别的活跃 Bot 占用(`hasSession` 查重);
720
+ // - 退役 Bot 里的同名 session 不阻止接管(终态已释放名额)。
721
+ const adopt = optionalString(adoptSession, 'adoptSession', 'create')
722
+ if (adopt !== undefined) {
723
+ for (const other of bots.values()) {
724
+ if (other.state !== 'retired' && other.sessionId === adopt) {
725
+ throw new RosterError(
726
+ `roster.create:会话 ${adopt} 已被 Bot「${other.name}」(${other.id})占用——`
727
+ + '同一会话不能注册成两个 Bot',
728
+ 'ROSTER_SESSION_TAKEN',
729
+ { sessionId: adopt, occupiedBy: other.id },
730
+ )
731
+ }
732
+ }
733
+ }
734
+
710
735
  // ── 同 cwd 提示(非阻塞):两个活 Bot 共用一个目录会撞 fs 写入边界 ──
711
736
  for (const other of bots.values()) {
712
737
  if (other.state !== 'retired' && other.cwd === dir) {
@@ -717,29 +742,41 @@ export function createRoster(ctx, config = {}) {
717
742
  }
718
743
  }
719
744
 
720
- // ── 会话创建:**唯一能指定 cwd 的路径**(kernel.js:249-287)──
721
- const id = `bot-${randomUUID()}`
722
- let handle
723
- try {
724
- handle = await kernel.createSession(ctx, {
725
- sessionId: id,
726
- cwd: dir,
727
- warn,
728
- ...(preset !== undefined && preset !== null && preset !== '' ? { agentPreset: String(preset) } : {}),
729
- })
730
- } catch (error) {
731
- throw new RosterError(
732
- `roster.create:Bot「${displayName}」的会话创建失败(cwd=${dir})→ ${error?.message ?? error}`
733
- + '——已中止注册(不产生半成品 Bot)',
734
- 'ROSTER_SESSION_CREATE_FAILED',
735
- { name: displayName, cwd: dir, cause: String(error?.message ?? error) },
736
- )
737
- }
738
- const sessionId = pickSessionId(handle, id)
739
- if (sessionId !== id) {
740
- warn(
741
- `[cs-bots/roster] 宿主返回的会话 id(${sessionId})与请求的(${id})不同——以宿主返回为准并记入注册表`,
742
- )
745
+ // ── 会话:要么新建,要么接管已有(adoptSession)──
746
+ //
747
+ // 新建时这是**唯一能指定 cwd 的路径**(kernel.js:249-287)。
748
+ // 接管时**不碰会话**:那个会话已经在跑、有自己的 cwd 与上下文,
749
+ // 我们只是把它登记进注册表 —— 因此 `id` 直接沿用该 sessionId
750
+ // (不生成 `bot-xxx`),这样 `wake`/任务派活的目标 id 与真实会话对得上。
751
+ let id
752
+ let sessionId
753
+ if (adopt !== undefined) {
754
+ id = adopt
755
+ sessionId = adopt
756
+ } else {
757
+ id = `bot-${randomUUID()}`
758
+ let handle
759
+ try {
760
+ handle = await kernel.createSession(ctx, {
761
+ sessionId: id,
762
+ cwd: dir,
763
+ warn,
764
+ ...(preset !== undefined && preset !== null && preset !== '' ? { agentPreset: String(preset) } : {}),
765
+ })
766
+ } catch (error) {
767
+ throw new RosterError(
768
+ `roster.create:Bot「${displayName}」的会话创建失败(cwd=${dir})→ ${error?.message ?? error}`
769
+ + '——已中止注册(不产生半成品 Bot)',
770
+ 'ROSTER_SESSION_CREATE_FAILED',
771
+ { name: displayName, cwd: dir, cause: String(error?.message ?? error) },
772
+ )
773
+ }
774
+ sessionId = pickSessionId(handle, id)
775
+ if (sessionId !== id) {
776
+ warn(
777
+ `[cs-bots/roster] 宿主返回的会话 id(${sessionId})与请求的(${id})不同——以宿主返回为准并记入注册表`,
778
+ )
779
+ }
743
780
  }
744
781
 
745
782
  /** @type {Record<string, unknown>} */
@@ -929,6 +966,74 @@ export function createRoster(ctx, config = {}) {
929
966
  return committed
930
967
  }
931
968
 
969
+ /**
970
+ * 清理(purge)已退役的 Bot —— **把记录从注册表里真正移走**(2026-11-12 t-40,Boss 要求)。
971
+ *
972
+ * 为什么 retire 不够:`retire` 只是把 `state` 置为 `retired`,**记录永远留在注册表里**
973
+ * (`listRetired()` 能查到、`roster.jsonl` 里一堆测试残留),时间一长注册表就被
974
+ * 一堆没用的测试 Bot 撑满,管理页看不清到底有哪些在役 Bot。
975
+ *
976
+ * 语义边界(必须守住的四条):
977
+ * 1. **只能清已退役的** —— 在役 Bot 一律拒绝(`ROSTER_PURGE_ACTIVE`)。
978
+ * 退役是"我不用它了",清理是"把这条记录抹掉";两步分开,避免手滑删在役 Bot。
979
+ * 2. **只动注册表,绝不动 cwd 目录、绝不动会话** —— Bot 的工作目录里可能有真实产物
980
+ * (实测 `/home/ubuntu/output/开发测试001` 里存着 t36 调研报告),
981
+ * 删目录是不可逆且越权的,本函数**不碰文件系统**。
982
+ * 3. **事件流仍留档**:往 `roster.jsonl` 追加一条 `purge` 事件(`after` = 清理后的状态),
983
+ * 重放时据此把该 Bot 从内存表里移除 —— 这样「谁在什么时候清了谁」可审计,
984
+ * 而不是把历史抹得干干净净。
985
+ * 4. **幂等**:已清理的再清一次直接跳过(计入 `skipped`),不抛错。
986
+ *
987
+ * @param {{ ids?: string[], allRetired?: boolean, reason?: string, byId: string, now?: number }} input
988
+ * `ids` 指定要清的 Bot;`allRetired: true` 清全部已退役的(与 `ids` 二选一)
989
+ * @returns {{ purged: string[], skipped: string[], refused: Array<{id: string, reason: string}> }}
990
+ */
991
+ function purge(input = {}) {
992
+ const { ids, allRetired, reason, byId, now } = input ?? {}
993
+ const by = requireString(byId, 'byId', 'purge')
994
+ const note = optionalString(reason, 'reason', 'purge')
995
+ const at = normalizeNow(now, 'purge')
996
+
997
+ let targets = []
998
+ if (allRetired === true) {
999
+ for (const b of bots.values()) if (b.state === 'retired') targets.push(b.id)
1000
+ } else if (Array.isArray(ids)) {
1001
+ targets = ids.map((x, i) => requireString(x, `ids[${i}]`, 'purge'))
1002
+ } else {
1003
+ throw new RosterError(
1004
+ 'roster.purge:必须给 ids(数组)或 allRetired:true(二选一)—— 拒绝无目标清理',
1005
+ 'ROSTER_PURGE_NO_TARGET',
1006
+ {},
1007
+ )
1008
+ }
1009
+
1010
+ const purged = []
1011
+ const skipped = []
1012
+ const refused = []
1013
+ for (const botId of targets) {
1014
+ const current = bots.get(botId)
1015
+ if (!current) { skipped.push(botId); continue }
1016
+ if (current.state !== 'retired') {
1017
+ // 在役 Bot 绝不清理:这是本条 API 最重要的护栏
1018
+ refused.push({ id: botId, reason: `在役(state=${current.state})—— 先 retire 再 purge` })
1019
+ continue
1020
+ }
1021
+ const before = clone(current)
1022
+ const next = clone(current)
1023
+ next.rev = current.rev + 1
1024
+ next.state = 'purged'
1025
+ next.purgedAt = at
1026
+ next.purgedBy = by
1027
+ if (note !== undefined) next.purgeReason = note
1028
+ // 写事件而不是把它从 jsonl 里抠掉:留档「谁清的、何时清的」
1029
+ commit(botId, 'purge', by, at, before, next)
1030
+ bots.delete(botId)
1031
+ purged.push(botId)
1032
+ info(`[cs-bots/roster] Bot ${botId}(「${current.name}」)已清理出注册表`)
1033
+ }
1034
+ return { purged, skipped, refused }
1035
+ }
1036
+
932
1037
  /**
933
1038
  * 换工作目录(**官方完全没有的语义**:teammate 强制继承父 cwd)。
934
1039
  *
@@ -1461,6 +1566,7 @@ export function createRoster(ctx, config = {}) {
1461
1566
  hasSession,
1462
1567
  rename,
1463
1568
  retire,
1569
+ purge,
1464
1570
  setPreset,
1465
1571
  setCwd,
1466
1572
  setPrepareCommand,
package/src/tools.js CHANGED
@@ -94,6 +94,18 @@ const DEFS = [
94
94
  priority: { type: 'number', description: '优先级(数值越大越先)' },
95
95
  dueAt: { type: 'number', description: '截止时间(epoch ms)' },
96
96
  sourceSeq: { type: 'string', description: '幂等键:同键重复创建只产生一条' },
97
+ source: {
98
+ type: 'string',
99
+ enum: ['human', 'ai'],
100
+ description: '谁下的令:human=人(Boss)/ ai=智能体。不传则按调用者身份自动推断。',
101
+ },
102
+ channel: {
103
+ type: 'string',
104
+ enum: ['user', 'session', 'external'],
105
+ description:
106
+ '从哪个入口下的令(t-40 需求①):user=用户在页面手工建 / session=会话里产出 / '
107
+ + 'external=外部渠道接入(预留)。不传则按 source 推断(人→user,智能体→session)。',
108
+ },
97
109
  expectedComplexity: {
98
110
  type: 'string',
99
111
  enum: ['T0', 'T1', 'T2', 'T3'],
@@ -144,7 +156,9 @@ const DEFS = [
144
156
  },
145
157
  {
146
158
  name: 'bot_task_list',
147
- description: '列出 cs-bots 任务板上的任务。默认不含已删除(tombstone);传 status 可只看某状态。',
159
+ description:
160
+ '列出 cs-bots 任务板上的任务。默认不含已删除(tombstone);传 status 可只看某状态;'
161
+ + '传 source / channel 可按「谁下的令 / 从哪个入口下的令」筛选(t-40 需求①)。',
148
162
  parameters: {
149
163
  type: 'object',
150
164
  properties: {
@@ -153,12 +167,46 @@ const DEFS = [
153
167
  enum: ['pending', 'in_progress', 'completed', 'blocked', 'deleted'],
154
168
  description: '只看该状态',
155
169
  },
170
+ source: { type: 'string', enum: ['human', 'ai'], description: '只看该来源(人 / 智能体)' },
171
+ channel: {
172
+ type: 'string',
173
+ enum: ['user', 'session', 'external'],
174
+ description: '只看该渠道(用户手工 / 会话产出 / 外部接入)',
175
+ },
156
176
  },
157
177
  additionalProperties: false,
158
178
  },
159
179
  run: (s, a) => {
160
180
  if (!s.board) return failure('board-unavailable')
161
- return success({ tasks: s.board.list(a?.status ? { status: a.status } : {}) })
181
+ // 只把**给了的**过滤条件传下去:给 undefined 会命中 `!== null` 分支造成误筛
182
+ const opts = {}
183
+ if (a?.status) opts.status = a.status
184
+ if (a?.source) opts.source = a.source
185
+ if (a?.channel) opts.channel = a.channel
186
+ return success({ tasks: s.board.list(opts) })
187
+ },
188
+ },
189
+ {
190
+ name: 'bot_task_room',
191
+ description:
192
+ '给任务开一个议事厅(群聊房间)。**长任务在建任务时就自动有了**,本工具用于:' +
193
+ '① 短任务按需开(Boss:「短任务默认不建房间,防止房间爆炸,需要时再建」);' +
194
+ '② 长任务的议事厅丢了/想补一个。幂等:已有就复用,不会重复建。' +
195
+ '需要任务有负责人(没负责人就没有团队可挂)。',
196
+ parameters: {
197
+ type: 'object',
198
+ properties: { id: { type: 'string', description: '任务 id' } },
199
+ required: ['id'],
200
+ additionalProperties: false,
201
+ },
202
+ run: async (s, a, exec) => {
203
+ if (typeof s.openTaskRoom !== 'function') return failure('task-room-unavailable', { hint: '后端未注入 openTaskRoom' })
204
+ try {
205
+ const r = await s.openTaskRoom(String(a.id ?? ''), callerOf(exec))
206
+ return r.ok ? success(r) : failure('task-room-failed', { message: r.reason })
207
+ } catch (error) {
208
+ return failure(error?.code ?? 'task-room-failed', { message: String(error?.message ?? error) })
209
+ }
162
210
  },
163
211
  },
164
212
  {
@@ -428,6 +476,31 @@ const DEFS = [
428
476
  return success({ bots: [...live, ...retired] })
429
477
  },
430
478
  },
479
+ {
480
+ name: 'bot_roster_purge',
481
+ description:
482
+ '把**已退役**的 Bot 从注册表里真正清掉(retire 只是标记,记录会一直留着)。' +
483
+ '**只能清已退役的**:在役 Bot 一律拒绝。**不删工作目录、不杀会话** —— ' +
484
+ '只移除注册表记录,cwd 里的产物原样保留。清理动作在审计流里留档,可追溯。' +
485
+ 'allRetired:true 一次清干净;或用 ids 指定。(**官方 roster 没有这个能力**)',
486
+ parameters: {
487
+ type: 'object',
488
+ properties: {
489
+ allRetired: { type: 'boolean', description: '清全部已退役的 Bot(与 ids 二选一)' },
490
+ ids: { type: 'array', items: { type: 'string' }, description: '指定要清的 Bot id 列表' },
491
+ reason: { type: 'string', description: '清理原因(写进审计流)' },
492
+ },
493
+ additionalProperties: false,
494
+ },
495
+ run: (s, a, exec) => {
496
+ if (!s.roster) return failure('roster-unavailable')
497
+ try {
498
+ return success(s.roster.purge({ ids: a.ids, allRetired: a.allRetired, reason: a.reason, byId: callerOf(exec) }))
499
+ } catch (error) {
500
+ return failure(error?.code ?? 'ROSTER_PURGE_FAILED', { message: String(error?.message ?? error) })
501
+ }
502
+ },
503
+ },
431
504
  {
432
505
  name: 'bot_roster_manage',
433
506
  description: