cs-bots 0.7.5 → 0.9.0

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'])
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
 
@@ -474,6 +505,239 @@ export function createTeams(ctx, config = {}) {
474
505
 
475
506
  // ── API:读 ────────────────────────────────────────────────────────────────
476
507
 
508
+ /**
509
+ * 给团队挂上一个房间(契约 §3:团队自动配议事厅)。
510
+ *
511
+ * 幂等:同一个 roomId 再调一次 ⇒ 直接返回现有团队(rev 不变)。
512
+ * 房间已经存在、只是想补关联时**不重复写事件** —— 否则 `/teams` 反复保存
513
+ * 会把事件流刷成一条重复行,rev 也跟着虚增,页面上的「谁改了团队」就失真了。
514
+ * @param {{ id: string, expectedRev?: number, roomId: string, by: string, now?: number }} input
515
+ */
516
+ function attachRoom(input = {}) {
517
+ assertAlive('attachRoom')
518
+ const { id, expectedRev, roomId, by, now } = input ?? {}
519
+ const author = requireString(by, 'by', 'attachRoom')
520
+ const at = normalizeNow(now, 'attachRoom')
521
+ const room = requireString(roomId, 'roomId', 'attachRoom')
522
+ const current = cas(id, expectedRev, 'attachRoom')
523
+ if (current.roomId === room) {
524
+ return clone(current) // 幂等:已关联同一房间,不重复落事件
525
+ }
526
+ const next = clone(current)
527
+ next.rev = current.rev + 1
528
+ next.roomId = room
529
+ next.updatedAt = at
530
+ next.updatedBy = author
531
+ commit('attachRoom', at, author, clone(current), next)
532
+ info(`[cs-bots/teams] 团队 ${id} 关联房间 ${room}(by ${author})`)
533
+ return clone(next)
534
+ }
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
+
739
+ // ── API:读 ────────────────────────────────────────────────────────────────
740
+
477
741
  function list(query = {}) {
478
742
  assertAlive('list')
479
743
  let rows = [...entries.values()]
@@ -483,6 +747,113 @@ export function createTeams(ctx, config = {}) {
483
747
  return clone(rows.map((r) => (r.adminPolicy ? r : { ...r, adminPolicy: DEFAULT_TEAM_ADMIN_POLICY })))
484
748
  }
485
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
+
486
857
  function get(id) {
487
858
  assertAlive('get')
488
859
  const t = entries.get(id)
@@ -515,8 +886,19 @@ export function createTeams(ctx, config = {}) {
515
886
  note,
516
887
  close,
517
888
  setPolicy,
889
+ // 契约 §3:把「议事厅」绑到团队上。api.js 的 ensureTeamRoom 会调它;
890
+ // 不导出 ⇒ 房间建好了但团队记不住 roomId,重启后自动配厅就永远重配一遍。
891
+ attachRoom,
892
+ // 契约 t-39:跨团队路由(只决定 + 留痕,投递由工具层用 dispatcher 完成)
893
+ resolveRoute,
894
+ recordRoute,
895
+ listRoutes,
896
+ // 一条龙:工具层与 HTTP 层共用的唯一序列编排
897
+ deliverRoute,
518
898
  list,
519
899
  get,
900
+ findByRoom,
901
+ syncToNativeLead,
520
902
  stats,
521
903
  replay,
522
904
  diagnostics: () => clone(diagnostics),
package/src/tools.js CHANGED
@@ -30,7 +30,8 @@ function success(payload) {
30
30
  * 从宿主上下文取本平台的域服务。
31
31
  * @param {object} ctx
32
32
  * @returns {{ board?: object, roster?: object, room?: object, memory?: object, dispatcher?: object,
33
- * todo?: object, scratch?: object, prompts?: object, discipline?: object, suggest?: object }}
33
+ * todo?: object, scratch?: object, prompts?: object, discipline?: object, suggest?: object,
34
+ * phaseWrite?: (taskId: string, patch: object) => object }}
34
35
  */
35
36
  function servicesOf(ctx) {
36
37
  return getServices(ctx)
@@ -258,6 +259,80 @@ const DEFS = [
258
259
  },
259
260
  },
260
261
 
262
+ // ── 阶段产出物(契约 §2.4,纯增量)──────────────────────────────────────────
263
+ {
264
+ name: 'bot_task_phase',
265
+ description:
266
+ '边做边交阶段产出物:每完成一个阶段就调用一次,带上这一阶段交出的文件路径/链接。' +
267
+ '【重要】不要等到整条任务做完才汇报 —— 阶段产物是给 Boss 点开就能看到的东西,' +
268
+ '分阶段交付(doing = 正在做这一阶段 / done = 这一阶段交完 / blocked = 卡在这一阶段)。' +
269
+ '需要 B 侧的 `services.phaseWrite(taskId, patch)` 能力;后端未注入时返回 failure,' +
270
+ '不会静默成功。',
271
+ parameters: {
272
+ type: 'object',
273
+ properties: {
274
+ id: { type: 'string', description: '任务 id' },
275
+ index: { type: 'number', description: '阶段下标(从 0 开始,必填;越界会被后端拒)' },
276
+ state: {
277
+ type: 'string',
278
+ enum: ['doing', 'done', 'blocked'],
279
+ description: '该阶段状态(默认 done)',
280
+ },
281
+ artifacts: {
282
+ type: 'array',
283
+ items: { type: 'string' },
284
+ description: '这一阶段交出的文件路径/URL(可为空数组,表示「这一阶段没产出物」)',
285
+ },
286
+ note: { type: 'string', description: '一句话说明' },
287
+ expectedRev: { type: 'number', description: '当前任务 rev(可选,CAS)' },
288
+ },
289
+ required: ['id', 'index'],
290
+ additionalProperties: false,
291
+ },
292
+ run: (s, a, exec) => {
293
+ // 契约 §2.4:阶段写入能力由 B 侧 installApi 挂到 services.phaseWrite。
294
+ // 拿不到就是拿不到 —— **绝不静默成功**,返回 failure 让调用方看见。
295
+ const phaseWrite = s?.phaseWrite
296
+ if (typeof phaseWrite !== 'function') {
297
+ return failure('phase-unavailable', {
298
+ hint: '后端未就绪:services.phaseWrite 未注入(应由 api.js 挂载)。'
299
+ + '请稍后重试;或先用 bot_task_update 交付总产出物。',
300
+ })
301
+ }
302
+ if (typeof a?.id !== 'string' || a.id === '') {
303
+ return failure('bad-argument', { field: 'id', hint: 'id 必填' })
304
+ }
305
+ const index = a?.index
306
+ if (typeof index !== 'number' || !Number.isInteger(index) || index < 0) {
307
+ return failure('bad-argument', { field: 'index', hint: 'index 必填,且必须是 >=0 的整数' })
308
+ }
309
+ const state = a?.state === undefined ? 'done' : a.state
310
+ if (state !== 'doing' && state !== 'done' && state !== 'blocked') {
311
+ return failure('bad-argument', { field: 'state', hint: 'state 只允许 doing | done | blocked' })
312
+ }
313
+ try {
314
+ // `by` 由调用者身份得出(事实留痕,不猜);`expectedRev` 若给了就带上做 CAS。
315
+ const res = phaseWrite(a.id, {
316
+ index,
317
+ state,
318
+ artifacts: a?.artifacts,
319
+ note: a?.note,
320
+ by: callerOf(exec),
321
+ expectedRev: a?.expectedRev,
322
+ })
323
+ if (!res || typeof res !== 'object') {
324
+ return failure('phase-write-failed', { message: 'phaseWrite 返回了非法结果' })
325
+ }
326
+ return res
327
+ } catch (error) {
328
+ // 写不进 task-meta.jsonl 必须让模型看见(不许吞)
329
+ return failure(error?.code ?? 'phase-write-failed', {
330
+ message: error?.message ?? String(error),
331
+ })
332
+ }
333
+ },
334
+ },
335
+
261
336
  // ── 唤醒桥(Dispatcher)────────────────────────────────────────────────────
262
337
  {
263
338
  name: 'bot_wake',
@@ -1057,7 +1132,7 @@ const DEFS = [
1057
1132
  },
1058
1133
  {
1059
1134
  name: 'bot_teams_add',
1060
- description: '向团队添加成员(需带 expectedRev 做 CAS)。',
1135
+ description: '向团队添加成员(需带 expectedRev 做 CAS)。若该团队已绑定房间(roomId),自动把成员拉进房间。',
1061
1136
  parameters: {
1062
1137
  type: 'object',
1063
1138
  properties: {
@@ -1071,7 +1146,18 @@ const DEFS = [
1071
1146
  run: (s, a, exec) => {
1072
1147
  if (!s.teams) return failure('teams-unavailable')
1073
1148
  try {
1074
- return success({ team: s.teams.addMember({ ...a, by: callerOf(exec) }) })
1149
+ const team = s.teams.addMember({ ...a, by: callerOf(exec) })
1150
+ // ── ③ bot_teams_add 补 attachRoom:若团队有 roomId,自动拉人进房间(失败不阻断主流程)──
1151
+ // 参照 api.js:1555 附近的逻辑:只回报 roomError,不让它把「加入团队成功」变成失败。
1152
+ let roomError
1153
+ if (team.roomId && s.room) {
1154
+ try {
1155
+ s.room.join({ roomId: team.roomId, sessionId: a.memberId, byId: callerOf(exec) })
1156
+ } catch (e) {
1157
+ roomError = `room.join 失败(${e?.code ?? 'room-join-failed'}):${e?.message ?? String(e)}`
1158
+ }
1159
+ }
1160
+ return success({ team, roomError })
1075
1161
  } catch (error) {
1076
1162
  return failure(error?.code ?? 'teams-add-failed', { message: error?.message ?? String(error) })
1077
1163
  }
@@ -1148,6 +1234,41 @@ const DEFS = [
1148
1234
  }
1149
1235
  },
1150
1236
  },
1237
+ // ── A. bot_team_route(t-39 第2批 · 跨团队定向路由)─────────────────────────
1238
+ {
1239
+ name: 'bot_team_route',
1240
+ description:
1241
+ '跨团队定向路由:把任务/消息路由给另一个团队或某个 Bot 所在团队的 lead。' +
1242
+ '只唤醒对方 leadId,不唤醒全部成员。先落 route 事件再投递(intent-first)。' +
1243
+ '失败一律返回可读的 {ok:false, decision:"refused", reason},绝不静默。',
1244
+ parameters: {
1245
+ type: 'object',
1246
+ properties: {
1247
+ fromTeam: { type: 'string', description: '发送方团队 id(可选;缺省按执行者会话 id 推导)' },
1248
+ toTeam: { type: 'string', description: '目标团队 id' },
1249
+ toBot: { type: 'string', description: '目标 Bot 的会话 id(toTeam 与 toBot 二选一)' },
1250
+ taskId: {
1251
+ type: 'string',
1252
+ description: '关联任务 id(可选;缺省自动生成 route-<时间戳>——页面「发协作请求」不一定挂任务)。'
1253
+ + '同一轮重发请复用同一 taskId,否则会被当成新路由(去重键含 taskId)',
1254
+ },
1255
+ text: { type: 'string', description: '要投递的正文内容' },
1256
+ hop: { type: 'number', description: '当前跳数(>3 拒绝;缺省 0)' },
1257
+ },
1258
+ additionalProperties: false,
1259
+ },
1260
+ run: (s, a, exec) => {
1261
+ if (!s.teams) return failure('teams-unavailable')
1262
+ // 唯一的序列编排在 `teams.deliverRoute()`(决定 → 投递 → 留痕)。
1263
+ // 工具层与 `POST /teams/route` 共用它 —— 两路各写一遍行为就会漂
1264
+ // (一边去重了、一边没去重;一边先投后记、一边先记后投)。
1265
+ return s.teams.deliverRoute({
1266
+ ...a,
1267
+ by: callerOf(exec),
1268
+ dispatcher: s.dispatcher,
1269
+ })
1270
+ },
1271
+ },
1151
1272
  ]
1152
1273
 
1153
1274