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/teams.js CHANGED
@@ -64,7 +64,10 @@ export const DEFAULT_TEAM_ADMIN_POLICY = 'open'
64
64
  const HUMAN_IDS = Object.freeze(['ui', 'user', 'person'])
65
65
 
66
66
  /** 合法动作(含 setPolicy:策略变更本身也是要留痕的事件)。 */
67
- const ACTIONS = Object.freeze(['create', 'addMember', 'removeMember', 'setRole', 'close', 'note', 'setPolicy', 'attachRoom'])
67
+ const ACTIONS = Object.freeze(['create', 'addMember', 'removeMember', 'setRole', 'close', 'note', 'setPolicy', 'attachRoom', 'route'])
68
+
69
+ /** 跨团队路由的最大 hop 数(>3 拒绝)。 */
70
+ const ROUTE_MAX_HOP = 3
68
71
 
69
72
  /** 结构化错误。 */
70
73
  export class TeamError extends Error {
@@ -192,6 +195,12 @@ export function createTeams(ctx, config = {}) {
192
195
  if (rows.length === 0) { diagnostics.hydratedFrom = 'empty'; return }
193
196
  let skipped = 0
194
197
  for (const ev of rows) {
198
+ // route 事件是**审计记录**,不是团队状态变更 —— 没有 id/rev,仍要进 events
199
+ // 供 listRoutes 查询;只有团队状态变更事件才需要 id + rev 去重。
200
+ if (ev?.action === 'route') {
201
+ events.push(clone(ev))
202
+ continue
203
+ }
195
204
  if (!ev || typeof ev.id !== 'string' || !ev.after) { skipped += 1; continue }
196
205
  const current = entries.get(ev.id)
197
206
  if (current && Number.isFinite(ev.rev) && ev.rev <= current.rev) { skipped += 1; continue }
@@ -282,7 +291,7 @@ export function createTeams(ctx, config = {}) {
282
291
  */
283
292
  function create(input = {}) {
284
293
  assertAlive('create')
285
- const { name, type, purpose, memberIds, leadId, adminPolicy, by, now } = input ?? {}
294
+ const { name, type, purpose, memberIds, leadId, adminPolicy, by, now, kind, nativeTeamId } = input ?? {}
286
295
  const nameNorm = requireString(name, 'name', 'create')
287
296
  if (nameNorm.length > 120) throw new TeamError(`teams.create:name ≤120 字符(收到 ${nameNorm.length})`, 'TEAM_BAD_FIELD', { field: 'name' })
288
297
  const teamType = type ?? 'big'
@@ -301,18 +310,40 @@ export function createTeams(ctx, config = {}) {
301
310
  throw new TeamError(`teams.create:leadId ${lead} 不能同时出现在 memberIds 中(会形成自引用)`, 'TEAM_BAD_FIELD', { field: 'memberIds' })
302
311
  }
303
312
 
313
+ // ── kind:人类/智能体(服务层强制推导,防止伪造)─────────────────────────
314
+ // 由执行者身份(author)硬推导:HUMAN_IDS ⇒ 'human',其他 ⇒ 'agent'。
315
+ // 显式传 kind 但与推导矛盾 ⇒ 抛 TEAM_KIND_MISMATCH(不许伪造 Boss 建的团队)。
316
+ const derivedKind = HUMAN_IDS.includes(author) ? 'human' : 'agent'
317
+ if (kind !== undefined && kind !== null && kind !== derivedKind) {
318
+ throw new TeamError(
319
+ `teams.create:kind=${JSON.stringify(kind)} 与执行者身份矛盾(执行者 ${author} ⇒ '${derivedKind}')——不许伪造`,
320
+ 'TEAM_KIND_MISMATCH',
321
+ { author, derivedKind, requested: kind },
322
+ )
323
+ }
324
+ const teamKind = derivedKind
325
+
304
326
  idCounter += 1
305
327
  const id = `team-${idCounter}`
306
328
  /** @type {Record<string, unknown>} */
307
329
  const rec = {
308
- id, rev: 1, name: nameNorm, type: teamType, purpose: purpose ?? '',
330
+ id, rev: 1, name: nameNorm, type: teamType, kind: teamKind, purpose: purpose ?? '',
309
331
  leadId: lead, members,
332
+ // ── t-39 第2批 C:绑定 DSH 原生 team ──
333
+ // cs-bots 团队(teams.jsonl)与 DSH 原生 team(agentTeams)是**两套人模型**,
334
+ // 没有任何自动交集(原生 TeamId = root session id,跟我们的 team-N 毫无关系)
335
+ // ⇒ 必须显式绑定。这个字段是「Boss 在这个 cs-bots 房间发言时,该同步给哪个
336
+ // 原生团队」的唯一依据。
337
+ //
338
+ // 为什么从 cs-bots 侧配:原生侧**没有**「列出所有原生 team」的接口,
339
+ // 只有 `tryMembership(agent)`(从某个成员反查它所属团队)⇒ 反向枚举不可行。
340
+ nativeTeamId: typeof nativeTeamId === 'string' && nativeTeamId.length > 0 ? nativeTeamId : null,
310
341
  adminPolicy: policy,
311
342
  status: 'open',
312
343
  createdAt: at, createdBy: author, updatedAt: at, updatedBy: author,
313
344
  }
314
345
  commit('create', at, author, null, rec)
315
- info(`[cs-bots/teams] 团队 ${id}(${nameNorm},${teamType})创建 by ${author}`)
346
+ info(`[cs-bots/teams] 团队 ${id}(${nameNorm},${teamType},${teamKind})创建 by ${author}`)
316
347
  return clone(rec)
317
348
  }
318
349
 
@@ -502,6 +533,209 @@ export function createTeams(ctx, config = {}) {
502
533
  return clone(next)
503
534
  }
504
535
 
536
+ // ── A. 跨团队路由(t-39 第2批 · bot_team_route 的服务层本体)────────────────
537
+
538
+ /**
539
+ * 解析并校验一条跨团队路由目标 —— **只做决定,不做投递**。
540
+ *
541
+ * 为什么拆开:投递要 dispatcher(wakes.jsonl / dedup / 背压),而 dispatcher 挂在
542
+ * `src/tools.js` 的组装层(`s.dispatcher`)。若把投递塞进 TeamsService,本服务就得
543
+ * 反向依赖 dispatcher —— 两个域服务互指会让装配顺序变成硬约束。所以这里只回答
544
+ * 「路由到谁」,投递由工具层完成,语义边界清晰。
545
+ *
546
+ * 失败一律**返回可读的 refused**,不抛裸错 —— 契约要求 `{ok:false, decision:'refused',
547
+ * reason}` 而不是异常(异常在工具层会被包成 `tool-crashed`,理由就丢了)。
548
+ *
549
+ * @param {{ fromTeam?: string, toTeam?: string, toBot?: string,
550
+ * taskId: string, text?: string, hop?: number, by: string }} input
551
+ * @returns {{ ok: true, decision: 'accepted', to: { kind: 'team'|'bot', id: string,
552
+ * leadId: string, name: string }, hop: number, dedupKey: string,
553
+ * fromTeam: string }
554
+ * | { ok: false, decision: 'refused', reason: string, to?: object, hop?: number }}
555
+ */
556
+ function resolveRoute(input = {}) {
557
+ assertAlive('resolveRoute')
558
+ const { fromTeam, toTeam, toBot, taskId, hop, by } = input ?? {}
559
+ const author = requireString(by, 'by', 'resolveRoute')
560
+
561
+ // ── 参数校验:全部**可读拒绝** ──
562
+ if (typeof taskId !== 'string' || taskId.trim() === '') {
563
+ return { ok: false, decision: 'refused', reason: 'ROUTE_TASK_ID_REQUIRED' }
564
+ }
565
+ if (!toTeam && !toBot) {
566
+ return { ok: false, decision: 'refused', reason: 'ROUTE_TARGET_REQUIRED' }
567
+ }
568
+ if (toTeam && toBot) {
569
+ return { ok: false, decision: 'refused', reason: 'ROUTE_TARGET_AMBIGUOUS' }
570
+ }
571
+
572
+ // ── hop 计数:>3 拒绝(环路防护第一道闸)──
573
+ const hopCount = Number.isFinite(Number(hop)) ? Math.max(0, Math.trunc(Number(hop))) : 0
574
+ if (hopCount > ROUTE_MAX_HOP) {
575
+ return { ok: false, decision: 'refused', reason: 'ROUTE_HOP_EXCEEDED', hop: hopCount }
576
+ }
577
+
578
+ // ── 解析目标团队 / 目标 lead ──
579
+ let targetTeamId = null
580
+ let targetLeadId = null
581
+ let targetName = null
582
+
583
+ if (toTeam) {
584
+ const team = entries.get(toTeam)
585
+ if (!team) return { ok: false, decision: 'refused', reason: 'ROUTE_TARGET_NOT_FOUND', hop: hopCount }
586
+ targetTeamId = team.id
587
+ targetLeadId = team.leadId ?? null
588
+ targetName = team.name
589
+ } else {
590
+ // toBot:必须**属于**某个 cs-bots 团队(leadId 或成员),否则无法确定唤醒谁
591
+ for (const t of entries.values()) {
592
+ if (t.leadId === toBot || (Array.isArray(t.members) && t.members.includes(toBot))) {
593
+ targetTeamId = t.id
594
+ targetLeadId = t.leadId ?? null
595
+ targetName = t.name
596
+ break
597
+ }
598
+ }
599
+ if (!targetTeamId) {
600
+ return { ok: false, decision: 'refused', reason: 'ROUTE_TARGET_NOT_FOUND', hop: hopCount }
601
+ }
602
+ }
603
+
604
+ // 目标团队必须有人可唤 —— 契约:**只唤 leadId**,不 wake 全部成员
605
+ if (!targetLeadId) {
606
+ return {
607
+ ok: false,
608
+ decision: 'refused',
609
+ reason: 'ROUTE_NO_LEAD',
610
+ to: { kind: toTeam ? 'team' : 'bot', id: targetTeamId, name: targetName },
611
+ hop: hopCount,
612
+ }
613
+ }
614
+
615
+ const fromKey = fromTeam ?? author
616
+
617
+ // 去重键:交给 dispatcher 的 dedupKey 机制(不自己造一套)。
618
+ // **不含 hop** —— 这样同一 (from→to, taskId) 的重复路由会命中 dispatcher 的
619
+ // IdempotencySet 而被判 skipped,正是「A→B→A→B 第 3 跳被拒」的实现路径。
620
+ const dedupKey = `route:${fromKey}:${targetTeamId}:${taskId}`
621
+
622
+ return {
623
+ ok: true,
624
+ decision: 'accepted',
625
+ to: { kind: toTeam ? 'team' : 'bot', id: targetTeamId, leadId: targetLeadId, name: targetName },
626
+ hop: hopCount,
627
+ dedupKey,
628
+ fromTeam: fromKey,
629
+ }
630
+ }
631
+
632
+ /**
633
+ * **一条龙路由**:决定 → 投递 → 留痕。工具层(`bot_team_route`)与 HTTP 层
634
+ * (`POST /teams/route`)**共用这一个方法**。
635
+ *
636
+ * 为什么必须共用:两条路各写一遍,行为就会漂 —— 工具层去重了、HTTP 层没去重,
637
+ * 或者一边先投后记、一边先记后投。契约要求「跨团队路由」只有一套语义,
638
+ * 所以序列编排只能有一处。
639
+ *
640
+ * 依赖注入:`dispatcher` 由调用方传入(TeamsService 不反向依赖 dispatcher,
641
+ * 否则两个域服务互指,装配顺序变成硬约束)。
642
+ *
643
+ * @param {{ fromTeam?: string, toTeam?: string, toBot?: string, taskId?: string,
644
+ * text?: string, hop?: number, by: string, now?: number,
645
+ * dispatcher: object }} input
646
+ * @returns {object} `{ok, decision, to, hop, wake, reason?, taskId}` —— DecisionKind 语义
647
+ */
648
+ function deliverRoute(input = {}) {
649
+ assertAlive('deliverRoute')
650
+ const { dispatcher, taskId: rawTaskId, by, now, text, hop } = input ?? {}
651
+ const author = requireString(by, 'by', 'deliverRoute')
652
+
653
+ // taskId 缺省生成:页面上的「发协作请求」不一定挂任务(Boss 只是想跨团队喊一句)。
654
+ // 每轮生成独立 id ⇒ 不会被 dispatcher 误判重(同一轮重发才判重,由调用方复用同一 id)。
655
+ const taskId = typeof rawTaskId === 'string' && rawTaskId.trim() !== ''
656
+ ? rawTaskId.trim()
657
+ : `route-${normalizeNow(now, 'deliverRoute')}`
658
+
659
+ const decision = resolveRoute({ ...input, taskId, by: author })
660
+ if (!decision.ok) return { ...decision, taskId }
661
+
662
+ if (!dispatcher || typeof dispatcher.notify !== 'function') {
663
+ return { ok: false, decision: 'refused', reason: 'ROUTE_NO_DISPATCHER', to: decision.to, hop: decision.hop, taskId }
664
+ }
665
+
666
+ // 投递:只唤对方 leadId;dedupKey 交给 dispatcher(不自己造去重);
667
+ // teamId 让 byTeam() 能按团队聚合唤醒成本。
668
+ const wakeText = text ?? `[cs-bots 跨团队路由] ${decision.fromTeam} → ${decision.to.id}`
669
+ + `(lead=${decision.to.leadId}, hop=${decision.hop}, task=${taskId})`
670
+ const wake = dispatcher.notify({
671
+ target: decision.to.leadId,
672
+ reason: 'room-message',
673
+ dedupKey: decision.dedupKey,
674
+ text: wakeText,
675
+ teamId: decision.to.id,
676
+ })
677
+
678
+ // 留痕在投递之后(投递已有 dedupKey 记在 wakes.jsonl;这里记的是「路由决策」本身)。
679
+ // 落盘失败**必须显式报错**,不能吞 —— 「投了没记」会让环路统计漏跳。
680
+ try {
681
+ recordRoute({ to: decision.to, fromTeam: decision.fromTeam, taskId, text, hop: decision.hop, by: author, now, wake })
682
+ } catch (recordError) {
683
+ return {
684
+ ok: false, decision: 'refused', reason: 'ROUTE_RECORD_FAILED',
685
+ message: recordError?.message ?? String(recordError),
686
+ to: decision.to, hop: decision.hop, wake, taskId,
687
+ }
688
+ }
689
+
690
+ return { ok: true, decision: 'delivered', to: decision.to, hop: decision.hop, wake, taskId }
691
+ }
692
+
693
+ /**
694
+ * 把已经做出的路由决定**落盘留痕**(intent-first:先记后投)。
695
+ *
696
+ * 顺序不可颠倒:`记了没投` 是可见可重试的;`投了没记` 则绕过限额/审计,
697
+ * 让环路防护统计不到这一跳 —— 那正是要防的。
698
+ * @param {{ to: object, fromTeam: string, taskId: string, text?: string, hop: number,
699
+ * by: string, now?: number, wake?: object }} input
700
+ */
701
+ function recordRoute(input = {}) {
702
+ assertAlive('recordRoute')
703
+ const { to, fromTeam, taskId, text, hop, by, now, wake } = input ?? {}
704
+ const author = requireString(by, 'by', 'recordRoute')
705
+ const at = normalizeNow(now, 'recordRoute')
706
+ const rec = {
707
+ toKind: to?.kind ?? 'team',
708
+ toId: to?.id ?? null,
709
+ toLeadId: to?.leadId ?? null,
710
+ toName: to?.name ?? null,
711
+ fromTeam: fromTeam ?? null,
712
+ taskId,
713
+ text: text ?? '',
714
+ hop: Number(hop ?? 0),
715
+ wake: wake ?? null,
716
+ at,
717
+ }
718
+ if (!appendJsonl(jsonlPath, { action: 'route', at, by: author, before: null, after: rec })) {
719
+ throw new TeamError(`teams.recordRoute:事件追加失败(${jsonlPath})—— 拒绝「投了没记」`, 'TEAM_APPEND_FAILED', { action: 'route' })
720
+ }
721
+ events.push({ action: 'route', at, by: author, before: null, after: clone(rec) })
722
+ info(`[cs-bots/teams] route ${rec.fromTeam} → ${rec.toId}(lead=${rec.toLeadId}, hop=${rec.hop}, task=${taskId})by ${author}`)
723
+ return clone(rec)
724
+ }
725
+
726
+ /**
727
+ * 查 route 审计记录(按 toId / taskId / fromTeam 过滤)。
728
+ * @param {{ toId?: string, fromTeam?: string, taskId?: string }} [query]
729
+ */
730
+ function listRoutes(query = {}) {
731
+ assertAlive('listRoutes')
732
+ let rows = events.filter((e) => e.action === 'route').map((e) => ({ ...e.after, by: e.by, at: e.at }))
733
+ if (query?.toId !== undefined && query?.toId !== null) rows = rows.filter((r) => r.toId === query.toId)
734
+ if (query?.fromTeam !== undefined && query?.fromTeam !== null) rows = rows.filter((r) => r.fromTeam === query.fromTeam)
735
+ if (query?.taskId !== undefined && query?.taskId !== null) rows = rows.filter((r) => r.taskId === query.taskId)
736
+ return clone(rows)
737
+ }
738
+
505
739
  // ── API:读 ────────────────────────────────────────────────────────────────
506
740
 
507
741
  function list(query = {}) {
@@ -513,6 +747,113 @@ export function createTeams(ctx, config = {}) {
513
747
  return clone(rows.map((r) => (r.adminPolicy ? r : { ...r, adminPolicy: DEFAULT_TEAM_ADMIN_POLICY })))
514
748
  }
515
749
 
750
+ /**
751
+ * 按房间 id 反查绑定它的团队(t-39 第2批 C)。
752
+ *
753
+ * 用途:Boss 在 cs-bots 团队的议事厅发言 ⇒ 知道该同步给哪个原生 team。
754
+ * 一个房间只可能被一个团队绑定(`attachRoom` 是 CAS 单值覆盖)。
755
+ *
756
+ * @param {string} roomId
757
+ * @returns {object|null} 团队记录(含 `nativeTeamId`),没绑定 ⇒ null
758
+ */
759
+ function findByRoom(roomId) {
760
+ assertAlive('findByRoom')
761
+ if (typeof roomId !== 'string' || roomId.length === 0) return null
762
+ for (const t of entries.values()) {
763
+ if (t.roomId === roomId) return get(t.id)
764
+ }
765
+ return null
766
+ }
767
+
768
+ /**
769
+ * **发言同步给 DSH 原生 team 的 Lead**(t-39 第2批 C · Boss 体验 item 5)。
770
+ *
771
+ * ### 为什么要这个
772
+ * Boss 在 cs-bots 团队的议事厅发言,原生 team 的 Lead 感知不到 —— 因为两套
773
+ * 团队模型是**分离的**(cs-bots `teams.jsonl` vs DSH `agentTeam`),消息不会自动
774
+ * 跨过去。这个方法负责把这条链补上。
775
+ *
776
+ * ### 链路(lead 批准的实现方向,2026-11-11)
777
+ * Boss 在 cs-bots 房间发言 → `findByRoom(roomId)` 反查绑定团队
778
+ * → 取该团队的 `nativeTeamId` → 投递给原生 team 的 Lead。
779
+ *
780
+ * **为什么投递 target = nativeTeamId 就是投递给 Lead**:
781
+ * DSH 原生 `TeamId` 就是 root session id(`TeamId(id: SessionId|string)`,
782
+ * 且 `tryMembership(agent).root.id === membership.id`)。所以原生 team 的 Lead
783
+ * 会话 id 就等于这个 TeamId。投递 root = 投递 Lead,走的是 root 路径,**合法**。
784
+ *
785
+ * ### 为什么用 dispatcher 而不是 agentTeams.sendMessage
786
+ * 官方 `sendMessage(caller, ...)` 需要 Lead 身份的 `Agent` 作为凭证,且校验
787
+ * Lead→child 边 —— 队友不能给 Lead 发消息。cs-bots 是 root session,用
788
+ * `dispatcher.followup(root)` 投递是 root 路径,不冒充任何人。
789
+ *
790
+ * ### 诚实原则(硬要求)
791
+ * 同步失败**绝不**返回 `ok:false`,也**绝不**假装成功:
792
+ * `{ ok: true, synced: false, reason }` —— 房间消息本身已经成功落盘,
793
+ * 同步是附加动作,失败必须可见但不影响消息。
794
+ *
795
+ * reason 取值(给前端 A 的确定形状):
796
+ * - `NATIVE_TEAM_NOT_FOUND` 房间没绑任何 cs-bots 团队
797
+ * - `NATIVE_TEAM_NOT_BOUND` 团队存在但 `nativeTeamId` 为 null(要先配置绑定)
798
+ * - `SYNC_DISPATCHER_UNAVAILABLE` 装配层没注入 dispatcher
799
+ * - `SYNC_DEDUP_HIT` 这条消息之前已同步过(幂等,不算新同步也不算失败)
800
+ * - `SYNC_DELIVERY_FAILED` 投递失败(dispatcher 返回 failed)
801
+ * - `SYNCED` 同步成功
802
+ *
803
+ * @param {{ roomId: string, messageId?: string, text?: string, by: string,
804
+ * dispatcher: object }} input
805
+ * @returns {{ ok: true, synced: boolean, reason: string,
806
+ * teamId?: string, nativeTeamId?: string, leadId?: string, wake?: object }}
807
+ */
808
+ function syncToNativeLead(input = {}) {
809
+ assertAlive('syncToNativeLead')
810
+ const { roomId, messageId, text, by, dispatcher } = input ?? {}
811
+ requireString(by, 'by', 'syncToNativeLead')
812
+
813
+ // ① 反查房间属于哪个 cs-bots 团队(一个房间只可能被一个团队绑定)
814
+ const team = findByRoom(roomId)
815
+ if (!team) {
816
+ return { ok: true, synced: false, reason: 'NATIVE_TEAM_NOT_FOUND', detail: '房间未绑定任何 cs-bots 团队' }
817
+ }
818
+
819
+ // ② 团队有没有绑定 DSH 原生 team —— 这是同步的唯一依据
820
+ if (!team.nativeTeamId) {
821
+ return {
822
+ ok: true, synced: false, reason: 'NATIVE_TEAM_NOT_BOUND',
823
+ teamId: team.id, detail: '团队未绑定 DSH 原生 team,请先在团队详情里配置 nativeTeamId',
824
+ }
825
+ }
826
+
827
+ // ③ dispatcher 在不在(缺失 ⇒ 无法投递,但不是消息本身的错)
828
+ if (!dispatcher || typeof dispatcher.notify !== 'function') {
829
+ return {
830
+ ok: true, synced: false, reason: 'SYNC_DISPATCHER_UNAVAILABLE',
831
+ teamId: team.id, nativeTeamId: team.nativeTeamId,
832
+ }
833
+ }
834
+
835
+ // ④ 投递给原生 team 的 Lead(root session id == nativeTeamId)
836
+ // dedupKey 用消息 id —— 同一消息重复 post 不会重复同步(幂等)。
837
+ const dedupKey = `lead-sync:${messageId ?? roomId}:${team.id}`
838
+ const wake = dispatcher.notify({
839
+ target: team.nativeTeamId,
840
+ reason: 'room-message',
841
+ dedupKey,
842
+ text: `[cs-bots 团队发言同步] 「${team.name}」房间新消息:${String(text ?? '').slice(0, 200)}`,
843
+ teamId: team.id,
844
+ })
845
+
846
+ // ⑤ 诚实汇报:只有真的发出去才算 synced:true
847
+ const leadId = team.nativeTeamId
848
+ if (wake?.ok === true && wake?.result === 'sent') {
849
+ return { ok: true, synced: true, reason: 'SYNCED', teamId: team.id, nativeTeamId: leadId, leadId, wake }
850
+ }
851
+ if (wake?.ok === true && wake?.result === 'skipped') {
852
+ return { ok: true, synced: false, reason: 'SYNC_DEDUP_HIT', teamId: team.id, nativeTeamId: leadId, leadId, wake }
853
+ }
854
+ return { ok: true, synced: false, reason: 'SYNC_DELIVERY_FAILED', teamId: team.id, nativeTeamId: leadId, leadId, wake }
855
+ }
856
+
516
857
  function get(id) {
517
858
  assertAlive('get')
518
859
  const t = entries.get(id)
@@ -548,8 +889,16 @@ export function createTeams(ctx, config = {}) {
548
889
  // 契约 §3:把「议事厅」绑到团队上。api.js 的 ensureTeamRoom 会调它;
549
890
  // 不导出 ⇒ 房间建好了但团队记不住 roomId,重启后自动配厅就永远重配一遍。
550
891
  attachRoom,
892
+ // 契约 t-39:跨团队路由(只决定 + 留痕,投递由工具层用 dispatcher 完成)
893
+ resolveRoute,
894
+ recordRoute,
895
+ listRoutes,
896
+ // 一条龙:工具层与 HTTP 层共用的唯一序列编排
897
+ deliverRoute,
551
898
  list,
552
899
  get,
900
+ findByRoom,
901
+ syncToNativeLead,
553
902
  stats,
554
903
  replay,
555
904
  diagnostics: () => clone(diagnostics),
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:
@@ -1132,7 +1205,7 @@ const DEFS = [
1132
1205
  },
1133
1206
  {
1134
1207
  name: 'bot_teams_add',
1135
- description: '向团队添加成员(需带 expectedRev 做 CAS)。',
1208
+ description: '向团队添加成员(需带 expectedRev 做 CAS)。若该团队已绑定房间(roomId),自动把成员拉进房间。',
1136
1209
  parameters: {
1137
1210
  type: 'object',
1138
1211
  properties: {
@@ -1146,7 +1219,18 @@ const DEFS = [
1146
1219
  run: (s, a, exec) => {
1147
1220
  if (!s.teams) return failure('teams-unavailable')
1148
1221
  try {
1149
- return success({ team: s.teams.addMember({ ...a, by: callerOf(exec) }) })
1222
+ const team = s.teams.addMember({ ...a, by: callerOf(exec) })
1223
+ // ── ③ bot_teams_add 补 attachRoom:若团队有 roomId,自动拉人进房间(失败不阻断主流程)──
1224
+ // 参照 api.js:1555 附近的逻辑:只回报 roomError,不让它把「加入团队成功」变成失败。
1225
+ let roomError
1226
+ if (team.roomId && s.room) {
1227
+ try {
1228
+ s.room.join({ roomId: team.roomId, sessionId: a.memberId, byId: callerOf(exec) })
1229
+ } catch (e) {
1230
+ roomError = `room.join 失败(${e?.code ?? 'room-join-failed'}):${e?.message ?? String(e)}`
1231
+ }
1232
+ }
1233
+ return success({ team, roomError })
1150
1234
  } catch (error) {
1151
1235
  return failure(error?.code ?? 'teams-add-failed', { message: error?.message ?? String(error) })
1152
1236
  }
@@ -1223,6 +1307,41 @@ const DEFS = [
1223
1307
  }
1224
1308
  },
1225
1309
  },
1310
+ // ── A. bot_team_route(t-39 第2批 · 跨团队定向路由)─────────────────────────
1311
+ {
1312
+ name: 'bot_team_route',
1313
+ description:
1314
+ '跨团队定向路由:把任务/消息路由给另一个团队或某个 Bot 所在团队的 lead。' +
1315
+ '只唤醒对方 leadId,不唤醒全部成员。先落 route 事件再投递(intent-first)。' +
1316
+ '失败一律返回可读的 {ok:false, decision:"refused", reason},绝不静默。',
1317
+ parameters: {
1318
+ type: 'object',
1319
+ properties: {
1320
+ fromTeam: { type: 'string', description: '发送方团队 id(可选;缺省按执行者会话 id 推导)' },
1321
+ toTeam: { type: 'string', description: '目标团队 id' },
1322
+ toBot: { type: 'string', description: '目标 Bot 的会话 id(toTeam 与 toBot 二选一)' },
1323
+ taskId: {
1324
+ type: 'string',
1325
+ description: '关联任务 id(可选;缺省自动生成 route-<时间戳>——页面「发协作请求」不一定挂任务)。'
1326
+ + '同一轮重发请复用同一 taskId,否则会被当成新路由(去重键含 taskId)',
1327
+ },
1328
+ text: { type: 'string', description: '要投递的正文内容' },
1329
+ hop: { type: 'number', description: '当前跳数(>3 拒绝;缺省 0)' },
1330
+ },
1331
+ additionalProperties: false,
1332
+ },
1333
+ run: (s, a, exec) => {
1334
+ if (!s.teams) return failure('teams-unavailable')
1335
+ // 唯一的序列编排在 `teams.deliverRoute()`(决定 → 投递 → 留痕)。
1336
+ // 工具层与 `POST /teams/route` 共用它 —— 两路各写一遍行为就会漂
1337
+ // (一边去重了、一边没去重;一边先投后记、一边先记后投)。
1338
+ return s.teams.deliverRoute({
1339
+ ...a,
1340
+ by: callerOf(exec),
1341
+ dispatcher: s.dispatcher,
1342
+ })
1343
+ },
1344
+ },
1226
1345
  ]
1227
1346
 
1228
1347