cs-bots 0.1.1 → 0.7.5

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.
Files changed (48) hide show
  1. package/CHANGELOG.md +539 -0
  2. package/README.md +65 -10
  3. package/docs/01-/346/236/266/346/236/204/346/226/207/346/241/243.md +364 -0
  4. package/docs/02-/351/234/200/346/261/202/350/247/204/346/240/274/344/271/246.md +284 -0
  5. package/docs/03-/350/257/246/347/273/206/350/256/276/350/256/241.md +567 -0
  6. package/docs/04-/346/223/215/344/275/234/346/211/213/345/206/214.md +569 -0
  7. package/docs/ARCHITECTURE.md +277 -0
  8. package/docs/adr/ADR-001-/351/241/265/347/255/276/347/273/223/346/236/204/345/233/236/345/210/260/344/270/203/351/241/265.md +70 -0
  9. package/docs/steps/step-01-/345/216/237/346/236/266/346/236/204/345/244/215/345/216/237/344/270/216IA/350/257/212/346/226/255.md +80 -0
  10. package/docs/steps/step-02-/351/241/265/347/255/276/346/224/266/346/225/233/344/270/216/345/215/207/347/272/247/351/200/232/351/201/223.md +51 -0
  11. package/docs/steps/step-03-/346/236/266/346/236/204/350/265/204/346/226/231/346/210/220/346/226/207.md +16 -0
  12. package/docs/steps/step-04-/344/270/212/347/272/277/344/270/216/351/252/214/350/257/201.md +46 -0
  13. package/docs//344/272/244/344/273/230/350/257/264/346/230/216-v0.2.0.md +95 -0
  14. package/docs//346/223/215/344/275/234/346/211/213/345/206/214-v1.md +509 -0
  15. package/docs//350/277/201/347/247/273/346/226/271/346/241/210-v1.md +64 -0
  16. package/docs//351/225/277/346/234/237/350/256/260/345/277/206/346/226/271/346/241/210-v1.md +85 -0
  17. package/package.json +4 -2
  18. package/src/admission.js +365 -0
  19. package/src/api.js +4097 -420
  20. package/src/board.js +883 -14
  21. package/src/botprofile.js +190 -3
  22. package/src/client.js +6596 -1082
  23. package/src/cycle.js +516 -0
  24. package/src/delivery-watch.js +255 -0
  25. package/src/dirbrowse.js +20 -0
  26. package/src/discipline.js +1188 -0
  27. package/src/fence.js +142 -0
  28. package/src/index.js +387 -4
  29. package/src/kernel.js +636 -24
  30. package/src/library.js +1224 -11
  31. package/src/memory.js +617 -37
  32. package/src/metrics.js +339 -0
  33. package/src/prompts.js +1318 -0
  34. package/src/reviewer.js +709 -0
  35. package/src/room.js +655 -14
  36. package/src/roster.js +346 -26
  37. package/src/routing/complexity.js +320 -0
  38. package/src/routing/hints.js +707 -0
  39. package/src/scratch.js +477 -0
  40. package/src/settings.js +97 -0
  41. package/src/stage.js +263 -0
  42. package/src/store.js +133 -1
  43. package/src/subscription.js +571 -0
  44. package/src/suggest.js +1203 -0
  45. package/src/teams.js +527 -0
  46. package/src/todo.js +1301 -0
  47. package/src/tools.js +734 -10
  48. package/src/verify.js +356 -0
package/src/teams.js ADDED
@@ -0,0 +1,527 @@
1
+ /**
2
+ * cs-bots TeamsService —— 大团队(Lead 聚合管理)与小团队(智能体自建)统一层。
3
+ *
4
+ * 为什么需要它:宿主的 Team 模型是「一个 root lead 会话 + 若干成员会话」,
5
+ * **不支持嵌套团队、没有管理者角色**(lead 即管理者,且成员必须是宿主 spawn 出的子代理)。
6
+ * 因此「把几个 Lead Bot 聚合成一个可管理的大团队」原生不成立 —— 本模块在 cs-bots 的
7
+ * roster 之上自建这一层。
8
+ *
9
+ * 两类团队(对应 Team Topologies 的两种组织形态):
10
+ * - **大团队(big)**:人建的、跨多个 lead bot 的稳定协作结构。人管成员、派任务、看全局。
11
+ * - **小团队(small)**:智能体自建的、临时的。人可查看/解散,一般不主动派任务。
12
+ *
13
+ * 不变量:事件流真相源 / 只追加 / 绝不静默失败 / CAS。
14
+ * 依赖面:node 内置 + `./store.js` + `./kernel.js`。零第三方依赖。
15
+ */
16
+
17
+ import { appendJsonl, readJsonl, statePath, writeJsonAtomic } from './store.js'
18
+
19
+ let kernel = null
20
+ let kernelError = null
21
+ try {
22
+ kernel = await import('./kernel.js')
23
+ } catch (error) {
24
+ kernelError = error
25
+ }
26
+
27
+ /** 团队类型。 */
28
+ export const TEAM_TYPES = Object.freeze(['big', 'small'])
29
+
30
+ /** 团队成员角色。 */
31
+ export const TEAM_ROLES = Object.freeze(['lead', 'member'])
32
+
33
+ /**
34
+ * 团队管理权限边界(2026-11-09 需求:「管理员权限边界从页面控制」)。
35
+ *
36
+ * 团队只有 lead / member 两种角色,**lead 即管理员**;在此之前「谁能管理团队」这个概念
37
+ * 根本不存在 —— 任何人只要知道 id 就能调 `removeMember` 把人踢掉。本字段把这条边界
38
+ * 变成团队级、可审计、可从页面切换的设置:
39
+ *
40
+ * - `'lead-only'` :只有负责人(leadId)或「人」能改团队(addMember/removeMember/setRole/close/note)
41
+ * - `'any-member'` :任何成员都能改,但**踢负责人永远禁止**(不变量,与策略无关)
42
+ * - `'open'` :不做校验(= 历史行为,留作逃生阀)
43
+ */
44
+ export const TEAM_ADMIN_POLICIES = Object.freeze(['lead-only', 'any-member', 'open'])
45
+
46
+ /**
47
+ * 缺省策略 = `'open'`。
48
+ *
49
+ * 为什么不是 `'lead-only'`:这个字段是**新加**的,而 teams.jsonl 里已经躺着历史团队、
50
+ * `src/tools.js` 的 `bot_teams_add` 也在用(by = 执行者会话 id)。把缺省改成 lead-only
51
+ * 等于给所有既有调用面**静默降权**,且既有测试会被迫改写以迁就新默认 —— 那说明改的是
52
+ * 契约而不是加能力。缺省 `'open'` 保证「升级不改变任何既有行为」,再由**页面**把新建
53
+ * 团队显式设成 `'lead-only'`(TeamsTab 的创建表单默认值),安全的默认值落在真正需要
54
+ * 它的地方(人建的团队),不需要它的地方保持原样。这么做的代价是:服务层直调(工具/
55
+ * 脚本)默认不设防 —— 所以 `close`/`removeMember` 的关键不变量(负责人不可移除)做在
56
+ * 策略之外,任何策略下都生效。
57
+ */
58
+ export const DEFAULT_TEAM_ADMIN_POLICY = 'open'
59
+
60
+ /**
61
+ * 视为「人」的身份:人永远是超级管理员,不受任何策略限制。
62
+ * 与 `src/api.js` 的 OWNER(`'user'`)和页面层发来的 `'ui'` 对齐,另收 `'person'`。
63
+ */
64
+ const HUMAN_IDS = Object.freeze(['ui', 'user', 'person'])
65
+
66
+ /** 合法动作(含 setPolicy:策略变更本身也是要留痕的事件)。 */
67
+ const ACTIONS = Object.freeze(['create', 'addMember', 'removeMember', 'setRole', 'close', 'note', 'setPolicy'])
68
+
69
+ /** 结构化错误。 */
70
+ export class TeamError extends Error {
71
+ constructor(message, code = 'TEAM_ERROR', details) {
72
+ super(message)
73
+ this.name = 'TeamError'
74
+ this.code = code
75
+ if (details !== undefined) this.details = details
76
+ }
77
+ }
78
+
79
+ function clone(value) {
80
+ if (value === undefined || value === null) return value
81
+ return JSON.parse(JSON.stringify(value))
82
+ }
83
+
84
+ function normalizeNow(now, where) {
85
+ if (now === undefined || now === null) return Date.now()
86
+ if (typeof now !== 'number' || !Number.isFinite(now)) {
87
+ throw new TeamError(`teams.${where}:now 必须是有限数字,收到 ${JSON.stringify(now)}`, 'TEAM_BAD_FIELD', { where, field: 'now' })
88
+ }
89
+ return now
90
+ }
91
+
92
+ function requireString(value, field, where) {
93
+ if (typeof value !== 'string' || value.trim() === '') {
94
+ throw new TeamError(`teams.${where}:${field} 必须是非空字符串,收到 ${JSON.stringify(value)}`, 'TEAM_BAD_FIELD', { where, field })
95
+ }
96
+ return value.trim()
97
+ }
98
+
99
+ function stringList(value, field, where) {
100
+ if (value === undefined || value === null) return []
101
+ if (!Array.isArray(value)) {
102
+ throw new TeamError(`teams.${where}:${field} 必须是数组,收到 ${JSON.stringify(value)}`, 'TEAM_BAD_FIELD', { where, field })
103
+ }
104
+ return value.map((v) => requireString(v, `${field}[]`, where))
105
+ }
106
+
107
+ /** 校验并归一化管理策略(undefined/null → 缺省策略;非法值直接拒绝,绝不静默降级)。 */
108
+ function normalizePolicy(value, where) {
109
+ if (value === undefined || value === null) return DEFAULT_TEAM_ADMIN_POLICY
110
+ if (typeof value !== 'string' || !TEAM_ADMIN_POLICIES.includes(value)) {
111
+ throw new TeamError(
112
+ `teams.${where}:adminPolicy=${JSON.stringify(value)} 非法(合法:${TEAM_ADMIN_POLICIES.join(' | ')})`,
113
+ 'TEAM_BAD_POLICY', { where, field: 'adminPolicy', adminPolicy: value },
114
+ )
115
+ }
116
+ return value
117
+ }
118
+
119
+ /** 该身份是否是「人」(人 = 超级管理员,不受任何策略约束)。 */
120
+ function isHuman(by) {
121
+ return HUMAN_IDS.includes(by)
122
+ }
123
+
124
+ function resolveStateDir(config) {
125
+ if (typeof config?.stateDir === 'string' && config.stateDir.trim() !== '') return config.stateDir.trim()
126
+ if (kernel && typeof kernel.stateDir === 'function') return kernel.stateDir()
127
+ const reason = kernelError?.message ?? 'kernel.stateDir 不存在'
128
+ throw new TeamError(`teams.createTeams:既未提供 config.stateDir,也无法从 kernel.js 取得 stateDir(${reason})。`, 'TEAM_NO_STATE_DIR')
129
+ }
130
+
131
+ function splitArgs(ctx, config) {
132
+ const cfgGiven = config && typeof config === 'object' && Object.keys(config).length > 0
133
+ if (cfgGiven) return { context: ctx ?? {}, config }
134
+ if (ctx && typeof ctx === 'object' && typeof ctx.stateDir === 'string') return { context: {}, config: ctx }
135
+ return { context: ctx ?? {}, config: config ?? {} }
136
+ }
137
+
138
+ /**
139
+ * 创建团队服务。
140
+ * @param {{ logger?: { info?: Function, warn?: Function } }} [ctx]
141
+ * @param {{ stateDir?: string, name?: string }} [config]
142
+ * @returns {object} teams service
143
+ */
144
+ export function createTeams(ctx, config = {}) {
145
+ const { context, config: cfg } = splitArgs(ctx, config)
146
+ const stateDir = resolveStateDir(cfg)
147
+
148
+ const jsonlPath = statePath(stateDir, 'teams.jsonl')
149
+ const snapPath = statePath(stateDir, 'teams.snap.json')
150
+
151
+ const info = (...args) => { try { context?.logger?.info?.(...args) } catch { /* */ } }
152
+ const warn = (...args) => { try { context?.logger?.warn?.(...args) } catch { /* */ } }
153
+
154
+ const entries = new Map()
155
+ const events = []
156
+ let idCounter = 0
157
+ let disposed = false
158
+
159
+ const diagnostics = {
160
+ stateDir, jsonlPath, snapPath,
161
+ kernel: kernel ? 'loaded' : `load-failed:${kernelError?.name ?? 'unknown'}`,
162
+ hydratedFrom: null, replayedEvents: 0, badLines: 0,
163
+ snapshotWriteFailures: 0, lastReplayAt: undefined,
164
+ }
165
+
166
+ function assertAlive(where) {
167
+ if (disposed) {
168
+ throw new TeamError(`teams.${where}:实例已 dispose(),请重新 createTeams()(拒绝在已销毁实例上静默返回空数据)`, 'TEAM_DISPOSED', { where })
169
+ }
170
+ }
171
+
172
+ function snapshot() { return { version: 1, savedAt: Date.now(), entries: [...entries.values()] } }
173
+
174
+ function commit(action, at, by, before, after) {
175
+ if (!appendJsonl(jsonlPath, { id: after.id, rev: after.rev, action, by, at, before, after })) {
176
+ throw new TeamError(`teams.${action}:事件追加失败(${jsonlPath})—— 拒绝「只改内存不落盘」`, 'TEAM_APPEND_FAILED', { action })
177
+ }
178
+ entries.set(after.id, clone(after))
179
+ events.push({ id: after.id, rev: after.rev, action, by, at, before: before ? clone(before) : null, after: clone(after) })
180
+ if (!writeJsonAtomic(snapPath, snapshot())) {
181
+ diagnostics.snapshotWriteFailures += 1
182
+ warn(`[cs-bots/teams] 快照写入失败(${snapPath})——事件流已落盘,replay() 可恢复`)
183
+ }
184
+ }
185
+
186
+ function hydrate() {
187
+ entries.clear(); events.length = 0; idCounter = 0
188
+ const { rows, bad } = readJsonl(jsonlPath)
189
+ diagnostics.badLines = bad
190
+ diagnostics.replayedEvents = rows.length
191
+ if (bad > 0) warn(`[cs-bots/teams] ${jsonlPath} 有 ${bad} 行无法解析——已跳过,请核对`)
192
+ if (rows.length === 0) { diagnostics.hydratedFrom = 'empty'; return }
193
+ let skipped = 0
194
+ for (const ev of rows) {
195
+ if (!ev || typeof ev.id !== 'string' || !ev.after) { skipped += 1; continue }
196
+ const current = entries.get(ev.id)
197
+ if (current && Number.isFinite(ev.rev) && ev.rev <= current.rev) { skipped += 1; continue }
198
+ entries.set(ev.id, clone(ev.after)); events.push(clone(ev))
199
+ const m = /^team-(\d+)$/.exec(ev.id)
200
+ if (m) idCounter = Math.max(idCounter, Number(m[1]))
201
+ }
202
+ if (skipped > 0) warn(`[cs-bots/teams] 回放跳过 ${skipped} 条非递增/残缺事件`)
203
+ diagnostics.hydratedFrom = 'jsonl'
204
+ }
205
+
206
+ function requireTeam(id, where) {
207
+ const t = entries.get(id)
208
+ if (!t) throw new TeamError(`teams.${where}:团队 ${id} 不存在`, 'TEAM_NOT_FOUND', { where, id })
209
+ return t
210
+ }
211
+
212
+ function cas(id, expectedRev, where) {
213
+ const t = requireTeam(id, where)
214
+ if (expectedRev !== undefined && expectedRev !== null) {
215
+ if (!Number.isInteger(expectedRev)) {
216
+ throw new TeamError(`teams.${where}:expectedRev 必须是整数(当前 rev=${t.rev})`, 'TEAM_BAD_FIELD', { where, field: 'expectedRev' })
217
+ }
218
+ if (t.rev !== expectedRev) {
219
+ throw new TeamError(`teams.${where} CAS 冲突:团队 ${id} 期望 rev=${expectedRev},实际 rev=${t.rev} —— 拒绝静默覆盖`, 'TEAM_REV_CONFLICT', { where, id, expectedRev, rev: t.rev })
220
+ }
221
+ }
222
+ return t
223
+ }
224
+
225
+ /** 读团队的实际生效策略(历史团队没有该字段 → 缺省策略;绝不用 undefined 参与判断)。 */
226
+ function policyOf(team) {
227
+ return team?.adminPolicy ?? DEFAULT_TEAM_ADMIN_POLICY
228
+ }
229
+
230
+ /**
231
+ * 管理动作的权限闸(2026-11-09「管理员权限边界」)。
232
+ *
233
+ * 判定顺序(**人永远第一**):人 → 放行;`open` → 放行;负责人 → 放行;
234
+ * `any-member` 且调用者是成员 → 放行;其余一律 `TEAM_FORBIDDEN`。
235
+ *
236
+ * 为什么 `open` 要放行而不是「人 + 负责人」:它是给既有调用面留的逃生阀,
237
+ * 语义必须是「和加这个字段之前一模一样」,否则升级即破坏(含 tools.js 的
238
+ * `bot_teams_add`,它的 by 是执行者会话 id)。
239
+ *
240
+ * @param {object} team 团队记录
241
+ * @param {string} by 调用者身份('ui'|'user'|'person' = 人)
242
+ * @param {string} where 动作名(用于错误信息)
243
+ */
244
+ function assertAdmin(team, by, where) {
245
+ const adminPolicy = policyOf(team)
246
+ if (adminPolicy === 'open') return adminPolicy
247
+ if (isHuman(by)) return adminPolicy
248
+ if (team.leadId && by === team.leadId) return adminPolicy
249
+ if (adminPolicy === 'any-member' && (team.members ?? []).includes(by)) return adminPolicy
250
+ throw new TeamError(
251
+ `teams.${where}:${by} 无权管理团队 ${team.id}(当前策略 ${adminPolicy}:`
252
+ + `${adminPolicy === 'lead-only' ? '仅负责人可管理' : '仅团队成员可管理'};人不受限)`,
253
+ 'TEAM_FORBIDDEN',
254
+ { where, id: team.id, by, adminPolicy, leadId: team.leadId },
255
+ )
256
+ }
257
+
258
+ /**
259
+ * 不变量:负责人(leadId)永不可被移出团队。
260
+ *
261
+ * **与 adminPolicy 无关**——哪怕策略是 `open`、哪怕调用者是人。
262
+ * 依据:leadId 是团队的锚,移掉就得到一个没有负责人的团队(既无法定义管理边界,
263
+ * 也无法回答「谁能管」)。要换负责人请走 `setRole(..., role:'lead')`,
264
+ * 那条路径会把原负责人降级为成员,不会制造空洞。
265
+ */
266
+ function assertNotLead(team, memberId, where) {
267
+ if (team.leadId && team.leadId === memberId) {
268
+ throw new TeamError(
269
+ `teams.${where}:${memberId} 是团队 ${team.id} 的负责人,不能被移出(换负责人请用 setRole)`,
270
+ 'TEAM_LEAD_IMMUTABLE', { where, id: team.id, leadId: team.leadId, memberId },
271
+ )
272
+ }
273
+ }
274
+
275
+ // ── API:写 ────────────────────────────────────────────────────────────────
276
+
277
+ /**
278
+ * 创建一个团队。
279
+ * @param {{ name: string, type?: 'big'|'small', purpose?: string,
280
+ * memberIds?: string[], leadId?: string, adminPolicy?: 'lead-only'|'any-member'|'open',
281
+ * by: string, now?: number }} input
282
+ */
283
+ function create(input = {}) {
284
+ assertAlive('create')
285
+ const { name, type, purpose, memberIds, leadId, adminPolicy, by, now } = input ?? {}
286
+ const nameNorm = requireString(name, 'name', 'create')
287
+ if (nameNorm.length > 120) throw new TeamError(`teams.create:name ≤120 字符(收到 ${nameNorm.length})`, 'TEAM_BAD_FIELD', { field: 'name' })
288
+ const teamType = type ?? 'big'
289
+ if (!TEAM_TYPES.includes(teamType)) {
290
+ throw new TeamError(`teams.create:type=${JSON.stringify(type)} 非法(合法:${TEAM_TYPES.join(' | ')})`, 'TEAM_BAD_TYPE', { type })
291
+ }
292
+ const author = requireString(by, 'by', 'create')
293
+ const at = normalizeNow(now, 'create')
294
+ // 缺省 'open':升级不改变既有行为(见 DEFAULT_TEAM_ADMIN_POLICY 注释)
295
+ const policy = normalizePolicy(adminPolicy, 'create')
296
+
297
+ // big 团队必须有人管(leadId);small 团队是自建的,leadId 可选
298
+ const lead = teamType === 'big' ? requireString(leadId, 'leadId', 'create') : (leadId ?? null)
299
+ const members = stringList(memberIds, 'memberIds', 'create')
300
+ if (members.includes(lead)) {
301
+ throw new TeamError(`teams.create:leadId ${lead} 不能同时出现在 memberIds 中(会形成自引用)`, 'TEAM_BAD_FIELD', { field: 'memberIds' })
302
+ }
303
+
304
+ idCounter += 1
305
+ const id = `team-${idCounter}`
306
+ /** @type {Record<string, unknown>} */
307
+ const rec = {
308
+ id, rev: 1, name: nameNorm, type: teamType, purpose: purpose ?? '',
309
+ leadId: lead, members,
310
+ adminPolicy: policy,
311
+ status: 'open',
312
+ createdAt: at, createdBy: author, updatedAt: at, updatedBy: author,
313
+ }
314
+ commit('create', at, author, null, rec)
315
+ info(`[cs-bots/teams] 团队 ${id}(${nameNorm},${teamType})创建 by ${author}`)
316
+ return clone(rec)
317
+ }
318
+
319
+ /**
320
+ * 加入成员。管理权限由团队的 `adminPolicy` 决定(缺省 = open,行为与历史一致)。
321
+ * @param {{ id: string, expectedRev?: number, memberId: string, by: string, now?: number }} input
322
+ */
323
+ function addMember(input = {}) {
324
+ assertAlive('addMember')
325
+ const { id, expectedRev, memberId, by, now } = input ?? {}
326
+ const author = requireString(by, 'by', 'addMember')
327
+ const at = normalizeNow(now, 'addMember')
328
+ const member = requireString(memberId, 'memberId', 'addMember')
329
+ const current = cas(id, expectedRev, 'addMember')
330
+ assertAdmin(current, author, 'addMember')
331
+ if (current.status === 'closed') throw new TeamError(`teams.addMember:团队 ${id} 已关闭`, 'TEAM_CLOSED', { id })
332
+ if (current.members.includes(member)) {
333
+ throw new TeamError(`teams.addMember:成员 ${member} 已在团队 ${id} 中`, 'TEAM_DUPLICATE_MEMBER', { id, member })
334
+ }
335
+ const next = clone(current)
336
+ next.rev = current.rev + 1
337
+ next.members = [...current.members, member]
338
+ next.updatedAt = at
339
+ next.updatedBy = author
340
+ commit('addMember', at, author, clone(current), next)
341
+ return clone(next)
342
+ }
343
+
344
+ /**
345
+ * 移除成员。
346
+ * 两条独立的闸:权限闸(adminPolicy)+ 不变量闸(负责人永不可移除)。
347
+ * @param {{ id: string, expectedRev?: number, memberId: string, by: string, now?: number }} input
348
+ */
349
+ function removeMember(input = {}) {
350
+ assertAlive('removeMember')
351
+ const { id, expectedRev, memberId, by, now } = input ?? {}
352
+ const author = requireString(by, 'by', 'removeMember')
353
+ const at = normalizeNow(now, 'removeMember')
354
+ const member = requireString(memberId, 'memberId', 'removeMember')
355
+ const current = cas(id, expectedRev, 'removeMember')
356
+ assertAdmin(current, author, 'removeMember')
357
+ assertNotLead(current, member, 'removeMember')
358
+ const next = clone(current)
359
+ next.rev = current.rev + 1
360
+ next.members = current.members.filter((m) => m !== member)
361
+ next.updatedAt = at
362
+ next.updatedBy = author
363
+ commit('removeMember', at, author, clone(current), next)
364
+ return clone(next)
365
+ }
366
+
367
+ /**
368
+ * 设成员角色(lead/member)。
369
+ * @param {{ id: string, expectedRev?: number, memberId: string, role: string, by: string, now?: number }} input
370
+ */
371
+ function setRole(input = {}) {
372
+ assertAlive('setRole')
373
+ const { id, expectedRev, memberId, role, by, now } = input ?? {}
374
+ const author = requireString(by, 'by', 'setRole')
375
+ const at = normalizeNow(now, 'setRole')
376
+ if (!TEAM_ROLES.includes(role)) {
377
+ throw new TeamError(`teams.setRole:role=${JSON.stringify(role)} 非法(合法:${TEAM_ROLES.join(' | ')})`, 'TEAM_BAD_ROLE', { role })
378
+ }
379
+ const member = requireString(memberId, 'memberId', 'setRole')
380
+ const current = cas(id, expectedRev, 'setRole')
381
+ assertAdmin(current, author, 'setRole')
382
+ const next = clone(current)
383
+ next.rev = current.rev + 1
384
+ if (role === 'lead') {
385
+ next.leadId = member
386
+ // 原 lead 降级为 member
387
+ if (current.leadId && current.leadId !== member && !next.members.includes(current.leadId)) {
388
+ next.members = [...next.members, current.leadId]
389
+ }
390
+ } else {
391
+ // member:确保它在 members 里
392
+ if (!next.members.includes(member)) next.members = [...next.members, member]
393
+ }
394
+ next.updatedAt = at
395
+ next.updatedBy = author
396
+ commit('setRole', at, author, clone(current), next)
397
+ return clone(next)
398
+ }
399
+
400
+ /**
401
+ * 记一条团队备注(变更/决策留痕,append-only 语义:只追加一条 note 事件)。
402
+ * @param {{ id: string, expectedRev?: number, note: string, by: string, now?: number }} input
403
+ */
404
+ function note(input = {}) {
405
+ assertAlive('note')
406
+ const { id, expectedRev, note, by, now } = input ?? {}
407
+ const author = requireString(by, 'by', 'note')
408
+ const at = normalizeNow(now, 'note')
409
+ const text = requireString(note, 'note', 'note')
410
+ if (text.length > 4000) throw new TeamError(`teams.note:note ≤4000 字符(收到 ${text.length})`, 'TEAM_BAD_FIELD', { field: 'note' })
411
+ const current = cas(id, expectedRev, 'note')
412
+ assertAdmin(current, author, 'note')
413
+ const next = clone(current)
414
+ next.rev = current.rev + 1
415
+ next.notes = [...(current.notes ?? []), { text, by: author, at }]
416
+ next.updatedAt = at
417
+ next.updatedBy = author
418
+ commit('note', at, author, clone(current), next)
419
+ return clone(next)
420
+ }
421
+
422
+ /**
423
+ * 关闭团队。
424
+ * @param {{ id: string, expectedRev?: number, by: string, now?: number }} input
425
+ */
426
+ function close(input = {}) {
427
+ assertAlive('close')
428
+ const { id, expectedRev, by, now } = input ?? {}
429
+ const author = requireString(by, 'by', 'close')
430
+ const at = normalizeNow(now, 'close')
431
+ const current = cas(id, expectedRev, 'close')
432
+ assertAdmin(current, author, 'close')
433
+ const next = clone(current)
434
+ next.rev = current.rev + 1
435
+ next.status = 'closed'
436
+ next.closedAt = at
437
+ next.updatedAt = at
438
+ next.updatedBy = author
439
+ commit('close', at, author, clone(current), next)
440
+ return clone(next)
441
+ }
442
+
443
+ /**
444
+ * 改团队的「管理员权限边界」(CAS)。这是本次需求「从页面控制」的服务端落点。
445
+ *
446
+ * 权限:只有人或负责人能改策略(`assertAdmin`)。`open` 策略下任何人都能改 ——
447
+ * 那正是逃生阀该有的样子;`lead-only`/`any-member` 下非负责人会被 `TEAM_FORBIDDEN`。
448
+ * 注意:把策略从受限改成 `open` 用的也是这套判定(人/负责人可放行),不存在
449
+ * 「用策略把自己解锁」的路径 —— 想放宽必须先有管理权。
450
+ *
451
+ * @param {{ id: string, expectedRev?: number, adminPolicy: string, by: string, now?: number }} input
452
+ */
453
+ function setPolicy(input = {}) {
454
+ assertAlive('setPolicy')
455
+ const { id, expectedRev, adminPolicy, by, now } = input ?? {}
456
+ const author = requireString(by, 'by', 'setPolicy')
457
+ const at = normalizeNow(now, 'setPolicy')
458
+ const policy = normalizePolicy(adminPolicy, 'setPolicy')
459
+ const current = cas(id, expectedRev, 'setPolicy')
460
+ assertAdmin(current, author, 'setPolicy')
461
+ if (policyOf(current) === policy) {
462
+ // 幂等:同样的策略不产生新事件(避免事件流被无意义写入灌满)
463
+ return clone(current)
464
+ }
465
+ const next = clone(current)
466
+ next.rev = current.rev + 1
467
+ next.adminPolicy = policy
468
+ next.updatedAt = at
469
+ next.updatedBy = author
470
+ commit('setPolicy', at, author, clone(current), next)
471
+ info(`[cs-bots/teams] 团队 ${id} 管理策略 ${policyOf(current)} → ${policy}(by ${author})`)
472
+ return clone(next)
473
+ }
474
+
475
+ // ── API:读 ────────────────────────────────────────────────────────────────
476
+
477
+ function list(query = {}) {
478
+ assertAlive('list')
479
+ let rows = [...entries.values()]
480
+ if (query?.type !== undefined && query?.type !== null) rows = rows.filter((r) => r.type === query.type)
481
+ if (query?.status !== undefined && query?.status !== null) rows = rows.filter((r) => r.status === query.status)
482
+ // 老团队没有 adminPolicy 字段:补成缺省值,保证读侧永远拿得到(页面才不用写「可能是 undefined」)
483
+ return clone(rows.map((r) => (r.adminPolicy ? r : { ...r, adminPolicy: DEFAULT_TEAM_ADMIN_POLICY })))
484
+ }
485
+
486
+ function get(id) {
487
+ assertAlive('get')
488
+ const t = entries.get(id)
489
+ if (!t) return null
490
+ return clone(t.adminPolicy ? t : { ...t, adminPolicy: DEFAULT_TEAM_ADMIN_POLICY })
491
+ }
492
+
493
+ function stats() {
494
+ const byType = {}
495
+ const byStatus = {}
496
+ for (const r of entries.values()) {
497
+ byType[r.type] = (byType[r.type] ?? 0) + 1
498
+ byStatus[r.status] = (byStatus[r.status] ?? 0) + 1
499
+ }
500
+ return { total: entries.size, byType, byStatus, events: events.length, diagnostics: clone(diagnostics) }
501
+ }
502
+
503
+ function replay() { hydrate(); diagnostics.lastReplayAt = Date.now(); return { from: diagnostics.hydratedFrom, events: events.length, bad: diagnostics.badLines } }
504
+ function dispose() { disposed = true }
505
+
506
+ hydrate()
507
+
508
+ return {
509
+ name: cfg.name ?? 'teams',
510
+ paths: Object.freeze({ stateDir, jsonl: jsonlPath, snapshot: snapPath }),
511
+ create,
512
+ addMember,
513
+ removeMember,
514
+ setRole,
515
+ note,
516
+ close,
517
+ setPolicy,
518
+ list,
519
+ get,
520
+ stats,
521
+ replay,
522
+ diagnostics: () => clone(diagnostics),
523
+ dispose,
524
+ }
525
+ }
526
+
527
+ export default createTeams