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/api.js CHANGED
@@ -12,10 +12,18 @@
12
12
  * - **前缀路由**:`/cs-bots/api`(与包名一致,避免和其他插件撞路径)。
13
13
  */
14
14
 
15
- import { registerRoute, listPresets, defaultPreset } from './kernel.js'
15
+ import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs'
16
+ import { createHash } from 'node:crypto'
17
+ import { join, resolve, sep as pathSep } from 'node:path'
18
+ import { registerRoute, listPresets, defaultPreset, getAgent, resumeSession, readSessionMessages, attachToWorkspace, readSessionWatermark } from './kernel.js'
16
19
  import { createLibrary } from './library.js'
17
20
  import { createDirBrowser } from './dirbrowse.js'
18
21
  import { getServices } from './registry.js'
22
+ import { createFence } from './fence.js'
23
+ import { appendJsonl, readJsonl, statePath } from './store.js'
24
+ import { deriveStage, attachStages, stageSummary } from './stage.js'
25
+ import { CMD_WHITELIST } from './verify.js'
26
+ import { execFile } from 'node:child_process'
19
27
 
20
28
  /** 请求体上限(防大包打爆内存)。 */
21
29
  const MAX_BODY = 2 * 1024 * 1024
@@ -23,6 +31,49 @@ const MAX_BODY = 2 * 1024 * 1024
23
31
  /** API 前缀。 */
24
32
  export const API_BASE = '/cs-bots/api'
25
33
 
34
+ /**
35
+ * 模块级告警出口。
36
+ *
37
+ * `ensureLive()` 是模块级函数,拿不到 `installApi(ctx, { warn })` 里的 warn,
38
+ * 但 preset 装配(`resumeSession` 内部)的降级告警必须能说出来 —— 所以在这里
39
+ * 挂一个可变出口,由 `installApi` 在装配时注入(缺省静默,绝不抛)。
40
+ */
41
+ let moduleWarn = () => {}
42
+
43
+ /**
44
+ * 页面操作者身份。
45
+ * 域服务用 `'user'` 表示「人类所有者 = 超级管理员」(见 room.js 的 USER 常量)。
46
+ * 之前 API 传的是 `'ui'`,导致房间设置页一改就 `ROOM_NOT_MEMBER` —— 人类反而没权限。
47
+ */
48
+ const OWNER = 'user'
49
+
50
+ /** 纪律层级与分类(前端下拉直接用,避免两边写死不一致)。 */
51
+ const DISCIPLINE_SCOPES = ['global', 'workspace', 'room', 'bot']
52
+ const DISCIPLINE_KINDS = ['rule', 'requirement', 'principle', 'style']
53
+
54
+ /** PDCA 复盘阶段(前端展示用;与 cycle.js 的 CYCLE_PHASES 逐字一致)。 */
55
+ const CYCLE_PHASES = ['planned', 'doing', 'checking', 'acting', 'done']
56
+ /** 需求4:周期类型——human=人跟长任务,agent=智能体事后归纳。 */
57
+ const CYCLE_KINDS = ['human', 'agent']
58
+
59
+ /** 团队类型与角色(前端展示用;与 teams.js 的 TEAM_TYPES/TEAM_ROLES 逐字一致)。 */
60
+ const TEAM_TYPES = ['big', 'small']
61
+ const TEAM_ROLES = ['lead', 'member']
62
+ /** 团队管理策略(与 teams.js 的 TEAM_ADMIN_POLICIES 逐字一致;页面下拉直接用)。 */
63
+ const TEAM_ADMIN_POLICIES = ['lead-only', 'any-member', 'open']
64
+
65
+ /**
66
+ * 团队域错误 → HTTP 状态码。
67
+ * 只有「无权管理团队」升级成 403(语义正确,且与 /teams/memory 的 403 口径一致);
68
+ * 其余 TEAM_* 保持历史口径 400;非团队错误原样抛出(503/500 不受影响)。
69
+ */
70
+ function markTeamError(error) {
71
+ if (error && typeof error.code === 'string' && error.code.startsWith('TEAM_')) {
72
+ error.status = error.code === 'TEAM_FORBIDDEN' ? 403 : 400
73
+ }
74
+ throw error
75
+ }
76
+
26
77
  /** 读请求体(JSON)。 */
27
78
  async function readBody(request) {
28
79
  const chunks = []
@@ -77,6 +128,42 @@ async function withService(services, key, fn) {
77
128
  return fn(svc)
78
129
  }
79
130
 
131
+ /** 数据浏览:合法文件名/子目录(防目录穿越)。 */
132
+ const SAFE_SEGMENT = /^[A-Za-z0-9._-]+$/
133
+
134
+ /** 判断是否是安全的单段路径(无 `..`、无分隔符)。 */
135
+ function safeSegment(value) {
136
+ if (typeof value !== 'string' || value === '') return false
137
+ if (value === '.' || value === '..') return false
138
+ if (value.includes('..')) return false
139
+ return SAFE_SEGMENT.test(value)
140
+ }
141
+
142
+ /** 把 artifacts 归一成字符串数组(对象用单行 JSON)。 */
143
+ function normalizeArtifactItems(list) {
144
+ if (!Array.isArray(list)) return []
145
+ const out = []
146
+ for (const item of list) {
147
+ if (item === null || item === undefined) continue
148
+ if (typeof item === 'string') {
149
+ const t = item.trim()
150
+ if (t) out.push(t)
151
+ } else if (typeof item === 'object') {
152
+ try {
153
+ out.push(JSON.stringify(item))
154
+ } catch {
155
+ out.push(String(item))
156
+ }
157
+ } else {
158
+ out.push(String(item))
159
+ }
160
+ }
161
+ return out
162
+ }
163
+
164
+ /** 任务状态集合(与 board.js STATUSES 同口径,删除态不参与)。 */
165
+ const GRAPH_STATUSES = ['pending', 'in_progress', 'completed', 'blocked', 'deleted']
166
+
80
167
 
81
168
  /**
82
169
  * 把 Bot 档案里的「预制记忆」同步进记忆服务(带 botId)。
@@ -110,579 +197,4169 @@ function seedBotMemory(services, bot, by) {
110
197
  }
111
198
 
112
199
  /**
113
- * 注册全部 API 路由。
200
+ * 把一条建议**真正写进目标域**(建议确认制的"采纳"落地)。
114
201
  *
115
- * @param {object} ctx 宿主上下文
116
- * @param {{ warn?: Function }} [deps]
117
- * @returns {(() => void) | undefined} disposer
202
+ * 为什么放在 api 层而不是 suggest.js:`suggest.js` 刻意不 import memory/library/discipline,
203
+ * 避免循环依赖与越界写入。真正的写库动作由调用方以回调注入 —— 就是这里。
204
+ *
205
+ * 任一步抛错都会向上抛:`suggest.accept` 收到异常后会把该条**保持 pending**
206
+ * 并记 `lastError`,绝不静默标记成"已采纳"。
207
+ *
208
+ * @param {object} services
209
+ * @param {{ kind: string, layer?: string, payload?: object }} suggestion
210
+ * @param {string} by
118
211
  */
119
- export function installApi(ctx, { warn } = {}) {
120
- const handler = async (request, response) => {
121
- const services = getServices(ctx)
122
- let url
123
- try {
124
- url = new URL(request.url || '/', 'http://localhost')
125
- } catch {
126
- sendJson(response, 400, { ok: false, reason: 'bad-url' })
127
- return
212
+ function applySuggestion(services, suggestion, by) {
213
+ const kind = suggestion?.kind
214
+ const payload = suggestion?.payload ?? {}
215
+
216
+ // ⚠️ 必须是**同步**函数:suggest.accept 的 apply 回调若返回 Promise,
217
+ // 它会在真正落库前就把状态标成 accepted(假采纳)。而这里要写的三个域服务
218
+ // (memory / library / discipline)全部是同步内存写 + 同步落盘,所以可以同步完成。
219
+ const need = (key) => {
220
+ const svc = services?.[key]
221
+ if (!svc) {
222
+ const error = new Error(`${key} 服务未装配,无法落地这条建议`)
223
+ error.code = `${String(key).toUpperCase()}_UNAVAILABLE`
224
+ throw error
128
225
  }
129
- // 去掉前缀,得到相对路径
130
- const path = url.pathname.startsWith(API_BASE) ? url.pathname.slice(API_BASE.length) || '/' : url.pathname
226
+ return svc
227
+ }
131
228
 
132
- try {
133
- // ── 读:状态总览 ──────────────────────────────────────────────────────
134
- if (request.method === 'GET' && path === '/status') {
135
- const out = { ok: true, services: {}, compat: services.compat ?? null }
136
- for (const key of ['roster', 'board', 'dispatcher', 'room', 'memory']) {
137
- const svc = services[key]
138
- out.services[key] = svc
139
- ? {
140
- available: true,
141
- stats: typeof svc.stats === 'function' ? svc.stats() : undefined,
142
- stateDir: svc.paths?.stateDir,
143
- }
144
- : { available: false }
145
- }
146
- sendJson(response, 200, out)
147
- return
148
- }
229
+ if (kind === 'memory') {
230
+ const entry = need('memory').add({
231
+ layer: suggestion.layer ?? payload.layer ?? 'L4',
232
+ content: payload.content,
233
+ tags: payload.tags,
234
+ by,
235
+ cwd: payload.cwd,
236
+ })
237
+ return { kind, id: Array.isArray(entry) ? entry[0]?.id : entry?.id }
238
+ }
239
+ if (kind === 'skill') {
240
+ const skill = need('library').createSkill({
241
+ name: payload.name,
242
+ description: payload.description,
243
+ body: payload.body,
244
+ tags: payload.tags,
245
+ by,
246
+ })
247
+ return { kind, id: skill?.id }
248
+ }
249
+ if (kind === 'discipline') {
250
+ const item = need('discipline').createDiscipline({
251
+ scope: payload.scope,
252
+ scopeId: payload.scopeId,
253
+ kind: payload.disciplineKind ?? payload.kind ?? 'rule',
254
+ title: payload.title,
255
+ content: payload.content,
256
+ priority: payload.priority,
257
+ by,
258
+ })
259
+ return { kind, id: item?.id }
260
+ }
261
+ throw new Error(`applySuggestion:未知建议类型 ${JSON.stringify(kind)}(拒绝静默忽略)`)
262
+ }
149
263
 
150
- // ── 任务板 ────────────────────────────────────────────────────────────
151
- if (request.method === 'GET' && path === '/tasks') {
152
- const status = qp(url, 'status')
153
- const onlyReady = qp(url, 'ready') === '1'
154
- const tasks = await withService(services, 'board', (b) =>
155
- onlyReady ? b.ready() : b.list(status ? { status } : {}),
156
- )
157
- sendJson(response, 200, { ok: true, tasks })
158
- return
159
- }
264
+ // ══════════════════════════════════════════════════════════════════════════════
265
+ // bots改造04:任务目标锚(objective anchor)与直发消息
266
+ //
267
+ // 设计依据:`design/CONTRACT-bots04.md` §2(**已冻结**,字段名/文案格式/状态码逐字照做)。
268
+ // 为什么需要:长任务几十轮后容易「目标漂移」。每次唤醒 Bot 都带**全量目标原文**,
269
+ // 补充说明只追加(task-notes.jsonl,append-only)、永不覆盖目标,从机制上防漂移。
270
+ // ══════════════════════════════════════════════════════════════════════════════
160
271
 
161
- if (request.method === 'POST' && path === '/tasks') {
162
- const body = (await readBody(request)) ?? {}
163
- const task = await withService(services, 'board', (b) =>
164
- b.create({ ...body, byId: body.byId ?? 'ui' }),
165
- )
166
- sendJson(response, 200, { ok: true, task })
167
- return
168
- }
272
+ /** 目标锚文案的固定标题(契约 §2.1 原文,逐字不可改)。 */
273
+ const ANCHOR_TITLE = '【任务目标 · 原文锚定,禁止改写】'
274
+ /** 补充说明落盘文件(新建;append-only,一行一条)。 */
275
+ const TASK_NOTES_FILE = 'task-notes.jsonl'
276
+ /** 补充说明 / 直发消息正文长度上限(契约 §2.2 / §2.4:trim 后 1–4000)。 */
277
+ const ANCHOR_TEXT_MAX = 4000
169
278
 
170
- if (request.method === 'POST' && path === '/tasks/update') {
171
- const body = (await readBody(request)) ?? {}
172
- const task = await withService(services, 'board', (b) =>
173
- b.update({ ...body, byId: body.byId ?? 'ui' }),
174
- )
175
- sendJson(response, 200, { ok: true, task })
176
- return
177
- }
279
+ /** 取状态目录(board 优先,roster 兜底;都没有返回 undefined)。 */
280
+ function anchorStateDir(services) {
281
+ return services?.board?.paths?.stateDir ?? services?.roster?.paths?.stateDir
282
+ }
178
283
 
179
- if (request.method === 'POST' && path === '/tasks/accept') {
180
- const body = (await readBody(request)) ?? {}
181
- const task = await withService(services, 'board', (b) =>
182
- b.accept({ ...body, by: body.by ?? 'ui' }),
183
- )
184
- sendJson(response, 200, { ok: true, task })
185
- return
186
- }
284
+ /** 补充说明文件路径(状态目录缺失时 undefined)。 */
285
+ function taskNotesPath(services) {
286
+ const dir = anchorStateDir(services)
287
+ return dir ? statePath(dir, TASK_NOTES_FILE) : undefined
288
+ }
187
289
 
188
- // ── agent 预设(建 Bot 时可选)─────────────────────────────────────────
189
- if (request.method === 'GET' && path === '/presets') {
190
- let presets = []
191
- let defaultId
192
- try {
193
- presets = await listPresets(ctx)
194
- defaultId = defaultPreset(ctx)
195
- } catch (error) {
196
- sendJson(response, 200, { ok: true, presets: [], defaultId: undefined, note: `预设服务不可用:${error?.message ?? error}` })
197
- return
198
- }
199
- sendJson(response, 200, {
200
- ok: true,
201
- presets: presets.map((p) => ({
202
- id: p.id,
203
- name: p.name ?? p.id,
204
- description: p.description,
205
- order: p.order,
206
- broken: p.broken,
207
- })),
208
- defaultId,
209
- note: presets.length ? undefined : '宿主未提供 agentPresets 服务(Bot 将使用默认预设)',
210
- })
211
- return
212
- }
290
+ /**
291
+ * 把时间戳格式化成人类可读的本地时间(`YYYY-MM-DD HH:mm:ss`)。
292
+ * 契约只写 `<时间>` 占位符,未冻结具体格式;这里选无 locale 依赖的固定格式,
293
+ * 保证跨环境稳定、可被测试断言。
294
+ */
295
+ function formatAnchorTime(at) {
296
+ const n = Number(at)
297
+ if (!Number.isFinite(n)) return '未知时间'
298
+ const d = new Date(n)
299
+ const pad = (x) => String(x).padStart(2, '0')
300
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} `
301
+ + `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
302
+ }
213
303
 
214
- // ── Bot 注册表 ────────────────────────────────────────────────────────
215
- if (request.method === 'GET' && path === '/roster') {
216
- const includeRetired = qp(url, 'includeRetired') === '1'
217
- const bots = await withService(services, 'roster', (r) => [
218
- ...r.list(),
219
- ...(includeRetired ? r.listRetired() : []),
220
- ])
221
- sendJson(response, 200, { ok: true, bots })
222
- return
223
- }
304
+ /** 逗号拼接;空数组返回契约规定的占位文案。 */
305
+ function joinAnchorList(list, emptyText) {
306
+ const items = Array.isArray(list) ? list.filter((x) => x !== null && x !== undefined && String(x).trim() !== '').map(String) : []
307
+ return items.length ? items.join(',') : emptyText
308
+ }
224
309
 
225
- if (request.method === 'POST' && path === '/roster') {
226
- const body = (await readBody(request)) ?? {}
227
- const bot = await withService(services, 'roster', (r) =>
228
- r.create({ ...body, byId: body.byId ?? 'ui' }),
229
- )
230
- // 预制记忆同步(建 Bot 与改档案必须一致)
231
- const memory = seedBotMemory(services, bot, body.byId ?? 'ui')
232
- // 同目录共享检测:**允许**,但必须告知(两个 Bot 在同一目录会互相覆盖文件)
233
- let sharedWith = []
234
- try {
235
- const all = services.roster?.list?.() ?? []
236
- sharedWith = all
237
- .filter((b) => b.id !== bot.id && b.state !== 'retired' && String(b.cwd) === String(bot.cwd))
238
- .map((b) => ({ id: b.id, name: b.name }))
239
- } catch {
240
- /* 检测失败不影响创建 */
241
- }
242
- sendJson(response, 200, {
243
- ok: true,
244
- bot,
245
- ...(memory ? { memory } : {}),
246
- ...(sharedWith.length
247
- ? {
248
- warnings: [
249
- `目录 ${bot.cwd} 已被 ${sharedWith.map((b) => b.name).join('、')} 使用 —— 两个 Bot 会互相覆盖文件`,
250
- ],
251
- sharedWith,
252
- }
253
- : {}),
254
- })
255
- return
256
- }
310
+ /**
311
+ * 从任务快照里抽出「目标锚」(契约 §2.1 的 `anchor` 结构)。
312
+ * 字段恒定出现(前端直接渲染,不判 undefined);unknown 值给安全默认。
313
+ * @param {object} task
314
+ */
315
+ function buildTaskAnchor(task) {
316
+ return {
317
+ subject: task?.subject ?? '',
318
+ description: task?.description ?? '',
319
+ writeScopes: Array.isArray(task?.writeScopes) ? [...task.writeScopes] : [],
320
+ blockedBy: Array.isArray(task?.blockedBy) ? [...task.blockedBy] : [],
321
+ priority: task?.priority ?? 0,
322
+ status: task?.status,
323
+ ownerId: task?.ownerId ?? null,
324
+ progress: task?.progress ?? 0,
325
+ artifacts: normalizeArtifactItems(task?.artifacts),
326
+ createdAt: task?.createdAt,
327
+ // 授权留痕(改造08):随锚定文本一起下发给执行者
328
+ mandate: task?.mandate ?? null,
329
+ }
330
+ }
257
331
 
258
- // ── Bot 档案(身份 / 经验 / 技能 / 预制记忆)──────────────────────────
259
- if (request.method === 'GET' && path === '/roster/profile') {
260
- const id = qp(url, 'id')
261
- if (!id) {
262
- sendJson(response, 400, { ok: false, reason: 'id 必填' })
263
- return
264
- }
265
- const detail = await withService(services, 'roster', (r) => r.getProfile(id))
266
- sendJson(response, 200, {
267
- ok: true,
268
- bot: detail.bot,
269
- profile: detail.profile ?? {},
270
- profilePath: detail.profilePath,
271
- managedText: detail.managedText,
272
- hasOutside: detail.hasOutside,
273
- profileError: detail.profileError,
274
- })
275
- return
276
- }
332
+ /** 读某任务的全部补充说明(按 `at` 升序;坏行由 readJsonl 计入 bad,不静默崩)。 */
333
+ function readTaskNotes(services, taskId) {
334
+ const file = taskNotesPath(services)
335
+ if (!file) return []
336
+ const { rows } = readJsonl(file)
337
+ return rows
338
+ .filter((row) => row && row.taskId === taskId)
339
+ .map((row) => ({ id: row.id, text: row.text, by: row.by ?? 'ui', at: row.at }))
340
+ .sort((a, b) => (Number(a.at) || 0) - (Number(b.at) || 0))
341
+ }
277
342
 
278
- if (request.method === 'POST' && path === '/roster/profile') {
279
- const body = (await readBody(request)) ?? {}
280
- if (!body.id) {
281
- sendJson(response, 400, { ok: false, reason: 'id 必填' })
282
- return
283
- }
284
- const bot = await withService(services, 'roster', (r) =>
285
- r.setProfile({ id: body.id, profile: body.profile ?? {}, byId: body.byId ?? 'ui', expectedRev: body.expectedRev }),
286
- )
287
- // 预制记忆同步(与建 Bot 共用同一段逻辑)
288
- const memory = seedBotMemory(services, bot, body.byId ?? 'ui')
289
- sendJson(response, 200, { ok: true, bot, ...(memory ? { memory } : {}) })
290
- return
291
- }
343
+ /** 生成下一个补充说明编号 `n-<seq>`(append-only 文件内全局递增,绝不复用)。 */
344
+ function nextTaskNoteId(rows) {
345
+ let max = 0
346
+ for (const row of rows) {
347
+ const m = /^n-(\d+)$/.exec(String(row?.id ?? ''))
348
+ if (!m) continue
349
+ const n = Number(m[1])
350
+ if (Number.isFinite(n) && n > max) max = n
351
+ }
352
+ // 契约允许「已读行数+1」;同时取两者最大值,防止删行/坏行导致编号重复。
353
+ return `n-${Math.max(max + 1, rows.length + 1)}`
354
+ }
292
355
 
293
- if (request.method === 'POST' && path === '/roster/manage') {
294
- const body = (await readBody(request)) ?? {}
295
- const byId = body.byId ?? 'ui'
296
- const result = await withService(services, 'roster', (r) => {
297
- switch (body.action) {
298
- case 'rename':
299
- return r.rename({ id: body.id, name: body.name, expectedRev: body.expectedRev, byId })
300
- case 'retire':
301
- return r.retire({ id: body.id, reason: body.reason, byId })
302
- case 'setCwd':
303
- return r.setCwd({ id: body.id, cwd: body.cwd, expectedRev: body.expectedRev, byId })
304
- case 'claim':
305
- return r.claim({ id: body.id, owner: byId, expectedRev: body.expectedRev })
306
- case 'release':
307
- return r.release({ id: body.id, owner: byId, expectedRev: body.expectedRev })
308
- case 'heartbeat':
309
- return r.heartbeat({ id: body.id, owner: byId, expectedRev: body.expectedRev })
310
- default:
311
- throw new Error(`未知 action: ${body.action}`)
312
- }
313
- })
314
- sendJson(response, 200, { ok: true, bot: result })
315
- return
316
- }
356
+ // ══════════════════════════════════════════════════════════════════════════════
357
+ // bots改造06:任务变更中心(重开纠错 / 派生优化单 / 转长任务 / 换负责人)
358
+ //
359
+ // 设计依据:`design/CONTRACT-bots06.md` §2(**已冻结**)。
360
+ // 根因:`board.reopen` 会**清空** `artifacts` / `acceptance` / `completedAt`
361
+ // (内核行为,board.js 冻结不能改),所以变更**前**必须先把「当时的样子」快照进
362
+ // `task-meta.jsonl`(append-only)—— 这是「纠错不丢证据」的唯一保障,同时充当
363
+ // 变更历史与长任务标记。
364
+ // ══════════════════════════════════════════════════════════════════════════════
317
365
 
318
- // ── 房间 ──────────────────────────────────────────────────────────────
319
- if (request.method === 'GET' && path === '/rooms') {
320
- const rooms = await withService(services, 'room', (r) => r.list())
321
- sendJson(response, 200, { ok: true, rooms })
322
- return
323
- }
366
+ /** 任务变更元数据文件(新建;append-only,一行一条)。 */
367
+ const TASK_META_FILE = 'task-meta.jsonl'
368
+ /** 变更说明长度上限(契约 §2.2:trim 后 1–4000)。 */
369
+ const EVOLVE_NOTE_MAX = ANCHOR_TEXT_MAX
370
+ /** evolve 支持的三种变更模式(契约 §2.2)。 */
371
+ const EVOLVE_MODES = ['reopen', 'followup', 'longrun']
372
+ /** 长任务阶段数上限 / 单项长度上限(契约 §2.2 longrun)。 */
373
+ const LONGRUN_PHASES_MAX = 12
374
+ const LONGRUN_PHASE_MAX = 200
375
+ /** 长任务未给 phases 时的默认三阶段(契约 §2.2 longrun)。 */
376
+ const LONGRUN_DEFAULT_PHASES = ['范围与验收标准确认', '执行与自测', '交付与复盘']
324
377
 
325
- if (request.method === 'POST' && path === '/rooms') {
326
- const body = (await readBody(request)) ?? {}
327
- const room = await withService(services, 'room', (r) =>
328
- r.create({ ...body, byId: body.byId ?? 'ui' }),
329
- )
330
- sendJson(response, 200, { ok: true, room })
331
- return
332
- }
378
+ /** task-meta.jsonl 路径(状态目录缺失时 undefined)。 */
379
+ function taskMetaPath(services) {
380
+ const dir = anchorStateDir(services)
381
+ return dir ? statePath(dir, TASK_META_FILE) : undefined
382
+ }
333
383
 
334
- if (request.method === 'GET' && path === '/rooms/messages') {
335
- const roomId = qp(url, 'roomId')
336
- if (!roomId) {
337
- sendJson(response, 400, { ok: false, reason: 'roomId 必填' })
338
- return
339
- }
340
- const messages = await withService(services, 'room', (r) =>
341
- r.listMessages({ roomId, sinceSeq: qn(url, 'sinceSeq'), limit: qn(url, 'limit') }),
342
- )
343
- sendJson(response, 200, { ok: true, messages })
384
+ /**
385
+ * 读某任务的全部变更记录(按 `at` 升序;同一毫秒按落盘顺序稳定排序)。
386
+ * 坏行由 readJsonl 计入 bad,不静默崩。
387
+ * @param {object} services
388
+ * @param {string} taskId
389
+ * @returns {object[]}
390
+ */
391
+ function readTaskMeta(services, taskId) {
392
+ const file = taskMetaPath(services)
393
+ if (!file) return []
394
+ const { rows } = readJsonl(file)
395
+ return rows
396
+ .map((row, index) => ({ row, index }))
397
+ .filter(({ row }) => row && typeof row === 'object' && row.taskId === taskId)
398
+ .sort((a, b) => (Number(a.row.at) || 0) - (Number(b.row.at) || 0) || a.index - b.index)
399
+ .map(({ row }) => row)
400
+ }
401
+
402
+ /**
403
+ * 追加一条变更记录。
404
+ * 落盘失败**必须抛 503**:静默丢历史 = 丢证据,是本平台最忌讳的失败方式。
405
+ * @param {object} services
406
+ * @param {object} record
407
+ */
408
+ function appendTaskMeta(services, record) {
409
+ const file = taskMetaPath(services)
410
+ if (!file) {
411
+ const error = new Error('状态目录不可用(task-meta.jsonl 无法落盘)')
412
+ error.status = 503
413
+ throw error
414
+ }
415
+ if (!appendJsonl(file, record)) {
416
+ const error = new Error(`变更记录落盘失败(${file})`)
417
+ error.status = 503
418
+ throw error
419
+ }
420
+ return record
421
+ }
422
+
423
+ /** 取某任务最近一条 `kind:'longrun'` 记录(没有则 undefined)。 */
424
+ function readLongrunMeta(services, taskId) {
425
+ const rows = readTaskMeta(services, taskId).filter(
426
+ (row) => row.kind === 'longrun' && Array.isArray(row.phases),
427
+ )
428
+ return rows.length ? rows[rows.length - 1] : undefined
429
+ }
430
+
431
+ /** 把 longrun 记录 / phases 数组归一成非空阶段清单。 */
432
+ function longrunPhaseList(longrun) {
433
+ const list = Array.isArray(longrun)
434
+ ? longrun
435
+ : (Array.isArray(longrun?.phases) ? longrun.phases : [])
436
+ return list
437
+ .filter((phase) => typeof phase === 'string' && phase.trim() !== '')
438
+ .map((phase) => phase.trim())
439
+ }
440
+
441
+ /**
442
+ * 归一化长任务阶段清单(**创建与 evolve 共用的单一真相**)。
443
+ *
444
+ * 为什么抽出来:创建时长任务和「事后转长任务」是同一份数据结构,
445
+ * 校验规则(上限 12 项 / 单项 200 字 / 不许空白)必须只有一处 ——
446
+ * 两处各写一遍,早晚会漂移成「创建的能过、转的不能过」这种鬼故事。
447
+ *
448
+ * @param {unknown} raw phases 数组(undefined/null/[] ⇒ 默认三阶段)
449
+ * @param {string} code 报错时用的错误码(区分调用方)
450
+ * @returns {string[]}
451
+ */
452
+ function normalizePhases(raw, code = 'EVOLVE_BAD_PHASES') {
453
+ const failPhases = (message) => {
454
+ const error = new Error(message)
455
+ error.status = 400
456
+ error.code = code
457
+ return error
458
+ }
459
+ if (raw === undefined || raw === null || (Array.isArray(raw) && raw.length === 0)) {
460
+ return [...LONGRUN_DEFAULT_PHASES]
461
+ }
462
+ if (!Array.isArray(raw)) throw failPhases('phases 必须是字符串数组')
463
+ if (raw.length > LONGRUN_PHASES_MAX) {
464
+ throw failPhases(`phases 最多 ${LONGRUN_PHASES_MAX} 项(收到 ${raw.length} 项)`)
465
+ }
466
+ return raw.map((phase, index) => {
467
+ if (typeof phase !== 'string') throw failPhases(`phases[${index}] 必须是字符串(收到 ${JSON.stringify(phase)})`)
468
+ const text = phase.trim()
469
+ if (!text) throw failPhases(`phases[${index}] 不能为空`)
470
+ if (text.length > LONGRUN_PHASE_MAX) {
471
+ throw failPhases(`phases[${index}] 过长(最多 ${LONGRUN_PHASE_MAX} 字,收到 ${text.length} 字)`)
472
+ }
473
+ return text
474
+ })
475
+ }
476
+
477
+ /** 变更**前**的任务快照(契约 §2.2 reopen 步骤 1)。 */
478
+ function buildTaskSnapshot(task) {
479
+ return {
480
+ kind: 'snapshot',
481
+ taskId: task.id,
482
+ at: Date.now(),
483
+ before: {
484
+ status: task.status,
485
+ artifacts: Array.isArray(task.artifacts) ? task.artifacts : [],
486
+ acceptance: task.acceptance === undefined ? null : task.acceptance,
487
+ completedAt: task.completedAt ?? null,
488
+ progress: task.progress ?? 0,
489
+ ownerId: task.ownerId ?? null,
490
+ },
491
+ }
492
+ }
493
+
494
+ /** 追加一条补充说明(append-only;长任务说明与 /tasks/note 共用同一份文件与编号规则)。 */
495
+ function appendTaskNoteRow(services, taskId, text, by) {
496
+ const file = taskNotesPath(services)
497
+ if (!file) {
498
+ const error = new Error('状态目录不可用(task-notes.jsonl 无法落盘)')
499
+ error.status = 503
500
+ throw error
501
+ }
502
+ const { rows } = readJsonl(file)
503
+ const note = { id: nextTaskNoteId(rows), taskId, text, by, at: Date.now() }
504
+ if (!appendJsonl(file, note)) {
505
+ const error = new Error(`补充说明落盘失败(${file})`)
506
+ error.status = 503
507
+ throw error
508
+ }
509
+ return note
510
+ }
511
+
512
+ /**
513
+ * 按契约 §2.2 的 reopen 流程:**先快照** → 再调内核 reopen → 记一条 reopen。
514
+ *
515
+ * 已 pending 且无租约时内核抛 `BOARD_NOTHING_TO_REOPEN` —— 契约明确「视为成功」,
516
+ * 不能把用户点「重开」当成报错(任务本来就是可做的)。
517
+ */
518
+ async function reopenWithSnapshot(services, task, by, note) {
519
+ const snapshot = appendTaskMeta(services, buildTaskSnapshot(task))
520
+ let after = task
521
+ try {
522
+ after = await withService(services, 'board', (b) => b.update({
523
+ id: task.id,
524
+ expectedRev: task.rev,
525
+ action: 'reopen',
526
+ byId: by,
527
+ }))
528
+ } catch (error) {
529
+ if (error?.code !== 'BOARD_NOTHING_TO_REOPEN') throw error
530
+ after = (await withService(services, 'board', (b) => b.get(task.id))) ?? task
531
+ }
532
+ const change = appendTaskMeta(services, {
533
+ kind: 'reopen', taskId: task.id, note, by, at: Date.now(),
534
+ })
535
+ return { task: after, snapshot, change }
536
+ }
537
+
538
+ /** 派生任务标题前缀(契约 §2.2 followup:修复 > 优化 > 变更,先命中先算)。 */
539
+ function evolvePrefixFor(note) {
540
+ if (/错|缺陷|bug|修复/i.test(note)) return '【修复】'
541
+ if (/优化|提升|改进/.test(note)) return '【优化】'
542
+ return '【变更】'
543
+ }
544
+
545
+ /**
546
+ * 生成契约 §2.1 的固定锚定文案(**前端只展示/复制,不得改写**)。
547
+ *
548
+ * 固定格式(逐行):
549
+ * ```
550
+ * 【任务目标 · 原文锚定,禁止改写】
551
+ * 任务 <id>:<subject>
552
+ * 目标:<description 原文;为空则(未填写)>
553
+ * 边界:只改 <writeScopes 逗号拼接;为空则(未限定)>
554
+ * 前置:<blockedBy 逗号拼接;为空则(无)>
555
+ * 进度:<progress>%
556
+ * 已交付:<artifacts 逗号拼接;为空则(暂无)>
557
+ * 补充说明(按时间,全部有效,不得忽略):
558
+ * - [<时间>] <text>
559
+ * ```
560
+ *
561
+ * 改造06 追加(契约 bots06 §2.4,**只加不改**):该任务在 `task-meta.jsonl` 里有
562
+ * `kind:'longrun'` 记录时,在「补充说明」**之前**插入阶段清单:
563
+ * ```
564
+ * 阶段(长任务,按序推进,不得跳步):
565
+ * 1. <phase1>
566
+ * 2. <phase2>
567
+ * ```
568
+ * 没有 longrun 记录时输出**逐字不变**(既有测试对文案有逐行断言)。
569
+ *
570
+ * @param {object} task
571
+ * @param {{ at: number, text: string }[]} [notes]
572
+ * @param {{ phases?: string[] } | string[]} [longrun] 该任务的 longrun 记录(或阶段数组)
573
+ * @returns {string}
574
+ */
575
+ /**
576
+ * 把 `task.mandate` 翻译成人话(改造08 · 借鉴 superagent Mandate)。
577
+ *
578
+ * 三条纪律:
579
+ * 1. **缺省不编造**:没有 mandate 就返回空数组,绝不说「用户已授权」;
580
+ * 2. **依据要写清楚**:`explicit`(人明确点名)和 `inferred`(按规则推断)
581
+ * 对人的意义完全不同,必须能分辨;
582
+ * 3. **不承诺越权**:明说「超出范围要重新要授权」——
583
+ * mandate 是「此刻已被要求什么」,不是「随便干」。
584
+ *
585
+ * @param {object|undefined} mandate
586
+ * @returns {string[]}
587
+ */
588
+ function describeMandate(mandate) {
589
+ if (!mandate || typeof mandate !== 'object') return []
590
+ const lines = []
591
+ const sender = typeof mandate.sender === 'string' && mandate.sender.trim() ? mandate.sender.trim() : ''
592
+ const BASIS_TEXT = {
593
+ explicit: '人明确点名要做(最强 —— 可以直接动手)',
594
+ inferred: '按规则推断(**推断,动手前先确认**)',
595
+ 'task-board': '任务板派发(管理面正式派活)',
596
+ room: '房间里的指令',
597
+ cycle: '周期复盘产物',
598
+ }
599
+ const basis = typeof mandate.basis === 'string' ? mandate.basis : ''
600
+ if (basis && BASIS_TEXT[basis]) {
601
+ lines.push(`- 依据:${BASIS_TEXT[basis]}`)
602
+ }
603
+ if (sender) lines.push(`- 下达者:${sender}`)
604
+ if (typeof mandate.policyVersion === 'string' && mandate.policyVersion) {
605
+ lines.push(`- 规则版本:${mandate.policyVersion}`)
606
+ }
607
+ if (typeof mandate.scope === 'string' && mandate.scope) {
608
+ lines.push(`- 范围:${mandate.scope}`)
609
+ }
610
+ return lines
611
+ }
612
+
613
+ function buildTaskAnchorText(task, notes = [], longrun = null) {
614
+ const anchor = buildTaskAnchor(task)
615
+ const desc = typeof anchor.description === 'string' ? anchor.description : ''
616
+ const lines = [
617
+ ANCHOR_TITLE,
618
+ `任务 ${task?.id}:${anchor.subject}`,
619
+ `目标:${desc.trim() ? desc : '(未填写)'}`,
620
+ `边界:只改 ${joinAnchorList(anchor.writeScopes, '(未限定)')}`,
621
+ `前置:${joinAnchorList(anchor.blockedBy, '(无)')}`,
622
+ `进度:${anchor.progress}%`,
623
+ `已交付:${joinAnchorList(anchor.artifacts, '(暂无)')}`,
624
+ ]
625
+ const phases = longrunPhaseList(longrun)
626
+ if (phases.length > 0) {
627
+ lines.push('阶段(长任务,按序推进,不得跳步):')
628
+ phases.forEach((phase, index) => lines.push(`${index + 1}. ${phase}`))
629
+ }
630
+ // ── 授权留痕(改造08 · 借鉴 superagent Mandate)────────────────────────
631
+ // 执行者被唤醒时必须知道:**这活是谁批的、凭什么**。
632
+ // 少了这一行,「我自己猜用户想要」和「用户明确要我干」看起来一模一样 ——
633
+ // 而那正是失控与越权的分界线。
634
+ //
635
+ // ⚠️ 但**没有 basis 就不渲染**:UI 随手建的任务记的是 `{ sender: 'ui' }`,
636
+ // 那是「谁建的」,不是「谁授权的」。把它渲染成「授权」等于编造授权 ——
637
+ // 正是这套纪律要防的。只有记录了明确依据(explicit/inferred/task-board…)
638
+ // 才说明这确实是一份授权。
639
+ const mandate = anchor.mandate
640
+ if (mandate && typeof mandate === 'object' && typeof mandate.basis === 'string' && mandate.basis) {
641
+ lines.push('授权(谁批准的、凭什么 —— 超出此授权范围的新需求要重新要授权):')
642
+ lines.push(...describeMandate(mandate))
643
+ }
644
+
645
+ lines.push('补充说明(按时间,全部有效,不得忽略):')
646
+ for (const note of notes) {
647
+ lines.push(`- [${formatAnchorTime(note?.at)}] ${note?.text ?? ''}`)
648
+ }
649
+ return lines.join('\n')
650
+ }
651
+
652
+ // ══════════════════════════════════════════════════════════════════════════════
653
+ // 来源跳转(契约 bots04 §2.5,2026-11-07 补)
654
+ //
655
+ // `sourceSeq` 是任务的**幂等键**,顺带承载「这个任务是从哪儿来的」。
656
+ // 契约冻结的三类前缀:
657
+ // `room:<roomId>:<seq>` → 房间消息出处(可跳回房间定位那条消息)
658
+ // `todo:<id>` → 待办(room/jump 均为 null)
659
+ // `evolve:<id>:<seq>` → 变更派生(room/jump 均为 null)
660
+ // 判定顺序**按前缀逐字匹配**,不做模糊猜测:匹配不上就 `unparsed`,
661
+ // 把原始标记如实返回,由前端提示「来源已失效」。
662
+ // ══════════════════════════════════════════════════════════════════════════════
663
+
664
+ /** 来源消息正文预览的截断长度(契约 §2.5:首行截 80 字,与 §3.4 建任务时取标题同口径)。 */
665
+ const SOURCE_PREVIEW_MAX = 80
666
+
667
+ /**
668
+ * 解析 `sourceSeq` 的前缀(契约 §2.5 的判定顺序)。
669
+ *
670
+ * 返回 `null` 表示「匹配不上任何已知前缀」(→ 后端判 `unparsed`)。
671
+ * 注意 `room:<roomId>:<seq>` 的三段切分是安全的:房间 id 恒为 `room-<uuid8>`
672
+ * (`src/room.js:1032`),**内部不含冒号**;测试里也固化这一点,防止将来
673
+ * 改 id 规则时被静默误判。
674
+ *
675
+ * @param {string} sourceSeq
676
+ * @returns {{ kind: 'room'|'todo'|'evolve', roomId?: string, seq?: number } | null}
677
+ */
678
+ function parseSourceSeq(sourceSeq) {
679
+ if (typeof sourceSeq !== 'string' || sourceSeq === '') return null
680
+ const parts = sourceSeq.split(':')
681
+ if (parts.length === 3 && parts[0] === 'room' && parts[1] !== '' && parts[2] !== '') {
682
+ // 「seq 为正整数」的判定:字符串必须**全是数字**且数值 > 0。
683
+ // 用 `^\d+$` 而不是 `Number.isInteger(Number(...))`:后者会把 `"7.0"`、
684
+ // `"1e2"`、`"0x10"` 之类全收进来,语义含糊;前者只认十进制正整数字面量
685
+ // (`"07"` 仍是数值 7,允许),把格式残缺的形态留给 `unparsed` 如实上报。
686
+ if (/^\d+$/.test(parts[2]) && Number(parts[2]) > 0) {
687
+ return { kind: 'room', roomId: parts[1], seq: Number(parts[2]) }
688
+ }
689
+ return null
690
+ }
691
+ if (parts.length === 2 && parts[0] === 'todo' && parts[1] !== '') {
692
+ return { kind: 'todo' }
693
+ }
694
+ if (parts.length === 3 && parts[0] === 'evolve' && parts[1] !== '' && parts[2] !== '') {
695
+ return { kind: 'evolve' }
696
+ }
697
+ return null
698
+ }
699
+
700
+ /** 来源消息正文 → 预览(首行截 80 字;与 §3.4 建任务时取标题的口径一致)。 */
701
+ function sourcePreview(message) {
702
+ const body = typeof message?.body === 'string' ? message.body : message?.text ?? ''
703
+ const firstLine = body.split('\n').map((line) => line.trim()).find((line) => line !== '') ?? ''
704
+ return firstLine.slice(0, SOURCE_PREVIEW_MAX)
705
+ }
706
+
707
+ /**
708
+ * 组装来源响应的公共部分(契约 §2.5 的字段形状)。
709
+ *
710
+ * 所有 kind 都带:`source` / `sourceSeq` / `kind` / `room` / `jump` / `reason` / `hint`。
711
+ * `room`/`jump` 只在 `kind: 'room'` 时有值;`reason` 只在 `kind: 'unparsed'` 时有值
712
+ * (契约原文:绝不猜测,reason 写明无法解析的来源标记)。
713
+ *
714
+ * @param {object} task 任务对象(只读)
715
+ * @param {object} [parsed] `parseSourceSeq` 的命中结果
716
+ */
717
+ function buildSourcePayload(task, parsed) {
718
+ const sourceSeq = typeof task?.sourceSeq === 'string' ? task.sourceSeq : ''
719
+ const base = {
720
+ taskId: task?.id ?? null,
721
+ source: task?.source ?? null,
722
+ sourceSeq: sourceSeq === '' ? null : sourceSeq,
723
+ room: null,
724
+ jump: null,
725
+ reason: null,
726
+ }
727
+
728
+ if (!sourceSeq) {
729
+ return { ...base, kind: 'none', hint: '这个任务不是从房间消息创建的' }
730
+ }
731
+
732
+ if (!parsed) {
733
+ return {
734
+ ...base,
735
+ kind: 'unparsed',
736
+ reason: `无法解析来源标记 "${sourceSeq}"(不是 room:/todo:/evolve: 前缀)`,
737
+ hint: '来源已失效,无法自动跳转',
738
+ }
739
+ }
740
+
741
+ if (parsed.kind === 'room') {
742
+ return {
743
+ ...base,
744
+ kind: 'room',
745
+ room: {
746
+ roomId: parsed.roomId,
747
+ name: null,
748
+ seq: parsed.seq,
749
+ messageId: `m-${parsed.seq}`,
750
+ exists: null,
751
+ messageExists: null,
752
+ at: null,
753
+ preview: null,
754
+ },
755
+ jump: { roomId: parsed.roomId, seq: parsed.seq },
756
+ hint: `来源:房间消息 ${parsed.roomId} #${parsed.seq}`,
757
+ }
758
+ }
759
+
760
+ if (parsed.kind === 'todo') return { ...base, kind: 'todo', hint: '来源:待办' }
761
+ return { ...base, kind: 'evolve', hint: '来源:任务变更派生' }
762
+ }
763
+
764
+ // ══════════════════════════════════════════════════════════════════════════════
765
+ // bots改造05:唤醒前自动复活会话(修「派活报 agent-not-live」)
766
+ //
767
+ // 设计依据:`design/CONTRACT-bots05.md` §2(**已冻结**)。
768
+ // 根因:dsh-web 重启后宿主不会自动把旧会话装载进 `agents` 注册表(懒加载),
769
+ // 而 dispatcher 投递走 `kernel.followup` → `getAgent`;拿不到 agent 就是
770
+ // `agent-not-live`。会话数据仍在磁盘上,`agents.resume({resumeSessionId})` 能救回来。
771
+ // ══════════════════════════════════════════════════════════════════════════════
772
+
773
+ /** `/bots/revive {all:true}` 单次最多处理的 Bot 数(契约 §2.2)。 */
774
+ const REVIVE_MAX = 50
775
+
776
+ /**
777
+ * 确保某个会话在宿主 `agents` 注册表里是 live 的;不在就尝试 resume。
778
+ *
779
+ * **绝不抛错**:复活失败必须让上层拿到 `reason`,由上层决定「继续投递并在
780
+ * wake 里如实报失败」还是「明确报错」。静默吞掉失败才是本平台最忌讳的。
781
+ *
782
+ * @param {object} ctx
783
+ * @param {string} sessionId
784
+ * @returns {Promise<{ ok: boolean, live: boolean, revived: boolean, reason?: string }>}
785
+ */
786
+ async function ensureLive(ctx, sessionId) {
787
+ if (typeof sessionId !== 'string' || sessionId.trim() === '') {
788
+ return { ok: false, live: false, revived: false, reason: 'missing-session' }
789
+ }
790
+ const id = sessionId.trim()
791
+ // 已经 live:**什么都不做**(不得多出任何副作用,保证既有行为完全一致)。
792
+ if (getAgent(ctx, id)) return { ok: true, live: true, revived: false }
793
+ try {
794
+ await resumeSession(ctx, { resumeSessionId: id, warn: moduleWarn })
795
+ return { ok: true, live: true, revived: true }
796
+ } catch (error) {
797
+ return { ok: false, live: false, revived: false, reason: `resume-failed:${error?.message ?? error}` }
798
+ }
799
+ }
800
+
801
+ /**
802
+ * 投递前取「会话水位」基线(用于投递后判定执行者到底动没动)。
803
+ *
804
+ * 读不到水位就返回 undefined —— 宁可不追踪,也不要拿假基线去误报。
805
+ *
806
+ * @param {object} ctx
807
+ * @param {string} sessionId
808
+ * @returns {Promise<object|undefined>}
809
+ */
810
+ async function takeWatermarkBaseline(ctx, sessionId) {
811
+ try {
812
+ const wm = await readSessionWatermark(ctx, sessionId)
813
+ return wm?.ok ? wm : undefined
814
+ } catch {
815
+ return undefined
816
+ }
817
+ }
818
+
819
+ /**
820
+ * 投递成功后登记一条「等回执」期望(2026-11-09 · 补 superagent inputsRead 那一层)。
821
+ *
822
+ * 为什么必须在**投递前**取基线:等投递完再读,可能已经把这次投递算进基线里,
823
+ * 于是「水位从来没涨过」也看不出差异 —— 那就等于没追踪。
824
+ *
825
+ * @param {object} services
826
+ * @param {string} taskId
827
+ * @param {string} sessionId
828
+ * @param {object|undefined} baseline 投递前 takeWatermarkBaseline() 的结果
829
+ * @param {object|undefined} wake dispatcher.notify 的返回
830
+ */
831
+ function armDeliveryWatch(services, taskId, sessionId, baseline, wake) {
832
+ const watch = services.deliveryWatch
833
+ if (!watch || !baseline || !baseline.ok) return undefined
834
+ // 只有真的「发出去了」才等回执:failed/pending 各有各的显形方式,不该混进来。
835
+ if (!wake || wake.result !== 'sent') return undefined
836
+ try {
837
+ const out = watch.expect({
838
+ taskId,
839
+ sessionId,
840
+ baselineSeq: baseline.seq,
841
+ baselineUserMessages: baseline.userMessages,
842
+ wakeId: wake.wakeId,
843
+ })
844
+ return out?.ok ? out : undefined
845
+ } catch {
846
+ return undefined
847
+ }
848
+ }
849
+
850
+ /**
851
+ * 执行者状态(「任务在跑吗、为什么没动」)。
852
+ *
853
+ * ## 三条事实分开说,别混成一句「进行中」
854
+ * 1. `live`:会话现在在不在宿主里活着(不在线 ⇒ 压根没法跑)
855
+ * 2. `lastWake`:最近一次唤醒的结果(sent 只是「交出去了」,不代表对方读了)
856
+ * 3. `deliveryError`:有没有投递失败留痕(这是**一等状态**,会一直显示到被解决)
857
+ *
858
+ * @param {object} ctx
859
+ * @param {object} services
860
+ * @param {object} task
861
+ * @returns {{ sessionId: string|null, live: boolean, lastWake: object|null, deliveryError: string|null }}
862
+ */
863
+ function buildExecutorStatus(ctx, services, task) {
864
+ const sessionId = task?.ownerId ?? task?.assignee ?? null
865
+ if (!sessionId) return { sessionId: null, live: false, lastWake: null, deliveryError: null }
866
+ let live = false
867
+ try { live = Boolean(getAgent(ctx, sessionId)) } catch { live = false }
868
+
869
+ let lastWake = null
870
+ try {
871
+ const rows = readTaskWakes(services, task.id)
872
+ if (rows.length) {
873
+ const w = rows[rows.length - 1]
874
+ lastWake = {
875
+ at: w.at ?? null,
876
+ result: w.result ?? null,
877
+ detail: w.detail ?? null,
878
+ reason: w.reason ?? null,
879
+ }
880
+ }
881
+ } catch { lastWake = null }
882
+
883
+ const deliveryError = typeof task?.delivery?.error === 'string' && task.delivery.error
884
+ ? task.delivery.error
885
+ : null
886
+ return { sessionId, live, lastWake, deliveryError }
887
+ }
888
+
889
+ /**
890
+ * 确保「执行某个任务的 Bot」有一个团队(需求4,2026-11-09)。
891
+ *
892
+ * 用户原话:「任务创建完成应该自动建一个团队,可以只有一个人,后续我再增加」。
893
+ *
894
+ * ## 为什么是「每 Bot 一个团队」而不是「每任务一个团队」
895
+ * 任务会不断产生,团队是组织结构、不该跟着任务膨胀。一个 Bot 一个团队 =
896
+ * 它的固定协作单元,人后续往里加人即可;同一个 Bot 的多个任务共享这个团队。
897
+ *
898
+ * ## 幂等
899
+ * 已经有以该 Bot 为负责人的团队 → 直接返回那个(不重复建)。
900
+ *
901
+ * ## 失败不阻断
902
+ * 建团队失败**不影响任务本身**(任务已经建好了)。只回一个 `autoTeam.reason`,
903
+ * 让用户知道「团队没建上,但不影响干活」。
904
+ *
905
+ * @param {object} services
906
+ * @param {string} botSessionId 执行者会话 id
907
+ * @param {string} by 谁触发的(用于留痕)
908
+ * @returns {Promise<{ team?: object, created?: boolean, reason?: string } | undefined>}
909
+ */
910
+ async function ensureTeamForBot(services, botSessionId, by) {
911
+ const teams = services.teams
912
+ if (!teams || typeof teams.create !== 'function' || typeof botSessionId !== 'string' || !botSessionId) {
913
+ return undefined
914
+ }
915
+ try {
916
+ const existing = teams.list({ type: 'big' }).find((t) => t.leadId === botSessionId && t.status === 'open')
917
+ if (existing) return { team: existing, created: false }
918
+
919
+ // 团队名用 Bot 的真名(roster 里查),查不到就用会话 id 缩写
920
+ let botName = ''
921
+ try {
922
+ const bots = services.roster?.list?.() ?? []
923
+ const hit = bots.find((b) => b.sessionId === botSessionId)
924
+ if (hit?.name) botName = hit.name
925
+ } catch { /* roster 不在就算了 */ }
926
+ const name = `${botName || `Bot ${String(botSessionId).slice(-8)}`} 的团队`
927
+
928
+ const team = teams.create({
929
+ name,
930
+ type: 'big',
931
+ leadId: botSessionId,
932
+ // 「可以只有一个人」:成员列表先空着,负责人就是唯一成员,人后续再加
933
+ memberIds: [],
934
+ purpose: '由任务派活自动创建;后续可在「团队」页加人或调整权限',
935
+ // 人建的团队默认「仅负责人可管理」,自动建的沿用同一安全默认
936
+ adminPolicy: 'lead-only',
937
+ by,
938
+ })
939
+ return { team, created: true }
940
+ } catch (error) {
941
+ return { reason: `自动建团队失败:${error?.message ?? error}` }
942
+ }
943
+ }
944
+
945
+ /**
946
+ * 投递能力预检(2026-11-09 修复「任务指派后卡在排队中、失败无声」)。
947
+ *
948
+ * ## 现场问题
949
+ * 任务建好并指派给 `bot-6b654c19…`,`wakes.jsonl` 记录 `result=sent`(我们把消息
950
+ * 交出去了),但 Bot 那一轮立刻失败:
951
+ * `agent "bot-…" has no provider/model: set AgentOptions.provider and
952
+ * AgentOptions.model or record a model selection`
953
+ * 失败发生在**宿主内部**,信封(wake)早已记为成功 ⇒ 任务停在 pending、
954
+ * 阶段判定没有 `delivery` 依据 ⇒ 页面显示「排队中」,人看不到任何错误。
955
+ * **这是最坏的一类失败:静默失败。**
956
+ *
957
+ * ## 处置
958
+ * 唤醒**之前**先确认「这个会话真的能跑起来」——即它是否已有模型路由。
959
+ * 没有就**不发唤醒**,而是把原因写进 `task.delivery.error`:
960
+ * - 阶段判定(`src/stage.js`)读到 delivery.error ⇒ 派 `person/delivery`
961
+ * - 页面显示「输入投递失败:…」,人立刻知道要做什么(去给 Bot 补 model)
962
+ *
963
+ * @param {object} ctx
964
+ * @param {string} sessionId
965
+ * @returns {{ ok: boolean, reason?: string }}
966
+ */
967
+ function assertDeliverable(ctx, sessionId) {
968
+ const agent = getAgent(ctx, sessionId)
969
+ // 会话不 live 不算硬失败 —— ensureLive() 会先复活它,复活失败时才报错。
970
+ if (!agent) return { ok: true }
971
+ try {
972
+ // 只有「真的带 model 访问器」的 agent 才能判定;测试替身/老宿主没有访问器时
973
+ // **一律放行** —— 预检的职责是拦住已知必败的投递,不是给所有投递加一道门。
974
+ if (typeof agent.model?.current !== 'function') return { ok: true }
975
+ const selection = agent.model.current()
976
+ if (selection && typeof selection.provider === 'string' && typeof selection.model === 'string') {
977
+ return { ok: true }
978
+ }
979
+ return {
980
+ ok: false,
981
+ reason: `会话 ${sessionId} 没有模型路由(provider/model 均为空)——`
982
+ + '请在「Bot 档案」给这个 Bot 选一个模型,或把它的会话模型补上再派活',
983
+ }
984
+ } catch (error) {
985
+ return { ok: false, reason: `读取会话模型失败:${error?.message ?? error}` }
986
+ }
987
+ }
988
+
989
+ /**
990
+ * 把投递失败落到任务上(`task.delivery.error`),让阶段机看得见。
991
+ * 失败**不吞**:写不进去时返回 false,调用方仍会把 reason 回给用户。
992
+ *
993
+ * @param {object} services
994
+ * @param {string} taskId
995
+ * @param {string} reason
996
+ * @param {string} by
997
+ * @returns {Promise<boolean>}
998
+ */
999
+ async function markDeliveryFailure(services, taskId, reason, by) {
1000
+ try {
1001
+ const board = services.board
1002
+ if (!board) return false
1003
+ const current = board.get(taskId)
1004
+ if (!current) return false
1005
+ // ⚠️ **合并写入**,不是整体覆盖(2026-11-09)。
1006
+ // `board.set` 对 `delivery` 是 `next[k] = fields[k]` —— 整体替换。
1007
+ // 直接写 `{ error, at, by }` 会冲掉这个字段上已有的其它键(将来若加
1008
+ // attempt/最后一次成功时间等,会被静默抹掉)。先读再展开才是安全写法。
1009
+ const prev = (current.delivery && typeof current.delivery === 'object') ? current.delivery : {}
1010
+ await board.update({
1011
+ id: taskId,
1012
+ expectedRev: current.rev,
1013
+ action: 'set',
1014
+ byId: by,
1015
+ delivery: { ...prev, error: String(reason), at: Date.now(), by },
1016
+ })
1017
+ return true
1018
+ } catch {
1019
+ return false
1020
+ }
1021
+ }
1022
+
1023
+ /**
1024
+ * 清掉投递失败留痕(**重试成功后必须清**,2026-11-09 修复)。
1025
+ *
1026
+ * ## 为什么必须有它(现场缺陷)
1027
+ * `stage.js:hasDeliveryFailure()` 的判定是「`task.delivery.error` 存在且非空」,
1028
+ * 而全项目**只有写入、没有人清除**(`grep delivery:` 只有两处 set)。
1029
+ * 后果:一次投递失败之后,即使人点「重试唤醒」投递成功、Bot 正常把活干完,
1030
+ * 任务也**永远**停在 `person/delivery`「输入投递失败:…」——
1031
+ * 阶段机被一条过期事实永久劫持,人看到的是假红字。
1032
+ *
1033
+ * 清除口径(故意保守):
1034
+ * - 只在**这一次投递确实成功**(`sent` 或 `skipped` 幂等命中)时清
1035
+ * - `pending`(已排队未送达)不清 —— 还没真投出去,失败留痕仍是事实
1036
+ * - `failed` 当然不清
1037
+ *
1038
+ * @param {object} services
1039
+ * @param {string} taskId
1040
+ * @param {{ result?: string }} [wake]
1041
+ * @returns {Promise<boolean>} 是否真的清了
1042
+ */
1043
+ async function clearDeliveryFailure(services, taskId, wake) {
1044
+ const result = wake?.result
1045
+ if (result !== 'sent' && result !== 'skipped') return false
1046
+ try {
1047
+ const board = services.board
1048
+ if (!board) return false
1049
+ const current = board.get(taskId)
1050
+ if (!current) return false
1051
+ const prev = current.delivery
1052
+ if (!prev || typeof prev !== 'object') return false
1053
+ if (typeof prev.error !== 'string' || prev.error === '') return false
1054
+ await board.update({
1055
+ id: taskId,
1056
+ expectedRev: current.rev,
1057
+ action: 'set',
1058
+ byId: 'cs-bots',
1059
+ delivery: { ...prev, error: undefined, clearedAt: Date.now(), clearedBy: wake?.target ?? 'cs-bots' },
1060
+ })
1061
+ return true
1062
+ } catch {
1063
+ return false
1064
+ }
1065
+ }
1066
+
1067
+ /**
1068
+ * 执行 Bot 的开工准备命令(改造07 · 借鉴 Super-agent 的 prepare command)。
1069
+ *
1070
+ * 安全边界(与 verify 的 kind:'command' 同口径):
1071
+ * - 第一段必须在 CMD_WHITELIST 内(拒绝任意二进制执行)
1072
+ * - 用 execFile 传参数组,**绝不经过 shell** → 无注入面
1073
+ * - 有硬超时(默认 10 分钟,与 Super-agent 的 prepare 一致)
1074
+ * - cwd 固定为 Bot 的工作目录(Bot 的写入边界)
1075
+ *
1076
+ * @param {{ prepareCommand?: string, cwd?: string }} bot
1077
+ * @param {{ timeoutMs?: number }} [opts]
1078
+ * @returns {Promise<object>}
1079
+ */
1080
+ async function runPrepareCommand(bot, opts = {}) {
1081
+ const raw = typeof bot?.prepareCommand === 'string' ? bot.prepareCommand.trim() : ''
1082
+ if (!raw) return { ok: true, skipped: true }
1083
+ const cwd = typeof bot?.cwd === 'string' && bot.cwd ? bot.cwd : undefined
1084
+ if (!cwd) return { ok: false, command: raw, error: 'bot-cwd-missing' }
1085
+
1086
+ // 只按空白切分,不做 shell 展开(不做引号/管道/重定向——那都是注入面)。
1087
+ const argv = raw.split(/\s+/).filter(Boolean)
1088
+ if (argv.length === 0) return { ok: true, skipped: true }
1089
+ if (!CMD_WHITELIST.includes(argv[0])) {
1090
+ return {
1091
+ ok: false,
1092
+ command: raw,
1093
+ cwd,
1094
+ error: `prepare-command-not-allowed:${argv[0]}`,
1095
+ stderr: `允许的命令:${CMD_WHITELIST.join(' | ')}`,
1096
+ }
1097
+ }
1098
+
1099
+ const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : 10 * 60 * 1000
1100
+ return new Promise((resolvePromise) => {
1101
+ let done = false
1102
+ const finish = (out) => {
1103
+ if (done) return
1104
+ done = true
1105
+ resolvePromise({ command: raw, cwd, ...out })
1106
+ }
1107
+ try {
1108
+ execFile(argv[0], argv.slice(1), { cwd, timeout: timeoutMs, maxBuffer: 4 * 1024 * 1024 }, (error, stdout, stderr) => {
1109
+ const so = String(stdout ?? '')
1110
+ const se = String(stderr ?? '')
1111
+ if (error) {
1112
+ const timedOut = error.killed === true || error.signal === 'SIGTERM'
1113
+ finish({
1114
+ ok: false,
1115
+ exitCode: typeof error.code === 'number' ? error.code : -1,
1116
+ stdout: so,
1117
+ stderr: se,
1118
+ error: timedOut ? 'timeout' : String(error?.message ?? error),
1119
+ })
1120
+ return
1121
+ }
1122
+ finish({ ok: true, exitCode: 0, stdout: so, stderr: se })
1123
+ })
1124
+ } catch (error) {
1125
+ finish({ ok: false, error: String(error?.message ?? error) })
1126
+ }
1127
+ })
1128
+ }
1129
+
1130
+ /**
1131
+ * 把 `ensureLive` 结果压成响应新增字段:`revived:boolean` + 失败时 `reviveReason`。
1132
+ * 原本就 live 时只多一个 `revived:false`(契约允许的新增字段),不产生任何副作用。
1133
+ * @param {{ live?: boolean, revived?: boolean, reason?: string } | undefined} revive
1134
+ */
1135
+ function reviveFields(revive) {
1136
+ const out = { revived: Boolean(revive?.revived) }
1137
+ if (revive && revive.live === false) out.reviveReason = revive.reason ?? 'revive-failed'
1138
+ return out
1139
+ }
1140
+
1141
+ // ══════════════════════════════════════════════════════════════════════════════
1142
+ // 改造06 §2:团队 L2 记忆 / 交付物文件预览 / 任务结论摘要 / CS-BOTS 工作区
1143
+ //
1144
+ // 设计依据:`design/CONTRACT-bots06.md`(字段名/文案/状态码逐字照做)。
1145
+ // 这四块都是**纯新增**端点:不改任何既有路由的行为,只追加私有 helper + 分支。
1146
+ // ══════════════════════════════════════════════════════════════════════════════
1147
+
1148
+ /** 团队记忆的标签(L2 层,一个团队一个 tag)。 */
1149
+ function teamMemoryTag(teamId) {
1150
+ return `team:${teamId}`
1151
+ }
1152
+
1153
+ /**
1154
+ * 读某团队的 L2 记忆(按 `at` 升序)。
1155
+ * 从 memory 服务读(`list({ layer:'L2' })` 后按 tags 精确匹配),不碰记忆文件。
1156
+ */
1157
+ function readTeamMemories(services, teamId) {
1158
+ if (!services?.memory) return []
1159
+ const tag = teamMemoryTag(teamId)
1160
+ let rows
1161
+ try {
1162
+ rows = services.memory.list({ layer: 'L2', includeArchived: true })
1163
+ } catch {
1164
+ return []
1165
+ }
1166
+ return rows
1167
+ .filter((row) => Array.isArray(row?.tags) && row.tags.includes(tag))
1168
+ .map((row) => ({
1169
+ id: row.id,
1170
+ layer: 'L2',
1171
+ content: row.content,
1172
+ by: row.createdBy ?? row.by,
1173
+ at: row.createdAt ?? row.at,
1174
+ tags: Array.isArray(row.tags) ? [...row.tags] : [],
1175
+ }))
1176
+ .sort((a, b) => (Number(a.at) || 0) - (Number(b.at) || 0))
1177
+ }
1178
+
1179
+ /** 团队记忆渲染文本:`- [时间] content(by)`;空列表 → 空串。 */
1180
+ function buildTeamMemoryText(memories) {
1181
+ return (Array.isArray(memories) ? memories : [])
1182
+ .map((m) => `- [${formatAnchorTime(m.at)}] ${m.content}(${m.by ?? '?'})`)
1183
+ .join('\n')
1184
+ }
1185
+
1186
+ /** 简单扩展名 → MIME 映射(只覆盖预览场景常见的文本类型)。 */
1187
+ const MIME_BY_EXT = {
1188
+ '.txt': 'text/plain; charset=utf-8',
1189
+ '.json': 'application/json; charset=utf-8',
1190
+ '.jsonl': 'application/x-ndjson; charset=utf-8',
1191
+ '.md': 'text/markdown; charset=utf-8',
1192
+ '.js': 'text/javascript; charset=utf-8',
1193
+ '.mjs': 'text/javascript; charset=utf-8',
1194
+ '.cjs': 'text/javascript; charset=utf-8',
1195
+ '.ts': 'text/typescript; charset=utf-8',
1196
+ '.css': 'text/css; charset=utf-8',
1197
+ '.html': 'text/html; charset=utf-8',
1198
+ '.csv': 'text/csv; charset=utf-8',
1199
+ '.log': 'text/plain; charset=utf-8',
1200
+ '.yml': 'text/yaml; charset=utf-8',
1201
+ '.yaml': 'text/yaml; charset=utf-8',
1202
+ }
1203
+
1204
+ /** 按扩展名取 MIME(未知 → `application/octet-stream`)。 */
1205
+ function mimeFor(path) {
1206
+ const m = /\.[A-Za-z0-9]+$/.exec(String(path ?? ''))
1207
+ const ext = m ? m[0].toLowerCase() : ''
1208
+ return MIME_BY_EXT[ext] ?? 'application/octet-stream'
1209
+ }
1210
+
1211
+ /**
1212
+ * 粗判二进制:NUL 字节或过高的非文本比例即判为不可预览。
1213
+ * 只用内置能力,不引第三方依赖。
1214
+ */
1215
+ function isBinaryBuffer(buf) {
1216
+ const n = buf?.length ?? 0
1217
+ if (n === 0) return false
1218
+ const sample = buf.subarray(0, Math.min(n, 8192))
1219
+ let suspicious = 0
1220
+ for (let i = 0; i < sample.length; i += 1) {
1221
+ const b = sample[i]
1222
+ if (b === 0) return true
1223
+ // 控制字符(除常见的 \t \n \r)占比高 → 二进制
1224
+ if (b < 0x09 || (b > 0x0d && b < 0x20)) suspicious += 1
1225
+ }
1226
+ return suspicious / sample.length > 0.02
1227
+ }
1228
+
1229
+ /** 按码点截断(不切断代理对,避免产生半个字符)。 */
1230
+ function truncateCodepoints(text, max) {
1231
+ const s = String(text ?? '')
1232
+ const limit = Number.isFinite(max) && max >= 0 ? max : 200
1233
+ const arr = Array.from(s)
1234
+ return arr.length > limit ? arr.slice(0, limit).join('') : s
1235
+ }
1236
+
1237
+ /** 任务状态 → 中文(与契约 §3 的 progressText 口径一致)。 */
1238
+ const STATUS_TEXT = {
1239
+ pending: '待办',
1240
+ in_progress: '进行中',
1241
+ blocked: '阻塞',
1242
+ completed: '已完成',
1243
+ }
1244
+
1245
+ /** 唤醒记录里是否属于该任务(按投递文案的任务锚定串或 dedupKey 中的 taskId 判定)。 */
1246
+ function wakeBelongsToTask(row, taskId) {
1247
+ const anchor = `任务 ${taskId}:`
1248
+ const text = typeof row?.text === 'string' ? row.text : ''
1249
+ if (text.includes(anchor)) return true
1250
+ const key = typeof row?.dedupKey === 'string' ? row.dedupKey : ''
1251
+ return key.includes(`:${taskId}:`) || key.endsWith(`:${taskId}`)
1252
+ }
1253
+
1254
+ /** 唤醒投递文本的首段(首行非空内容),截 200 字;无文本 → null。 */
1255
+ function wakeExcerpt(text, max = 200) {
1256
+ const s = typeof text === 'string' ? text : ''
1257
+ for (const line of s.split('\n')) {
1258
+ const t = line.trim()
1259
+ if (t) return truncateCodepoints(t, max)
1260
+ }
1261
+ return null
1262
+ }
1263
+
1264
+ /**
1265
+ * 从最近一条 L4 笔记里提取「下一步」行。
1266
+ * 关键词:下一步 / todo / 计划 / 接下来 / 待(大小写不敏感)。
1267
+ * 无匹配 → null;命中行截 200 字。
1268
+ */
1269
+ function extractNextStep(content, max = 200) {
1270
+ const s = typeof content === 'string' ? content : ''
1271
+ const lines = s.split('\n')
1272
+ for (const line of lines) {
1273
+ const t = line.trim()
1274
+ if (!t) continue
1275
+ if (/下一步|todo|计划|接下来|待/i.test(t)) return truncateCodepoints(t, max)
1276
+ }
1277
+ return null
1278
+ }
1279
+
1280
+ /** 任务结论摘要的固定说明文案(契约逐字)。 */
1281
+ const SUMMARY_NOTE = '结论由唤醒记录与 L4 任务笔记提炼;队长可通过任务笔记补充下一步方向'
1282
+
1283
+ /** 取某任务最近一条 L4 笔记(notesForTask 已是倒序);无 → null。 */
1284
+ function latestTaskNote(services, taskId) {
1285
+ const mem = services?.memory
1286
+ if (!mem || typeof mem.notesForTask !== 'function') return null
1287
+ try {
1288
+ const rows = mem.notesForTask({ taskId })
1289
+ return Array.isArray(rows) && rows.length ? rows[0] : null
1290
+ } catch {
1291
+ return null
1292
+ }
1293
+ }
1294
+
1295
+ /**
1296
+ * 读某任务的唤醒记录。
1297
+ * 优先用 dispatcher 暴露的读取能力(`list()` 读盘,进程重启后仍可审计);
1298
+ * 若该能力缺失或读盘失败,再退回直接读 `wakes.jsonl`(契约 §2.3 的兜底口径)。
1299
+ */
1300
+ function readTaskWakes(services, taskId) {
1301
+ const dispatcher = services?.dispatcher
1302
+ if (dispatcher && typeof dispatcher.list === 'function') {
1303
+ try {
1304
+ const rows = dispatcher.list({})
1305
+ if (Array.isArray(rows)) return rows.filter((row) => wakeBelongsToTask(row, taskId))
1306
+ } catch {
1307
+ /* 落到下面的读盘兜底 */
1308
+ }
1309
+ }
1310
+ const dir = anchorStateDir(services)
1311
+ if (!dir) return []
1312
+ try {
1313
+ const { rows } = readJsonl(statePath(dir, 'wakes.jsonl'))
1314
+ return rows.filter((row) => wakeBelongsToTask(row, taskId))
1315
+ } catch {
1316
+ return []
1317
+ }
1318
+ }
1319
+
1320
+ /** 唤醒记录 → 摘要行 `{ at, result, excerpt }`。 */
1321
+ function toStepConclusion(row) {
1322
+ const at = row?.updatedAt ?? row?.sentAt ?? row?.scheduledAt ?? null
1323
+ return {
1324
+ at: Number.isFinite(Number(at)) ? Number(at) : null,
1325
+ result: row?.result ?? 'unknown',
1326
+ excerpt: wakeExcerpt(row?.text),
1327
+ }
1328
+ }
1329
+
1330
+ /** 状态 → 中文(未知状态原样返回,不静默丢信息)。 */
1331
+ function statusText(status) {
1332
+ return STATUS_TEXT[status] ?? String(status ?? '未知')
1333
+ }
1334
+
1335
+
1336
+ /**
1337
+ * 注册全部 API 路由。
1338
+ *
1339
+ * @param {object} ctx 宿主上下文
1340
+ * @param {{ warn?: Function }} [deps]
1341
+ * @returns {(() => void) | undefined} disposer
1342
+ */
1343
+ export function installApi(ctx, { warn } = {}) {
1344
+ /** 模块级告警出口:ensureLive 这类模块级函数也要能把 preset 装配失败说出来。 */
1345
+ moduleWarn = warn ?? (() => {})
1346
+ // ⚠️ 修复(2026-11-04 测试,P0):此前本 API **完全没有鉴权**——
1347
+ // 公网 `curl -X POST http://<IP>/cs-bots/api/tasks` 直接 200,
1348
+ // 任何人都能建任务/改记忆/写房间/改 Bot 注册表,并借此绕过下游的受保护区写闸门。
1349
+ // 现在所有请求先过 fence.judgeHttp:宿主不认证就一律 403。
1350
+ const fence = createFence({ ctx, warn })
1351
+
1352
+ const handler = async (request, response) => {
1353
+ const services = getServices(ctx)
1354
+ // 鉴权先于一切业务逻辑(连 URL 解析都不做,避免攻击面)
1355
+ const verdict = fence.judgeHttp(request)
1356
+ if (!verdict.ok) {
1357
+ sendJson(response, verdict.status ?? 403, { ok: false, reason: verdict.reason ?? 'unauthorized' })
1358
+ return
1359
+ }
1360
+ let url
1361
+ try {
1362
+ url = new URL(request.url || '/', 'http://localhost')
1363
+ } catch {
1364
+ sendJson(response, 400, { ok: false, reason: 'bad-url' })
1365
+ return
1366
+ }
1367
+ // 去掉前缀,得到相对路径
1368
+ const path = url.pathname.startsWith(API_BASE) ? url.pathname.slice(API_BASE.length) || '/' : url.pathname
1369
+
1370
+ try {
1371
+ // ── 读:状态总览 ──────────────────────────────────────────────────────
1372
+ if (request.method === 'GET' && path === '/status') {
1373
+ const out = { ok: true, services: {}, compat: services.compat ?? null }
1374
+ // 状态面要覆盖**全部**域服务:新增模块若不上报,页面「系统体检」就会
1375
+ // 显示「一切正常」而实际某功能没装配 —— 那是本平台最忌讳的静默失败。
1376
+ const SERVICE_KEYS = [
1377
+ 'roster', 'board', 'dispatcher', 'room', 'memory',
1378
+ 'library', 'discipline', 'todo', 'scratch', 'prompts', 'suggest', 'reviewer',
1379
+ 'metrics', 'verify', 'cycle', 'teams', 'subscription',
1380
+ // 投递回执看护(2026-11-09):不上报的话,它装配失败时
1381
+ // 「系统体检」会显示一切正常,而实际没有任何回执追踪 —— 静默失败。
1382
+ 'deliveryWatch',
1383
+ ]
1384
+ for (const key of SERVICE_KEYS) {
1385
+ const svc = services[key]
1386
+ out.services[key] = svc
1387
+ ? {
1388
+ available: true,
1389
+ stats: typeof svc.stats === 'function' ? svc.stats() : undefined,
1390
+ stateDir: svc.paths?.stateDir,
1391
+ // 看护没有状态目录(期望表只在内存),补一句人话说明
1392
+ ...(key === 'deliveryWatch'
1393
+ ? { note: '投递回执看护(只在内存;重启后旧期望失效是设计如此)' }
1394
+ : {}),
1395
+ }
1396
+ : { available: false }
1397
+ }
1398
+ sendJson(response, 200, out)
1399
+ return
1400
+ }
1401
+
1402
+ // ── 任务板 ────────────────────────────────────────────────────────────
1403
+ if (request.method === 'GET' && path === '/tasks') {
1404
+ const status = qp(url, 'status')
1405
+ const onlyReady = qp(url, 'ready') === '1'
1406
+ const attention = qp(url, 'attention') === '1'
1407
+ const priority = qn(url, 'priority')
1408
+ const source = qp(url, 'source')
1409
+ const sort = qp(url, 'sort')
1410
+ const byId = qp(url, 'byId')
1411
+ const opts = { status: status ?? undefined, attention: attention || undefined, priority: priority ?? undefined, source: source ?? undefined, sort: sort ?? undefined, byId }
1412
+ const tasks = await withService(services, 'board', (b) =>
1413
+ onlyReady ? b.ready() : b.list(opts),
1414
+ )
1415
+ // 改造07(借鉴 Super-agent stage table):每个任务附带执行阶段(纯派生,不落盘)。
1416
+ // 前端用 stageInfo.group 上色、needsPerson 判断「该我动手」。
1417
+ const enriched = attachStages(tasks)
1418
+ sendJson(response, 200, {
1419
+ ok: true,
1420
+ tasks: enriched,
1421
+ stageSummary: stageSummary(tasks),
1422
+ })
1423
+ return
1424
+ }
1425
+
1426
+ if (request.method === 'POST' && path === '/tasks') {
1427
+ const body = (await readBody(request)) ?? {}
1428
+ // `mandateBasis`/`mandateSender` 是扁平参数(HTTP 与工具调用同形),
1429
+ // 落到 board 时组装成 `mandate` 对象 —— 校验在 board 侧统一做。
1430
+ // 与 `src/tools.js` 的 bot_task_create 保持**同一套参数名**,
1431
+ // 否则同一个概念会出现两种形状,调用方必须记两套规则。
1432
+ const { mandateBasis, mandateSender, longrun, phases, ...rest } = body
1433
+ const mandate = {}
1434
+ if (mandateBasis !== undefined && mandateBasis !== null) mandate.basis = mandateBasis
1435
+ if (mandateSender !== undefined && mandateSender !== null) mandate.sender = mandateSender
1436
+
1437
+ // ── 长任务「一次到位」(2026-11-09 需求3)───────────────────────────
1438
+ // 用户原话:「建任务的时候选择长任务就是长表单一次到位」。
1439
+ // 此前 longrun 只能建完再转(`/tasks/evolve mode=longrun`),于是想直接
1440
+ // 建长任务的人必须先建一条普通任务、再转一次 —— 两步操作、两次留痕,
1441
+ // 而且第一步派活时唤醒文案里**没有阶段清单**,执行者第一轮就看不到。
1442
+ //
1443
+ // 判定:显式 `longrun:true` 或给了 `phases` 数组 ⇒ 按长任务建。
1444
+ // 校验放在**建任务之前**(非法 phases 不该留下一条半成品任务)。
1445
+ const wantsLongrun = longrun === true || (Array.isArray(phases) && phases.length > 0)
1446
+ const normalizedPhases = wantsLongrun ? normalizePhases(phases, 'TASK_BAD_PHASES') : undefined
1447
+
1448
+ const task = await withService(services, 'board', (b) =>
1449
+ b.create({
1450
+ ...rest,
1451
+ byId: rest.byId ?? 'ui',
1452
+ ...(Object.keys(mandate).length > 0 ? { mandate } : {}),
1453
+ }),
1454
+ )
1455
+
1456
+ // ── 需求4:任务建好自动配一个团队(可以只有一个人,后续再加)────────
1457
+ // 有负责人(assignee)才有意义:团队要有 lead,而 lead 就是执行这个活的人。
1458
+ // 没指派时**不建**(建一个没有负责人的团队等于制造垃圾数据)。
1459
+ // 失败不阻断:任务已经建好了,团队没建上只是少一份组织关系。
1460
+ let autoTeam
1461
+ if (rest.assignee && rest.autoTeam !== false) {
1462
+ autoTeam = await ensureTeamForBot(services, rest.assignee, rest.byId ?? 'ui')
1463
+ }
1464
+
1465
+ // 阶段清单落在 task-meta.jsonl(与 evolve 同一份存储、同一形状)⇒
1466
+ // `buildTaskAnchorText` 自动带上,第一次唤醒就可见,不需要再转。
1467
+ if (wantsLongrun) {
1468
+ appendTaskMeta(services, {
1469
+ kind: 'longrun',
1470
+ taskId: task.id,
1471
+ phases: normalizedPhases,
1472
+ note: '创建时长任务',
1473
+ by: rest.byId ?? 'ui',
1474
+ at: Date.now(),
1475
+ })
1476
+ sendJson(response, 200, {
1477
+ ok: true, task, phases: normalizedPhases, longrun: true, autoTeam,
1478
+ note: '已按长任务创建;此后每次唤醒都会带上阶段清单',
1479
+ })
1480
+ return
1481
+ }
1482
+
1483
+ sendJson(response, 200, { ok: true, task, autoTeam })
1484
+ return
1485
+ }
1486
+
1487
+ if (request.method === 'POST' && path === '/tasks/update') {
1488
+ const body = (await readBody(request)) ?? {}
1489
+ const task = await withService(services, 'board', (b) =>
1490
+ b.update({ ...body, byId: body.byId ?? 'ui' }),
1491
+ )
1492
+ sendJson(response, 200, { ok: true, task })
1493
+ return
1494
+ }
1495
+
1496
+ if (request.method === 'POST' && path === '/tasks/accept') {
1497
+ const body = (await readBody(request)) ?? {}
1498
+ const task = await withService(services, 'board', (b) =>
1499
+ b.accept({ ...body, by: body.by ?? 'ui' }),
1500
+ )
1501
+ sendJson(response, 200, { ok: true, task })
1502
+ return
1503
+ }
1504
+
1505
+ // ── agent 预设(建 Bot 时可选)─────────────────────────────────────────
1506
+ if (request.method === 'GET' && path === '/presets') {
1507
+ let presets = []
1508
+ let defaultId
1509
+ try {
1510
+ presets = await listPresets(ctx)
1511
+ defaultId = defaultPreset(ctx)
1512
+ } catch (error) {
1513
+ sendJson(response, 200, { ok: true, presets: [], defaultId: undefined, note: `预设服务不可用:${error?.message ?? error}` })
1514
+ return
1515
+ }
1516
+ sendJson(response, 200, {
1517
+ ok: true,
1518
+ presets: presets.map((p) => ({
1519
+ id: p.id,
1520
+ name: p.name ?? p.id,
1521
+ description: p.description,
1522
+ order: p.order,
1523
+ broken: p.broken,
1524
+ })),
1525
+ defaultId,
1526
+ note: presets.length ? undefined : '宿主未提供 agentPresets 服务(Bot 将使用默认预设)',
1527
+ })
1528
+ return
1529
+ }
1530
+
1531
+ // ── Bot 注册表 ────────────────────────────────────────────────────────
1532
+ // ── 工作区补挂(2026-11-09:「主页面左侧栏还是看不到工作区和对话」)──────
1533
+ //
1534
+ // 新建的 Bot 会话现在会自动挂进「cwd 对应的工作区」(kernel.attachToWorkspace)。
1535
+ // 但**存量 Bot**(本次修复之前建的)从来没挂过,于是永远躺在「未分组」里。
1536
+ // 这个端点做一次性补挂:遍历 roster,把每个 Bot 的会话挂到它 cwd 的工作区。
1537
+ //
1538
+ // 为什么不在装配时自动跑:attachSession 会**写**宿主的工作区注册表,
1539
+ // 插件装配期做写操作是不可接受的副作用(用户没要求、失败还难查)。
1540
+ // 做成显式端点(页面「Bot 档案」有按钮,也可手动调)。
1541
+ if (request.method === 'POST' && path === '/roster/attach-workspaces') {
1542
+ const bots = await withService(services, 'roster', (r) => r.list({ includeRetired: false }))
1543
+ const results = []
1544
+ for (const bot of bots) {
1545
+ if (typeof bot.cwd !== 'string' || bot.cwd.trim() === '') {
1546
+ results.push({ name: bot.name, ok: false, reason: '没有工作目录' })
1547
+ continue
1548
+ }
1549
+ try {
1550
+ const attached = await attachToWorkspace(ctx, bot.cwd, bot.sessionId)
1551
+ results.push({
1552
+ name: bot.name,
1553
+ cwd: bot.cwd,
1554
+ sessionId: bot.sessionId,
1555
+ ok: attached,
1556
+ reason: attached ? null : '宿主未提供 workspaceRegistry(版本过旧)',
1557
+ })
1558
+ } catch (error) {
1559
+ results.push({ name: bot.name, ok: false, reason: `挂载失败:${error?.message ?? error}` })
1560
+ }
1561
+ }
1562
+ const okCount = results.filter((r) => r.ok).length
1563
+ sendJson(response, 200, {
1564
+ ok: true,
1565
+ total: results.length,
1566
+ attached: okCount,
1567
+ failed: results.length - okCount,
1568
+ results,
1569
+ note: okCount > 0
1570
+ ? `已把 ${okCount} 个 Bot 的会话挂进各自工作目录对应的工作区,刷新左侧栏即可看到`
1571
+ : '没有需要补挂的(或宿主版本过旧不支持工作区)',
1572
+ })
1573
+ return
1574
+ }
1575
+
1576
+ if (request.method === 'GET' && path === '/roster') {
1577
+ const includeRetired = qp(url, 'includeRetired') === '1'
1578
+ const bots = await withService(services, 'roster', (r) => [
1579
+ ...r.list(),
1580
+ ...(includeRetired ? r.listRetired() : []),
1581
+ ])
1582
+ sendJson(response, 200, { ok: true, bots })
1583
+ return
1584
+ }
1585
+
1586
+ if (request.method === 'POST' && path === '/roster') {
1587
+ const body = (await readBody(request)) ?? {}
1588
+ // cwd 兜底(契约 bots06 §2.4):未提供/空白时用 settings.botWorkspaceRoot;
1589
+ // 两者都空则保持原行为 —— roster 自己抛 400。
1590
+ const bodyWithFallback = { ...body }
1591
+ if (typeof body.cwd !== 'string' || body.cwd.trim() === '') {
1592
+ const fallback = services.settings?.botWorkspaceRoot
1593
+ if (typeof fallback === 'string' && fallback.trim() !== '') bodyWithFallback.cwd = fallback.trim()
1594
+ }
1595
+ const bot = await withService(services, 'roster', (r) =>
1596
+ r.create({ ...bodyWithFallback, byId: body.byId ?? 'ui' }),
1597
+ )
1598
+ // 预制记忆同步(建 Bot 与改档案必须一致)
1599
+ const memory = seedBotMemory(services, bot, body.byId ?? 'ui')
1600
+ // 同目录共享检测:**允许**,但必须告知(两个 Bot 在同一目录会互相覆盖文件)
1601
+ let sharedWith = []
1602
+ try {
1603
+ const all = services.roster?.list?.() ?? []
1604
+ sharedWith = all
1605
+ .filter((b) => b.id !== bot.id && b.state !== 'retired' && String(b.cwd) === String(bot.cwd))
1606
+ .map((b) => ({ id: b.id, name: b.name }))
1607
+ } catch {
1608
+ /* 检测失败不影响创建 */
1609
+ }
1610
+ sendJson(response, 200, {
1611
+ ok: true,
1612
+ bot,
1613
+ ...(memory ? { memory } : {}),
1614
+ ...(sharedWith.length
1615
+ ? {
1616
+ warnings: [
1617
+ `目录 ${bot.cwd} 已被 ${sharedWith.map((b) => b.name).join('、')} 使用 —— 两个 Bot 会互相覆盖文件`,
1618
+ ],
1619
+ sharedWith,
1620
+ }
1621
+ : {}),
1622
+ })
1623
+ return
1624
+ }
1625
+
1626
+ // ── Bot 档案(身份 / 经验 / 技能 / 预制记忆)──────────────────────────
1627
+ if (request.method === 'GET' && path === '/roster/profile') {
1628
+ const id = qp(url, 'id')
1629
+ if (!id) {
1630
+ sendJson(response, 400, { ok: false, reason: 'id 必填' })
1631
+ return
1632
+ }
1633
+ const detail = await withService(services, 'roster', (r) => r.getProfile(id))
1634
+ sendJson(response, 200, {
1635
+ ok: true,
1636
+ bot: detail.bot,
1637
+ profile: detail.profile ?? {},
1638
+ profilePath: detail.profilePath,
1639
+ managedText: detail.managedText,
1640
+ hasOutside: detail.hasOutside,
1641
+ profileError: detail.profileError,
1642
+ })
1643
+ return
1644
+ }
1645
+
1646
+ if (request.method === 'POST' && path === '/roster/profile') {
1647
+ const body = (await readBody(request)) ?? {}
1648
+ if (!body.id) {
1649
+ sendJson(response, 400, { ok: false, reason: 'id 必填' })
1650
+ return
1651
+ }
1652
+ const bot = await withService(services, 'roster', (r) =>
1653
+ r.setProfile({ id: body.id, profile: body.profile ?? {}, byId: body.byId ?? 'ui', expectedRev: body.expectedRev }),
1654
+ )
1655
+ // 预制记忆同步(与建 Bot 共用同一段逻辑)
1656
+ const memory = seedBotMemory(services, bot, body.byId ?? 'ui')
1657
+ sendJson(response, 200, { ok: true, bot, ...(memory ? { memory } : {}) })
1658
+ return
1659
+ }
1660
+
1661
+ if (request.method === 'POST' && path === '/roster/manage') {
1662
+ const body = (await readBody(request)) ?? {}
1663
+ const byId = body.byId ?? 'ui'
1664
+ const result = await withService(services, 'roster', (r) => {
1665
+ switch (body.action) {
1666
+ case 'rename':
1667
+ return r.rename({ id: body.id, name: body.name, expectedRev: body.expectedRev, byId })
1668
+ case 'retire':
1669
+ return r.retire({ id: body.id, reason: body.reason, byId })
1670
+ case 'setCwd':
1671
+ return r.setCwd({ id: body.id, cwd: body.cwd, expectedRev: body.expectedRev, byId })
1672
+ // 改造07(借鉴 Super-agent prepare command):设置/清除开工准备命令
1673
+ case 'prepare':
1674
+ return r.setPrepareCommand({
1675
+ id: body.id,
1676
+ prepareCommand: body.prepareCommand,
1677
+ expectedRev: body.expectedRev,
1678
+ byId,
1679
+ })
1680
+ case 'claim':
1681
+ return r.claim({ id: body.id, owner: byId, expectedRev: body.expectedRev })
1682
+ case 'release':
1683
+ return r.release({ id: body.id, owner: byId, expectedRev: body.expectedRev })
1684
+ case 'heartbeat':
1685
+ return r.heartbeat({ id: body.id, owner: byId, expectedRev: body.expectedRev })
1686
+ default:
1687
+ throw new Error(`未知 action: ${body.action}`)
1688
+ }
1689
+ })
1690
+ sendJson(response, 200, { ok: true, bot: result })
1691
+ return
1692
+ }
1693
+
1694
+ // ── 房间 ──────────────────────────────────────────────────────────────
1695
+ if (request.method === 'GET' && path === '/rooms') {
1696
+ // includeArchived=1 时连归档房一起返回(超级管理员的"回收站"视图)
1697
+ const rooms = await withService(services, 'room', (r) =>
1698
+ r.list({ includeArchived: qp(url, 'includeArchived') === '1' }),
1699
+ )
1700
+ sendJson(response, 200, { ok: true, rooms })
1701
+ return
1702
+ }
1703
+
1704
+ if (request.method === 'POST' && path === '/rooms') {
1705
+ const body = (await readBody(request)) ?? {}
1706
+ const room = await withService(services, 'room', (r) =>
1707
+ r.create({ ...body, byId: body.byId ?? 'ui' }),
1708
+ )
1709
+ sendJson(response, 200, { ok: true, room })
1710
+ return
1711
+ }
1712
+
1713
+ if (request.method === 'GET' && path === '/rooms/messages') {
1714
+ const roomId = qp(url, 'roomId')
1715
+ if (!roomId) {
1716
+ sendJson(response, 400, { ok: false, reason: 'roomId 必填' })
1717
+ return
1718
+ }
1719
+ const messages = await withService(services, 'room', (r) =>
1720
+ r.listMessages({
1721
+ roomId,
1722
+ sinceSeq: qn(url, 'sinceSeq'),
1723
+ limit: qn(url, 'limit'),
1724
+ withBody: qp(url, 'withBody') === '1',
1725
+ }),
1726
+ )
1727
+ sendJson(response, 200, { ok: true, messages })
1728
+ return
1729
+ }
1730
+
1731
+ if (request.method === 'POST' && path === '/rooms/post') {
1732
+ const body = (await readBody(request)) ?? {}
1733
+ const message = await withService(services, 'room', (r) =>
1734
+ r.post({ ...body, from: body.from ?? 'ui' }),
1735
+ )
1736
+
1737
+ // ── 发言后自动唤醒房间内的 Bot ──
1738
+ //
1739
+ // 为什么需要这步:主管报"我在群聊里说话,Bot 没有反应"。
1740
+ // 根因是 /rooms/post 只落消息、不做任何投递,房间变成"死信箱"——
1741
+ // 消息写进去了,但没有任何机制告诉 Bot "有人在说话"。
1742
+ //
1743
+ // 唤醒策略(与 dispatcher 的 dedupKey 一致):
1744
+ // - 只唤醒**非本人**的成员(不唤醒发送者自己);
1745
+ // - 房间设置 `allowBotAutoPost` 为 false 时跳过(给用户关开关的权利);
1746
+ // - dedupKey 用消息 ID,天然幂等,同一消息不会重复唤醒;
1747
+ // - dispatcher 缺失时降级为「只落消息不唤醒」,不抛错。
1748
+ const room = await withService(services, 'room', (r) => r.get(message.roomId))
1749
+ const senderSession = body.from ?? 'ui'
1750
+ const members = room?.members ?? []
1751
+ const skipSelf = (senderSession !== 'ui' && senderSession)
1752
+ const toWake = members.filter((m) => m !== skipSelf)
1753
+ const wakeByTarget = new Map()
1754
+ if (toWake.length > 0 && room?.settings?.allowBotAutoPost !== false) {
1755
+ for (const target of toWake) {
1756
+ const result = await withService(services, 'dispatcher', (d) => d.notify({
1757
+ target,
1758
+ reason: 'room-message',
1759
+ dedupKey: `room-msg:${message.id}:${target}`,
1760
+ text: `[房间「${room.name ?? message.roomId}」有新消息] ${message.text.slice(0, 200)}`,
1761
+ })).catch(() => {
1762
+ // dispatcher 降级:唤醒失败不影响消息已落盘的事实
1763
+ return undefined
1764
+ })
1765
+ wakeByTarget.set(target, result)
1766
+ }
1767
+ }
1768
+
1769
+ // ── 补投(契约 bots05 §2.5):首轮投递失败的成员,先复活会话再用新
1770
+ // dedupKey 重投一次(`room-msg-retry:<msgId>:<target>`)。
1771
+ //
1772
+ // 为什么只补投**首轮失败**的:首轮已成功(sent/pending)的成员若再投一次,
1773
+ // 用户会收到同一消息的两次唤醒 —— 那是噪音,不是修复。
1774
+ // 为什么只补投本平台注册的 Bot 会话:房间成员也可能是主对话/人类会话,
1775
+ // 盲目 resume 会把非 Bot 会话拉起来(副作用不可控)。
1776
+ const revives = []
1777
+ for (const target of toWake) {
1778
+ const first = wakeByTarget.get(target)
1779
+ const firstFailed = !first || first.ok === false
1780
+ if (!firstFailed) continue
1781
+ if (!services.roster?.hasSession?.(target)) continue
1782
+ const revive = await ensureLive(ctx, target)
1783
+ const entry = { target, live: revive.live, revived: revive.revived, retried: false }
1784
+ if (!revive.live) {
1785
+ entry.reason = revive.reason ?? 'revive-failed'
1786
+ } else {
1787
+ entry.wake = await withService(services, 'dispatcher', (d) => d.notify({
1788
+ target,
1789
+ reason: 'room-message',
1790
+ dedupKey: `room-msg-retry:${message.id}:${target}`,
1791
+ text: `[房间「${room.name ?? message.roomId}」有新消息] ${message.text.slice(0, 200)}`,
1792
+ })).catch((error) => ({ ok: false, result: 'failed', reason: String(error?.message ?? error) }))
1793
+ entry.retried = true
1794
+ }
1795
+ revives.push(entry)
1796
+ }
1797
+
1798
+ // ── 改造07(借鉴 Super-agent Subscription 扇出):
1799
+ // 订阅该房间的任务,自动收到这条消息作为上下文。
1800
+ // **只加上下文、不改任务状态、不授予开工权力**——谁有权开工仍由 claim 决定。
1801
+ let fanout
1802
+ if (services.subscription) {
1803
+ try {
1804
+ fanout = await services.subscription.fanout({
1805
+ roomId: message.roomId,
1806
+ message,
1807
+ wake: false, // 上下文到达不等于要打扰执行者
1808
+ by: body.from ?? 'ui',
1809
+ })
1810
+ } catch (error) {
1811
+ fanout = { error: `fanout-failed:${error?.message ?? error}` }
1812
+ }
1813
+ }
1814
+
1815
+ sendJson(response, 200, { ok: true, message, wakes: toWake.length, revives, fanout })
1816
+ return
1817
+ }
1818
+
1819
+ if (request.method === 'GET' && path === '/rooms/body') {
1820
+ const roomId = qp(url, 'roomId')
1821
+ const messageId = qp(url, 'messageId')
1822
+ if (!roomId || !messageId) {
1823
+ sendJson(response, 400, { ok: false, reason: 'roomId 与 messageId 必填' })
1824
+ return
1825
+ }
1826
+ const text = await withService(services, 'room', (r) => r.readBody({ roomId, messageId }))
1827
+ sendJson(response, 200, { ok: true, text })
1828
+ return
1829
+ }
1830
+
1831
+ // ── 记忆 ──────────────────────────────────────────────────────────────
1832
+ if (request.method === 'GET' && path === '/memory') {
1833
+ const layer = qp(url, 'layer')
1834
+ const query = qp(url, 'query')
1835
+ const botId = qp(url, 'botId')
1836
+ const entries = await withService(services, 'memory', (m) =>
1837
+ query ? m.search({
1838
+ query,
1839
+ layers: layer ? [layer] : undefined,
1840
+ limit: qn(url, 'limit'),
1841
+ botId,
1842
+ includeArchived: qp(url, 'includeArchived') === '1',
1843
+ })
1844
+ : m.list({
1845
+ layer,
1846
+ limit: qn(url, 'limit'),
1847
+ botId,
1848
+ cwd: qp(url, 'cwd'),
1849
+ includeArchived: qp(url, 'includeArchived') === '1',
1850
+ onlyArchived: qp(url, 'onlyArchived') === '1',
1851
+ }),
1852
+ )
1853
+ sendJson(response, 200, { ok: true, entries })
1854
+ return
1855
+ }
1856
+
1857
+ if (request.method === 'POST' && path === '/memory') {
1858
+ const body = (await readBody(request)) ?? {}
1859
+ const entry = await withService(services, 'memory', (m) =>
1860
+ m.add({ ...body, by: body.by ?? 'ui' }),
1861
+ )
1862
+ sendJson(response, 200, { ok: true, entry })
1863
+ return
1864
+ }
1865
+
1866
+ if (request.method === 'GET' && path === '/memory/stats') {
1867
+ const stats = await withService(services, 'memory', (m) => m.stats())
1868
+ sendJson(response, 200, { ok: true, stats })
1869
+ return
1870
+ }
1871
+
1872
+ if (request.method === 'GET' && path === '/memory/context') {
1873
+ // cwd:按项目裁剪注入内容(不传=全局口径,与改造前逐字一致)
1874
+ const text = await withService(services, 'memory', (m) => m.contextFor({
1875
+ branch: qp(url, 'branch'),
1876
+ cwd: qp(url, 'cwd'),
1877
+ }))
1878
+ sendJson(response, 200, { ok: true, text })
1879
+ return
1880
+ }
1881
+
1882
+ // ── 成果聚合(只读)────────────────────────────────────────────────────
1883
+ if (request.method === 'GET' && path === '/artifacts') {
1884
+ const q = qp(url, 'q')
1885
+ const ownerId = qp(url, 'ownerId')
1886
+ const status = qp(url, 'status')
1887
+ const limit = qn(url, 'limit')
1888
+ const cap = limit !== undefined && limit > 0 ? Math.min(limit, 1000) : 200
1889
+ const kw = q ? String(q).toLowerCase() : ''
1890
+ const rows = await withService(services, 'board', (b) => b.list({ status: status ?? undefined }))
1891
+ const artifacts = []
1892
+ let itemCount = 0
1893
+ let danglingSkipped = 0
1894
+ for (const t of rows) {
1895
+ if (ownerId && t.ownerId !== ownerId) continue
1896
+ const items = normalizeArtifactItems(t.artifacts)
1897
+ if (items.length === 0) continue
1898
+ if (kw) {
1899
+ const hay = `${t.subject ?? ''} ${items.join(' ')}`.toLowerCase()
1900
+ if (!hay.includes(kw)) continue
1901
+ }
1902
+ itemCount += items.length
1903
+ artifacts.push({
1904
+ taskId: t.id,
1905
+ subject: t.subject,
1906
+ status: t.status,
1907
+ ownerId: t.ownerId,
1908
+ accepted: t.accepted === true,
1909
+ completedAt: t.completedAt ?? t.createdAt,
1910
+ items,
1911
+ })
1912
+ }
1913
+ artifacts.sort((a, b) => (b.completedAt ?? 0) - (a.completedAt ?? 0))
1914
+ const sliced = artifacts.slice(0, cap)
1915
+ sendJson(response, 200, {
1916
+ ok: true,
1917
+ artifacts: sliced,
1918
+ stats: { tasks: sliced.length, items: sliced.reduce((n, a) => n + a.items.length, 0) },
1919
+ note: `已扫描 ${rows.length} 个任务${danglingSkipped ? `,跳过 ${danglingSkipped} 个悬空引用` : ''}`,
1920
+ })
1921
+ return
1922
+ }
1923
+
1924
+ // ── 数据浏览(只读,绝不提供写接口)────────────────────────────────────
1925
+ if (request.method === 'GET' && path === '/data/files') {
1926
+ const dirParam = qp(url, 'dir')
1927
+ const boardSvc = services.board
1928
+ const stateDir = boardSvc?.paths?.stateDir ?? services.roster?.paths?.stateDir
1929
+ if (!stateDir) {
1930
+ sendJson(response, 503, { ok: false, reason: '状态目录不可用(域服务均未装配)' })
1931
+ return
1932
+ }
1933
+ let root = stateDir
1934
+ if (dirParam !== undefined && dirParam !== '') {
1935
+ if (!safeSegment(dirParam)) {
1936
+ sendJson(response, 400, { ok: false, reason: '非法子目录名' })
1937
+ return
1938
+ }
1939
+ root = join(stateDir, dirParam)
1940
+ }
1941
+ const files = []
1942
+ let note
1943
+ try {
1944
+ for (const name of readdirSync(root)) {
1945
+ if (!/\.(jsonl|json)$/i.test(name)) continue
1946
+ if (!safeSegment(name)) continue
1947
+ let st
1948
+ try {
1949
+ st = statSync(join(root, name))
1950
+ } catch {
1951
+ continue
1952
+ }
1953
+ if (!st.isFile()) continue
1954
+ const kind = /\.jsonl$/i.test(name) ? 'jsonl' : 'json'
1955
+ let lines = 0
1956
+ let truncated = false
1957
+ if (kind === 'jsonl') {
1958
+ if (st.size > 8 * 1024 * 1024) {
1959
+ lines = null
1960
+ truncated = true
1961
+ } else {
1962
+ try {
1963
+ const text = readFileSync(join(root, name), 'utf8')
1964
+ lines = text === '' ? 0 : text.split('\n').filter((x) => x.trim() !== '').length
1965
+ } catch {
1966
+ lines = null
1967
+ truncated = true
1968
+ }
1969
+ }
1970
+ }
1971
+ const row = { name, bytes: st.size, lines, mtime: st.mtimeMs, kind }
1972
+ if (truncated) row.truncated = true
1973
+ files.push(row)
1974
+ }
1975
+ files.sort((a, b) => b.mtime - a.mtime)
1976
+ note = `共 ${files.length} 个文件(只读浏览;写请走域服务与 CAS)`
1977
+ } catch (error) {
1978
+ note = `目录不可读:${error?.message ?? error}`
1979
+ }
1980
+ sendJson(response, 200, { ok: true, stateDir: root, files, note })
1981
+ return
1982
+ }
1983
+
1984
+ // ── 单文件只读预览(契约 bots06 §2.2)────────────────────────────────
1985
+ // 只允许在状态目录内读:先按段校验(防 `..` / 通配符),再 resolve 后
1986
+ // 断言仍在 stateDir 内(防符号链接与绝对路径逃逸)。**绝不写任何文件**。
1987
+ if (request.method === 'GET' && path === '/data/file') {
1988
+ const rel = qp(url, 'path')
1989
+ if (!rel) {
1990
+ sendJson(response, 400, { ok: false, reason: 'path 必填' })
1991
+ return
1992
+ }
1993
+ const raw = String(rel)
1994
+ // 含 `..` 一律拒绝(段级判定对绝对路径不适用,但仍先挡穿越)。
1995
+ if (raw.includes('..')) {
1996
+ sendJson(response, 400, { ok: false, reason: '非法路径' })
1997
+ return
1998
+ }
1999
+ // 相对路径:逐段必须都是安全片段(空段 = 绝对路径 / 重复斜杠,另作处理)。
2000
+ const isAbs = raw.startsWith('/')
2001
+ if (!isAbs) {
2002
+ const segs = raw.split('/')
2003
+ if (segs.some((s) => s === '' || s === '.' || s === '..' || !SAFE_SEGMENT.test(s))) {
2004
+ sendJson(response, 400, { ok: false, reason: '非法路径' })
2005
+ return
2006
+ }
2007
+ }
2008
+ const stateDir = services.board?.paths?.stateDir ?? services.roster?.paths?.stateDir
2009
+ if (!stateDir) {
2010
+ sendJson(response, 503, { ok: false, reason: '状态目录不可用(域服务均未装配)' })
2011
+ return
2012
+ }
2013
+ const root = resolve(stateDir)
2014
+ const abs = resolve(root, raw)
2015
+ // 必须在 stateDir 内(含 stateDir 本身,但那必然是目录 → 后面 404)。
2016
+ if (abs !== root && !abs.startsWith(root + pathSep)) {
2017
+ sendJson(response, 403, { ok: false, reason: '越权访问(路径超出状态目录)' })
2018
+ return
2019
+ }
2020
+ let st
2021
+ try {
2022
+ st = statSync(abs)
2023
+ } catch {
2024
+ sendJson(response, 404, { ok: false, reason: '文件不存在' })
2025
+ return
2026
+ }
2027
+ if (!st.isFile()) {
2028
+ sendJson(response, 404, { ok: false, reason: '文件不存在' })
2029
+ return
2030
+ }
2031
+ let buf
2032
+ try {
2033
+ buf = readFileSync(abs)
2034
+ } catch {
2035
+ sendJson(response, 404, { ok: false, reason: '文件不存在' })
2036
+ return
2037
+ }
2038
+ if (isBinaryBuffer(buf)) {
2039
+ sendJson(response, 415, { ok: false, reason: '不支持的文件类型(仅预览文本)' })
2040
+ return
2041
+ }
2042
+ const content = buf.toString('utf8')
2043
+ const bytes = buf.length
2044
+ const PREVIEW_MAX = 100 * 1024
2045
+ let shown = content
2046
+ let truncated = false
2047
+ if (bytes > PREVIEW_MAX) {
2048
+ truncated = true
2049
+ shown = buf.subarray(0, PREVIEW_MAX).toString('utf8')
2050
+ }
2051
+ sendJson(response, 200, {
2052
+ ok: true,
2053
+ path: raw,
2054
+ content: shown,
2055
+ bytes,
2056
+ truncated,
2057
+ ...(truncated
2058
+ ? { shownBytes: Buffer.byteLength(shown, 'utf8'), totalBytes: bytes }
2059
+ : {}),
2060
+ mime: mimeFor(raw),
2061
+ })
2062
+ return
2063
+ }
2064
+
2065
+ if (request.method === 'GET' && path === '/data/rows') {
2066
+ const name = qp(url, 'name')
2067
+ if (!name) {
2068
+ sendJson(response, 400, { ok: false, reason: 'name 必填' })
2069
+ return
2070
+ }
2071
+ if (!safeSegment(name) || !/\.(jsonl|json)$/i.test(name)) {
2072
+ sendJson(response, 400, { ok: false, reason: '非法文件名' })
2073
+ return
2074
+ }
2075
+ const dirParam = qp(url, 'dir')
2076
+ if (dirParam !== undefined && dirParam !== '' && !safeSegment(dirParam)) {
2077
+ sendJson(response, 400, { ok: false, reason: '非法子目录名' })
2078
+ return
2079
+ }
2080
+ const boardSvc = services.board
2081
+ const stateDir = boardSvc?.paths?.stateDir ?? services.roster?.paths?.stateDir
2082
+ if (!stateDir) {
2083
+ sendJson(response, 503, { ok: false, reason: '状态目录不可用(域服务均未装配)' })
2084
+ return
2085
+ }
2086
+ const root = dirParam ? join(stateDir, dirParam) : stateDir
2087
+ const abs = join(root, name)
2088
+ const limitRaw = qn(url, 'limit')
2089
+ const limit = limitRaw !== undefined && limitRaw > 0 ? Math.min(limitRaw, 200) : 50
2090
+ const offsetRaw = qn(url, 'offset')
2091
+ const offset = offsetRaw !== undefined && offsetRaw > 0 ? offsetRaw : 0
2092
+ let text
2093
+ try {
2094
+ text = readFileSync(abs, 'utf8')
2095
+ } catch (error) {
2096
+ sendJson(response, 404, { ok: false, reason: `读取失败:${error?.message ?? error}` })
2097
+ return
2098
+ }
2099
+ const all = text.split('\n').filter((line) => line.trim() !== '')
2100
+ const page = all.slice(offset, offset + limit)
2101
+ const rowsOut = page.map((raw, i) => {
2102
+ const row = { _line: offset + i + 1, _raw: raw.length > 4000 ? raw.slice(0, 4000) : raw }
2103
+ if (raw.length > 4000) row._truncated = true
2104
+ if (/\.jsonl$/i.test(name)) {
2105
+ try {
2106
+ JSON.parse(raw)
2107
+ } catch {
2108
+ row._parseError = true
2109
+ }
2110
+ }
2111
+ return row
2112
+ })
2113
+ sendJson(response, 200, {
2114
+ ok: true,
2115
+ name,
2116
+ total: all.length,
2117
+ offset,
2118
+ rows: rowsOut,
2119
+ note: `共 ${all.length} 行,显示 ${all.length ? offset + 1 : 0}-${Math.min(offset + limit, all.length)}`,
2120
+ })
2121
+ return
2122
+ }
2123
+
2124
+ // ── 任务依赖图(只读)──────────────────────────────────────────────────
2125
+ if (request.method === 'GET' && path === '/tasks/graph') {
2126
+ const id = qp(url, 'id')
2127
+ const all = await withService(services, 'board', (b) =>
2128
+ b.list({}).filter((t) => t.status !== 'deleted'))
2129
+ const byIdMap = new Map(all.map((t) => [t.id, t]))
2130
+ const dependents = new Map()
2131
+ let danglingEdges = 0
2132
+ for (const t of all) {
2133
+ for (const dep of t.blockedBy ?? []) {
2134
+ if (!byIdMap.has(dep)) {
2135
+ danglingEdges += 1
2136
+ continue
2137
+ }
2138
+ if (!dependents.has(dep)) dependents.set(dep, [])
2139
+ dependents.get(dep).push(t.id)
2140
+ }
2141
+ }
2142
+ let picked
2143
+ if (id) {
2144
+ if (!byIdMap.has(id)) {
2145
+ sendJson(response, 404, { ok: false, reason: `任务 ${id} 不存在` })
2146
+ return
2147
+ }
2148
+ const keep = new Set()
2149
+ const up = [id]
2150
+ while (up.length) {
2151
+ const cur = up.pop()
2152
+ if (keep.has(cur)) continue
2153
+ keep.add(cur)
2154
+ const t = byIdMap.get(cur)
2155
+ if (!t) continue
2156
+ for (const dep of t.blockedBy ?? []) {
2157
+ if (byIdMap.has(dep) && !keep.has(dep)) up.push(dep)
2158
+ }
2159
+ }
2160
+ const down = [id]
2161
+ while (down.length) {
2162
+ const cur = down.pop()
2163
+ for (const child of dependents.get(cur) ?? []) {
2164
+ if (!keep.has(child)) {
2165
+ keep.add(child)
2166
+ down.push(child)
2167
+ }
2168
+ }
2169
+ }
2170
+ picked = all.filter((t) => keep.has(t.id))
2171
+ } else {
2172
+ picked = all
2173
+ }
2174
+ // 字段恒定出现(前端画图不判 undefined):ownerId/priority 缺省给 null/0
2175
+ const nodes = picked.map((t) => ({
2176
+ id: t.id,
2177
+ subject: t.subject,
2178
+ status: t.status,
2179
+ ownerId: t.ownerId ?? null,
2180
+ progress: t.progress ?? 0,
2181
+ priority: t.priority ?? 0,
2182
+ }))
2183
+ const pickedIds = new Set(nodes.map((n) => n.id))
2184
+ const edges = []
2185
+ for (const t of picked) {
2186
+ for (const dep of t.blockedBy ?? []) {
2187
+ if (!pickedIds.has(dep)) continue
2188
+ edges.push({ from: t.id, to: dep, kind: 'blockedBy' })
2189
+ }
2190
+ }
2191
+ sendJson(response, 200, {
2192
+ ok: true,
2193
+ nodes,
2194
+ edges,
2195
+ note: `节点 ${nodes.length} 个,边 ${edges.length} 条${danglingEdges ? `,忽略悬空依赖 ${danglingEdges} 条` : ''}`,
2196
+ })
2197
+ return
2198
+ }
2199
+
2200
+ // ── 任务:详情穿透 / 指派 / 终止 / 编辑 ──────────────────────────────
2201
+ if (request.method === 'GET' && path === '/tasks/detail') {
2202
+ const id = qp(url, 'id')
2203
+ if (!id) {
2204
+ sendJson(response, 400, { ok: false, reason: 'id 必填' })
2205
+ return
2206
+ }
2207
+ const detail = await withService(services, 'board', (b) => ({
2208
+ task: b.get(id),
2209
+ history: typeof b.history === 'function' ? b.history(id) : [],
2210
+ }))
2211
+ if (!detail.task) {
2212
+ sendJson(response, 404, { ok: false, reason: `任务 ${id} 不存在` })
2213
+ return
2214
+ }
2215
+ sendJson(response, 200, {
2216
+ ok: true,
2217
+ // 改造07(借鉴 Super-agent stage table):阶段机结果附在详情里
2218
+ stageInfo: deriveStage(detail.task),
2219
+ ...detail,
2220
+ })
2221
+ return
2222
+ }
2223
+
2224
+ if (request.method === 'POST' && path === '/tasks/assign') {
2225
+ const body = (await readBody(request)) ?? {}
2226
+ if (!body.id || !body.ownerId) {
2227
+ sendJson(response, 400, { ok: false, reason: 'id 与 ownerId 必填' })
2228
+ return
2229
+ }
2230
+ // 指派 = 用 ownerId 作为 claim 的 byId(board 的 claim 把 by 记为 owner)
2231
+ // 改造07:claim 前先跑 Bot 的 prepare command(借鉴 Super-agent)。
2232
+ // 准备失败 → **不 claim**,任务保持原状(绝不产生「claim 了但没准备好」的半成品)。
2233
+ const bot = await withService(services, 'roster', (r) => r.get(body.ownerId))
2234
+ let prepare = { ok: true, skipped: true }
2235
+ if (bot && typeof bot.prepareCommand === 'string' && bot.prepareCommand.trim()) {
2236
+ prepare = await runPrepareCommand(bot)
2237
+ if (!prepare.ok) {
2238
+ const current = await withService(services, 'board', (b) => b.get(body.id))
2239
+ sendJson(response, 200, {
2240
+ ok: false,
2241
+ code: 'PREPARE_FAILED',
2242
+ task: current ?? undefined,
2243
+ prepare,
2244
+ reason: `开工准备失败(${prepare.error ?? '未知原因'}),任务未认领`
2245
+ + `。准备命令:${prepare.command ?? bot.prepareCommand}`,
2246
+ })
2247
+ return
2248
+ }
2249
+ }
2250
+ const task = await withService(services, 'board', (b) =>
2251
+ b.update({
2252
+ id: body.id,
2253
+ expectedRev: body.expectedRev,
2254
+ action: 'claim',
2255
+ byId: body.ownerId,
2256
+ ...(body.leaseMs ? { leaseMs: body.leaseMs } : {}),
2257
+ }),
2258
+ )
2259
+ // 指派后立刻定向唤醒(dedupKey 幂等:同任务同原因只唤一次)
2260
+ let wake
2261
+ let revive
2262
+ if (services.dispatcher && body.wake !== false) {
2263
+ // 契约 bots05 §2.3:投递前先确保会话 live(重启后宿主懒加载 → 否则必然
2264
+ // agent-not-live)。复活失败**不阻断**:仍照原样 notify,让 wake 如实报 reason。
2265
+ revive = await ensureLive(ctx, body.ownerId)
2266
+ // 2026-11-09:投递能力预检 —— 没有模型路由的会话**唤醒了也必然失败**,
2267
+ // 且失败发生在宿主内部、wake 仍记 sent ⇒ 任务静默卡在「排队中」。
2268
+ // 这里改成「先判、不投、把原因写到任务上」,让它以 person/delivery 显形。
2269
+ const deliverable = assertDeliverable(ctx, body.ownerId)
2270
+ if (!deliverable.ok) {
2271
+ const marked = await markDeliveryFailure(services, task.id, deliverable.reason, body.ownerId)
2272
+ wake = { result: 'failed', reason: `undeliverable:${deliverable.reason}`, target: body.ownerId }
2273
+ sendJson(response, 200, {
2274
+ ok: true, task, wake, prepare, marked, ...reviveFields(revive),
2275
+ })
2276
+ return
2277
+ }
2278
+ // 投递前取基线:投递后靠它判断「执行者到底动没动」
2279
+ const assignBaseline = await takeWatermarkBaseline(ctx, body.ownerId)
2280
+ wake = services.dispatcher.notify({
2281
+ target: body.ownerId,
2282
+ reason: 'task-assigned',
2283
+ dedupKey: `task-assigned:${task.id}:${task.rev}`,
2284
+ // 2026-11-09:改成**锚定文案**(与 /tasks/wake、补充说明同一套)。
2285
+ // 原来的 `新任务 t-x:标题\n描述` 有三处不如它:
2286
+ // ① 长任务的阶段清单不在里面 —— 第一次派活执行者看不到路线(需求3 的核心)
2287
+ // ② 没有写入边界 / 依赖 / 授权依据(mandate)—— 越界风险
2288
+ // ③ 与后面每次唤醒的文案不同源,容易出现「第一轮和后面几轮看到的活不一样」
2289
+ text: buildTaskAnchorText(task, readTaskNotes(services, task.id), readLongrunMeta(services, task.id)),
2290
+ })
2291
+ // wake 层就失败了(agent-not-live / dispatcher 拒收)→ 同样落到任务上。
2292
+ if (wake && wake.result === 'failed') {
2293
+ await markDeliveryFailure(services, task.id, wake.reason ?? 'wake-failed', body.ownerId)
2294
+ } else if (wake) {
2295
+ // 2026-11-09:投递真的成功了 ⇒ 清掉上一次的失败留痕(否则任务会
2296
+ // 永远停在「输入投递失败」,阶段机被过期事实劫持,见
2297
+ // `clearDeliveryFailure` 的说明)。
2298
+ await clearDeliveryFailure(services, task.id, wake)
2299
+ }
2300
+ // 发出去了就等回执:过了宽限期会话水位还没涨 ⇒ 判「未开始动作」
2301
+ armDeliveryWatch(services, task.id, body.ownerId, assignBaseline, wake)
2302
+ }
2303
+ sendJson(response, 200, { ok: true, task, wake, prepare, ...reviveFields(revive) })
2304
+ return
2305
+ }
2306
+
2307
+ if (request.method === 'POST' && path === '/tasks/expire-leases') {
2308
+ // ⚠️ 修复(2026-11-04 测试,P1-4):expireLeases 之前无任何调用者,
2309
+ // owner 崩溃/失联后任务永久停在 in_progress。现在通过 API 暴露回收能力。
2310
+ const body = (await readBody(request)) ?? {}
2311
+ const recycled = await withService(services, 'board', (b) =>
2312
+ b.expireLeases({ now: Number.isFinite(body.now) ? body.now : undefined }),
2313
+ )
2314
+ sendJson(response, 200, { ok: true, recycled, note: recycled.length ? `回收了 ${recycled.length} 个过期任务` : '没有过期租约需要回收' })
2315
+ return
2316
+ }
2317
+
2318
+ if (request.method === 'POST' && path === '/tasks/abort') {
2319
+ const body = (await readBody(request)) ?? {}
2320
+ if (!body.id) {
2321
+ sendJson(response, 400, { ok: false, reason: 'id 必填' })
2322
+ return
2323
+ }
2324
+ // 终止 = block + 原因(board 的 block 会置 blocked 并留 reason;可 reopen)
2325
+ const task = await withService(services, 'board', (b) =>
2326
+ b.update({
2327
+ id: body.id,
2328
+ expectedRev: body.expectedRev,
2329
+ action: 'block',
2330
+ note: body.reason ?? '用户终止',
2331
+ byId: body.byId ?? 'ui',
2332
+ }),
2333
+ )
2334
+ sendJson(response, 200, { ok: true, task })
2335
+ return
2336
+ }
2337
+
2338
+ if (request.method === 'POST' && path === '/tasks/edit') {
2339
+ const body = (await readBody(request)) ?? {}
2340
+ if (!body.id) {
2341
+ sendJson(response, 400, { ok: false, reason: 'id 必填' })
2342
+ return
2343
+ }
2344
+ const task = await withService(services, 'board', (b) =>
2345
+ b.update({ ...body, action: 'edit', byId: body.byId ?? 'ui' }),
2346
+ )
2347
+ sendJson(response, 200, { ok: true, task })
2348
+ return
2349
+ }
2350
+
2351
+ if (request.method === 'GET' && path === '/tasks/history') {
2352
+ const id = qp(url, 'id')
2353
+ const history = await withService(services, 'board', (b) =>
2354
+ typeof b.history === 'function' ? b.history(id) : [],
2355
+ )
2356
+ sendJson(response, 200, { ok: true, history })
2357
+ return
2358
+ }
2359
+
2360
+ // ── 任务目标锚(契约 bots04 §2.1):创建时冻结的目标原文 + 锚定文案 ──
2361
+ if (request.method === 'GET' && path === '/tasks/brief') {
2362
+ const id = qp(url, 'id')
2363
+ if (!id) {
2364
+ sendJson(response, 400, { ok: false, reason: 'id 必填' })
2365
+ return
2366
+ }
2367
+ const task = await withService(services, 'board', (b) => b.get(id))
2368
+ if (!task) {
2369
+ sendJson(response, 404, { ok: false, reason: `任务 ${id} 不存在` })
2370
+ return
2371
+ }
2372
+ const notes = readTaskNotes(services, task.id)
2373
+ sendJson(response, 200, {
2374
+ ok: true,
2375
+ taskId: task.id,
2376
+ anchor: buildTaskAnchor(task),
2377
+ notes,
2378
+ text: buildTaskAnchorText(task, notes, readLongrunMeta(services, task.id)),
2379
+ note: '目标锚来自任务创建时的原文;补充说明另存,不会覆盖目标',
2380
+ })
2381
+ return
2382
+ }
2383
+
2384
+ // ── 补充说明(契约 bots04 §2.2):append-only,永不覆盖目标 ──────────
2385
+ if (request.method === 'POST' && path === '/tasks/note') {
2386
+ const body = (await readBody(request)) ?? {}
2387
+ if (!body.id) {
2388
+ sendJson(response, 400, { ok: false, reason: 'id 必填' })
2389
+ return
2390
+ }
2391
+ const text = typeof body.text === 'string' ? body.text.trim() : ''
2392
+ if (!text) {
2393
+ sendJson(response, 400, { ok: false, reason: '补充说明不能为空' })
2394
+ return
2395
+ }
2396
+ if (text.length > ANCHOR_TEXT_MAX) {
2397
+ sendJson(response, 400, { ok: false, reason: `补充说明过长(最多 ${ANCHOR_TEXT_MAX} 字)` })
2398
+ return
2399
+ }
2400
+ const task = await withService(services, 'board', (b) => b.get(body.id))
2401
+ if (!task) {
2402
+ sendJson(response, 404, { ok: false, reason: `任务 ${body.id} 不存在` })
2403
+ return
2404
+ }
2405
+ // 唤醒前置检查放在落盘**之前**:dispatcher 缺失时直接 503,
2406
+ // 避免「说明已落盘但唤醒静默失败」的半成品状态。
2407
+ const shouldWake = body.wake !== false && Boolean(task.ownerId)
2408
+ const dispatcher = shouldWake
2409
+ ? await withService(services, 'dispatcher', (d) => d)
2410
+ : undefined
2411
+
2412
+ const file = taskNotesPath(services)
2413
+ if (!file) {
2414
+ const error = new Error('状态目录不可用(task-notes.jsonl 无法落盘)')
2415
+ error.status = 503
2416
+ throw error
2417
+ }
2418
+ const { rows } = readJsonl(file)
2419
+ const note = {
2420
+ id: nextTaskNoteId(rows),
2421
+ taskId: task.id,
2422
+ text,
2423
+ by: typeof body.by === 'string' && body.by.trim() ? body.by.trim() : 'ui',
2424
+ at: Date.now(),
2425
+ }
2426
+ if (!appendJsonl(file, note)) {
2427
+ const error = new Error(`补充说明落盘失败(${file})`)
2428
+ error.status = 503
2429
+ throw error
2430
+ }
2431
+
2432
+ let wake = null
2433
+ let revive
2434
+ if (shouldWake) {
2435
+ // 契约 bots05 §2.3:投递前先确保 owner 会话 live(失败不阻断,wake 会如实报错)。
2436
+ revive = await ensureLive(ctx, task.ownerId)
2437
+ // 唤醒文案 = 完整锚定文案(含刚追加的这条)+ 【新增补充说明】<text>
2438
+ // 这正是「长任务不失真」的落点:Bot 每次被唤醒都看到全量目标原文。
2439
+ const notes = readTaskNotes(services, task.id)
2440
+ services.deliveryWatch?.cancelByTask?.(task.id)
2441
+ const noteBaseline = await takeWatermarkBaseline(ctx, task.ownerId)
2442
+ wake = dispatcher.notify({
2443
+ target: task.ownerId,
2444
+ reason: 'task-assigned',
2445
+ dedupKey: `task-note:${task.id}:${note.id}`,
2446
+ text: `${buildTaskAnchorText(task, notes, readLongrunMeta(services, task.id))}\n\n【新增补充说明】${note.text}`,
2447
+ })
2448
+ armDeliveryWatch(services, task.id, task.ownerId, noteBaseline, wake)
2449
+ }
2450
+ sendJson(response, 200, { ok: true, note, wake, ...reviveFields(revive) })
2451
+ return
2452
+ }
2453
+
2454
+ // ── 补充说明历史(契约 bots04 §2.3):只读,按 at 升序 ────────────────
2455
+ if (request.method === 'GET' && path === '/tasks/notes') {
2456
+ const id = qp(url, 'id')
2457
+ if (!id) {
2458
+ sendJson(response, 400, { ok: false, reason: 'id 必填' })
2459
+ return
2460
+ }
2461
+ const task = await withService(services, 'board', (b) => b.get(id))
2462
+ if (!task) {
2463
+ sendJson(response, 404, { ok: false, reason: `任务 ${id} 不存在` })
2464
+ return
2465
+ }
2466
+ sendJson(response, 200, { ok: true, taskId: task.id, notes: readTaskNotes(services, task.id) })
2467
+ return
2468
+ }
2469
+
2470
+ // ── 任务结论摘要(契约 bots06 §2.3):只读 ────────────────────────────
2471
+ if (request.method === 'GET' && path === '/tasks/summary') {
2472
+ const id = qp(url, 'id')
2473
+ if (!id) {
2474
+ sendJson(response, 400, { ok: false, reason: 'id 必填' })
2475
+ return
2476
+ }
2477
+ const task = await withService(services, 'board', (b) => b.get(id))
2478
+ if (!task) {
2479
+ sendJson(response, 404, { ok: false, reason: `任务 ${id} 不存在` })
2480
+ return
2481
+ }
2482
+ const note = latestTaskNote(services, task.id)
2483
+ const lastNoteContent = note ? String(note.content ?? '') : null
2484
+ const stepConclusions = readTaskWakes(services, task.id).map(toStepConclusion)
2485
+ sendJson(response, 200, {
2486
+ ok: true,
2487
+ taskId: task.id,
2488
+ currentProgress: {
2489
+ status: task.status,
2490
+ progress: task.progress ?? 0,
2491
+ progressText: `${task.progress ?? 0}% · ${statusText(task.status)}`,
2492
+ lastNote: lastNoteContent,
2493
+ },
2494
+ // 改造07(借鉴 Super-agent stage table):阶段机结果
2495
+ stageInfo: deriveStage(task),
2496
+ stepConclusions,
2497
+ nextStep: lastNoteContent ? extractNextStep(lastNoteContent) : null,
2498
+ // ── 执行者状态(2026-11-09:「任务显示正在执行中,但我看不到过程」)────
2499
+ // 「投递成功」不等于「真的在跑」。这里把三件事一次说清:
2500
+ // 会话在不在线 / 最近一次唤醒的结果 / 有没有投递失败
2501
+ // 这样用户能区分「Bot 在干活」和「Bot 根本没动」。
2502
+ executor: buildExecutorStatus(ctx, services, task),
2503
+ note: SUMMARY_NOTE,
2504
+ })
2505
+ return
2506
+ }
2507
+
2508
+ // ── 来源跳转(契约 bots04 §2.5):任务 → 消息出处 ──────────────────
2509
+ //
2510
+ // 只读端点:**不写任何文件**(与 §2.1/§2.3 同口径)。
2511
+ // 房间侧信息全部来自 room 服务:
2512
+ // - `get(roomId)` 拿名字、判断房间是否还在(已删 → exists:false);
2513
+ // - `listMessages({roomId, sinceSeq: seq-1, limit:1, withBody:true})`
2514
+ // 精确判断那条消息是否存在(seq 连续 → 取不到就是真的不存在)。
2515
+ // 房间已删时 `listMessages` 会抛 `ROOM_NOT_FOUND`:那是**房间不存在**
2516
+ // 的事实,不是「消息不存在」,两者必须区分开(契约要求不静默、不猜测)。
2517
+ if (request.method === 'GET' && path === '/tasks/source') {
2518
+ const id = qp(url, 'id')
2519
+ if (!id) {
2520
+ sendJson(response, 400, { ok: false, reason: 'id 必填' })
2521
+ return
2522
+ }
2523
+ const task = await withService(services, 'board', (b) => b.get(id))
2524
+ if (!task) {
2525
+ sendJson(response, 404, { ok: false, reason: `任务 ${id} 不存在` })
2526
+ return
2527
+ }
2528
+
2529
+ const parsed = parseSourceSeq(task.sourceSeq)
2530
+ let payload = buildSourcePayload(task, parsed)
2531
+
2532
+ if (parsed?.kind === 'room') {
2533
+ const room = await withService(services, 'room', (r) => r.get(parsed.roomId))
2534
+ const roomExists = room !== undefined && room !== null
2535
+ let messageExists = null
2536
+ let messageAt = null
2537
+ let preview = null
2538
+
2539
+ if (roomExists) {
2540
+ // seq 连续:sinceSeq = seq-1 + limit 1 只会命中 seq 那一条
2541
+ const rows = await withService(services, 'room', (r) =>
2542
+ r.listMessages({ roomId: parsed.roomId, sinceSeq: parsed.seq - 1, limit: 1, withBody: true }),
2543
+ )
2544
+ const hit = Array.isArray(rows) ? rows.find((m) => Number(m?.seq) === parsed.seq) : undefined
2545
+ messageExists = hit !== undefined
2546
+ messageAt = hit?.at ?? null
2547
+ preview = hit ? sourcePreview(hit) : null
2548
+ }
2549
+
2550
+ payload = {
2551
+ ...payload,
2552
+ room: {
2553
+ ...payload.room,
2554
+ name: room?.name ?? null,
2555
+ exists: roomExists,
2556
+ messageExists,
2557
+ at: messageAt,
2558
+ preview,
2559
+ },
2560
+ // 房间已删也如实返回 jump(数据不丢,是否可跳由前端决定 —— 契约 §2.5)
2561
+ jump: payload.jump,
2562
+ }
2563
+ }
2564
+
2565
+ sendJson(response, 200, payload)
2566
+ return
2567
+ }
2568
+
2569
+ // ── 手动重试唤醒(契约 bots05 §2.4):专治「重试看起来没反应」 ──────
2570
+ //
2571
+ // 为什么需要单独端点:/tasks/assign 的 dedupKey 是 `task-assigned:<id>:<rev>`,
2572
+ // 重试时 rev 没变 ⇒ 命中去重 ⇒ skipped,人点「重试」看起来像没反应。
2573
+ if (request.method === 'POST' && path === '/tasks/wake') {
2574
+ const body = (await readBody(request)) ?? {}
2575
+ if (!body.id) {
2576
+ sendJson(response, 400, { ok: false, reason: 'id 必填' })
2577
+ return
2578
+ }
2579
+ const task = await withService(services, 'board', (b) => b.get(body.id))
2580
+ if (!task) {
2581
+ sendJson(response, 404, { ok: false, reason: `任务 ${body.id} 不存在` })
2582
+ return
2583
+ }
2584
+ if (!task.ownerId) {
2585
+ sendJson(response, 400, { ok: false, reason: '任务没有负责人,无法唤醒' })
2586
+ return
2587
+ }
2588
+ // dispatcher 缺失 → 503(先于复活副作用,避免「复活了却没地方投递」)。
2589
+ const dispatcher = await withService(services, 'dispatcher', (d) => d)
2590
+ const revive = await ensureLive(ctx, task.ownerId)
2591
+ const force = body.force !== false
2592
+ const dedupKey = typeof body.dedupKey === 'string' && body.dedupKey.trim()
2593
+ ? body.dedupKey.trim()
2594
+ : (force ? `task-wake:${task.id}:${Date.now()}` : `task-wake:${task.id}:${task.rev}`)
2595
+ // 2026-11-09:同一道投递能力预检(手动重试也不该往死会话里投)。
2596
+ const deliverable = assertDeliverable(ctx, task.ownerId)
2597
+ if (!deliverable.ok) {
2598
+ const marked = await markDeliveryFailure(services, task.id, deliverable.reason, task.ownerId)
2599
+ sendJson(response, 200, {
2600
+ ok: true,
2601
+ taskId: task.id,
2602
+ ownerId: task.ownerId,
2603
+ live: revive.live,
2604
+ revived: revive.revived,
2605
+ marked,
2606
+ wake: { result: 'failed', reason: `undeliverable:${deliverable.reason}`, target: task.ownerId },
2607
+ })
2608
+ return
2609
+ }
2610
+ // 人点「重试」= 重新建立一次期望(旧的那条自然被覆盖)
2611
+ services.deliveryWatch?.cancelByTask?.(task.id)
2612
+ const wakeBaseline = await takeWatermarkBaseline(ctx, task.ownerId)
2613
+ const wake = dispatcher.notify({
2614
+ target: task.ownerId,
2615
+ reason: 'task-assigned',
2616
+ dedupKey,
2617
+ text: buildTaskAnchorText(task, readTaskNotes(services, task.id), readLongrunMeta(services, task.id)),
2618
+ })
2619
+ if (wake && wake.result === 'failed') {
2620
+ await markDeliveryFailure(services, task.id, wake.reason ?? 'wake-failed', task.ownerId)
2621
+ } else if (wake) {
2622
+ // 2026-11-09:手动重试成功 ⇒ 清掉上次的失败留痕(否则任务永久停在
2623
+ // 「输入投递失败」),见 `clearDeliveryFailure` 的说明。
2624
+ await clearDeliveryFailure(services, task.id, wake)
2625
+ }
2626
+ armDeliveryWatch(services, task.id, task.ownerId, wakeBaseline, wake)
2627
+ sendJson(response, 200, {
2628
+ ok: true,
2629
+ taskId: task.id,
2630
+ ownerId: task.ownerId,
2631
+ live: revive.live,
2632
+ revived: revive.revived,
2633
+ ...(revive.live ? {} : { reviveReason: revive.reason ?? 'revive-failed' }),
2634
+ wake,
2635
+ })
2636
+ return
2637
+ }
2638
+
2639
+ // ══════════════════════════════════════════════════════════════════════
2640
+ // 改造06 · 任务变更中心(契约 CONTRACT-bots06 §2)
2641
+ //
2642
+ // 用户原话:「完成的任务应该可以纠错或者优化或者更改或其他变更,变更成长任务」
2643
+ // 「任务可以变更负责人」。
2644
+ // 两条铁律:
2645
+ // · 变更必须有理由(note 必填);没有理由的变更不许进历史。
2646
+ // · 证据不能丢(reopen 前先快照,见 reopenWithSnapshot)。
2647
+ // ══════════════════════════════════════════════════════════════════════
2648
+
2649
+ // ── 换负责人(契约 §2.1)────────────────────────────────────────────
2650
+ if (request.method === 'POST' && path === '/tasks/reassign') {
2651
+ const body = (await readBody(request)) ?? {}
2652
+ if (!body.id) {
2653
+ sendJson(response, 400, { ok: false, reason: 'id 必填' })
2654
+ return
2655
+ }
2656
+ if (!body.ownerId || typeof body.ownerId !== 'string' || !body.ownerId.trim()) {
2657
+ sendJson(response, 400, { ok: false, reason: 'ownerId 必填' })
2658
+ return
2659
+ }
2660
+ const ownerId = body.ownerId.trim()
2661
+ const by = typeof body.byId === 'string' && body.byId.trim() ? body.byId.trim() : 'ui'
2662
+ let task = await withService(services, 'board', (b) => b.get(body.id))
2663
+ if (!task) {
2664
+ sendJson(response, 404, { ok: false, reason: `任务 ${body.id} 不存在` })
2665
+ return
2666
+ }
2667
+ // ② 已完成任务先拦:不让用户撞 board.claim 的 BOARD_ALREADY_COMPLETED,
2668
+ // 而是给一条「接下来该点哪」的可操作提示。
2669
+ if (task.status === 'completed') {
2670
+ const error = new Error('任务已完成,请先「重开」或「派生优化单」再换负责人')
2671
+ error.status = 400
2672
+ error.code = 'TASK_COMPLETED'
2673
+ throw error
2674
+ }
2675
+ // 前端会带 expectedRev(CAS):先于任何变更校验,避免覆盖别人的改动。
2676
+ if (body.expectedRev !== undefined && body.expectedRev !== null && task.rev !== body.expectedRev) {
2677
+ const error = new Error(
2678
+ `任务 ${task.id} 版本冲突:期望 rev=${body.expectedRev},实际 rev=${task.rev} —— 请刷新后重试`,
2679
+ )
2680
+ error.status = 400
2681
+ error.code = 'BOARD_REV_CONFLICT'
2682
+ throw error
2683
+ }
2684
+ const from = task.ownerId ?? task.lease?.ownerId ?? null
2685
+ // dispatcher 缺失时**先** 503(此操作无副作用):避免「任务已换人但唤不醒」
2686
+ // 或「租约已交还却换不了人」的半成品状态。
2687
+ const shouldWake = body.wake !== false
2688
+ const dispatcher = shouldWake
2689
+ ? await withService(services, 'dispatcher', (d) => d)
2690
+ : undefined
2691
+ // ③ 原租约仍有效且持有人不是新负责人 → 以**原持有者身份**交还
2692
+ // (board.release 要求 byId === lease.ownerId,平台只是代为操作)。
2693
+ const lease = task.lease
2694
+ const leaseLive = Boolean(lease)
2695
+ && Number.isFinite(Number(lease.expiresAt))
2696
+ && Number(lease.expiresAt) > Date.now()
2697
+ if (leaseLive && lease.ownerId && lease.ownerId !== ownerId) {
2698
+ task = await withService(services, 'board', (b) => b.update({
2699
+ id: task.id,
2700
+ expectedRev: task.rev,
2701
+ action: 'release',
2702
+ byId: String(lease.ownerId),
2703
+ }))
2704
+ }
2705
+ // ④ 以新负责人身份领取(force:true 绕过 blockedBy 未完成)。
2706
+ let next
2707
+ try {
2708
+ next = await withService(services, 'board', (b) => b.update({
2709
+ id: task.id,
2710
+ expectedRev: task.rev,
2711
+ action: 'claim',
2712
+ byId: ownerId,
2713
+ force: true,
2714
+ ...(body.leaseMs !== undefined && body.leaseMs !== null ? { leaseMs: body.leaseMs } : {}),
2715
+ }))
2716
+ } catch (error) {
2717
+ // WIP 超限:**原样回传内核错误码**,只补一句人话,绝不吞成「success」。
2718
+ if (error?.code === 'BOARD_WIP_EXCEEDED' || error?.code === 'BOARD_WIP_LIMIT') {
2719
+ const wrapped = new Error(
2720
+ `${error.message}(提示:先让该 Bot 交付或释放已有任务,或在设置里调高 wipLimitPerBot 后再换负责人)`,
2721
+ )
2722
+ wrapped.status = 400
2723
+ wrapped.code = error.code
2724
+ throw wrapped
2725
+ }
2726
+ throw error
2727
+ }
2728
+ // ⑤ 唤醒:确保新负责人会话 live(失败不阻断,wake 会如实报错误原因)。
2729
+ let wake = null
2730
+ let revive
2731
+ if (shouldWake) {
2732
+ revive = await ensureLive(ctx, ownerId)
2733
+ wake = dispatcher.notify({
2734
+ target: ownerId,
2735
+ reason: 'task-assigned',
2736
+ dedupKey: `task-reassign:${next.id}:${next.rev}`,
2737
+ text: buildTaskAnchorText(
2738
+ next,
2739
+ readTaskNotes(services, next.id),
2740
+ readLongrunMeta(services, next.id),
2741
+ ),
2742
+ })
2743
+ }
2744
+ // ⑥ 变更历史(append-only;与快照/重开共用同一份 task-meta.jsonl)。
2745
+ appendTaskMeta(services, {
2746
+ kind: 'reassign', taskId: next.id, from, to: ownerId, by, at: Date.now(),
2747
+ })
2748
+ // ⑦ 回显:from/to 让前端能显示「已从 A 换到 B」。
2749
+ sendJson(response, 200, {
2750
+ ok: true,
2751
+ task: next,
2752
+ from,
2753
+ to: ownerId,
2754
+ wake,
2755
+ ...reviveFields(revive),
2756
+ })
2757
+ return
2758
+ }
2759
+
2760
+ // ── 变更中心(契约 §2.2):reopen | followup | longrun ──────────────
2761
+ if (request.method === 'POST' && path === '/tasks/evolve') {
2762
+ const body = (await readBody(request)) ?? {}
2763
+ if (!body.id) {
2764
+ sendJson(response, 400, { ok: false, reason: 'id 必填' })
2765
+ return
2766
+ }
2767
+ const mode = typeof body.mode === 'string' ? body.mode.trim() : ''
2768
+ if (!EVOLVE_MODES.includes(mode)) {
2769
+ const error = new Error(`mode 必须是 ${EVOLVE_MODES.join(' | ')}(收到 ${JSON.stringify(body.mode)})`)
2770
+ error.status = 400
2771
+ error.code = 'EVOLVE_BAD_MODE'
2772
+ throw error
2773
+ }
2774
+ // note 三种模式都必填:没有理由的变更不许进历史。
2775
+ const note = typeof body.note === 'string' ? body.note.trim() : ''
2776
+ if (!note) {
2777
+ const error = new Error('变更说明不能为空(没有理由的变更不许进历史)')
2778
+ error.status = 400
2779
+ error.code = 'EVOLVE_NOTE_REQUIRED'
2780
+ throw error
2781
+ }
2782
+ if (note.length > EVOLVE_NOTE_MAX) {
2783
+ const error = new Error(`变更说明过长(最多 ${EVOLVE_NOTE_MAX} 字)`)
2784
+ error.status = 400
2785
+ error.code = 'EVOLVE_NOTE_TOO_LONG'
2786
+ throw error
2787
+ }
2788
+ const by = typeof body.byId === 'string' && body.byId.trim() ? body.byId.trim() : 'ui'
2789
+ const task = await withService(services, 'board', (b) => b.get(body.id))
2790
+ if (!task) {
2791
+ sendJson(response, 404, { ok: false, reason: `任务 ${body.id} 不存在` })
2792
+ return
2793
+ }
2794
+
2795
+ // mode=reopen(纠错 / 继续做):快照 → 内核 reopen → 记历史。
2796
+ if (mode === 'reopen') {
2797
+ const { task: reopened, snapshot } = await reopenWithSnapshot(services, task, by, note)
2798
+ sendJson(response, 200, {
2799
+ ok: true,
2800
+ task: reopened,
2801
+ snapshot,
2802
+ note: '已重开;上一轮交付物已存档,见 changes',
2803
+ })
2804
+ return
2805
+ }
2806
+
2807
+ // mode=followup(优化 / 修复单):原任务**保持已完成**,派生一张新任务。
2808
+ if (mode === 'followup') {
2809
+ const prefix = evolvePrefixFor(note)
2810
+ // 幂等键:note 的 sha1 前缀 —— 连点两次同一说明只建一个;
2811
+ // 不同说明是不同诉求,允许各建一张(不能用 task.rev,否则第二次派生
2812
+ // 会静默命中第一张,用户看不到自己新写的理由)。
2813
+ const seq = createHash('sha1').update(note).digest('hex').slice(0, 12)
2814
+ const sourceSeq = `evolve:${task.id}:${seq}`
2815
+ const originDesc = typeof task.description === 'string' ? task.description : ''
2816
+ const description = [
2817
+ originDesc.trim() ? originDesc : null,
2818
+ `【变更说明】${note}`,
2819
+ `【来源】原任务 ${task.id}:${task.subject}`,
2820
+ ].filter((part) => part !== null).join('\n\n')
2821
+ const createInput = {
2822
+ subject: `${prefix}${task.subject}`,
2823
+ description,
2824
+ byId: by,
2825
+ sourceSeq,
2826
+ }
2827
+ // priority 继承原任务;assignee 继承 assignee ?? ownerId(契约 §2.2)。
2828
+ if (task.priority !== undefined) createInput.priority = task.priority
2829
+ const assignee = task.assignee ?? task.ownerId
2830
+ if (assignee) createInput.assignee = assignee
2831
+ const created = await withService(services, 'board', (b) => b.create(createInput))
2832
+ // 幂等命中(连点)时不重复记历史:同一条 followup 只留一行。
2833
+ const already = readTaskMeta(services, task.id).some(
2834
+ (row) => row.kind === 'followup' && row.newTaskId === created.id,
2835
+ )
2836
+ if (!already) {
2837
+ appendTaskMeta(services, {
2838
+ kind: 'followup',
2839
+ taskId: task.id,
2840
+ newTaskId: created.id,
2841
+ note,
2842
+ by,
2843
+ at: Date.now(),
2844
+ })
2845
+ }
2846
+ sendJson(response, 200, {
2847
+ ok: true,
2848
+ task: created,
2849
+ origin: task.id,
2850
+ prefix,
2851
+ note: `已派生 ${created.id}(幂等键 ${sourceSeq},重复点不会建两个)`,
2852
+ })
2853
+ return
2854
+ }
2855
+
2856
+ // mode=longrun(转成长任务):阶段清单进 task-meta,唤醒文案自动带上。
2857
+ const phases = normalizePhases(body.phases, 'EVOLVE_BAD_PHASES')
2858
+ // ① reopen:true 时按 reopen 流程重开(快照必须在内核清空 artifacts 之前落盘)。
2859
+ let current = task
2860
+ if (body.reopen === true) {
2861
+ current = (await reopenWithSnapshot(services, task, by, note)).task
2862
+ }
2863
+ // ② 记 longrun(契约 §2.2 longrun 步骤 2;不动任务本体)。
2864
+ appendTaskMeta(services, {
2865
+ kind: 'longrun', taskId: task.id, phases, note, by, at: Date.now(),
2866
+ })
2867
+ // ③ 记一条补充说明,内容 = 【转长任务】 + note + 阶段清单(步骤 4)。
2868
+ appendTaskNoteRow(
2869
+ services,
2870
+ task.id,
2871
+ `【转长任务】${note}\n阶段清单:\n${phases.map((phase, index) => `${index + 1}. ${phase}`).join('\n')}`,
2872
+ by,
2873
+ )
2874
+ sendJson(response, 200, {
2875
+ ok: true,
2876
+ task: current,
2877
+ phases,
2878
+ note: '已标记为长任务;后续每次唤醒都会带上阶段清单',
2879
+ })
2880
+ return
2881
+ }
2882
+
2883
+ // ── 变更历史(契约 §2.3):只读,按 at 升序 ─────────────────────────
2884
+ if (request.method === 'GET' && path === '/tasks/changes') {
2885
+ const id = qp(url, 'id')
2886
+ if (!id) {
2887
+ sendJson(response, 400, { ok: false, reason: 'id 必填' })
2888
+ return
2889
+ }
2890
+ const task = await withService(services, 'board', (b) => b.get(id))
2891
+ if (!task) {
2892
+ sendJson(response, 404, { ok: false, reason: `任务 ${id} 不存在` })
2893
+ return
2894
+ }
2895
+ const rows = readTaskMeta(services, task.id)
2896
+ // 契约 §2.3 的条目只暴露 kind/at/...(taskId 由外层给,逐条不重复)。
2897
+ const changes = rows.map(({ taskId, ...rest }) => rest)
2898
+ const longrunRows = rows.filter((row) => row.kind === 'longrun' && Array.isArray(row.phases))
2899
+ const lastLongrun = longrunRows.length ? longrunRows[longrunRows.length - 1] : null
2900
+ const snapshotRows = rows.filter((row) => row.kind === 'snapshot' && row.before)
2901
+ const lastSnapshot = snapshotRows.length ? snapshotRows[snapshotRows.length - 1] : null
2902
+ sendJson(response, 200, {
2903
+ ok: true,
2904
+ taskId: task.id,
2905
+ changes,
2906
+ longrun: lastLongrun ? { phases: [...lastLongrun.phases], at: lastLongrun.at } : null,
2907
+ latestSnapshot: lastSnapshot ? lastSnapshot.before : null,
2908
+ })
2909
+ return
2910
+ }
2911
+
2912
+ // ── 直发消息(契约 bots04 §2.4):从协作平台直接给某个 Bot 会话说话 ──
2913
+ if (request.method === 'POST' && path === '/bots/message') {
2914
+ const body = (await readBody(request)) ?? {}
2915
+ if (!body.botId && !body.sessionId) {
2916
+ sendJson(response, 400, { ok: false, reason: 'botId 或 sessionId 必填' })
2917
+ return
2918
+ }
2919
+ const text = typeof body.text === 'string' ? body.text.trim() : ''
2920
+ if (!text) {
2921
+ sendJson(response, 400, { ok: false, reason: '消息不能为空' })
2922
+ return
2923
+ }
2924
+ if (text.length > ANCHOR_TEXT_MAX) {
2925
+ sendJson(response, 400, { ok: false, reason: `消息过长(最多 ${ANCHOR_TEXT_MAX} 字)` })
2926
+ return
2927
+ }
2928
+ let sessionId
2929
+ if (body.botId) {
2930
+ const bot = await withService(services, 'roster', (r) => r.get(body.botId))
2931
+ if (!bot) {
2932
+ sendJson(response, 404, { ok: false, reason: `Bot ${body.botId} 不存在` })
2933
+ return
2934
+ }
2935
+ sessionId = bot.sessionId
2936
+ } else {
2937
+ sessionId = body.sessionId
2938
+ }
2939
+ // 未知 sessionId 仍允许(可能是主对话),但必须如实告知 knownBot:false。
2940
+ const knownBot = await withService(services, 'roster', (r) => r.hasSession(sessionId))
2941
+
2942
+ let prefix = ''
2943
+ if (body.taskId) {
2944
+ const task = await withService(services, 'board', (b) => b.get(body.taskId))
2945
+ if (!task) {
2946
+ sendJson(response, 404, { ok: false, reason: `任务 ${body.taskId} 不存在` })
2947
+ return
2948
+ }
2949
+ prefix = `${buildTaskAnchorText(task, readTaskNotes(services, task.id), readLongrunMeta(services, task.id))}\n\n`
2950
+ }
2951
+ const deliveredText = prefix + text
2952
+ const hash = createHash('sha1')
2953
+ .update(`${body.taskId ?? ''}${text}`)
2954
+ .digest('hex')
2955
+ .slice(0, 16)
2956
+ const dedupKey = typeof body.dedupKey === 'string' && body.dedupKey.trim()
2957
+ ? body.dedupKey.trim()
2958
+ : `ui-msg:${hash}`
2959
+ const dispatcher = await withService(services, 'dispatcher', (d) => d)
2960
+ // 契约 bots05 §2.3:投递前先确保目标会话 live(失败不阻断,wake 会如实报错)。
2961
+ const revive = await ensureLive(ctx, sessionId)
2962
+ const wake = dispatcher.notify({
2963
+ target: sessionId,
2964
+ reason: 'room-message',
2965
+ dedupKey,
2966
+ text: deliveredText,
2967
+ })
2968
+ sendJson(response, 200, {
2969
+ ok: true,
2970
+ sessionId,
2971
+ knownBot,
2972
+ wake,
2973
+ text: deliveredText,
2974
+ ...reviveFields(revive),
2975
+ })
2976
+ return
2977
+ }
2978
+
2979
+ // ── 手动复活 Bot 会话(契约 bots05 §2.2):重启 dsh-web 后一键拉起来 ──
2980
+ if (request.method === 'POST' && path === '/bots/revive') {
2981
+ const body = (await readBody(request)) ?? {}
2982
+ // 批量:遍历未退役的 Bot,**串行** ensureLive(最多 REVIVE_MAX 个)。
2983
+ if (body.all === true) {
2984
+ const bots = await withService(services, 'roster', (r) => r.list())
2985
+ const targets = bots.filter((b) => b?.state !== 'retired').slice(0, REVIVE_MAX)
2986
+ const revived = []
2987
+ const live = []
2988
+ const failed = []
2989
+ for (const bot of targets) {
2990
+ const result = await ensureLive(ctx, bot.sessionId)
2991
+ if (result.live) live.push(bot.sessionId)
2992
+ if (result.revived) revived.push(bot.sessionId)
2993
+ if (!result.live) failed.push({ sessionId: bot.sessionId, reason: result.reason })
2994
+ }
2995
+ sendJson(response, 200, { ok: true, total: targets.length, revived, live, failed })
2996
+ return
2997
+ }
2998
+ // 单个:botId 解析出 sessionId(找不到 404),或直接给 sessionId。
2999
+ let sessionId
3000
+ if (body.botId) {
3001
+ const bot = await withService(services, 'roster', (r) => r.get(body.botId))
3002
+ if (!bot) {
3003
+ sendJson(response, 404, { ok: false, reason: `Bot ${body.botId} 不存在` })
3004
+ return
3005
+ }
3006
+ sessionId = bot.sessionId
3007
+ } else if (typeof body.sessionId === 'string' && body.sessionId.trim()) {
3008
+ sessionId = body.sessionId.trim()
3009
+ } else {
3010
+ sendJson(response, 400, { ok: false, reason: 'botId / sessionId / all 三选一必填' })
3011
+ return
3012
+ }
3013
+ const revive = await ensureLive(ctx, sessionId)
3014
+ // ok:true = 请求已处理;复活是否成功看 live(失败给 reason,不是 500)。
3015
+ sendJson(response, 200, {
3016
+ ok: true,
3017
+ sessionId,
3018
+ live: revive.live,
3019
+ revived: revive.revived,
3020
+ ...(revive.live ? {} : { reason: revive.reason ?? 'revive-failed' }),
3021
+ })
3022
+ return
3023
+ }
3024
+
3025
+ // ── 房间:成员管理 / 转发 ─────────────────────────────────────────────
3026
+ if (request.method === 'POST' && path === '/rooms/update') {
3027
+ const body = (await readBody(request)) ?? {}
3028
+ const room = await withService(services, 'room', (r) =>
3029
+ r.update({ ...body, byId: body.byId ?? 'ui' }),
3030
+ )
3031
+ sendJson(response, 200, { ok: true, room })
3032
+ return
3033
+ }
3034
+
3035
+ if (request.method === 'POST' && path === '/rooms/join') {
3036
+ const body = (await readBody(request)) ?? {}
3037
+ const room = await withService(services, 'room', (r) =>
3038
+ r.join({ roomId: body.roomId, sessionId: body.sessionId, byId: body.byId ?? 'ui' }),
3039
+ )
3040
+ sendJson(response, 200, { ok: true, room })
3041
+ return
3042
+ }
3043
+
3044
+ if (request.method === 'POST' && path === '/rooms/leave') {
3045
+ const body = (await readBody(request)) ?? {}
3046
+ const room = await withService(services, 'room', (r) =>
3047
+ r.leave({ roomId: body.roomId, sessionId: body.sessionId, byId: body.byId ?? 'ui' }),
3048
+ )
3049
+ sendJson(response, 200, { ok: true, room })
3050
+ return
3051
+ }
3052
+
3053
+ if (request.method === 'POST' && path === '/rooms/relay') {
3054
+ const body = (await readBody(request)) ?? {}
3055
+ const result = await withService(services, 'room', (r) =>
3056
+ r.relay({ roomId: body.roomId, messageId: body.messageId, targets: body.targets }),
3057
+ )
3058
+ sendJson(response, 200, { ok: true, ...result })
3059
+ return
3060
+ }
3061
+
3062
+ // ── 记忆:改 / 归档 / 恢复 ────────────────────────────────────────────
3063
+ if (request.method === 'POST' && path === '/memory/update') {
3064
+ const body = (await readBody(request)) ?? {}
3065
+ const entry = await withService(services, 'memory', (m) =>
3066
+ m.update({ ...body, by: body.by ?? 'ui' }),
3067
+ )
3068
+ sendJson(response, 200, { ok: true, entry })
3069
+ return
3070
+ }
3071
+
3072
+ if (request.method === 'POST' && path === '/memory/remove') {
3073
+ const body = (await readBody(request)) ?? {}
3074
+ const entry = await withService(services, 'memory', (m) =>
3075
+ m.remove({ ...body, by: body.by ?? 'ui' }),
3076
+ )
3077
+ sendJson(response, 200, { ok: true, entry })
3078
+ return
3079
+ }
3080
+
3081
+ if (request.method === 'POST' && path === '/memory/archive') {
3082
+ const body = (await readBody(request)) ?? {}
3083
+ const entry = await withService(services, 'memory', (m) =>
3084
+ m.archive({ ...body, by: body.by ?? 'ui' }),
3085
+ )
3086
+ sendJson(response, 200, { ok: true, entry })
3087
+ return
3088
+ }
3089
+
3090
+ if (request.method === 'POST' && path === '/memory/restore') {
3091
+ const body = (await readBody(request)) ?? {}
3092
+ const entry = await withService(services, 'memory', (m) =>
3093
+ m.restore({ ...body, by: body.by ?? 'ui' }),
3094
+ )
3095
+ sendJson(response, 200, { ok: true, entry })
3096
+ return
3097
+ }
3098
+
3099
+ // ── 目录浏览(宿主原生选择器不可用时的兜底,也是唯一能「新建/重命名」的路径)──
3100
+ if (request.method === 'GET' && path === '/fs/roots') {
3101
+ const r = await withService(services, 'dirbrowse', (d) => d.roots())
3102
+ sendJson(response, 200, { ok: true, ...r })
3103
+ return
3104
+ }
3105
+ if (request.method === 'GET' && path === '/fs/list') {
3106
+ const dir = qp(url, 'path')
3107
+ const showHidden = qp(url, 'showHidden') === '1'
3108
+ const listing = await withService(services, 'dirbrowse', (d) =>
3109
+ d.list({ ...(dir ? { path: dir } : {}), showHidden }),
3110
+ )
3111
+ sendJson(response, 200, { ok: true, ...listing })
3112
+ return
3113
+ }
3114
+ if (request.method === 'POST' && path === '/fs/mkdir') {
3115
+ const body = (await readBody(request)) ?? {}
3116
+ const created = await withService(services, 'dirbrowse', (d) => d.mkdir(body))
3117
+ sendJson(response, 200, { ok: true, ...created })
3118
+ return
3119
+ }
3120
+ if (request.method === 'POST' && path === '/fs/rename') {
3121
+ const body = (await readBody(request)) ?? {}
3122
+ const renamed = await withService(services, 'dirbrowse', (d) => d.rename(body))
3123
+ sendJson(response, 200, { ok: true, ...renamed })
3124
+ return
3125
+ }
3126
+ if (request.method === 'POST' && path === '/fs/validate') {
3127
+ const body = (await readBody(request)) ?? {}
3128
+ const verdict = await withService(services, 'dirbrowse', (d) => d.validateAsBotCwd({ path: body.path }))
3129
+ sendJson(response, 200, { ok: true, verdict })
3130
+ return
3131
+ }
3132
+
3133
+ // ── 技能库 / 工作流库 ─────────────────────────────────────────────────
3134
+ if (request.method === 'GET' && path === '/library/skills') {
3135
+ const rows = await withService(services, 'library', (l) => l.listSkills({ tag: qp(url, 'tag') }))
3136
+ sendJson(response, 200, { ok: true, skills: rows })
3137
+ return
3138
+ }
3139
+ if (request.method === 'POST' && path === '/library/skills') {
3140
+ const body = (await readBody(request)) ?? {}
3141
+ const skill = await withService(services, 'library', (l) => l.createSkill({ ...body, by: body.by ?? 'ui' }))
3142
+ sendJson(response, 200, { ok: true, skill })
3143
+ return
3144
+ }
3145
+ if (request.method === 'POST' && path === '/library/skills/update') {
3146
+ const body = (await readBody(request)) ?? {}
3147
+ const skill = await withService(services, 'library', (l) => l.updateSkill({ ...body, by: body.by ?? 'ui' }))
3148
+ sendJson(response, 200, { ok: true, skill })
3149
+ return
3150
+ }
3151
+ if (request.method === 'POST' && path === '/library/skills/remove') {
3152
+ const body = (await readBody(request)) ?? {}
3153
+ const result = await withService(services, 'library', (l) => l.removeSkill({ id: body.id, by: body.by ?? 'ui' }))
3154
+ sendJson(response, 200, { ok: true, result })
3155
+ return
3156
+ }
3157
+ if (request.method === 'GET' && path === '/library/workflows') {
3158
+ const rows = await withService(services, 'library', (l) => l.listWorkflows({ tag: qp(url, 'tag') }))
3159
+ sendJson(response, 200, { ok: true, workflows: rows })
3160
+ return
3161
+ }
3162
+ if (request.method === 'POST' && path === '/library/workflows') {
3163
+ const body = (await readBody(request)) ?? {}
3164
+ const workflow = await withService(services, 'library', (l) => l.createWorkflow({ ...body, by: body.by ?? 'ui' }))
3165
+ sendJson(response, 200, { ok: true, workflow })
3166
+ return
3167
+ }
3168
+ if (request.method === 'POST' && path === '/library/workflows/update') {
3169
+ const body = (await readBody(request)) ?? {}
3170
+ const workflow = await withService(services, 'library', (l) => l.updateWorkflow({ ...body, by: body.by ?? 'ui' }))
3171
+ sendJson(response, 200, { ok: true, workflow })
3172
+ return
3173
+ }
3174
+ if (request.method === 'POST' && path === '/library/workflows/remove') {
3175
+ const body = (await readBody(request)) ?? {}
3176
+ const result = await withService(services, 'library', (l) => l.removeWorkflow({ id: body.id, by: body.by ?? 'ui' }))
3177
+ sendJson(response, 200, { ok: true, result })
3178
+ return
3179
+ }
3180
+
3181
+ // ── 设置 ──────────────────────────────────────────────────────────────
3182
+ // ⚠️ 修复(2026-11-04 测试):
3183
+ // ① GET 之前只返回 services.settings(启动时一次性加载的内存副本)⇒
3184
+ // 「保存后立刻 GET 却看不到新值」的假象(磁盘上其实已写对)。
3185
+ // 现改为:`saved` 永远来自磁盘(真相源),`effective` 才是运行时内存值。
3186
+ // ② POST 之前只回 saved.values,调用方无法区分「写进去的」与「当前生效的」⇒
3187
+ // 现补 `effective` 与 `persisted` 两个字段,让 UI 能如实标注。
3188
+ if (request.method === 'GET' && path === '/settings') {
3189
+ const { SETTING_SPECS, loadSettings } = await import('./settings.js')
3190
+ const dir = services.board?.paths?.stateDir ?? services.roster?.paths?.stateDir
3191
+ const disk = dir ? loadSettings(dir) : { values: {}, diagnostics: { file: '', source: 'unknown', badKeys: [] } }
3192
+ sendJson(response, 200, {
3193
+ ok: true,
3194
+ values: disk.values,
3195
+ saved: disk.values,
3196
+ effective: services.settings ?? {},
3197
+ specs: SETTING_SPECS,
3198
+ diagnostics: disk.diagnostics,
3199
+ baseDir: dir,
3200
+ note: Object.keys(disk.values).length
3201
+ && JSON.stringify(disk.values) !== JSON.stringify(services.settings ?? {})
3202
+ ? '磁盘值与内存生效值不同:内核级参数需重启 DSH 才生效'
3203
+ : undefined,
3204
+ })
3205
+ return
3206
+ }
3207
+
3208
+ if (request.method === 'POST' && path === '/settings') {
3209
+ const body = (await readBody(request)) ?? {}
3210
+ const { saveSettings } = await import('./settings.js')
3211
+ const dir = services.board?.paths?.stateDir ?? services.roster?.paths?.stateDir
3212
+ const saved = saveSettings(dir, body)
3213
+ if (!saved.ok) {
3214
+ sendJson(response, 400, { ok: false, reason: saved.reason })
3215
+ return
3216
+ }
3217
+ sendJson(response, 200, {
3218
+ ok: true,
3219
+ values: saved.values,
3220
+ saved: saved.values,
3221
+ effective: services.settings ?? {},
3222
+ persisted: true,
3223
+ note: '已写入 settings.json;内核级参数需重启 DSH 生效',
3224
+ })
3225
+ return
3226
+ }
3227
+
3228
+ // ── 手动唤醒 ──────────────────────────────────────────────────────────
3229
+ if (request.method === 'POST' && path === '/wakes/notify') {
3230
+ const body = (await readBody(request)) ?? {}
3231
+ const result = await withService(services, 'dispatcher', (d) => d.notify(body))
3232
+ sendJson(response, 200, { ok: true, result })
3233
+ return
3234
+ }
3235
+
3236
+ // ── 唤醒审计 ──────────────────────────────────────────────────────────
3237
+ if (request.method === 'GET' && path === '/wakes') {
3238
+ const wakes = await withService(services, 'dispatcher', (d) =>
3239
+ d.list({ target: qp(url, 'target'), limit: qn(url, 'limit') ?? 100 }),
3240
+ )
3241
+ sendJson(response, 200, { ok: true, wakes })
344
3242
  return
345
3243
  }
346
3244
 
347
- if (request.method === 'POST' && path === '/rooms/post') {
3245
+
3246
+ // ══════════════════════════════════════════════════════════════════════
3247
+ // v2 新增端点(2026-11-04,五大功能)
3248
+ // ══════════════════════════════════════════════════════════════════════
3249
+
3250
+ // ── 特性①:房间超级管理员 ────────────────────────────────────────────
3251
+ if (request.method === 'POST' && path === '/rooms/archive') {
348
3252
  const body = (await readBody(request)) ?? {}
349
- const message = await withService(services, 'room', (r) =>
350
- r.post({ ...body, from: body.from ?? 'ui' }),
3253
+ const room = await withService(services, 'room', (r) =>
3254
+ r.archiveRoom({ ...body, byId: body.byId ?? OWNER }),
351
3255
  )
352
- sendJson(response, 200, { ok: true, message })
3256
+ sendJson(response, 200, { ok: true, room })
353
3257
  return
354
3258
  }
355
-
356
- if (request.method === 'GET' && path === '/rooms/body') {
357
- const roomId = qp(url, 'roomId')
358
- const messageId = qp(url, 'messageId')
359
- if (!roomId || !messageId) {
360
- sendJson(response, 400, { ok: false, reason: 'roomId 与 messageId 必填' })
361
- return
362
- }
363
- const text = await withService(services, 'room', (r) => r.readBody({ roomId, messageId }))
364
- sendJson(response, 200, { ok: true, text })
3259
+ if (request.method === 'POST' && path === '/rooms/unarchive') {
3260
+ const body = (await readBody(request)) ?? {}
3261
+ const room = await withService(services, 'room', (r) =>
3262
+ r.unarchiveRoom({ ...body, byId: body.byId ?? OWNER }),
3263
+ )
3264
+ sendJson(response, 200, { ok: true, room })
365
3265
  return
366
3266
  }
367
-
368
- // ── 记忆 ──────────────────────────────────────────────────────────────
369
- if (request.method === 'GET' && path === '/memory') {
370
- const layer = qp(url, 'layer')
371
- const query = qp(url, 'query')
372
- const entries = await withService(services, 'memory', (m) =>
373
- query ? m.search({ query, layers: layer ? [layer] : undefined, limit: qn(url, 'limit') })
374
- : m.list({ layer, limit: qn(url, 'limit') }),
3267
+ if (request.method === 'POST' && path === '/rooms/remove') {
3268
+ const body = (await readBody(request)) ?? {}
3269
+ const result = await withService(services, 'room', (r) =>
3270
+ r.removeRoom({ ...body, byId: body.byId ?? OWNER }),
375
3271
  )
376
- sendJson(response, 200, { ok: true, entries })
3272
+ sendJson(response, 200, { ok: true, result })
377
3273
  return
378
3274
  }
379
-
380
- if (request.method === 'POST' && path === '/memory') {
3275
+ if (request.method === 'POST' && path === '/rooms/member-role') {
381
3276
  const body = (await readBody(request)) ?? {}
382
- const entry = await withService(services, 'memory', (m) =>
383
- m.add({ ...body, by: body.by ?? 'ui' }),
3277
+ const room = await withService(services, 'room', (r) =>
3278
+ r.setMemberRole({ ...body, byId: body.byId ?? OWNER }),
384
3279
  )
385
- sendJson(response, 200, { ok: true, entry })
3280
+ sendJson(response, 200, { ok: true, room })
386
3281
  return
387
3282
  }
388
-
389
- if (request.method === 'GET' && path === '/memory/context') {
390
- const text = await withService(services, 'memory', (m) => m.contextFor({ branch: qp(url, 'branch') }))
391
- sendJson(response, 200, { ok: true, text })
3283
+ if (request.method === 'POST' && path === '/rooms/kick') {
3284
+ const body = (await readBody(request)) ?? {}
3285
+ const room = await withService(services, 'room', (r) =>
3286
+ r.kickMember({ ...body, byId: body.byId ?? OWNER }),
3287
+ )
3288
+ sendJson(response, 200, { ok: true, room })
392
3289
  return
393
3290
  }
394
3291
 
395
- // ── 任务:详情穿透 / 指派 / 终止 / 编辑 ──────────────────────────────
396
- if (request.method === 'GET' && path === '/tasks/detail') {
397
- const id = qp(url, 'id')
398
- if (!id) {
399
- sendJson(response, 400, { ok: false, reason: 'id 必填' })
400
- return
401
- }
402
- const detail = await withService(services, 'board', (b) => ({
403
- task: b.get(id),
404
- history: typeof b.history === 'function' ? b.history(id) : [],
3292
+ // ── 特性②:分级纪律体系 ──────────────────────────────────────────────
3293
+ if (request.method === 'GET' && path === '/disciplines') {
3294
+ const items = await withService(services, 'discipline', (d) => d.listDisciplines({
3295
+ scope: qp(url, 'scope'),
3296
+ scopeId: qp(url, 'scopeId'),
3297
+ kind: qp(url, 'kind'),
3298
+ query: qp(url, 'query'),
3299
+ enabled: qp(url, 'enabled') === undefined ? undefined : qp(url, 'enabled') === '1',
405
3300
  }))
406
- if (!detail.task) {
407
- sendJson(response, 404, { ok: false, reason: `任务 ${id} 不存在` })
408
- return
409
- }
410
- sendJson(response, 200, { ok: true, ...detail })
3301
+ sendJson(response, 200, { ok: true, items, scopes: DISCIPLINE_SCOPES, kinds: DISCIPLINE_KINDS })
3302
+ return
3303
+ }
3304
+ if (request.method === 'POST' && path === '/disciplines') {
3305
+ const body = (await readBody(request)) ?? {}
3306
+ const item = await withService(services, 'discipline', (d) =>
3307
+ d.createDiscipline({ ...body, by: body.by ?? OWNER }),
3308
+ )
3309
+ sendJson(response, 200, { ok: true, item })
3310
+ return
3311
+ }
3312
+ if (request.method === 'POST' && path === '/disciplines/update') {
3313
+ const body = (await readBody(request)) ?? {}
3314
+ const item = await withService(services, 'discipline', (d) =>
3315
+ d.updateDiscipline({ ...body, by: body.by ?? OWNER }),
3316
+ )
3317
+ sendJson(response, 200, { ok: true, item })
3318
+ return
3319
+ }
3320
+ if (request.method === 'POST' && path === '/disciplines/remove') {
3321
+ const body = (await readBody(request)) ?? {}
3322
+ const result = await withService(services, 'discipline', (d) =>
3323
+ d.removeDiscipline({ id: body.id, expectedRev: body.expectedRev, by: body.by ?? OWNER }),
3324
+ )
3325
+ sendJson(response, 200, { ok: true, result })
3326
+ return
3327
+ }
3328
+ if (request.method === 'POST' && path === '/disciplines/import') {
3329
+ const body = (await readBody(request)) ?? {}
3330
+ const result = await withService(services, 'discipline', (d) =>
3331
+ d.importDisciplines({ items: body.items ?? [], mode: body.mode, by: body.by ?? OWNER }),
3332
+ )
3333
+ sendJson(response, 200, { ok: true, result })
3334
+ return
3335
+ }
3336
+ // 合并预览:给定 bot/cwd/room,看"这个对象实际会看到哪些纪律"
3337
+ if (request.method === 'GET' && path === '/disciplines/resolve') {
3338
+ const resolved = await withService(services, 'discipline', (d) => d.resolveDisciplines({
3339
+ botId: qp(url, 'botId'),
3340
+ cwd: qp(url, 'cwd'),
3341
+ roomId: qp(url, 'roomId'),
3342
+ }))
3343
+ sendJson(response, 200, { ok: true, ...resolved })
3344
+ return
3345
+ }
3346
+ if (request.method === 'GET' && path === '/disciplines/preview') {
3347
+ const resolved = await withService(services, 'discipline', (d) => d.renderDisciplinePrompt({
3348
+ botId: qp(url, 'botId'),
3349
+ cwd: qp(url, 'cwd'),
3350
+ roomId: qp(url, 'roomId'),
3351
+ tokenBudget: qn(url, 'tokenBudget'),
3352
+ }))
3353
+ sendJson(response, 200, { ok: true, ...resolved })
411
3354
  return
412
3355
  }
413
3356
 
414
- if (request.method === 'POST' && path === '/tasks/assign') {
3357
+ // ── 特性③:Bot 档案增强(导入记忆 / 关联技能)────────────────────────
3358
+ if (request.method === 'POST' && path === '/roster/profile/memory') {
415
3359
  const body = (await readBody(request)) ?? {}
416
- if (!body.id || !body.ownerId) {
417
- sendJson(response, 400, { ok: false, reason: 'id 与 ownerId 必填' })
3360
+ if (!body.id) {
3361
+ sendJson(response, 400, { ok: false, reason: 'id 必填' })
418
3362
  return
419
3363
  }
420
- // 指派 = 用 ownerId 作为 claim 的 byId(board 的 claim 把 by 记为 owner)
421
- const task = await withService(services, 'board', (b) =>
422
- b.update({
3364
+ const out = await withService(services, 'roster', (r) =>
3365
+ r.addProfileMemory({
423
3366
  id: body.id,
3367
+ entries: body.entries ?? [],
3368
+ dedup: body.dedup !== false,
3369
+ byId: body.byId ?? OWNER,
424
3370
  expectedRev: body.expectedRev,
425
- action: 'claim',
426
- byId: body.ownerId,
427
- ...(body.leaseMs ? { leaseMs: body.leaseMs } : {}),
428
3371
  }),
429
3372
  )
430
- // 指派后立刻定向唤醒(dedupKey 幂等:同任务同原因只唤一次)
431
- let wake
432
- if (services.dispatcher && body.wake !== false) {
433
- wake = services.dispatcher.notify({
434
- target: body.ownerId,
435
- reason: 'task-assigned',
436
- dedupKey: `task-assigned:${task.id}:${task.rev}`,
437
- text: `新任务 ${task.id}:${task.subject}\n${task.description ?? ''}`.trim(),
438
- })
439
- }
440
- sendJson(response, 200, { ok: true, task, wake })
3373
+ sendJson(response, 200, { ok: true, ...out })
441
3374
  return
442
3375
  }
443
-
444
- if (request.method === 'POST' && path === '/tasks/abort') {
3376
+ if (request.method === 'POST' && path === '/roster/skills/link') {
445
3377
  const body = (await readBody(request)) ?? {}
446
3378
  if (!body.id) {
447
3379
  sendJson(response, 400, { ok: false, reason: 'id 必填' })
448
3380
  return
449
3381
  }
450
- // 终止 = block + 原因(board 的 block 会置 blocked 并留 reason;可 reopen)
451
- const task = await withService(services, 'board', (b) =>
452
- b.update({
3382
+ const bot = await withService(services, 'roster', (r) =>
3383
+ r.linkSkills({
453
3384
  id: body.id,
3385
+ skills: body.skills ?? [],
3386
+ byId: body.byId ?? OWNER,
454
3387
  expectedRev: body.expectedRev,
455
- action: 'block',
456
- note: body.reason ?? '用户终止',
457
- byId: body.byId ?? 'ui',
458
3388
  }),
459
3389
  )
460
- sendJson(response, 200, { ok: true, task })
3390
+ sendJson(response, 200, { ok: true, bot })
461
3391
  return
462
3392
  }
463
3393
 
464
- if (request.method === 'POST' && path === '/tasks/edit') {
3394
+ // ── 特性④:记忆档案完整增删改查 ──────────────────────────────────────
3395
+ if (request.method === 'GET' && path === '/memory/detail') {
3396
+ const id = qp(url, 'id')
3397
+ const layer = qp(url, 'layer')
3398
+ if (!id || !layer) {
3399
+ sendJson(response, 400, { ok: false, reason: 'id 与 layer 必填' })
3400
+ return
3401
+ }
3402
+ const entry = await withService(services, 'memory', (m) => m.get({ layer, id }))
3403
+ const history = await withService(services, 'memory', (m) => m.history(id))
3404
+ sendJson(response, 200, { ok: true, entry, history })
3405
+ return
3406
+ }
3407
+ if (request.method === 'POST' && path === '/memory/purge') {
3408
+ // 彻底删除(不可恢复):与 /memory/remove(软删除,可恢复)区分开
465
3409
  const body = (await readBody(request)) ?? {}
466
- if (!body.id) {
3410
+ const entry = await withService(services, 'memory', (m) =>
3411
+ m.purge({ layer: body.layer, id: body.id, expectedRev: body.expectedRev, by: body.by ?? OWNER }))
3412
+ sendJson(response, 200, { ok: true, purged: entry })
3413
+ return
3414
+ }
3415
+ if (request.method === 'POST' && path === '/memory/migrate-cwd') {
3416
+ const body = (await readBody(request)) ?? {}
3417
+ const result = await withService(services, 'memory', (m) =>
3418
+ m.migrateCwd({ cwd: body.cwd, by: body.by ?? OWNER, now: body.now }))
3419
+ sendJson(response, 200, { ok: true, result })
3420
+ return
3421
+ }
3422
+ if (request.method === 'POST' && path === '/memory/import') {
3423
+ const body = (await readBody(request)) ?? {}
3424
+ const rows = Array.isArray(body.entries) ? body.entries : []
3425
+ const added = []
3426
+ const failed = []
3427
+ for (const row of rows) {
3428
+ try {
3429
+ const entry = await withService(services, 'memory', (m) => m.add({
3430
+ layer: row.layer ?? body.layer ?? 'L4',
3431
+ content: row.content ?? row,
3432
+ by: body.by ?? OWNER,
3433
+ tags: row.tags,
3434
+ explicit: row.explicit,
3435
+ }))
3436
+ const id = Array.isArray(entry) ? entry[0]?.id : entry?.id
3437
+ if (id) added.push(id)
3438
+ } catch (error) {
3439
+ failed.push({ content: String(row?.content ?? row).slice(0, 60), reason: error?.message ?? String(error) })
3440
+ }
3441
+ }
3442
+ sendJson(response, 200, { ok: true, added: added.length, ids: added, failed })
3443
+ return
3444
+ }
3445
+
3446
+ // ── 特性⑤:技能导入 / 导出 ───────────────────────────────────────────
3447
+ if (request.method === 'POST' && path === '/library/skills/parse') {
3448
+ const body = (await readBody(request)) ?? {}
3449
+ const parsed = await withService(services, 'library', (l) =>
3450
+ l.parseSkillMarkdown({ text: body.text ?? '', filename: body.filename }),
3451
+ )
3452
+ sendJson(response, 200, { ok: true, parsed })
3453
+ return
3454
+ }
3455
+ if (request.method === 'POST' && path === '/library/skills/import') {
3456
+ const body = (await readBody(request)) ?? {}
3457
+ const result = await withService(services, 'library', (l) =>
3458
+ l.importSkills({
3459
+ items: body.items ?? [],
3460
+ mode: body.mode ?? 'skip',
3461
+ by: body.by ?? OWNER,
3462
+ }),
3463
+ )
3464
+ sendJson(response, 200, { ok: true, result })
3465
+ return
3466
+ }
3467
+ if (request.method === 'POST' && path === '/library/skills/scan') {
3468
+ const body = (await readBody(request)) ?? {}
3469
+ const result = await withService(services, 'library', (l) =>
3470
+ l.scanSkillsDir({ dir: body.dir }),
3471
+ )
3472
+ sendJson(response, 200, { ok: true, ...result })
3473
+ return
3474
+ }
3475
+ if (request.method === 'GET' && path === '/library/skills/detail') {
3476
+ const id = qp(url, 'id')
3477
+ if (!id) {
467
3478
  sendJson(response, 400, { ok: false, reason: 'id 必填' })
468
3479
  return
469
3480
  }
470
- const task = await withService(services, 'board', (b) =>
471
- b.update({ ...body, action: 'edit', byId: body.byId ?? 'ui' }),
472
- )
473
- sendJson(response, 200, { ok: true, task })
3481
+ const skill = await withService(services, 'library', (l) => l.getSkill(id))
3482
+ sendJson(response, 200, { ok: true, skill })
3483
+ return
3484
+ }
3485
+ if (request.method === 'GET' && path === '/library/skills/export') {
3486
+ const ids = qp(url, 'ids')
3487
+ const out = await withService(services, 'library', (l) =>
3488
+ l.exportSkills({ ids: ids ? ids.split(',').filter(Boolean) : undefined }),
3489
+ )
3490
+ sendJson(response, 200, { ok: true, ...out })
3491
+ return
3492
+ }
3493
+
3494
+ // ── 特性⑥:四轨待办 ──────────────────────────────────────────────────
3495
+ if (request.method === 'GET' && path === '/todos') {
3496
+ const out = await withService(services, 'todo', (t) => t.listTodos({
3497
+ track: qp(url, 'track'),
3498
+ cwd: qp(url, 'cwd'),
3499
+ date: qp(url, 'date'),
3500
+ status: qp(url, 'status'),
3501
+ query: qp(url, 'query'),
3502
+ all: qp(url, 'all') === '1',
3503
+ past: qp(url, 'past') === '1',
3504
+ expired: qp(url, 'expired') === '1',
3505
+ limit: qn(url, 'limit'),
3506
+ }))
3507
+ // listTodos 返回 {items,total,truncated,defaultView,today,hint};
3508
+ // 原样展开(页面读 items)+ 保留 todos 别名(老调用方兼容)
3509
+ sendJson(response, 200, { ok: true, ...out, todos: out.items ?? [] })
3510
+ return
3511
+ }
3512
+ if (request.method === 'POST' && path === '/todos') {
3513
+ const body = (await readBody(request)) ?? {}
3514
+ const todo = await withService(services, 'todo', (t) => t.createTodo({ ...body, by: body.by ?? OWNER }))
3515
+ sendJson(response, 200, { ok: true, todo })
3516
+ return
3517
+ }
3518
+ if (request.method === 'POST' && path === '/todos/update') {
3519
+ const body = (await readBody(request)) ?? {}
3520
+ const todo = await withService(services, 'todo', (t) => t.updateTodo({ ...body, by: body.by ?? OWNER }))
3521
+ sendJson(response, 200, { ok: true, todo })
3522
+ return
3523
+ }
3524
+ if (request.method === 'POST' && path === '/todos/promote') {
3525
+ // 「待办 → 任务」升级:待办是个人备忘,任务是要交给别人/Bot 干的协作单元。
3526
+ // 两者是漏斗上下游,所以必须有一条**确定性**的升级通道(不是让用户手抄一遍)。
3527
+ // 幂等靠 board 的 sourceSeq=`todo:<id>`:连点两次也只会产生一个任务。
3528
+ const body = (await readBody(request)) ?? {}
3529
+ const result = await withService(services, 'todo', async (t) => {
3530
+ const todo = t.getTodo(body.id)
3531
+ if (!todo) {
3532
+ const error = new Error(`待办不存在:${body.id}`)
3533
+ error.status = 404
3534
+ error.code = 'TODO_NOT_FOUND'
3535
+ throw error
3536
+ }
3537
+ if (body.expectedRev !== undefined && todo.rev !== body.expectedRev) {
3538
+ const error = new Error(`待办 CAS 冲突:期望 rev=${body.expectedRev},实际 rev=${todo.rev}`)
3539
+ error.status = 409
3540
+ error.code = 'TODO_REV_CONFLICT'
3541
+ throw error
3542
+ }
3543
+ const task = await withService(services, 'board', (b) => b.create({
3544
+ subject: String(todo.content).split(String.fromCharCode(10))[0].slice(0, 120),
3545
+ description: todo.content,
3546
+ priority: body.priority,
3547
+ dueAt: todo.due ? Date.parse(`${todo.due}T00:00:00+08:00`) : undefined,
3548
+ byId: body.by ?? OWNER,
3549
+ sourceSeq: `todo:${todo.id}`,
3550
+ }))
3551
+ // 升级后把待办标成"进行中",避免它又出现在「要关注」里被重复安排
3552
+ let next = todo
3553
+ if (todo.status === 'pending') {
3554
+ next = t.updateTodo({ id: todo.id, expectedRev: todo.rev, status: 'doing', by: body.by ?? OWNER })
3555
+ }
3556
+ return { task, todo: next }
3557
+ })
3558
+ sendJson(response, 200, { ok: true, ...result })
3559
+ return
3560
+ }
3561
+ if (request.method === 'POST' && path === '/todos/remove') {
3562
+ const body = (await readBody(request)) ?? {}
3563
+ const result = await withService(services, 'todo', (t) =>
3564
+ t.removeTodo({ id: body.id, expectedRev: body.expectedRev, by: body.by ?? OWNER }))
3565
+ sendJson(response, 200, { ok: true, result })
3566
+ return
3567
+ }
3568
+
3569
+ // ── 特性⑦:便签 ──────────────────────────────────────────────────────
3570
+ if (request.method === 'GET' && path === '/scratch') {
3571
+ const out = await withService(services, 'scratch', (s) => s.read({ scope: qp(url, 'scope') }))
3572
+ sendJson(response, 200, { ok: true, ...out })
3573
+ return
3574
+ }
3575
+ if (request.method === 'POST' && path === '/scratch') {
3576
+ const body = (await readBody(request)) ?? {}
3577
+ const out = await withService(services, 'scratch', (s) =>
3578
+ s.write({ scope: body.scope, text: body.text ?? '', by: body.by ?? OWNER }))
3579
+ sendJson(response, 200, { ok: true, ...out })
3580
+ return
3581
+ }
3582
+ if (request.method === 'GET' && path === '/scratch/scopes') {
3583
+ const scopes = await withService(services, 'scratch', (s) => s.listScopes())
3584
+ sendJson(response, 200, { ok: true, scopes })
3585
+ return
3586
+ }
3587
+
3588
+ // ── 特性⑧:提示词库 + 注入轨 ──────────────────────────────────────────
3589
+ if (request.method === 'GET' && path === '/prompts') {
3590
+ const items = await withService(services, 'prompts', (p) => p.listPrompts({
3591
+ category: qp(url, 'category'),
3592
+ tag: qp(url, 'tag'),
3593
+ query: qp(url, 'query'),
3594
+ enabled: qp(url, 'enabled') === undefined ? undefined : qp(url, 'enabled') === '1',
3595
+ limit: qn(url, 'limit'),
3596
+ }))
3597
+ const categories = await withService(services, 'prompts', (p) => p.listCategories())
3598
+ sendJson(response, 200, { ok: true, items, categories })
3599
+ return
3600
+ }
3601
+ if (request.method === 'POST' && path === '/prompts') {
3602
+ const body = (await readBody(request)) ?? {}
3603
+ const item = await withService(services, 'prompts', (p) => p.createPrompt({ ...body, by: body.by ?? OWNER }))
3604
+ sendJson(response, 200, { ok: true, item })
3605
+ return
3606
+ }
3607
+ if (request.method === 'POST' && path === '/prompts/update') {
3608
+ const body = (await readBody(request)) ?? {}
3609
+ const item = await withService(services, 'prompts', (p) => p.updatePrompt({ ...body, by: body.by ?? OWNER }))
3610
+ sendJson(response, 200, { ok: true, item })
3611
+ return
3612
+ }
3613
+ if (request.method === 'POST' && path === '/prompts/remove') {
3614
+ const body = (await readBody(request)) ?? {}
3615
+ const result = await withService(services, 'prompts', (p) =>
3616
+ p.removePrompt({ id: body.id, expectedRev: body.expectedRev, by: body.by ?? OWNER }))
3617
+ sendJson(response, 200, { ok: true, result })
3618
+ return
3619
+ }
3620
+ if (request.method === 'POST' && path === '/prompts/categories/add') {
3621
+ const body = (await readBody(request)) ?? {}
3622
+ const categories = await withService(services, 'prompts', (p) =>
3623
+ p.addCategory({ name: body.name, by: body.by ?? OWNER }))
3624
+ sendJson(response, 200, { ok: true, categories })
3625
+ return
3626
+ }
3627
+ if (request.method === 'POST' && path === '/prompts/categories/remove') {
3628
+ const body = (await readBody(request)) ?? {}
3629
+ const categories = await withService(services, 'prompts', (p) =>
3630
+ p.removeCategory({ name: body.name, by: body.by ?? OWNER }))
3631
+ sendJson(response, 200, { ok: true, categories })
3632
+ return
3633
+ }
3634
+ if (request.method === 'POST' && path === '/prompts/inject') {
3635
+ const body = (await readBody(request)) ?? {}
3636
+ const injection = await withService(services, 'prompts', (p) => p.inject({
3637
+ promptId: body.promptId,
3638
+ text: body.text,
3639
+ sessionId: body.sessionId,
3640
+ times: body.times,
3641
+ every: body.every,
3642
+ immediate: body.immediate,
3643
+ by: body.by ?? OWNER,
3644
+ }))
3645
+ sendJson(response, 200, { ok: true, injection })
3646
+ return
3647
+ }
3648
+ if (request.method === 'POST' && path === '/prompts/injection/stop') {
3649
+ const body = (await readBody(request)) ?? {}
3650
+ const result = await withService(services, 'prompts', (p) => p.stopInjection({ id: body.id, by: body.by ?? OWNER }))
3651
+ sendJson(response, 200, { ok: true, result })
3652
+ return
3653
+ }
3654
+ if (request.method === 'GET' && path === '/prompts/injections') {
3655
+ const injections = await withService(services, 'prompts', (p) => p.listInjections({ sessionId: qp(url, 'sessionId') }))
3656
+ sendJson(response, 200, { ok: true, injections })
3657
+ return
3658
+ }
3659
+
3660
+ // ── 特性⑨:建议确认队列 + 审查调度 ───────────────────────────────────
3661
+ if (request.method === 'GET' && path === '/suggestions') {
3662
+ const items = await withService(services, 'suggest', (s) => s.listSuggestions({
3663
+ kind: qp(url, 'kind'),
3664
+ status: qp(url, 'status') ?? 'pending',
3665
+ limit: qn(url, 'limit'),
3666
+ }))
3667
+ sendJson(response, 200, { ok: true, items })
3668
+ return
3669
+ }
3670
+ if (request.method === 'POST' && path === '/suggestions/propose') {
3671
+ const body = (await readBody(request)) ?? {}
3672
+ const suggestion = await withService(services, 'suggest', (sg) => sg.suggest({
3673
+ kind: body.kind,
3674
+ layer: body.layer,
3675
+ payload: body.payload,
3676
+ reason: body.reason,
3677
+ source: body.source ?? 'model',
3678
+ confidence: body.confidence,
3679
+ by: body.by ?? OWNER,
3680
+ }))
3681
+ sendJson(response, 200, { ok: true, suggestion })
3682
+ return
3683
+ }
3684
+ if (request.method === 'POST' && path === '/suggestions/accept') {
3685
+ const body = (await readBody(request)) ?? {}
3686
+ // apply 回调把建议真正写进目标域(memory / library / discipline)
3687
+ const applied = await withService(services, 'suggest', (s) => s.accept({
3688
+ id: body.id,
3689
+ by: body.by ?? OWNER,
3690
+ apply: (sg) => applySuggestion(services, sg, body.by ?? OWNER),
3691
+ }))
3692
+ sendJson(response, 200, { ok: true, suggestion: applied })
3693
+ return
3694
+ }
3695
+ if (request.method === 'POST' && path === '/suggestions/reject') {
3696
+ const body = (await readBody(request)) ?? {}
3697
+ const item = await withService(services, 'suggest', (s) =>
3698
+ s.reject({ id: body.id, reason: body.reason, by: body.by ?? OWNER }))
3699
+ sendJson(response, 200, { ok: true, suggestion: item })
3700
+ return
3701
+ }
3702
+ if (request.method === 'GET' && path === '/review/status') {
3703
+ const status = await withService(services, 'reviewer', (r) => r.status({ sessionId: qp(url, 'sessionId') }))
3704
+ sendJson(response, 200, { ok: true, status })
3705
+ return
3706
+ }
3707
+ if (request.method === 'POST' && path === '/review/note-turn') {
3708
+ const body = (await readBody(request)) ?? {}
3709
+ const out = await withService(services, 'reviewer', (r) => r.noteTurn({ sessionId: body.sessionId }))
3710
+ sendJson(response, 200, { ok: true, ...out })
3711
+ return
3712
+ }
3713
+ if (request.method === 'POST' && path === '/review/mark-reviewed') {
3714
+ const body = (await readBody(request)) ?? {}
3715
+ const out = await withService(services, 'reviewer', (r) =>
3716
+ r.markReviewed({ sessionId: body.sessionId, by: body.by ?? OWNER }))
3717
+ sendJson(response, 200, { ok: true, ...out })
3718
+ return
3719
+ }
3720
+ // ── 需求4:智能体定期归纳 —— 人点一下,把「该归纳了」变成一次真唤醒 ────────
3721
+ //
3722
+ // 为什么需要它:reviewer 只做被动计数(每回合 +1,到 interval 就 due),
3723
+ // 它不自己起定时器(宿主生命周期不可控)。所以「定期归纳」的闭环落点是:
3724
+ // UI/接口看到 due → 人点「让它归纳」→ 唤醒该会话 → 会话自己用
3725
+ // bot_cycle_start(kind=agent) + bot_cycle_review 一次性提交并自动闭环。
3726
+ // 归纳出来的建议仍然走 bot_suggestion_propose(确认制,人采纳才落库),
3727
+ // 不存在「智能体自己改自己的长期纪律」这条暗道。
3728
+ if (request.method === 'POST' && path === '/review/urge') {
3729
+ const body = (await readBody(request)) ?? {}
3730
+ const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : ''
3731
+ if (!sessionId) {
3732
+ sendJson(response, 400, { ok: false, reason: 'sessionId 必填' })
3733
+ return
3734
+ }
3735
+ const st = await withService(services, 'reviewer', (r) => r.status({ sessionId }))
3736
+ if (st.known === false) {
3737
+ sendJson(response, 200, {
3738
+ ok: false,
3739
+ code: 'REVIEW_UNKNOWN_SESSION',
3740
+ reason: `会话 ${sessionId} 还没被计数过(未记录任何回合),没有可归纳的内容`,
3741
+ })
3742
+ return
3743
+ }
3744
+ if (st.due !== true && body.force !== true) {
3745
+ sendJson(response, 200, {
3746
+ ok: false,
3747
+ code: 'REVIEW_NOT_DUE',
3748
+ reason: `会话 ${sessionId} 才累计 ${st.turnsSinceLastReview} 回合(阈值 ${st.interval}),还没到该归纳的时候`,
3749
+ status: st,
3750
+ })
3751
+ return
3752
+ }
3753
+ const bots = services.roster?.list?.({ includeRetired: true }) ?? []
3754
+ const bot = bots.find((b) => b.sessionId === sessionId || b.id === sessionId)
3755
+ const text = '[cs-bots 归纳提醒] 你的会话已累计 ' + `${st.turnsSinceLastReview}` + ' 个回合没有归纳了。\n'
3756
+ + '请现在做一次「事后归纳」(这是你自己的 PDCA,不需要人来跟):\n'
3757
+ + ' 1. 用 bot_cycle_start 开一个 kind=agent 的周期,taskId 填你当前主任务的 id,objective 写这次归纳要解决什么;\n'
3758
+ + ` 2. 用 bot_cycle_review 一次性提交(id + summary + lessons[] + standards[]),提交后周期自动闭环;\n`
3759
+ + ' 3. standards 里只写**你确实想让它长期生效**的纪律/原则,它们会进建议队列等人采纳,不会自动落库。\n'
3760
+ + '做完之后调 POST /review/mark-reviewed 把计数清零。'
3761
+ const revive = await ensureLive(ctx, sessionId)
3762
+ let wake
3763
+ try {
3764
+ const dispatcher = await withService(services, 'dispatcher', (d) => d)
3765
+ wake = dispatcher.notify({
3766
+ target: sessionId,
3767
+ reason: 'timer',
3768
+ dedupKey: `review-urge:${sessionId}:${Date.now()}`,
3769
+ text,
3770
+ })
3771
+ } catch (error) {
3772
+ sendJson(response, 503, {
3773
+ ok: false,
3774
+ code: 'REVIEW_URGE_NO_DISPATCHER',
3775
+ reason: `没有可用的唤醒投递器:${error?.message ?? error}`,
3776
+ })
3777
+ return
3778
+ }
3779
+ sendJson(response, 200, {
3780
+ ok: true,
3781
+ sessionId,
3782
+ ...(bot ? { bot: { id: bot.id, name: bot.name, label: bot.label ?? null } } : {}),
3783
+ status: st,
3784
+ live: revive.live,
3785
+ revived: revive.revived,
3786
+ ...(revive.live ? {} : { reviveReason: revive.reason ?? 'revive-failed' }),
3787
+ wake,
3788
+ })
474
3789
  return
475
3790
  }
476
3791
 
477
- if (request.method === 'GET' && path === '/tasks/history') {
478
- const id = qp(url, 'id')
479
- const history = await withService(services, 'board', (b) =>
480
- typeof b.history === 'function' ? b.history(id) : [],
481
- )
482
- sendJson(response, 200, { ok: true, history })
3792
+ if (request.method === 'POST' && path === '/review/interval') {
3793
+ const body = (await readBody(request)) ?? {}
3794
+ const out = await withService(services, 'reviewer', (r) => r.setInterval({ interval: body.interval }))
3795
+ sendJson(response, 200, { ok: true, ...out })
483
3796
  return
484
3797
  }
485
3798
 
486
- // ── 房间:成员管理 / 转发 ─────────────────────────────────────────────
487
- if (request.method === 'POST' && path === '/rooms/update') {
3799
+ // ── 特性⑩:技能管理器(来源/启用/自定义目录/浏览)────────────────────
3800
+ if (request.method === 'POST' && path === '/library/skills/enable') {
488
3801
  const body = (await readBody(request)) ?? {}
489
- const room = await withService(services, 'room', (r) =>
490
- r.update({ ...body, byId: body.byId ?? 'ui' }),
491
- )
492
- sendJson(response, 200, { ok: true, room })
3802
+ const skill = await withService(services, 'library', (l) => l.setSkillEnabled({
3803
+ id: body.id, enabled: body.enabled, by: body.by ?? OWNER, expectedRev: body.expectedRev,
3804
+ }))
3805
+ sendJson(response, 200, { ok: true, skill })
493
3806
  return
494
3807
  }
495
-
496
- if (request.method === 'POST' && path === '/rooms/join') {
3808
+ if (request.method === 'POST' && path === '/library/skills/source') {
497
3809
  const body = (await readBody(request)) ?? {}
498
- const room = await withService(services, 'room', (r) =>
499
- r.join({ roomId: body.roomId, sessionId: body.sessionId, byId: body.byId ?? 'ui' }),
500
- )
501
- sendJson(response, 200, { ok: true, room })
3810
+ const skill = await withService(services, 'library', (l) => l.setSkillSource({
3811
+ id: body.id, source: body.source, by: body.by ?? OWNER, expectedRev: body.expectedRev,
3812
+ }))
3813
+ sendJson(response, 200, { ok: true, skill })
502
3814
  return
503
3815
  }
504
-
505
- if (request.method === 'POST' && path === '/rooms/leave') {
3816
+ if (request.method === 'GET' && path === '/library/skills/dirs') {
3817
+ const dirs = await withService(services, 'library', (l) => l.listSkillDirs())
3818
+ sendJson(response, 200, { ok: true, dirs })
3819
+ return
3820
+ }
3821
+ if (request.method === 'POST' && path === '/library/skills/dirs/add') {
506
3822
  const body = (await readBody(request)) ?? {}
507
- const room = await withService(services, 'room', (r) =>
508
- r.leave({ roomId: body.roomId, sessionId: body.sessionId, byId: body.byId ?? 'ui' }),
509
- )
510
- sendJson(response, 200, { ok: true, room })
3823
+ const out = await withService(services, 'library', (l) =>
3824
+ l.addSkillDir({ dir: body.dir, label: body.label, by: body.by ?? OWNER }))
3825
+ sendJson(response, 200, { ok: true, ...out })
511
3826
  return
512
3827
  }
513
-
514
- if (request.method === 'POST' && path === '/rooms/relay') {
3828
+ if (request.method === 'POST' && path === '/library/skills/dirs/remove') {
515
3829
  const body = (await readBody(request)) ?? {}
516
- const result = await withService(services, 'room', (r) =>
517
- r.relay({ roomId: body.roomId, messageId: body.messageId, targets: body.targets }),
518
- )
519
- sendJson(response, 200, { ok: true, ...result })
3830
+ const out = await withService(services, 'library', (l) =>
3831
+ l.removeSkillDir({ dir: body.dir, by: body.by ?? OWNER }))
3832
+ sendJson(response, 200, { ok: true, ...out })
3833
+ return
3834
+ }
3835
+ if (request.method === 'GET' && path === '/library/skills/browse') {
3836
+ const out = await withService(services, 'library', (l) =>
3837
+ l.browseSkillDir({ dir: qp(url, 'dir'), sub: qp(url, 'sub') }))
3838
+ sendJson(response, 200, { ok: true, ...out })
3839
+ return
3840
+ }
3841
+ if (request.method === 'POST' && path === '/library/skills/scan-all') {
3842
+ const out = await withService(services, 'library', (l) => l.scanAllSkillDirs())
3843
+ sendJson(response, 200, { ok: true, ...out })
520
3844
  return
521
3845
  }
522
3846
 
523
- // ── 记忆:改 / 归档 / 恢复 ────────────────────────────────────────────
524
- if (request.method === 'POST' && path === '/memory/update') {
3847
+ // ── PDCA-2:验收证据(C 的机器闸门)────────────────────────────────────
3848
+ if (request.method === 'GET' && path === '/verify/history') {
3849
+ const items = await withService(services, 'verify', (v) => v.history(Number(qp(url, 'limit')) ?? 50))
3850
+ sendJson(response, 200, { ok: true, items })
3851
+ return
3852
+ }
3853
+ if (request.method === 'GET' && path === '/verify/stats') {
3854
+ const stats = await withService(services, 'verify', (v) => v.stats())
3855
+ sendJson(response, 200, { ok: true, stats })
3856
+ return
3857
+ }
3858
+ if (request.method === 'POST' && path === '/verify/check') {
525
3859
  const body = (await readBody(request)) ?? {}
526
- const entry = await withService(services, 'memory', (m) =>
527
- m.update({ ...body, by: body.by ?? 'ui' }),
528
- )
529
- sendJson(response, 200, { ok: true, entry })
3860
+ const out = await withService(services, 'verify', (v) => v.check({ ...body, by: body.by ?? OWNER }))
3861
+ sendJson(response, 200, { ok: true, ...out })
530
3862
  return
531
3863
  }
532
-
533
- if (request.method === 'POST' && path === '/memory/remove') {
3864
+ if (request.method === 'POST' && path === '/verify/evaluate') {
534
3865
  const body = (await readBody(request)) ?? {}
535
- const entry = await withService(services, 'memory', (m) =>
536
- m.remove({ ...body, by: body.by ?? 'ui' }),
537
- )
538
- sendJson(response, 200, { ok: true, entry })
3866
+ const out = await withService(services, 'verify', (v) => v.evaluate(body.artifacts ?? []))
3867
+ sendJson(response, 200, { ok: true, ...out })
539
3868
  return
540
3869
  }
541
-
542
- if (request.method === 'POST' && path === '/memory/archive') {
3870
+ if (request.method === 'POST' && path === '/verify/task') {
543
3871
  const body = (await readBody(request)) ?? {}
544
- const entry = await withService(services, 'memory', (m) =>
545
- m.archive({ ...body, by: body.by ?? 'ui' }),
546
- )
547
- sendJson(response, 200, { ok: true, entry })
3872
+ const out = await withService(services, 'verify', (v) =>
3873
+ v.verifyTask({ id: body.id, artifacts: body.artifacts ?? [], writeScopes: body.writeScopes ?? [] }, { by: body.by ?? OWNER }))
3874
+ sendJson(response, 200, { ok: true, ...out })
548
3875
  return
549
3876
  }
550
3877
 
551
- if (request.method === 'POST' && path === '/memory/restore') {
3878
+ // ── PDCA-3:测量(metrics)──────────────────────────────────────────────
3879
+ if (request.method === 'GET' && path === '/metrics') {
3880
+ const out = await withService(services, 'metrics', (m) => m.stats())
3881
+ sendJson(response, 200, { ok: true, ...out })
3882
+ return
3883
+ }
3884
+ if (request.method === 'GET' && path === '/metrics/series') {
3885
+ const out = await withService(services, 'metrics', (m) => m.series({
3886
+ kind: qp(url, 'kind'), taskId: qp(url, 'taskId'),
3887
+ since: qp(url, 'since') === undefined ? undefined : Number(qp(url, 'since')),
3888
+ limit: qp(url, 'limit') === undefined ? undefined : Number(qp(url, 'limit')),
3889
+ }))
3890
+ sendJson(response, 200, { ok: true, items: out })
3891
+ return
3892
+ }
3893
+ if (request.method === 'GET' && path === '/metrics/aggregate') {
3894
+ const out = await withService(services, 'metrics', (m) => m.aggregate({
3895
+ kind: qp(url, 'kind'),
3896
+ since: qp(url, 'since') === undefined ? undefined : Number(qp(url, 'since')),
3897
+ }))
3898
+ sendJson(response, 200, { ok: true, ...out })
3899
+ return
3900
+ }
3901
+ if (request.method === 'POST' && path === '/metrics/record') {
552
3902
  const body = (await readBody(request)) ?? {}
553
- const entry = await withService(services, 'memory', (m) =>
554
- m.restore({ ...body, by: body.by ?? 'ui' }),
555
- )
556
- sendJson(response, 200, { ok: true, entry })
3903
+ const out = await withService(services, 'metrics', (m) => m.record({ ...body, by: body.by ?? OWNER }))
3904
+ sendJson(response, 200, { ok: true, record: out })
557
3905
  return
558
3906
  }
559
3907
 
560
- // ── 目录浏览(宿主原生选择器不可用时的兜底,也是唯一能「新建/重命名」的路径)──
561
- if (request.method === 'GET' && path === '/fs/roots') {
562
- const r = await withService(services, 'dirbrowse', (d) => d.roots())
563
- sendJson(response, 200, { ok: true, ...r })
3908
+ // ── PDCA-4:复盘与标准化(Act)──────────────────────────────────────────
3909
+ if (request.method === 'GET' && path === '/cycles') {
3910
+ const items = await withService(services, 'cycle', (c) => c.list({
3911
+ status: qp(url, 'status'), phase: qp(url, 'phase'), taskId: qp(url, 'taskId'),
3912
+ // 需求4:kind 过滤——人只看自己的长任务周期,AI 的归纳周期另开视图
3913
+ kind: qp(url, 'kind'),
3914
+ }))
3915
+ sendJson(response, 200, { ok: true, items, phases: CYCLE_PHASES, kinds: CYCLE_KINDS })
564
3916
  return
565
3917
  }
566
- if (request.method === 'GET' && path === '/fs/list') {
567
- const dir = qp(url, 'path')
568
- const showHidden = qp(url, 'showHidden') === '1'
569
- const listing = await withService(services, 'dirbrowse', (d) =>
570
- d.list({ ...(dir ? { path: dir } : {}), showHidden }),
571
- )
572
- sendJson(response, 200, { ok: true, ...listing })
3918
+ if (request.method === 'POST' && path === '/cycles') {
3919
+ const body = (await readBody(request)) ?? {}
3920
+ const out = await withService(services, 'cycle', (c) =>
3921
+ c.start({ ...body, by: body.by ?? OWNER }))
3922
+ sendJson(response, 200, { ok: true, cycle: out })
573
3923
  return
574
3924
  }
575
- if (request.method === 'POST' && path === '/fs/mkdir') {
3925
+ if (request.method === 'POST' && path === '/cycles/advance') {
576
3926
  const body = (await readBody(request)) ?? {}
577
- const created = await withService(services, 'dirbrowse', (d) => d.mkdir(body))
578
- sendJson(response, 200, { ok: true, ...created })
3927
+ const out = await withService(services, 'cycle', (c) =>
3928
+ c.advance({ ...body, by: body.by ?? OWNER }))
3929
+ sendJson(response, 200, { ok: true, cycle: out })
579
3930
  return
580
3931
  }
581
- if (request.method === 'POST' && path === '/fs/rename') {
3932
+ if (request.method === 'POST' && path === '/cycles/check') {
582
3933
  const body = (await readBody(request)) ?? {}
583
- const renamed = await withService(services, 'dirbrowse', (d) => d.rename(body))
584
- sendJson(response, 200, { ok: true, ...renamed })
3934
+ const out = await withService(services, 'cycle', (c) =>
3935
+ c.check({ ...body, by: body.by ?? OWNER }))
3936
+ sendJson(response, 200, { ok: true, cycle: out })
585
3937
  return
586
3938
  }
587
- if (request.method === 'POST' && path === '/fs/validate') {
3939
+ if (request.method === 'POST' && path === '/cycles/review') {
588
3940
  const body = (await readBody(request)) ?? {}
589
- const verdict = await withService(services, 'dirbrowse', (d) => d.validateAsBotCwd({ path: body.path }))
590
- sendJson(response, 200, { ok: true, verdict })
3941
+ const out = await withService(services, 'cycle', (c) =>
3942
+ c.review({ ...body, by: body.by ?? OWNER }))
3943
+ sendJson(response, 200, { ok: true, ...out })
3944
+ return
3945
+ }
3946
+ if (request.method === 'GET' && path === '/cycles/stats') {
3947
+ const stats = await withService(services, 'cycle', (c) => c.stats())
3948
+ sendJson(response, 200, { ok: true, stats })
591
3949
  return
592
3950
  }
593
3951
 
594
- // ── 技能库 / 工作流库 ─────────────────────────────────────────────────
595
- if (request.method === 'GET' && path === '/library/skills') {
596
- const rows = await withService(services, 'library', (l) => l.listSkills({ tag: qp(url, 'tag') }))
597
- sendJson(response, 200, { ok: true, skills: rows })
3952
+ // ── 大/小团队(Lead 聚合管理)──────────────────────────────────────────
3953
+ if (request.method === 'GET' && path === '/teams') {
3954
+ const items = await withService(services, 'teams', (t) => t.list({
3955
+ type: qp(url, 'type'), status: qp(url, 'status'),
3956
+ }))
3957
+ sendJson(response, 200, {
3958
+ ok: true, items,
3959
+ types: TEAM_TYPES, roles: TEAM_ROLES, policies: TEAM_ADMIN_POLICIES,
3960
+ })
598
3961
  return
599
3962
  }
600
- if (request.method === 'POST' && path === '/library/skills') {
3963
+ if (request.method === 'POST' && path === '/teams') {
601
3964
  const body = (await readBody(request)) ?? {}
602
- const skill = await withService(services, 'library', (l) => l.createSkill({ ...body, by: body.by ?? 'ui' }))
603
- sendJson(response, 200, { ok: true, skill })
3965
+ const out = await withService(services, 'teams', (t) =>
3966
+ t.create({ ...body, by: body.by ?? OWNER })).catch(markTeamError)
3967
+ sendJson(response, 200, { ok: true, team: out })
604
3968
  return
605
3969
  }
606
- if (request.method === 'POST' && path === '/library/skills/update') {
3970
+ if (request.method === 'POST' && path === '/teams/add') {
607
3971
  const body = (await readBody(request)) ?? {}
608
- const skill = await withService(services, 'library', (l) => l.updateSkill({ ...body, by: body.by ?? 'ui' }))
609
- sendJson(response, 200, { ok: true, skill })
3972
+ const out = await withService(services, 'teams', (t) =>
3973
+ t.addMember({ ...body, by: body.by ?? OWNER })).catch(markTeamError)
3974
+ sendJson(response, 200, { ok: true, team: out })
610
3975
  return
611
3976
  }
612
- if (request.method === 'POST' && path === '/library/skills/remove') {
3977
+ if (request.method === 'POST' && path === '/teams/remove') {
613
3978
  const body = (await readBody(request)) ?? {}
614
- const result = await withService(services, 'library', (l) => l.removeSkill({ id: body.id, by: body.by ?? 'ui' }))
615
- sendJson(response, 200, { ok: true, result })
3979
+ const out = await withService(services, 'teams', (t) =>
3980
+ t.removeMember({ ...body, by: body.by ?? OWNER })).catch(markTeamError)
3981
+ sendJson(response, 200, { ok: true, team: out })
616
3982
  return
617
3983
  }
618
- if (request.method === 'GET' && path === '/library/workflows') {
619
- const rows = await withService(services, 'library', (l) => l.listWorkflows({ tag: qp(url, 'tag') }))
620
- sendJson(response, 200, { ok: true, workflows: rows })
3984
+ if (request.method === 'POST' && path === '/teams/role') {
3985
+ const body = (await readBody(request)) ?? {}
3986
+ const out = await withService(services, 'teams', (t) =>
3987
+ t.setRole({ ...body, by: body.by ?? OWNER })).catch(markTeamError)
3988
+ sendJson(response, 200, { ok: true, team: out })
621
3989
  return
622
3990
  }
623
- if (request.method === 'POST' && path === '/library/workflows') {
3991
+ if (request.method === 'POST' && path === '/teams/note') {
624
3992
  const body = (await readBody(request)) ?? {}
625
- const workflow = await withService(services, 'library', (l) => l.createWorkflow({ ...body, by: body.by ?? 'ui' }))
626
- sendJson(response, 200, { ok: true, workflow })
3993
+ const out = await withService(services, 'teams', (t) =>
3994
+ t.note({ ...body, by: body.by ?? OWNER })).catch(markTeamError)
3995
+ sendJson(response, 200, { ok: true, team: out })
627
3996
  return
628
3997
  }
629
- if (request.method === 'POST' && path === '/library/workflows/update') {
3998
+ if (request.method === 'POST' && path === '/teams/close') {
630
3999
  const body = (await readBody(request)) ?? {}
631
- const workflow = await withService(services, 'library', (l) => l.updateWorkflow({ ...body, by: body.by ?? 'ui' }))
632
- sendJson(response, 200, { ok: true, workflow })
4000
+ const out = await withService(services, 'teams', (t) =>
4001
+ t.close({ ...body, by: body.by ?? OWNER })).catch(markTeamError)
4002
+ sendJson(response, 200, { ok: true, team: out })
633
4003
  return
634
4004
  }
635
- if (request.method === 'POST' && path === '/library/workflows/remove') {
4005
+ // 管理策略(2026-11-09「管理员权限边界从页面控制」):CAS 改团队的 adminPolicy。
4006
+ if (request.method === 'POST' && path === '/teams/policy') {
636
4007
  const body = (await readBody(request)) ?? {}
637
- const result = await withService(services, 'library', (l) => l.removeWorkflow({ id: body.id, by: body.by ?? 'ui' }))
638
- sendJson(response, 200, { ok: true, result })
4008
+ if (!body.id) {
4009
+ sendJson(response, 400, { ok: false, reason: 'id 必填' })
4010
+ return
4011
+ }
4012
+ if (!TEAM_ADMIN_POLICIES.includes(body.adminPolicy)) {
4013
+ sendJson(response, 400, {
4014
+ ok: false,
4015
+ code: 'TEAM_BAD_POLICY',
4016
+ reason: `adminPolicy 非法(合法:${TEAM_ADMIN_POLICIES.join(' | ')})`,
4017
+ })
4018
+ return
4019
+ }
4020
+ const out = await withService(services, 'teams', (t) =>
4021
+ t.setPolicy({
4022
+ id: body.id,
4023
+ expectedRev: body.expectedRev,
4024
+ adminPolicy: body.adminPolicy,
4025
+ by: body.by ?? OWNER,
4026
+ })).catch(markTeamError)
4027
+ sendJson(response, 200, { ok: true, team: out, adminPolicy: out.adminPolicy })
639
4028
  return
640
4029
  }
641
4030
 
642
- // ── 设置 ──────────────────────────────────────────────────────────────
643
- if (request.method === 'GET' && path === '/settings') {
644
- const { SETTING_SPECS } = await import('./settings.js')
4031
+ // ── CS-BOTS 工作区(契约 bots06 §2.4):只读 ───────────────────────────
4032
+ if (request.method === 'GET' && path === '/workspace') {
4033
+ const root = typeof services.settings?.botWorkspaceRoot === 'string'
4034
+ ? services.settings.botWorkspaceRoot
4035
+ : null
4036
+ let exists = false
4037
+ if (root) {
4038
+ try {
4039
+ exists = existsSync(root) && statSync(root).isDirectory()
4040
+ } catch {
4041
+ exists = false
4042
+ }
4043
+ }
4044
+ const dshHome = resolve(process.env.DSH_HOME || `${process.env.HOME ?? '.'}/.dsh`)
645
4045
  sendJson(response, 200, {
646
4046
  ok: true,
647
- values: services.settings ?? {},
648
- specs: SETTING_SPECS,
649
- diagnostics: services.settingsDiagnostics,
650
- baseDir: services.board?.paths?.stateDir ?? services.roster?.paths?.stateDir,
4047
+ root,
4048
+ exists,
4049
+ dshHome,
4050
+ note: 'CS-BOTS 所有会话的默认工作区:shared/ 放交付物,scratch/ 放草稿,archive/ 放归档',
651
4051
  })
652
4052
  return
653
4053
  }
654
4054
 
655
- if (request.method === 'POST' && path === '/settings') {
4055
+ // ── 团队 L2 记忆(契约 bots06 §2.1)────────────────────────────────
4056
+ // 读:只从 memory 服务取 L2 + tag=team:<id>,按 at 升序;不写任何文件。
4057
+ if (request.method === 'GET' && path === '/teams/memory') {
4058
+ const id = qp(url, 'id')
4059
+ if (!id) {
4060
+ sendJson(response, 400, { ok: false, reason: 'id 必填' })
4061
+ return
4062
+ }
4063
+ const team = await withService(services, 'teams', (t) => t.get(id))
4064
+ if (!team) {
4065
+ sendJson(response, 404, { ok: false, reason: `团队 ${id} 不存在` })
4066
+ return
4067
+ }
4068
+ const memories = readTeamMemories(services, id)
4069
+ sendJson(response, 200, {
4070
+ ok: true,
4071
+ teamId: id,
4072
+ memories,
4073
+ text: buildTeamMemoryText(memories),
4074
+ note: '团队记忆属 L2 团队层,全队可见;任务进度另记在 L4 任务笔记',
4075
+ })
4076
+ return
4077
+ }
4078
+
4079
+ // 写:append-only;权限只认队长(leadId)或管理员('user')。
4080
+ if (request.method === 'POST' && path === '/teams/memory') {
656
4081
  const body = (await readBody(request)) ?? {}
657
- const { saveSettings } = await import('./settings.js')
658
- const dir = services.board?.paths?.stateDir ?? services.roster?.paths?.stateDir
659
- const saved = saveSettings(dir, body)
660
- if (!saved.ok) {
661
- sendJson(response, 400, { ok: false, reason: saved.reason })
4082
+ if (!body.id) {
4083
+ sendJson(response, 400, { ok: false, reason: 'id 必填' })
662
4084
  return
663
4085
  }
4086
+ const text = typeof body.text === 'string' ? body.text.trim() : ''
4087
+ if (!text) {
4088
+ sendJson(response, 400, { ok: false, reason: '团队记忆不能为空' })
4089
+ return
4090
+ }
4091
+ if (text.length > ANCHOR_TEXT_MAX) {
4092
+ sendJson(response, 400, { ok: false, reason: `团队记忆过长(最多 ${ANCHOR_TEXT_MAX} 字)` })
4093
+ return
4094
+ }
4095
+ const team = await withService(services, 'teams', (t) => t.get(body.id))
4096
+ if (!team) {
4097
+ sendJson(response, 404, { ok: false, reason: `团队 ${body.id} 不存在` })
4098
+ return
4099
+ }
4100
+ const by = typeof body.by === 'string' && body.by.trim() ? body.by.trim() : ''
4101
+ if (by !== team.leadId && by !== OWNER) {
4102
+ sendJson(response, 403, { ok: false, reason: '只有队长或管理员能写团队记忆' })
4103
+ return
4104
+ }
4105
+ // 落盘走 memory 服务(L2 + tag=team:<id>);memory 缺失时 503。
4106
+ const entry = await withService(services, 'memory', (m) =>
4107
+ m.add({ layer: 'L2', content: text, by, tags: [teamMemoryTag(body.id)] }))
4108
+ const saved = Array.isArray(entry) ? entry[0] : entry
664
4109
  sendJson(response, 200, {
665
4110
  ok: true,
666
- values: saved.values,
667
- note: '已写入 settings.json;内核级参数需重启 DSH 生效',
4111
+ memory: {
4112
+ id: saved?.id,
4113
+ content: saved?.content ?? text,
4114
+ by: saved?.createdBy ?? by,
4115
+ at: saved?.createdAt ?? Date.now(),
4116
+ },
4117
+ note: '已写入团队记忆(L2,全队可见)',
668
4118
  })
669
4119
  return
670
4120
  }
671
4121
 
672
- // ── 手动唤醒 ──────────────────────────────────────────────────────────
673
- if (request.method === 'POST' && path === '/wakes/notify') {
4122
+ // ── 需求2:任务对话内容查看(2026-11-08)──────────────────────────────
4123
+ // 用户反馈:「任务栏页面指派 bot 后,无法追踪任务进度,无法看到产出物,
4124
+ // 没有跳转对话也没有对话内容查看」
4125
+ // ⇒ 在任务详情里直接读执行者的会话历史,不必跳走。
4126
+ if (request.method === 'GET' && path === '/tasks/conversation') {
4127
+ const id = qp(url, 'id')
4128
+ if (!id) {
4129
+ sendJson(response, 400, { ok: false, reason: 'id 必填' })
4130
+ return
4131
+ }
4132
+ const task = await withService(services, 'board', (b) => b.get(id))
4133
+ if (!task) {
4134
+ sendJson(response, 404, { ok: false, reason: `任务 ${id} 不存在` })
4135
+ return
4136
+ }
4137
+ // 找执行者会话:assignee → ownerId → 平台伪身份排除
4138
+ const candidates = [task.assignee, task.ownerId, task.anchor?.ownerId]
4139
+ .filter((v) => typeof v === 'string' && v && !['ui', 'user', 'anyone', 'system'].includes(v))
4140
+ if (candidates.length === 0) {
4141
+ sendJson(response, 200, {
4142
+ ok: false,
4143
+ reason: '这个任务还没有执行者(未派活),没有对话可看',
4144
+ code: 'NO_EXECUTOR',
4145
+ })
4146
+ return
4147
+ }
4148
+ // 把候选 sessionId 映射到 Bot 记录(拿真名)
4149
+ let bot
4150
+ try {
4151
+ const all = services.roster?.list?.({ includeRetired: true }) ?? []
4152
+ bot = all.find((b) => candidates.includes(b.sessionId) || candidates.includes(b.id))
4153
+ } catch { bot = undefined }
4154
+ const sessionId = bot?.sessionId ?? candidates[0]
4155
+ const limit = qn(url, 'limit')
4156
+ const out = await readSessionMessages(ctx, sessionId, { limit: limit ?? 40 })
4157
+ sendJson(response, 200, {
4158
+ ...(bot ? { bot: { id: bot.id, name: bot.name, label: bot.label ?? null } } : {}),
4159
+ sessionId,
4160
+ ...out,
4161
+ })
4162
+ return
4163
+ }
4164
+
4165
+ // ── 改造07:任务订阅(借鉴 Super-agent Subscription 扇出)─────────────────
4166
+ // GET /subscriptions —— 列出订阅(按 taskId/roomId/state 过滤)
4167
+ if (request.method === 'GET' && path === '/subscriptions') {
4168
+ const subs = await withService(services, 'subscription', (s) => s.list({
4169
+ taskId: qp(url, 'taskId'),
4170
+ roomId: qp(url, 'roomId'),
4171
+ state: qp(url, 'state'),
4172
+ }))
4173
+ sendJson(response, 200, { ok: true, subs, stats: services.subscription?.stats?.() })
4174
+ return
4175
+ }
4176
+
4177
+ // POST /subscriptions —— 订阅(任务接收房间后续消息)
4178
+ if (request.method === 'POST' && path === '/subscriptions') {
674
4179
  const body = (await readBody(request)) ?? {}
675
- const result = await withService(services, 'dispatcher', (d) => d.notify(body))
676
- sendJson(response, 200, { ok: true, result })
4180
+ if (!body.taskId || !body.roomId) {
4181
+ sendJson(response, 400, { ok: false, reason: 'taskId 与 roomId 必填' })
4182
+ return
4183
+ }
4184
+ const sub = await withService(services, 'subscription', (s) =>
4185
+ s.subscribe({ ...body, by: body.by ?? OWNER }))
4186
+ sendJson(response, 200, { ok: true, sub })
677
4187
  return
678
4188
  }
679
4189
 
680
- // ── 唤醒审计 ──────────────────────────────────────────────────────────
681
- if (request.method === 'GET' && path === '/wakes') {
682
- const wakes = await withService(services, 'dispatcher', (d) =>
683
- d.list({ target: qp(url, 'target'), limit: qn(url, 'limit') ?? 100 }),
684
- )
685
- sendJson(response, 200, { ok: true, wakes })
4190
+ // POST /subscriptions/unsubscribe —— 取消订阅
4191
+ if (request.method === 'POST' && path === '/subscriptions/unsubscribe') {
4192
+ const body = (await readBody(request)) ?? {}
4193
+ if (!body.id) {
4194
+ sendJson(response, 400, { ok: false, reason: 'id 必填' })
4195
+ return
4196
+ }
4197
+ const sub = await withService(services, 'subscription', (s) =>
4198
+ s.unsubscribe({ ...body, by: body.by ?? OWNER }))
4199
+ sendJson(response, 200, { ok: true, sub })
4200
+ return
4201
+ }
4202
+
4203
+ // ── 改造07:穿透面板(下层 AI 基础设施查看)─────────────────────────────
4204
+ // GET /deepdive/blocks —— 列出 7 个下层区块 + 概览统计
4205
+ if (request.method === 'GET' && path === '/deepdive/blocks') {
4206
+ const blocks = []
4207
+ // 注册表:Bot 数量 + 各状态计数
4208
+ await withService(services, 'roster', (r) => {
4209
+ const rows = typeof r.list === 'function' ? r.list({ includeRetired: true }) : []
4210
+ const byState = {}
4211
+ for (const row of rows) {
4212
+ const st = row?.state ?? row?.status ?? 'unknown'
4213
+ byState[st] = (byState[st] ?? 0) + 1
4214
+ }
4215
+ blocks.push({
4216
+ id: 'registry', label: '注册表', description: 'Bot 注册表 / 任务板 / 团队注册',
4217
+ stats: { bots: rows.length, states: byState },
4218
+ })
4219
+ })
4220
+ // 唤醒桥:Dispatcher 统计
4221
+ const dStats = services.dispatcher?.stats?.()
4222
+ if (dStats) {
4223
+ blocks.push({
4224
+ id: 'wake', label: '唤醒桥', description: 'Dispatcher 队列 / 去重 / 背压',
4225
+ stats: { total: dStats.total, sent: dStats.sent, skipped: dStats.skipped, failed: dStats.failed, pending: dStats.pending },
4226
+ })
4227
+ }
4228
+ // 存储:各 jsonl 健康
4229
+ const storeFiles = []
4230
+ for (const f of ['board.jsonl', 'roster.jsonl', 'wakes.jsonl', 'memory.jsonl', 'cycle.jsonl', 'todo.jsonl', 'rooms']) {
4231
+ try {
4232
+ const p = statePath(services.board?.paths?.stateDir ?? '', f)
4233
+ const st = existsSync(p) ? statSync(p) : null
4234
+ storeFiles.push({ name: f, exists: Boolean(st), size: st ? st.size : 0, mtime: st ? st.mtimeMs : null })
4235
+ } catch {}
4236
+ }
4237
+ blocks.push({
4238
+ id: 'store', label: '存储', description: 'jsonl 事件流 / 快照 / 原子写',
4239
+ stats: { files: storeFiles.filter((f) => f.exists).length, total: storeFiles.length },
4240
+ })
4241
+ // 路由:复杂度路由规则(静态展示)
4242
+ blocks.push({
4243
+ id: 'routing', label: '路由', description: '复杂度路由 T0-T3 / 免费付费策略',
4244
+ stats: { tiers: ['T0', 'T1', 'T2', 'T3'], default: 'free_only (fail-closed)' },
4245
+ })
4246
+ // 验证:验收统计
4247
+ const vStats = services.verify?.stats?.()
4248
+ blocks.push({
4249
+ id: 'verify', label: '验证', description: '验收证据 / 退出码 / 测试结果',
4250
+ stats: vStats ?? { note: 'verify 服务未装配' },
4251
+ })
4252
+ // 建议:建议队列
4253
+ const sugStats = services.suggest?.stats?.()
4254
+ blocks.push({
4255
+ id: 'suggest', label: '建议', description: '建议确认队列 / 纪律提议',
4256
+ stats: sugStats ?? { note: 'suggest 服务未装配' },
4257
+ })
4258
+ // 订阅:任务 ↔ 房间订阅关系(服务不可用时计数为 0,绝不抛)
4259
+ const subStats = services.subscription?.stats?.()
4260
+ blocks.push({
4261
+ id: 'subscription', label: '订阅', description: '任务订阅、房间扇出',
4262
+ stats: {
4263
+ total: subStats?.total ?? 0,
4264
+ active: subStats?.active ?? 0,
4265
+ dropped: subStats?.dropped ?? 0,
4266
+ fanouts: subStats?.fanouts ?? 0,
4267
+ },
4268
+ })
4269
+ sendJson(response, 200, { ok: true, blocks })
4270
+ return
4271
+ }
4272
+
4273
+ // GET /deepdive/block/:id —— 某区块详情 + 可执行操作
4274
+ if (request.method === 'GET' && path.startsWith('/deepdive/block/')) {
4275
+ const id = decodeURIComponent(path.slice('/deepdive/block/'.length))
4276
+ const data = {}
4277
+ const operations = []
4278
+ try {
4279
+ if (id === 'registry') {
4280
+ const rows = await withService(services, 'roster', (r) => r.list({ includeRetired: true }))
4281
+ data.bots = rows
4282
+ operations.push({ id: 'revive', label: '重启全部 Bot 会话', body: {} })
4283
+ } else if (id === 'wake') {
4284
+ data.stats = services.dispatcher?.stats?.()
4285
+ data.recent = services.dispatcher?.list?.({ limit: 20 }) ?? []
4286
+ operations.push({ id: 'flush', label: '强制投递队列', body: {} })
4287
+ } else if (id === 'store') {
4288
+ const dir = services.board?.paths?.stateDir ?? ''
4289
+ const files = []
4290
+ try {
4291
+ for (const entry of readdirSync(dir)) {
4292
+ const p = join(dir, entry)
4293
+ const st = statSync(p)
4294
+ files.push({ name: entry, size: st.size, mtime: st.mtimeMs })
4295
+ }
4296
+ } catch {}
4297
+ data.files = files.slice(0, 50)
4298
+ data.stateDir = dir
4299
+ operations.push({ id: 'replay', label: '重建内存态(replay)', body: {} })
4300
+ } else if (id === 'routing') {
4301
+ data.tiers = ['T0', 'T1', 'T2', 'T3']
4302
+ data.description = 'T0 一句话问答 / T1 常规改动 / T2 跨文件或多步 / T3 架构级。缺省 = free_only(只走免费渠道,fail-closed)。'
4303
+ } else if (id === 'verify') {
4304
+ data.stats = services.verify?.stats?.()
4305
+ data.records = services.verify?.list?.({ limit: 20 }) ?? []
4306
+ operations.push({ id: 'recheck', label: '手动重跑验收', body: {} })
4307
+ } else if (id === 'suggest') {
4308
+ data.stats = services.suggest?.stats?.()
4309
+ data.pending = services.suggest?.list?.({ status: 'pending', limit: 20 }) ?? []
4310
+ } else if (id === 'subscription') {
4311
+ // 订阅:任务 ↔ 房间订阅关系。
4312
+ // ⚠️ 服务未装配时**不能抛** —— 穿透面板是「管理/查看」,不是硬依赖。
4313
+ if (!services.subscription) {
4314
+ sendJson(response, 200, {
4315
+ ok: true, block: id,
4316
+ available: false, reason: '订阅服务未装配', data: {}, operations: [],
4317
+ })
4318
+ return
4319
+ }
4320
+ data.stats = services.subscription.stats()
4321
+ data.subscriptions = services.subscription.list({}).slice(0, 100)
4322
+ operations.push({ id: 'unsubscribe', label: '取消某订阅', body: { id: '' } })
4323
+ } else {
4324
+ sendJson(response, 404, { ok: false, reason: `未知区块: ${id}` })
4325
+ return
4326
+ }
4327
+ } catch (error) {
4328
+ sendJson(response, 500, { ok: false, reason: `区块详情读取失败:${error?.message ?? error}` })
4329
+ return
4330
+ }
4331
+ sendJson(response, 200, { ok: true, block: id, data, operations })
4332
+ return
4333
+ }
4334
+
4335
+ // POST /deepdive/op —— 区块操作(手动唤醒/flush/replay 等)
4336
+ if (request.method === 'POST' && path === '/deepdive/op') {
4337
+ const body = (await readBody(request)) ?? {}
4338
+ const { block, op } = body
4339
+ try {
4340
+ if (block === 'wake' && op === 'flush') {
4341
+ const result = services.dispatcher?.flush?.({ force: true })
4342
+ sendJson(response, 200, { ok: true, text: `已强制投递:sent=${result?.sent ?? 0}, failed=${result?.failed ?? 0}`, result })
4343
+ } else if (block === 'store' && op === 'replay') {
4344
+ const result = services.board?.replay?.()
4345
+ sendJson(response, 200, { ok: true, text: `replay 完成:${JSON.stringify(result ?? {})}`, result })
4346
+ } else if (block === 'registry' && op === 'revive') {
4347
+ sendJson(response, 200, { ok: true, text: 'Bot 会话重启需要内核支持,请通过页面操作' })
4348
+ } else if (block === 'verify' && op === 'recheck') {
4349
+ sendJson(response, 200, { ok: true, text: '验收重跑需要在任务详情中操作' })
4350
+ } else if (block === 'subscription' && op === 'unsubscribe') {
4351
+ const result = services.subscription?.unsubscribe(body.id)
4352
+ if (!result?.ok) {
4353
+ sendJson(response, 400, { ok: false, reason: result?.reason ?? '取消订阅失败' })
4354
+ return
4355
+ }
4356
+ sendJson(response, 200, { ok: true, text: `已取消订阅 ${body.id}`, result })
4357
+ } else {
4358
+ sendJson(response, 400, { ok: false, reason: `不支持的操作:${block}/${op}` })
4359
+ }
4360
+ } catch (error) {
4361
+ sendJson(response, 400, { ok: false, reason: `操作失败:${error?.message ?? error}` })
4362
+ }
686
4363
  return
687
4364
  }
688
4365