@marsken/dsh-studio 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/AGENTS.md +148 -0
  2. package/LICENSE +202 -0
  3. package/README.md +1367 -3
  4. package/client.js +952 -0
  5. package/client.src.js +2466 -0
  6. package/cordis.patch.yml +9 -0
  7. package/dist/client.js +952 -0
  8. package/docs/compatibility.md +101 -0
  9. package/docs/images/board.png +0 -0
  10. package/docs/images/cases.png +0 -0
  11. package/docs/images/quality.png +0 -0
  12. package/docs/publish.md +123 -0
  13. package/docs/quickstart.md +53 -0
  14. package/icon.svg +6 -0
  15. package/index.js +2674 -0
  16. package/locale/en.json +11 -0
  17. package/locale/zh.json +11 -0
  18. package/package.json +64 -4
  19. package/scripts/smoke-store.mjs +76 -0
  20. package/src/host/cases.js +135 -0
  21. package/src/host/design.js +87 -0
  22. package/src/host/dispatcher.js +936 -0
  23. package/src/host/domain.js +1966 -0
  24. package/src/host/executor.js +1396 -0
  25. package/src/host/gitlab-facts.js +22 -0
  26. package/src/host/impact.js +32 -0
  27. package/src/host/integrations.js +587 -0
  28. package/src/host/quality.js +106 -0
  29. package/src/host/routes/admin.js +100 -0
  30. package/src/host/routes/agents.js +71 -0
  31. package/src/host/routes/auth.js +155 -0
  32. package/src/host/routes/coding.js +203 -0
  33. package/src/host/routes/connect.js +166 -0
  34. package/src/host/routes/exports.js +122 -0
  35. package/src/host/routes/insights.js +97 -0
  36. package/src/host/routes/releases.js +335 -0
  37. package/src/host/routes/requirements.js +430 -0
  38. package/src/host/routes/skills.js +75 -0
  39. package/src/host/routes/tasks.js +573 -0
  40. package/src/host/store.js +522 -0
  41. package/src/host/team-memory.js +46 -0
  42. package/src/host/tools.js +1211 -0
@@ -0,0 +1,1966 @@
1
+ /**
2
+ * dsh-studio — domain layer.
3
+ *
4
+ * Board columns, card / requirement / release shapes, their sanitizers, the
5
+ * default settings, and the code-number generators. Pure data and validation:
6
+ * no filesystem, no host services, no HTTP. Everything here is unit-testable.
7
+ *
8
+ * @module dsh-studio/src/host/domain
9
+ */
10
+ import { createHash, randomBytes } from 'node:crypto'
11
+ import { homedir } from 'node:os'
12
+ import { dirname, join } from 'node:path'
13
+
14
+ /** Board columns, in render order. */
15
+ export const STATUSES = [
16
+ { id: 'clarify', label: '待澄清', role: 'product' },
17
+ { id: 'design', label: '待方案', role: 'architect' },
18
+ { id: 'dev_ready', label: '待开发', role: 'dev' },
19
+ { id: 'dev', label: '开发中', role: 'dev' },
20
+ { id: 'review', label: '待评审', role: 'architect' },
21
+ { id: 'test', label: '待测试', role: 'qa' },
22
+ { id: 'accept', label: '待验收', role: 'product' },
23
+ { id: 'done', label: '已完成', role: 'product' },
24
+ ]
25
+
26
+ /** 阻塞不是车道,是卡上的一个标记:卡留在原列,等人决定怎么继续。 */
27
+ export const LEGACY_BLOCKED_STATUS = 'blocked'
28
+
29
+ export const STATUS_IDS = STATUSES.map(s => s.id)
30
+ /** 解析时接受历史数据里的 blocked,但它永远不会被当成一列渲染。 */
31
+ export const STATUS_SET = new Set([...STATUS_IDS, LEGACY_BLOCKED_STATUS])
32
+ export const ROLE_IDS = ['product', 'architect', 'dev', 'qa']
33
+ export const PRIORITIES = ['urgent', 'normal', 'relaxed']
34
+ /** 卡片类型:需求与缺陷走同一条流水线,但派单提示词和默认落列不同。 */
35
+ export const TASK_TYPES = ['requirement', 'bug', 'design', 'dev', 'test', 'ops']
36
+ /** 缺陷严重级别,P0 最高。 */
37
+ export const SEVERITIES = ['p0', 'p1', 'p2', 'p3']
38
+
39
+ /** 交付物的种类:一张卡干完,得留下能给人看的东西。 */
40
+ export const ARTIFACT_KINDS = ['requirement', 'design', 'mr', 'cases', 'report', 'release', 'doc', 'screenshot', 'other']
41
+ /** 交付物状态:draft 草稿 / delivered 已交付 / accepted 已验收。 */
42
+ export const ARTIFACT_STATUS = ['draft', 'delivered', 'accepted']
43
+
44
+ /**
45
+ * 沙箱档位来自 DSH 的 permission preset(read-only / workspace-write /
46
+ * danger-full-access),是操作系统级边界:卡会话能写哪、能跑什么。
47
+ */
48
+ export const SANDBOX_MODES = ['read-only', 'workspace-write', 'danger-full-access']
49
+ /** 隔离档位:一卡一会话,是否再给一张卡一个独立的 git worktree。 */
50
+ export const ISOLATION_MODES = ['none', 'worktree']
51
+
52
+ /**
53
+ * 成员角色:管理员能改配置与删除,成员能推进卡,只读只能看。
54
+ * 没启用成员权限时所有人按管理员算(向后兼容单机用法)。
55
+ */
56
+ export const ACCESS_ROLES = ['admin', 'member', 'viewer']
57
+ export const ACCESS_ROLE_LABEL = { admin: '管理员', member: '成员', viewer: '只读' }
58
+ export const ROLE_LEVEL = { viewer: 0, member: 1, admin: 2 }
59
+ export const LEVEL_RANK = { read: 0, write: 1, admin: 2 }
60
+
61
+ /** 只有管理员能碰的动作:改全局配置、删东西、发布/回滚、装插件。 */
62
+ export const ADMIN_ROUTES = new Set([
63
+ '/config', '/access/save', '/access/delete',
64
+ '/task/delete', '/experts/delete', '/teams/delete', '/skills/delete',
65
+ '/projects/delete', '/repos/delete', '/bots/delete', '/requirement/delete',
66
+ '/release/delete', '/release/approve', '/release/publish', '/release/rollback',
67
+ '/market/install', '/market/uninstall', '/audit/clear', '/snapshot/restore',
68
+ ])
69
+
70
+ /**
71
+ * 需求(REQ)是整个交付链路的锚点:卡、产物、用例、缺陷、MR、发布都挂在它下面。
72
+ * 需求一旦「active」,下游每小时都在拿它当唯一真相,所以它自己也要有状态机。
73
+ */
74
+ export const REQ_STATUSES = ['draft', 'review', 'active', 'delivered', 'closed']
75
+ export const REQ_STATUS_LABEL = { draft: '草稿', review: '待评审', active: '进行中', delivered: '已交付', closed: '已关闭' }
76
+ /** 需求评审结论:提交评审 → 通过 / 打回。 */
77
+ export const REQ_REVIEW_STATUS = ['pending', 'approved', 'changes_requested']
78
+ export const REQ_REVIEW_LABEL = { pending: '待评审', approved: '已通过', changes_requested: '已打回' }
79
+ /** 卡片编号前缀:同一需求下 DEV-001、TEST-001、BUG-001 各自递增。 */
80
+ export const CODE_PREFIX = { requirement: 'REQ', design: 'DES', dev: 'DEV', test: 'TEST', bug: 'BUG', ops: 'OPS' }
81
+
82
+ /** 每种卡片默认要交什么,建卡时铺成验收清单(可在设置里改)。 */
83
+ export function defaultTemplates() {
84
+ return {
85
+ requirement: ['需求说明写清楚背景、目标与边界', '验收标准可逐条验证', '影响范围与数据口径说明'],
86
+ design: ['技术方案(含接口与数据改动)', '改动边界与回滚方案', '风险与依赖列清楚'],
87
+ dev: ['代码已提交并提 MR', '自测通过(附命令与结果)', '必要文档与注释已更新'],
88
+ review: ['MR 已关联到这张卡', '改动说明与影响范围写清楚', '评审意见已处理或明确记录'],
89
+ test: ['测试用例覆盖主流程与边界', '执行记录(环境、版本、结果)', '缺陷已登记、复现步骤齐全'],
90
+ bug: ['复现步骤与影响面', '根因定位结论', '修复验证与回归点'],
91
+ ops: ['变更步骤与回滚预案', '执行记录', '监控与告警确认'],
92
+ }
93
+ }
94
+
95
+ // 卡上 agent 的兜底模型:宿主默认型号可能没有可用通道(deepseek-v4-pro 会 503),
96
+ // 所以不配模型时一律落到这个可用的默认值。
97
+ export const FALLBACK_AGENT_MODEL = 'deepseek-v4.1-flash'
98
+
99
+ /** The four shipped personas; also the seed for a fresh expert roster. */
100
+ export const DEFAULT_ROLES = {
101
+ product: {
102
+ label: '产品',
103
+ name: '产品经理',
104
+ emoji: '📋',
105
+ model: 'deepseek-v4.1-flash',
106
+ brief: '把模糊需求变成清晰的需求说明、边界与验收标准。',
107
+ prompt: [
108
+ '你是产品经理,负责把一句话的诉求变成能开工的需求。',
109
+ '动手前先读:需求页上的 PRD/原型文档、同一需求下已有的卡与评论、相关领域的历史卡。信息不足就明确列出「需要业务确认的问题」,不要自己编业务规则。',
110
+ '产出必须包含:目标用户与场景、主流程、异常与边界、业务规则(含参数与阈值)、验收口径(可判定的条件)。',
111
+ '需求要拆成功能点(每个功能点带验收条件),一条功能点对应一张或几张可交付的卡。',
112
+ '不确定优先级时按「影响客户/影响金额/上线时间」排序并写明理由。',
113
+ '收口前自检:卡上的验收清单是否可验证、是否留了交付物(文档正文)、是否需要找架构师确认技术可行性(要就 @架构师)。',
114
+ ].join('\n'),
115
+ },
116
+ architect: {
117
+ label: '架构',
118
+ name: '架构师',
119
+ emoji: '🏛️',
120
+ model: 'deepseek-v4.1-flash',
121
+ brief: '出技术方案、拆任务、定接口与改动边界。',
122
+ prompt: [
123
+ '你是架构师,负责技术方案、接口契约与改动边界。',
124
+ '先读现有代码与配置再下判断:用 ls/grep 找到相关模块、看已有约定,不要凭空设计。',
125
+ '方案必须给出:数据模型与迁移方式、接口契约(路径/入参/出参/错误码)、失败与回滚、并发与幂等、性能与容量估算、兼容性与灰度策略。',
126
+ '把方案拆成研发可以直接开工的步骤,每步写清改哪些文件、怎么自验。',
127
+ '明确列出「不做/遗留」的边界,以及需要业务或研发确认的风险点。',
128
+ '评审别人的 MR 时按「正确性→边界与异常→可读性与一致性→性能」四层给结论;要求修改必须给出可执行的修改点。',
129
+ ].join('\n'),
130
+ },
131
+ dev: {
132
+ label: '研发',
133
+ name: '开发工程师',
134
+ emoji: '🛠️',
135
+ model: 'deepseek-v4.1-flash',
136
+ brief: '按方案实现代码,自己先跑通,交可验证的改动。',
137
+ prompt: [
138
+ '你是开发工程师,负责把方案落成可验证的代码。',
139
+ '在卡专属的 worktree/目录里工作,只改这张卡需要的文件;动手前先看你将要改的函数和它被谁调用。',
140
+ '改动要小步:先跑通最小路径,再补异常分支;不顺手重构无关代码,不引入未评估的依赖。',
141
+ '自验:能跑测试就跑测试,把命令与结果贴进评论;跑不了就写清为什么没跑、需要谁在什么环境验证。',
142
+ '必须用 studio_task_coding 登记执行记录(Coding Agent、模型、分支、提交、改动文件、单测通过数),再用 studio_task_artifact 登记交付物(带正文)。',
143
+ '推卡前自检验收清单,逐条勾并附证据;做不到的项不要勾,直接说明卡在哪并 studio_task_block 写清原因。',
144
+ ].join('\n'),
145
+ },
146
+ qa: {
147
+ label: '测试',
148
+ name: '测试工程师',
149
+ emoji: '🧪',
150
+ model: 'deepseek-v4.1-flash',
151
+ brief: '设计用例、执行验证、给出可复现的缺陷与结论。',
152
+ prompt: [
153
+ '你是测试工程师,负责验证交付是否真的可用。',
154
+ '用例来源:需求的验收条件、功能点的验收口径、上一次缺陷的复现路径。每个功能点至少覆盖正常流、边界值、异常流;接口类用例写成可执行的请求(method/url/期望状态码/期望响应片段),并用 studio_test_run 真跑。',
155
+ '结果只写实测:通过写实际状态码与关键字段,失败写请求、响应、期望与实际、可复现步骤。',
156
+ '判失败就如实判:平台会自动开缺陷卡并挂回这张卡,不要手工再建一张重复的。',
157
+ '不稳定(环境/数据导致)的用例标 flaky,不要为了绿灯把断言改松;真的缺陷不许标 flaky。',
158
+ '测试结论要给出准出建议:通过哪些、遗留哪些风险、是否建议发布。',
159
+ ].join('\n'),
160
+ },
161
+ }
162
+
163
+ /** Column → who owns the work when a card lands there. */
164
+ export function defaultColumns() {
165
+ return {
166
+ // 待澄清没有默认负责人:需求/缺陷的类型不同,交给谁由人来定(设置里可改)。
167
+ clarify: { kind: 'none', id: '' },
168
+ design: { kind: 'expert', id: 'architect' },
169
+ dev_ready: { kind: 'expert', id: 'dev' },
170
+ dev: { kind: 'expert', id: 'dev' },
171
+ review: { kind: 'expert', id: 'architect' },
172
+ test: { kind: 'expert', id: 'qa' },
173
+ accept: { kind: 'expert', id: 'product' },
174
+ done: { kind: 'expert', id: 'product' },
175
+ }
176
+ }
177
+
178
+ /** The four shipped experts, used until the company edits them. */
179
+ export function seedExperts(roles) {
180
+ return ROLE_IDS.map(role => {
181
+ const seed = DEFAULT_ROLES[role]
182
+ return {
183
+ id: role,
184
+ name: roles?.[role]?.label !== undefined && roles[role].label !== DEFAULT_ROLES[role].label
185
+ ? String(roles[role].label)
186
+ : seed.name,
187
+ emoji: seed.emoji,
188
+ role,
189
+ domain: '',
190
+ skills: [],
191
+ description: seed.brief,
192
+ prompt: String(roles?.[role]?.prompt ?? '') || String(seed.prompt ?? ''),
193
+ model: String(roles?.[role]?.model ?? seed.model),
194
+ preset: '',
195
+ enabled: true,
196
+ }
197
+ })
198
+ }
199
+
200
+ /** Fresh default settings. */
201
+ export function defaultConfig() {
202
+ return {
203
+ schema: 6,
204
+ board: { title: '需求&任务工作空间' },
205
+ experts: seedExperts(DEFAULT_ROLES),
206
+ teams: [],
207
+ columns: defaultColumns(),
208
+ /** 业务项目:一个项目可以盯多个 GitLab 仓库,里面按迭代分组卡片。 */
209
+ projects: [],
210
+ /** 每种卡片的默认验收清单模板。 */
211
+ templates: defaultTemplates(),
212
+ gitlab: { url: '', token: '', group: '', project: '', syncEnabled: false, syncLabel: '', lastSyncAt: 0, lastSyncError: '', projects: [] },
213
+ feishu: { appId: '', appSecret: '', chatId: '', baseToken: '', tableId: '', webhook: '', notifyOnChange: true, bots: [], driveFolder: 'IuJPfTCtWlDrzwdCFmLc71aynbg', autoSyncArtifacts: true, escalateWebhook: '', escalateAfter: 3 },
214
+ /** 设计评审闸门:打开后,卡进 columns 里的列之前设计必须 approved。 */
215
+ design: { enabled: false, columns: ['dev'] },
216
+ /** 成本闸门:只统计「跑了多少次、花了多少机上时间」,宿主没把 token 用量回传给插件。 */
217
+ costs: { budgetRuns: 500, budgetHours: 80, warnAt: 80 },
218
+ roles: {
219
+ product: { label: '产品', model: 'deepseek-v4.1-flash', prompt: '' },
220
+ architect: { label: '架构', model: 'deepseek-v4.1-flash', prompt: '' },
221
+ dev: { label: '研发', model: 'deepseek-v4.1-flash', prompt: '' },
222
+ qa: { label: '测试', model: 'deepseek-v4.1-flash', prompt: '' },
223
+ },
224
+ /** The company catalogue: entries are npm specs or local bundle paths. */
225
+ market: { title: '公司内部市场', items: [] },
226
+ /** Automatic hand-off of a card to its role agent when it enters a column. */
227
+ agents: {
228
+ enabled: false,
229
+ maxConcurrent: 2,
230
+ workspaceDir: '',
231
+ /** Agent preset that gives the card session its real tools (shell, fs). */
232
+ agentPreset: 'standard',
233
+ autoStatuses: ['design', 'dev_ready', 'test'],
234
+ /** 沙箱:卡会话的操作系统级文件/命令边界(DSH 的 permission preset)。 */
235
+ sandbox: 'workspace-write',
236
+ /** 隔离:none = 所有卡共用 workspaceDir;worktree = 每张卡一个 git worktree。 */
237
+ isolation: 'none',
238
+ worktreeRoot: '',
239
+ /** on_done = 卡进「已完成」时回收 worktree;manual = 留着等人看。 */
240
+ cleanupWorktree: 'manual',
241
+ /** AI 失败重试:失败后按 baseMs × factor^n 退避,超过 max 次升级成人处理。 */
242
+ retry: { max: 2, baseMs: 60000, factor: 2 },
243
+ },
244
+ /** 发布准入:需要的话可以要求「上过预发」「需求签过字」才能发布。 */
245
+ releaseRules: { requireStaging: false, requireRequirementSignoff: false },
246
+ /** 测试执行:接口测试用(环境、超时、允许打的域名)。 */
247
+ testing: {
248
+ envs: [],
249
+ defaultEnv: '',
250
+ timeoutMs: 15000,
251
+ allowHosts: ['127.0.0.1', 'localhost'],
252
+ concurrency: 4,
253
+ },
254
+ /** 权限:启用成员名单后,写操作要认人,删除/配置/发布要管理员。 */
255
+ access: { enabled: false, members: [] },
256
+ /** 缺陷 SLA:按严重级别给小时数,超时在卡上看红点。 */
257
+ sla: { p0: 4, p1: 24, p2: 72, p3: 168 },
258
+ /** 部署环境:只用来登记「哪个版本上过哪个环境」,平台不触发部署。 */
259
+ environments: defaultDeployEnvs(),
260
+ /** 统一登录:LDAP。bindPassword 只进密钥文件,落库时清空。 */
261
+ ldap: {
262
+ enabled: false,
263
+ url: 'ldap://localhost:389',
264
+ baseDN: 'DC=LYF,DC=COM',
265
+ bindDN: 'cn=root',
266
+ bindPassword: '',
267
+ userFilter: '(|(sAMAccountName={user})(uid={user})(mail={user}))',
268
+ nameAttr: 'displayName',
269
+ mailAttr: 'mail',
270
+ deptAttr: 'department',
271
+ defaultRole: 'member',
272
+ requireLogin: false,
273
+ timeoutMs: 6000,
274
+ },
275
+ /**
276
+ * 本地超管账号:没配 LDAP 时平台直接以它身份使用(不要求登录),
277
+ * 配了 LDAP 也能用它兜底。password 为 null 表示「还是默认口令 admin」。
278
+ */
279
+ localAuth: { enabled: true, username: 'admin', password: null, mustChange: true, updatedAt: 0 },
280
+ /** 登录过的人(LDAP 身份 + 最近登录情况),用于归属与审计。 */
281
+ users: [],
282
+ }
283
+ }
284
+
285
+ /** LDAP 设置:只收白名单字段,URL 必须是 ldap(s)://,过滤器里的 {user} 由调用方替换。 */
286
+ export function sanitizeLdap(raw) {
287
+ const input = raw !== null && typeof raw === 'object' ? raw : {}
288
+ const url = String(input.url ?? '').trim().slice(0, 200)
289
+ const baseDN = String(input.baseDN ?? '').trim().slice(0, 200)
290
+ const bindDN = String(input.bindDN ?? '').trim().slice(0, 200)
291
+ const filterRaw = String(input.userFilter ?? '').trim().slice(0, 300)
292
+ const userFilter = filterRaw.includes('{user}') ? filterRaw : '(|(sAMAccountName={user})(uid={user})(mail={user}))'
293
+ const timeout = Number(input.timeoutMs)
294
+ return {
295
+ enabled: input.enabled === true,
296
+ url: /^ldaps?:\/\/[^\s]+$/i.test(url) ? url : 'ldap://localhost:389',
297
+ baseDN: baseDN === '' ? 'DC=LYF,DC=COM' : baseDN,
298
+ bindDN: bindDN === '' ? 'cn=root' : bindDN,
299
+ bindPassword: String(input.bindPassword ?? '').trim().slice(0, 200),
300
+ userFilter,
301
+ nameAttr: String(input.nameAttr ?? 'displayName').trim().slice(0, 60) || 'displayName',
302
+ mailAttr: String(input.mailAttr ?? 'mail').trim().slice(0, 60) || 'mail',
303
+ deptAttr: String(input.deptAttr ?? 'department').trim().slice(0, 60) || 'department',
304
+ defaultRole: ACCESS_ROLES.includes(input.defaultRole) ? input.defaultRole : 'member',
305
+ requireLogin: input.requireLogin === true,
306
+ timeoutMs: Number.isFinite(timeout) ? Math.max(1000, Math.min(Math.round(timeout), 30000)) : 6000,
307
+ }
308
+ }
309
+
310
+ /** 本地超管:默认账号 admin、默认口令 admin;口令用 scrypt + 随机盐存,不落明文。 */
311
+ export const LOCAL_DEFAULT_PASSWORD = 'admin'
312
+
313
+ export function sanitizeLocalAuth(raw) {
314
+ const input = raw !== null && typeof raw === 'object' ? raw : {}
315
+ const username = String(input.username ?? 'admin').trim().toLowerCase().slice(0, 40) || 'admin'
316
+ const stored = input.password !== null && typeof input.password === 'object' ? input.password : null
317
+ const password =
318
+ stored !== null && typeof stored.salt === 'string' && typeof stored.hash === 'string'
319
+ ? { salt: String(stored.salt).slice(0, 64), hash: String(stored.hash).slice(0, 256), at: Number(stored.at) || 0 }
320
+ : null
321
+ return {
322
+ enabled: input.enabled !== false,
323
+ username,
324
+ password,
325
+ mustChange: password === null ? true : input.mustChange === true,
326
+ updatedAt: Number(input.updatedAt) || 0,
327
+ }
328
+ }
329
+
330
+ export function hashLocalPassword(password) {
331
+ const salt = randomBytes(16).toString('hex')
332
+ const hash = scryptSync(String(password), salt, 64).toString('hex')
333
+ return { salt, hash, at: Date.now() }
334
+ }
335
+
336
+ /** 校验本地超管:用户名大小写不敏感;没设过口令时比对默认口令。 */
337
+ export function verifyLocalPassword(localAuth, username, password) {
338
+ const local = sanitizeLocalAuth(localAuth)
339
+ if (local.enabled !== true) return { ok: false, reason: 'disabled' }
340
+ if (String(username ?? '').trim().toLowerCase() !== local.username) return { ok: false, reason: 'nouser' }
341
+ const given = String(password ?? '')
342
+ if (local.password === null) {
343
+ return { ok: given === LOCAL_DEFAULT_PASSWORD, reason: given === LOCAL_DEFAULT_PASSWORD ? '' : 'badpass' }
344
+ }
345
+ const hash = scryptSync(given, local.password.salt, 64).toString('hex')
346
+ const stored = Buffer.from(local.password.hash, 'hex')
347
+ const computed = Buffer.from(hash, 'hex')
348
+ const ok = stored.length === computed.length && timingSafeEqual(stored, computed)
349
+ return { ok, reason: ok ? '' : 'badpass' }
350
+ }
351
+
352
+ /** 登录过的人:最多 500 条,id 统一小写,按最近登录排序。 */
353
+ export function sanitizeUsers(raw) {
354
+ const list = Array.isArray(raw) ? raw : []
355
+ const out = []
356
+ const seen = new Set()
357
+ for (const item of list) {
358
+ if (out.length >= 500) break
359
+ if (item === null || typeof item !== 'object') continue
360
+ const id = String(item.id ?? item.name ?? '').trim().toLowerCase().slice(0, 80)
361
+ if (id === '' || seen.has(id)) continue
362
+ seen.add(id)
363
+ out.push({
364
+ id,
365
+ name: String(item.name ?? id).trim().slice(0, 80) || id,
366
+ mail: String(item.mail ?? '').trim().slice(0, 160),
367
+ dept: String(item.dept ?? '').trim().slice(0, 120),
368
+ dn: String(item.dn ?? '').trim().slice(0, 400),
369
+ role: ACCESS_ROLES.includes(item.role) ? item.role : 'member',
370
+ source: item.source === 'ldap' ? 'ldap' : 'local',
371
+ firstLoginAt: Number(item.firstLoginAt) || 0,
372
+ lastLoginAt: Number(item.lastLoginAt) || 0,
373
+ loginCount: Math.max(0, Math.round(Number(item.loginCount) || 0)),
374
+ lastIp: String(item.lastIp ?? '').trim().slice(0, 60),
375
+ })
376
+ }
377
+ return out.sort((a, b) => b.lastLoginAt - a.lastLoginAt)
378
+ }
379
+
380
+ /** Shallow-merge one section of a saved settings object over the defaults. */
381
+ export function mergeSection(defaults, saved) {
382
+ if (saved === null || typeof saved !== 'object' || Array.isArray(saved)) return defaults
383
+ const out = { ...defaults }
384
+ for (const key of Object.keys(defaults)) {
385
+ const value = saved[key]
386
+ if (value === undefined || value === null) continue
387
+ if (typeof defaults[key] === 'object' && !Array.isArray(defaults[key])) {
388
+ out[key] = mergeSection(defaults[key], value)
389
+ } else if (typeof value === typeof defaults[key]) {
390
+ out[key] = value
391
+ }
392
+ }
393
+ return out
394
+ }
395
+
396
+ /** Merge a whole saved config over the defaults. */
397
+ export function mergeConfig(saved) {
398
+ const base = defaultConfig()
399
+ if (saved === null || typeof saved !== 'object') return base
400
+ const experts = sanitizeExperts(saved.experts)
401
+ const roster = experts.length > 0 ? experts : base.experts
402
+ const gitlab = { ...mergeSection(base.gitlab, saved.gitlab), projects: sanitizeProjects(saved.gitlab?.projects) }
403
+ const feishu = { ...mergeSection(base.feishu, saved.feishu), bots: sanitizeBots(saved.feishu?.bots) }
404
+ // A settings file written before multi-project/multi-bot support carries the
405
+ // single connection in the legacy fields; fold it into the new lists once.
406
+ if (gitlab.projects.length === 0 && gitlab.project.length > 0) {
407
+ gitlab.projects.push({
408
+ id: 'default',
409
+ name: gitlab.project.split('/').pop() ?? gitlab.project,
410
+ url: gitlab.url,
411
+ project: gitlab.project,
412
+ token: '',
413
+ group: gitlab.group,
414
+ syncEnabled: gitlab.syncEnabled,
415
+ syncLabel: gitlab.syncLabel,
416
+ lastSyncAt: gitlab.lastSyncAt,
417
+ lastSyncError: gitlab.lastSyncError,
418
+ })
419
+ }
420
+ if (feishu.bots.length === 0 && feishu.webhook.length > 0) {
421
+ feishu.bots.push({ id: 'default', name: '默认群', webhook: feishu.webhook, chatId: feishu.chatId, notifyOnChange: feishu.notifyOnChange, enabled: true })
422
+ }
423
+ return {
424
+ schema: Number.isInteger(saved.schema) ? saved.schema : 1,
425
+ board: mergeSection(base.board, saved.board),
426
+ experts: roster,
427
+ teams: sanitizeTeams(saved.teams, roster),
428
+ columns: sanitizeColumns(saved.columns, roster, sanitizeTeams(saved.teams, roster)),
429
+ projects: sanitizeWorkProjects(saved.projects),
430
+ templates: sanitizeTemplates(saved.templates),
431
+ gitlab,
432
+ feishu,
433
+ roles: mergeSection(base.roles, saved.roles),
434
+ market: mergeSection(base.market, saved.market),
435
+ agents: mergeSection(base.agents, saved.agents),
436
+ testing: sanitizeTesting(saved.testing),
437
+ access: sanitizeAccess(saved.access),
438
+ sla: sanitizeSla(saved.sla),
439
+ costs: sanitizeCosts(saved.costs),
440
+ // 设计闸门:加了这个键就必须在这里也列一遍,否则重启一次配置就被 mergeConfig 抹掉。
441
+ design: (() => {
442
+ const merged = { ...base.design, ...(saved.design !== null && typeof saved.design === 'object' ? saved.design : {}) }
443
+ return { enabled: merged.enabled === true, columns: (Array.isArray(merged.columns) ? merged.columns : ['dev']).map(String).slice(0, 8) }
444
+ })(),
445
+ environments: (() => {
446
+ const list = sanitizeDeployEnvs(saved.environments)
447
+ return list.length > 0 ? list : defaultDeployEnvs()
448
+ })(),
449
+ releaseRules: mergeSection(base.releaseRules, saved.releaseRules),
450
+ ldap: sanitizeLdap(saved.ldap),
451
+ localAuth: sanitizeLocalAuth(saved.localAuth),
452
+ users: sanitizeUsers(saved.users),
453
+ }
454
+ }
455
+
456
+ /** 缺陷 SLA:每个严重级别给一个修复时限(小时)。 */
457
+ export function sanitizeSla(raw) {
458
+ const input = raw !== null && typeof raw === 'object' ? raw : {}
459
+ const out = { p0: 4, p1: 24, p2: 72, p3: 168 }
460
+ for (const key of Object.keys(out)) {
461
+ const value = Number(input[key])
462
+ if (Number.isFinite(value) && value > 0) out[key] = Math.min(Math.round(value), 24 * 60)
463
+ }
464
+ return out
465
+ }
466
+
467
+ /** 成本预算:跑多少轮、机上多少小时,超过 warnAt% 就在界面标黄。 */
468
+ export function sanitizeCosts(raw) {
469
+ const input = raw !== null && typeof raw === 'object' ? raw : {}
470
+ const budgetRuns = Number(input.budgetRuns)
471
+ const budgetHours = Number(input.budgetHours)
472
+ const warnAt = Number(input.warnAt)
473
+ return {
474
+ budgetRuns: Number.isFinite(budgetRuns) && budgetRuns > 0 ? Math.min(Math.round(budgetRuns), 100000) : 500,
475
+ budgetHours: Number.isFinite(budgetHours) && budgetHours > 0 ? Math.min(Math.round(budgetHours), 100000) : 80,
476
+ warnAt: Number.isFinite(warnAt) && warnAt > 0 ? Math.max(10, Math.min(Math.round(warnAt), 100)) : 80,
477
+ }
478
+ }
479
+ export function cleanId(value, prefix) {
480
+ const text = String(value ?? '').trim()
481
+ const ascii = text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
482
+ // 太短的 slug(比如中文名里只剩一个「2」)当 id 既难看又容易撞,干脆走哈希。
483
+ if (ascii.length >= 3) return ascii.slice(0, 40)
484
+ // 纯中文(或其它非 ASCII)名字没法拼 slug:用名字的哈希做后缀,
485
+ // 保证「用同一个名字再保存一次」命中的是同一条,而不是又新建一条。
486
+ if (text.length > 0) return `${prefix}-${createHash('sha1').update(text).digest('hex').slice(0, 8)}`
487
+ return `${prefix}-${randomBytes(3).toString('hex')}`
488
+ }
489
+
490
+ /** Coerce whatever is in the settings file into a valid expert roster. */
491
+ export function sanitizeExperts(input) {
492
+ if (!Array.isArray(input)) return []
493
+ const seen = new Set()
494
+ const out = []
495
+ for (const raw of input.slice(0, 80)) {
496
+ if (raw === null || typeof raw !== 'object') continue
497
+ const id = cleanId(raw.id ?? raw.name, 'expert')
498
+ if (seen.has(id)) continue
499
+ seen.add(id)
500
+ const name = String(raw.name ?? id).trim().slice(0, 40) || id
501
+ out.push({
502
+ id,
503
+ name,
504
+ emoji: String(raw.emoji ?? '🤖').trim().slice(0, 4) || '🤖',
505
+ role: ROLE_IDS.includes(String(raw.role ?? '')) ? String(raw.role) : (ROLE_IDS.includes(id) ? id : ''),
506
+ domain: String(raw.domain ?? '').trim().slice(0, 40),
507
+ description: String(raw.description ?? '').slice(0, 600),
508
+ prompt: String(raw.prompt ?? '').slice(0, 8000),
509
+ /** 提示词指纹:改了角色提示词,历史派单能看出当时用的是哪一版。 */
510
+ promptVersion: createHash('sha1').update(String(raw.prompt ?? '')).digest('hex').slice(0, 8),
511
+ skills: (Array.isArray(raw.skills) ? raw.skills : String(raw.skills ?? '').split(/[,,\s]+/))
512
+ .map(skill => String(skill ?? '').trim().slice(0, 60))
513
+ .filter(skill => skill.length > 0)
514
+ .filter((skill, index, all) => all.indexOf(skill) === index)
515
+ .slice(0, 12),
516
+ model: String(raw.model ?? '').trim().slice(0, 120),
517
+ preset: String(raw.preset ?? '').trim().slice(0, 80),
518
+ /** 该专家自己的沙箱档位;空 = 跟随「设置 → 自动派单」的全局档位。 */
519
+ sandbox: SANDBOX_MODES.includes(String(raw.sandbox ?? '')) ? String(raw.sandbox) : '',
520
+ enabled: raw.enabled !== false,
521
+ })
522
+ }
523
+ return out
524
+ }
525
+
526
+ /** Teams reference experts by id; unknown members are dropped. */
527
+ export function sanitizeTeams(input, experts) {
528
+ if (!Array.isArray(input)) return []
529
+ const known = new Set(experts.map(expert => expert.id))
530
+ const seen = new Set()
531
+ const out = []
532
+ for (const raw of input.slice(0, 40)) {
533
+ if (raw === null || typeof raw !== 'object') continue
534
+ const id = cleanId(raw.id ?? raw.name, 'team')
535
+ if (seen.has(id)) continue
536
+ seen.add(id)
537
+ const members = (Array.isArray(raw.members) ? raw.members : [])
538
+ .map(member => cleanId(member, 'expert'))
539
+ .filter(member => known.has(member))
540
+ .filter((member, index, all) => all.indexOf(member) === index)
541
+ .slice(0, 12)
542
+ const lead = cleanId(raw.lead ?? members[0] ?? '', 'expert')
543
+ out.push({
544
+ id,
545
+ name: String(raw.name ?? id).trim().slice(0, 40) || id,
546
+ emoji: String(raw.emoji ?? '👥').trim().slice(0, 4) || '👥',
547
+ description: String(raw.description ?? '').slice(0, 600),
548
+ members,
549
+ lead: members.includes(lead) ? lead : (members[0] ?? ''),
550
+ enabled: raw.enabled !== false,
551
+ })
552
+ }
553
+ return out
554
+ }
555
+
556
+ /** Column owners, validated against the roster. Unknown columns are ignored. */
557
+ export function sanitizeColumns(input, experts, teams) {
558
+ const base = defaultColumns()
559
+ const out = { ...base }
560
+ if (input === null || typeof input !== 'object' || Array.isArray(input)) return out
561
+ const expertIds = new Set(experts.map(expert => expert.id))
562
+ const teamIds = new Set(teams.map(team => team.id))
563
+ for (const status of STATUS_IDS) {
564
+ const raw = input[status]
565
+ if (raw === null || typeof raw !== 'object') continue
566
+ // 候选池:一列可以挂多个人,进来时按策略挑一个。
567
+ if (Array.isArray(raw.members)) {
568
+ const members = []
569
+ const seen = new Set()
570
+ for (const entry of raw.members.slice(0, 12)) {
571
+ if (entry === null || typeof entry !== 'object') continue
572
+ // 候选池里可以写「角色位」(product/architect/dev/qa):派单时按卡的业务领域
573
+ // 解析成对应的领域专家;也可以写具体专家或专家团。
574
+ const kind = entry.kind === 'team' ? 'team' : entry.kind === 'role' ? 'role' : 'expert'
575
+ const id = String(entry.id ?? '').trim()
576
+ if (kind === 'role' ? !ROLE_IDS.includes(id) : kind === 'team' ? !teamIds.has(id) : !expertIds.has(id)) continue
577
+ const key = `${kind}:${id}`
578
+ if (seen.has(key)) continue
579
+ seen.add(key)
580
+ members.push({ kind, id })
581
+ }
582
+ if (members.length > 0) {
583
+ out[status] = { kind: 'pool', id: '', members, strategy: normalizeStrategy(raw.strategy) }
584
+ continue
585
+ }
586
+ }
587
+ const kind = raw.kind === 'team' ? 'team' : raw.kind === 'none' ? 'none' : raw.kind === 'role' ? 'role' : 'expert'
588
+ if (kind === 'none') { out[status] = { kind: 'none', id: '' }; continue }
589
+ const id = String(raw.id ?? raw.role ?? '').trim()
590
+ if (kind === 'role' && ROLE_IDS.includes(id)) out[status] = { kind: 'role', id, ...(out[status]?.wip !== undefined ? { wip: out[status].wip } : {}) }
591
+ else if (kind === 'team' && teamIds.has(id)) out[status] = { kind: 'team', id }
592
+ else if (kind === 'expert' && expertIds.has(id)) out[status] = { kind: 'expert', id }
593
+ }
594
+ /* 每列的 WIP 上限:0 表示不限;超出只提示,不阻止推进。 */
595
+ for (const status of STATUS_IDS) {
596
+ const raw = input[status]
597
+ if (raw === null || typeof raw !== 'object') continue
598
+ const wip = Number(raw.wip)
599
+ if (Number.isFinite(wip) && out[status] !== undefined) out[status].wip = Math.min(50, Math.max(0, Math.trunc(wip)))
600
+ }
601
+ return out
602
+ }
603
+
604
+ /** 候选池的分派策略。 */
605
+ export const COLUMN_STRATEGIES = ['round_robin', 'best_match', 'lead']
606
+ export function normalizeStrategy(value) {
607
+ return COLUMN_STRATEGIES.includes(value) ? value : 'round_robin'
608
+ }
609
+
610
+ /** GitLab connections: one per repository the board watches. */
611
+ export function sanitizeProjects(input) {
612
+ if (!Array.isArray(input)) return []
613
+ const seen = new Set()
614
+ const out = []
615
+ for (const raw of input.slice(0, 40)) {
616
+ if (raw === null || typeof raw !== 'object') continue
617
+ const id = cleanId(raw.id ?? raw.project ?? raw.name, 'repo')
618
+ if (seen.has(id)) continue
619
+ seen.add(id)
620
+ out.push({
621
+ id,
622
+ name: String(raw.name ?? raw.project ?? id).trim().slice(0, 80) || id,
623
+ url: String(raw.url ?? '').trim().slice(0, 500),
624
+ project: String(raw.project ?? '').trim().slice(0, 300),
625
+ group: String(raw.group ?? '').trim().slice(0, 300),
626
+ token: String(raw.token ?? '').trim().slice(0, 400),
627
+ syncEnabled: raw.syncEnabled === true,
628
+ syncLabel: String(raw.syncLabel ?? '').trim().slice(0, 100),
629
+ lastSyncAt: Number(raw.lastSyncAt) || 0,
630
+ lastSyncError: String(raw.lastSyncError ?? '').slice(0, 300),
631
+ })
632
+ }
633
+ return out
634
+ }
635
+
636
+ /** Feishu custom-bot webhooks: one card change can notify several groups. */
637
+ export function sanitizeBots(input) {
638
+ if (!Array.isArray(input)) return []
639
+ const seen = new Set()
640
+ const out = []
641
+ for (const raw of input.slice(0, 40)) {
642
+ if (raw === null || typeof raw !== 'object') continue
643
+ const id = cleanId(raw.id ?? raw.name, 'bot')
644
+ if (seen.has(id)) continue
645
+ seen.add(id)
646
+ out.push({
647
+ id,
648
+ name: String(raw.name ?? id).trim().slice(0, 60) || id,
649
+ webhook: String(raw.webhook ?? '').trim().slice(0, 500),
650
+ chatId: String(raw.chatId ?? '').trim().slice(0, 200),
651
+ // 哪些事件推到这个群。老数据只有 notifyOnChange,落到 events.change 上。
652
+ events: (() => {
653
+ const events = raw.events !== null && typeof raw.events === 'object' ? raw.events : {}
654
+ return {
655
+ change: events.change !== false && raw.notifyOnChange !== false,
656
+ sla: events.sla !== false,
657
+ bug: events.bug !== false,
658
+ release: events.release !== false,
659
+ }
660
+ })(),
661
+ notifyOnChange: raw.notifyOnChange !== false && (raw.events === null || raw.events === undefined || raw.events.change !== false),
662
+ enabled: raw.enabled !== false,
663
+ })
664
+ }
665
+ return out
666
+ }
667
+
668
+ // ------------------------------------------------- 项目 / 迭代 / 交付物 / 清单
669
+
670
+ /** 迭代(里程碑):一个项目里的排期单位。 */
671
+ export function sanitizeIterations(input) {
672
+ if (!Array.isArray(input)) return []
673
+ const seen = new Set()
674
+ const out = []
675
+ for (const raw of input.slice(0, 60)) {
676
+ if (raw === null || typeof raw !== 'object') continue
677
+ const id = cleanId(raw.id ?? raw.name, 'iter')
678
+ if (seen.has(id)) continue
679
+ seen.add(id)
680
+ const status = ['planned', 'active', 'closed'].includes(String(raw.status)) ? String(raw.status) : 'planned'
681
+ out.push({
682
+ id,
683
+ name: String(raw.name ?? id).trim().slice(0, 60) || id,
684
+ start: Number(raw.start) || 0,
685
+ due: Number(raw.due) || 0,
686
+ status,
687
+ })
688
+ }
689
+ return out
690
+ }
691
+
692
+ /** 业务项目:卡片挂项目,项目盯一个或多个 GitLab 仓库。 */
693
+ export function sanitizeServices(raw, legacyPath) {
694
+ const out = []
695
+ const seen = new Set()
696
+ const list = Array.isArray(raw) ? raw : []
697
+ for (const item of list) {
698
+ if (item === null || typeof item !== 'object') continue
699
+ const id = cleanId(item.id ?? item.name, 'svc')
700
+ if (seen.has(id)) continue
701
+ seen.add(id)
702
+ out.push({
703
+ id,
704
+ name: String(item.name ?? id).trim().slice(0, 80) || id,
705
+ repoId: String(item.repoId ?? '').trim().slice(0, 60),
706
+ repoPath: String(item.repoPath ?? '').trim().slice(0, 400),
707
+ baseBranch: String(item.baseBranch ?? '').trim().slice(0, 120) || 'main',
708
+ /** 本服务依赖哪些服务先完成(同一项目里的服务 id,不跨项目)。 */
709
+ dependsOn: (Array.isArray(item.dependsOn) ? item.dependsOn : [])
710
+ .map(dep => String(dep ?? '').trim().slice(0, 60))
711
+ .filter(dep => dep.length > 0),
712
+ })
713
+ if (out.length >= 20) break
714
+ }
715
+ // 依赖只能指向同一项目里存在的服务、不能指向自己,也不能成环(A→B→A 会让两张卡互相等)。
716
+ const known = new Map(out.map(one => [one.id, one]))
717
+ for (const one of out) {
718
+ one.dependsOn = one.dependsOn
719
+ .filter(dep => dep !== one.id && known.has(dep))
720
+ .filter((dep, index, all) => all.indexOf(dep) === index)
721
+ .slice(0, 10)
722
+ }
723
+ const reaches = (from, to, seen) => {
724
+ if (from === to) return true
725
+ if (seen.has(from)) return false
726
+ seen.add(from)
727
+ return (known.get(from)?.dependsOn ?? []).some(next => reaches(next, to, seen))
728
+ }
729
+ for (const one of out) {
730
+ // 加一条 one→dep 之前先看 dep 是不是已经(间接)依赖 one,是就丢掉这条边。
731
+ one.dependsOn = one.dependsOn.filter(dep => !reaches(dep, one.id, new Set()))
732
+ }
733
+ // 老数据只有项目级 repoPath:折成一个默认服务,行为不变。
734
+ if (out.length === 0 && String(legacyPath ?? '').trim().length > 0) {
735
+ out.push({ id: 'svc-default', name: '默认服务', repoId: '', repoPath: String(legacyPath).trim().slice(0, 400), baseBranch: 'main', dependsOn: [] })
736
+ }
737
+ return out
738
+ }
739
+
740
+ export function sanitizeWorkProjects(input) {
741
+ if (!Array.isArray(input)) return []
742
+ const seen = new Set()
743
+ const out = []
744
+ for (const raw of input.slice(0, 60)) {
745
+ if (raw === null || typeof raw !== 'object') continue
746
+ const id = cleanId(raw.id ?? raw.name, 'proj')
747
+ if (seen.has(id)) continue
748
+ seen.add(id)
749
+ out.push({
750
+ id,
751
+ name: String(raw.name ?? id).trim().slice(0, 80) || id,
752
+ key: String(raw.key ?? '').trim().toUpperCase().slice(0, 12),
753
+ description: String(raw.description ?? '').slice(0, 600),
754
+ lead: String(raw.lead ?? '').trim().slice(0, 40),
755
+ status: String(raw.status) === 'archived' ? 'archived' : 'active',
756
+ repos: (Array.isArray(raw.repos) ? raw.repos : [])
757
+ .map(repo => String(repo ?? '').trim().slice(0, 60))
758
+ .filter(repo => repo.length > 0)
759
+ .filter((repo, index, all) => all.indexOf(repo) === index)
760
+ .slice(0, 20),
761
+ iterations: sanitizeIterations(raw.iterations),
762
+ /** 项目里的服务:一个服务一个本机 git 路径,card.service 指向其中一条。 */
763
+ services: sanitizeServices(raw.services, raw.repoPath),
764
+ /** 这个项目的代码在本机哪个目录(worktree 隔离从这里切分支)。 */
765
+ repoPath: String(raw.repoPath ?? '').trim().slice(0, 400),
766
+ baseBranch: String(raw.baseBranch ?? '').trim().slice(0, 120) || 'main',
767
+ })
768
+ }
769
+ return out
770
+ }
771
+
772
+ /** 需求:整条交付链路的锚点,所有下游产物都挂在它下面。 */
773
+ export const REQ_DOC_KINDS = ['prd', 'proto', 'acceptance', 'other']
774
+ export const REQ_DOC_KIND_LABEL = { prd: 'PRD', proto: '原型', acceptance: '验收标准', other: '其它' }
775
+ export const REQ_DOC_STATUS = ['draft', 'review', 'baseline']
776
+
777
+ export function sanitizeReqDoc(raw, index) {
778
+ if (raw === null || typeof raw !== 'object') return undefined
779
+ const title = String(raw.title ?? '').trim().slice(0, 200)
780
+ if (title.length === 0) return undefined
781
+ const now = Date.now()
782
+ return {
783
+ id: cleanId(raw.id ?? '', `doc${index}`),
784
+ kind: REQ_DOC_KINDS.includes(String(raw.kind)) ? String(raw.kind) : 'prd',
785
+ title,
786
+ url: String(raw.url ?? '').trim().slice(0, 500),
787
+ version: String(raw.version ?? '').trim().slice(0, 20) || 'v1.0',
788
+ status: REQ_DOC_STATUS.includes(String(raw.status)) ? String(raw.status) : 'draft',
789
+ content: String(raw.content ?? '').slice(0, 60000),
790
+ by: String(raw.by ?? '').trim().slice(0, 40),
791
+ at: Number(raw.at) || now,
792
+ }
793
+ }
794
+
795
+ export function sanitizeReqDocs(input) {
796
+ if (!Array.isArray(input)) return []
797
+ const seen = new Set()
798
+ const out = []
799
+ input.slice(0, 40).forEach((raw, index) => {
800
+ const doc = sanitizeReqDoc(raw, index)
801
+ if (doc === undefined || seen.has(doc.id)) return
802
+ seen.add(doc.id)
803
+ out.push(doc)
804
+ })
805
+ return out
806
+ }
807
+
808
+ export function sanitizeBaseline(raw) {
809
+ if (raw === null || typeof raw !== 'object') return null
810
+ const version = String(raw.version ?? '').trim().slice(0, 20)
811
+ if (version === '') return null
812
+ return {
813
+ docId: String(raw.docId ?? '').slice(0, 60),
814
+ title: String(raw.title ?? '').slice(0, 200),
815
+ version,
816
+ content: String(raw.content ?? '').slice(0, 60000),
817
+ at: Number(raw.at) || Date.now(),
818
+ by: String(raw.by ?? '').trim().slice(0, 40),
819
+ }
820
+ }
821
+
822
+ export function sanitizeAcceptanceItem(raw, index) {
823
+ if (raw === null || raw === undefined) return undefined
824
+ const source = typeof raw === 'string' ? { text: raw } : raw
825
+ const text = String(source.text ?? '').trim().slice(0, 300)
826
+ if (text.length === 0) return undefined
827
+ return {
828
+ id: String(source.id ?? `a${index + 1}`).trim().slice(0, 24) || `a${index + 1}`,
829
+ text,
830
+ done: source.done === true,
831
+ }
832
+ }
833
+
834
+ /** 需求里的「功能点」:可追溯矩阵的锚点,卡与用例都挂到它上面。 */
835
+ export function sanitizeRequirementPoint(raw, index) {
836
+ if (raw === null || typeof raw !== 'object') return undefined
837
+ const title = String(raw.title ?? '').trim().slice(0, 200)
838
+ if (title.length === 0) return undefined
839
+ const acceptance = Array.isArray(raw.acceptance)
840
+ ? raw.acceptance.map((item, i) => sanitizeAcceptanceItem(item, i)).filter(item => item !== undefined).slice(0, 30)
841
+ : []
842
+ return {
843
+ id: String(raw.id ?? '').trim().slice(0, 24) || `p${index + 1}`,
844
+ title,
845
+ description: String(raw.description ?? '').slice(0, 2000),
846
+ priority: PRIORITIES.includes(String(raw.priority)) ? String(raw.priority) : 'normal',
847
+ status: ['draft', 'ready', 'done'].includes(String(raw.status)) ? String(raw.status) : 'draft',
848
+ acceptance,
849
+ }
850
+ }
851
+
852
+ export function sanitizeRequirementPoints(input) {
853
+ if (!Array.isArray(input)) return []
854
+ const seen = new Set()
855
+ const out = []
856
+ for (const raw of input.slice(0, 60)) {
857
+ const point = sanitizeRequirementPoint(raw, out.length)
858
+ if (point === undefined) continue
859
+ let id = point.id
860
+ let n = 2
861
+ while (seen.has(id)) { id = `${point.id}-${n}`; n += 1 }
862
+ seen.add(id)
863
+ out.push({ ...point, id })
864
+ }
865
+ return out
866
+ }
867
+
868
+ /**
869
+ * 需求变更影响面:改了哪几处(标题/需求明细/功能点/文档链接)、影响哪些已经产出过的卡、
870
+ * 原来签过的字是不是已经作废。没改过就是 null。
871
+ */
872
+ export function sanitizeRequirementChange(raw) {
873
+ if (raw === null || raw === undefined || typeof raw !== 'object') return null
874
+ const parts = (Array.isArray(raw.parts) ? raw.parts : [])
875
+ .map(item => String(item).trim().slice(0, 20))
876
+ .filter(item => item !== '')
877
+ .slice(0, 8)
878
+ if (parts.length === 0) return null
879
+ return {
880
+ at: Number(raw.at) || Date.now(),
881
+ by: String(raw.by ?? '').trim().slice(0, 60),
882
+ parts,
883
+ affected: (Array.isArray(raw.affected) ? raw.affected : []).slice(0, 60).map(item => ({
884
+ id: String(item?.id ?? '').slice(0, 60),
885
+ code: String(item?.code ?? '').slice(0, 40),
886
+ title: String(item?.title ?? '').slice(0, 200),
887
+ status: String(item?.status ?? '').slice(0, 20),
888
+ })),
889
+ invalidated: raw.invalidated === true,
890
+ }
891
+ }
892
+
893
+ /** 需求评审记录:结论 + 人 + 时间 + 意见。没评审过就是 null。 */
894
+ export function sanitizeRequirementReview(raw) {
895
+ if (raw === null || raw === undefined || typeof raw !== 'object') return null
896
+ const status = REQ_REVIEW_STATUS.includes(String(raw.status)) ? String(raw.status) : 'pending'
897
+ return {
898
+ status,
899
+ by: String(raw.by ?? '').trim().slice(0, 60),
900
+ at: Number(raw.at) || 0,
901
+ note: String(raw.note ?? '').slice(0, 600),
902
+ }
903
+ }
904
+
905
+ export const MINE_HTML = `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>我提过的需求</title>
906
+ <style>body{font-family:system-ui,sans-serif;margin:0;background:#fafafa;color:#18181b}main{max-width:640px;margin:40px auto;padding:24px;background:#fff;border:1px solid #e4e4e7;border-radius:12px}h1{font-size:18px;margin:0 0 6px}p.hint{color:#71717a;font-size:13px;margin:0 0 14px}input{width:100%;box-sizing:border-box;height:36px;padding:4px 12px;border:1px solid #e4e4e7;border-radius:8px;font-size:14px}button{margin-top:12px;height:36px;padding:0 18px;border:0;border-radius:8px;background:#18181b;color:#fafafa;font-size:14px;cursor:pointer}#msg{margin-top:10px;font-size:13px;color:#b91c1c}.row{padding:12px 0;border-top:1px solid #f4f4f5;font-size:13px;line-height:1.6}.row b{font-size:14px}.tag{display:inline-block;padding:0 6px;border:1px solid #e4e4e7;border-radius:999px;font-size:11px;color:#52525b;margin-left:6px}.kv{color:#71717a;font-size:12px}</style></head>
907
+ <body><main><h1>我提过的需求</h1><p class="hint">输入提交时留的联系方式,只看得到你自己提的单。</p>
908
+ <input id="contact" placeholder="手机号或飞书"><button id="go">查询</button><div id="msg"></div><div id="list"></div></main>
909
+ <script>document.getElementById('go').onclick=async()=>{const msg=document.getElementById('msg');const list=document.getElementById('list');msg.textContent='';list.innerHTML='查询中…';try{const r=await fetch(location.pathname.replace(/\/intake\/mine$/,'')+'/intake/mine/query',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({contact:document.getElementById('contact').value})});const j=await r.json();if(!r.ok)throw new Error(j.error||'查询失败');if(j.rows.length===0){list.innerHTML='<p class="kv">没有查到记录,确认联系方式与提交时一致。</p>';return}list.innerHTML=j.rows.map(v=>'<div class="row"><b>'+v.code+'</b><span class="tag">'+v.statusLabel+'</span><br>'+v.title+'<br><span class="kv">待确认问题 '+v.openQuestions+' 条 · 已拆卡 '+v.cards+' 张(完成 '+v.doneCards+')· 最近更新 '+new Date(v.updatedAt||Date.now()).toLocaleString()+'</span></div>').join('')}catch(e){msg.textContent=e.message;list.innerHTML=''}}</script></body></html>`
910
+
911
+ export const STATUS_HTML = `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>需求进度</title>
912
+ <style>body{font-family:system-ui,sans-serif;margin:0;background:#fafafa;color:#18181b}main{max-width:560px;margin:40px auto;padding:24px;background:#fff;border:1px solid #e4e4e7;border-radius:12px}h1{font-size:18px;margin:0 0 6px}p.hint{color:#71717a;font-size:13px;margin:0 0 16px}label{display:block;font-size:12px;color:#52525b;margin:12px 0 6px}input{width:100%;box-sizing:border-box;height:36px;padding:4px 12px;border:1px solid #e4e4e7;border-radius:8px;font-size:14px}button{margin-top:16px;height:36px;padding:0 18px;border:0;border-radius:8px;background:#18181b;color:#fafafa;font-size:14px;cursor:pointer}#out{margin-top:16px;font-size:13px;line-height:1.7}#out b{font-size:15px}#out[data-bad="1"]{color:#b91c1c}.kv{color:#52525b}</style></head>
913
+ <body><main><h1>查需求进度</h1><p class="hint">用提交时拿到的需求编号,加联系方式的后四位。</p>
914
+ <label>需求编号</label><input id="code" placeholder="REQ-2026-001">
915
+ <label>联系方式后四位</label><input id="contact" maxlength="8" placeholder="0000">
916
+ <button id="go">查询</button><div id="out"></div></main>
917
+ <script>document.getElementById('go').onclick=async()=>{const out=document.getElementById('out');out.textContent='查询中…';try{const r=await fetch(location.pathname.replace(/\/intake\/status$/,'')+'/intake/query',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({code:document.getElementById('code').value,contact:document.getElementById('contact').value})});const j=await r.json();if(!r.ok)throw new Error(j.error||'查询失败');out.removeAttribute('data-bad');out.innerHTML='<b>'+j.code+'</b><br>'+j.title+'<br><span class="kv">状态:'+j.statusLabel+(j.reviewStatus==='pending'?'(评审中)':j.reviewStatus==='changes_requested'?'(评审打回)':'')+'<br>待确认问题:'+j.openQuestions+' 条<br>已拆卡:'+j.cards+' 张(完成 '+j.doneCards+')<br>最近更新:'+new Date(j.updatedAt||Date.now()).toLocaleString()+'</span>'}catch(e){out.setAttribute('data-bad','1');out.textContent=e.message}}</script></body></html>`
918
+
919
+ export const INTAKE_HTML = `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>提需求</title>
920
+ <style>body{font-family:system-ui,sans-serif;margin:0;background:#fafafa;color:#18181b}main{max-width:640px;margin:40px auto;padding:24px;background:#fff;border:1px solid #e4e4e7;border-radius:12px}h1{font-size:18px;margin:0 0 6px}p.hint{color:#71717a;font-size:13px;margin:0 0 18px}label{display:block;font-size:12px;color:#52525b;margin:14px 0 6px}input,textarea,select{width:100%;box-sizing:border-box;height:36px;padding:4px 12px;border:1px solid #e4e4e7;border-radius:8px;font-size:14px}textarea{height:140px;padding:8px 12px;resize:vertical}button{margin-top:18px;height:36px;padding:0 18px;border:0;border-radius:8px;background:#18181b;color:#fafafa;font-size:14px;cursor:pointer}#msg{margin-top:12px;font-size:13px;color:#0f766e}#msg[data-bad="1"]{color:#b91c1c}</style></head>
921
+ <body><main><h1>提交需求</h1><p class="hint">写清楚背景和期望结果即可,产品会来接洽澄清。提交后会生成 REQ 编号。</p>
922
+ <label>需求标题</label><input id="title" maxlength="200" placeholder="一句话说清要解决的问题">
923
+ <label>需求描述</label><textarea id="description" placeholder="现状怎么做的、希望变成什么样、有什么硬约束(时间/合规/系统)"></textarea>
924
+ <div style="display:flex;gap:12px"><div style="flex:1"><label>您的姓名</label><input id="requester" placeholder="张三"></div><div style="flex:1"><label>部门</label><input id="dept" placeholder="财务部"></div></div>
925
+ <label>联系方式</label><input id="contact" placeholder="手机或飞书">
926
+ <label>紧急程度</label><select id="priority"><option value="normal">一般</option><option value="urgent">紧急</option><option value="relaxed">不急</option></select>
927
+ <button id="send">提交需求</button><div id="msg"></div></main>
928
+ <script>document.getElementById('send').onclick=async()=>{const msg=document.getElementById('msg');const body={title:document.getElementById('title').value,description:document.getElementById('description').value,requester:document.getElementById('requester').value,dept:document.getElementById('dept').value,contact:document.getElementById('contact').value,priority:document.getElementById('priority').value};try{const r=await fetch(location.pathname.replace(/\/intake$/,'')+'/intake/requirement',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(body)});const j=await r.json();if(!r.ok)throw new Error(j.error||'提交失败');msg.removeAttribute('data-bad');msg.textContent='已提交,需求编号 '+j.code+'。产品会尽快接洽。'}catch(e){msg.setAttribute('data-bad','1');msg.textContent=e.message}}</script></body></html>`
929
+
930
+ /** 变更历史:需求每次被改,记下字段级 diff,追得到「谁在什么时候把验收口径改了」。 */
931
+ export function sanitizeRequirementHistory(raw) {
932
+ if (!Array.isArray(raw)) return []
933
+ return raw.slice(-100).map(entry => (entry !== null && typeof entry === 'object') ? {
934
+ at: Number(entry.at) > 0 ? Number(entry.at) : 0,
935
+ by: String(entry.by ?? '').slice(0, 40),
936
+ field: String(entry.field ?? '').slice(0, 40),
937
+ from: String(entry.from ?? '').slice(0, 300),
938
+ to: String(entry.to ?? '').slice(0, 300),
939
+ } : null).filter(Boolean)
940
+ }
941
+
942
+ /** 需求池打分:RICE = Reach×Impact×Confidence/Effort,score 后端算,前端只填四项。 */
943
+ export function sanitizeRequirementPool(raw) {
944
+ if (raw === null || raw === undefined || typeof raw !== 'object') return { reach: 0, impact: 0, confidence: 0, effort: 0, score: 0 }
945
+ const num = (value, max) => { const n = Number(value); return Number.isFinite(n) ? Math.min(max, Math.max(0, n)) : 0 }
946
+ const reach = Math.round(num(raw.reach, 100000))
947
+ const impact = num(raw.impact, 3)
948
+ const confidence = num(raw.confidence, 100)
949
+ const effort = num(raw.effort, 365)
950
+ const score = effort <= 0 ? 0 : Math.round((reach * impact * (confidence / 100) / effort) * 10) / 10
951
+ return { reach, impact, confidence, effort, score }
952
+ }
953
+
954
+ /** 待确认清单:需求澄清时沿用「问题 / 谁答 / 答了什么」三件事。 */
955
+ export function sanitizeRequirementQuestions(raw) {
956
+ if (!Array.isArray(raw)) return []
957
+ const out = []
958
+ for (const entry of raw.slice(0, 50)) {
959
+ if (entry === null || typeof entry !== 'object') continue
960
+ const text = String(entry.text ?? '').trim().slice(0, 300)
961
+ if (text.length === 0) continue
962
+ out.push({
963
+ id: cleanId(entry.id, 'q'),
964
+ text,
965
+ status: entry.status === 'answered' ? 'answered' : 'open',
966
+ owner: String(entry.owner ?? '').trim().slice(0, 40),
967
+ answer: String(entry.answer ?? '').trim().slice(0, 1000),
968
+ at: Number(entry.at) > 0 ? Number(entry.at) : Date.now(),
969
+ answeredAt: Number(entry.answeredAt) > 0 ? Number(entry.answeredAt) : 0,
970
+ })
971
+ }
972
+ return out
973
+ }
974
+
975
+ export function sanitizeRequirement(raw) {
976
+ if (raw === null || typeof raw !== 'object') return undefined
977
+ const title = String(raw.title ?? '').trim().slice(0, 200)
978
+ if (title.length === 0) return undefined
979
+ const now = Date.now()
980
+ const links = raw.links !== null && typeof raw.links === 'object' ? raw.links : {}
981
+ const folderRaw = raw.folder !== null && typeof raw.folder === 'object' ? raw.folder : {}
982
+ return {
983
+ id: cleanId(raw.id ?? '', 'req'),
984
+ code: String(raw.code ?? '').trim().toUpperCase().slice(0, 40),
985
+ title,
986
+ description: String(raw.description ?? '').slice(0, 20000),
987
+ project: String(raw.project ?? '').trim().slice(0, 60),
988
+ iteration: String(raw.iteration ?? '').trim().slice(0, 60),
989
+ owner: String(raw.owner ?? '').trim().slice(0, 40),
990
+ /** 业务领域:卡没写领域时继承它,用来把卡路由给同领域专家。 */
991
+ domain: String(raw.domain ?? '').trim().slice(0, 40),
992
+ priority: PRIORITIES.includes(String(raw.priority)) ? String(raw.priority) : 'normal',
993
+ status: REQ_STATUSES.includes(String(raw.status)) ? String(raw.status) : 'draft',
994
+ links: {
995
+ prd: String(links.prd ?? '').trim().slice(0, 500),
996
+ proto: String(links.proto ?? '').trim().slice(0, 500),
997
+ acceptance: String(links.acceptance ?? '').trim().slice(0, 500),
998
+ },
999
+ folder: {
1000
+ token: String(folderRaw.token ?? '').trim().slice(0, 80),
1001
+ url: String(folderRaw.url ?? '').trim().slice(0, 500),
1002
+ name: String(folderRaw.name ?? '').trim().slice(0, 200),
1003
+ at: Number(folderRaw.at) || 0,
1004
+ error: String(folderRaw.error ?? '').slice(0, 300),
1005
+ },
1006
+ docs: sanitizeReqDocs(raw.docs),
1007
+ points: sanitizeRequirementPoints(raw.points),
1008
+ baseline: sanitizeBaseline(raw.baseline),
1009
+ /** 排序权重:数字小的排前面,同权重按编号。 */
1010
+ order: Number.isFinite(Number(raw.order)) ? Number(raw.order) : 0,
1011
+ /** 依赖的需求:被依赖的还没交付,这条需求就不该签字收口。 */
1012
+ dependsOn: (Array.isArray(raw.dependsOn) ? raw.dependsOn : []).map(item => String(item).trim()).filter(item => item !== '').slice(0, 20),
1013
+ // 需求级验收签字:整条需求交付前的最后一道人工确认,和卡级 signoff 分开。
1014
+ source: String(raw.source ?? '').slice(0, 20),
1015
+ requester: (raw.requester === null || raw.requester === undefined || typeof raw.requester !== 'object') ? null : {
1016
+ name: String(raw.requester.name ?? '').slice(0, 40),
1017
+ dept: String(raw.requester.dept ?? '').slice(0, 40),
1018
+ contact: String(raw.requester.contact ?? '').slice(0, 80),
1019
+ at: Number(raw.requester.at) > 0 ? Number(raw.requester.at) : 0,
1020
+ },
1021
+ questions: sanitizeRequirementQuestions(raw.questions),
1022
+ pool: sanitizeRequirementPool(raw.pool),
1023
+ history: sanitizeRequirementHistory(raw.history),
1024
+ signoff: raw.signoff === null || raw.signoff === undefined
1025
+ ? null
1026
+ : {
1027
+ by: String(raw.signoff.by ?? '').slice(0, 60),
1028
+ at: Number(raw.signoff.at) || now,
1029
+ note: String(raw.signoff.note ?? '').slice(0, 600),
1030
+ },
1031
+ // 需求评审:谁在什么时候看了这版需求、结论是什么。通过之前不往下拆卡。
1032
+ review: sanitizeRequirementReview(raw.review),
1033
+ // 需求变更影响面:改完标题/明细/功能点之后,已经产出过的卡要复核,签字作废。
1034
+ change: sanitizeRequirementChange(raw.change),
1035
+ createdAt: Number(raw.createdAt) || now,
1036
+ updatedAt: Number(raw.updatedAt) || now,
1037
+ }
1038
+ }
1039
+
1040
+ export function sanitizeRequirements(input) {
1041
+ if (!Array.isArray(input)) return []
1042
+ const seen = new Set()
1043
+ const out = []
1044
+ for (const raw of input.slice(0, 500)) {
1045
+ const req = sanitizeRequirement(raw)
1046
+ if (req === undefined || seen.has(req.id)) continue
1047
+ seen.add(req.id)
1048
+ out.push(req)
1049
+ }
1050
+ return out
1051
+ }
1052
+
1053
+ /** 同一需求下,DEV-001 / TEST-001 各自递增。 */
1054
+ export function nextTaskCode(tasks, requirementId, prefix) {
1055
+ const pattern = new RegExp(`^${prefix}-(\\d+)$`)
1056
+ let max = 0
1057
+ for (const task of tasks) {
1058
+ if ((task.requirementId ?? '') !== requirementId) continue
1059
+ const match = pattern.exec(String(task.code ?? ''))
1060
+ if (match !== null) max = Math.max(max, Number(match[1]))
1061
+ }
1062
+ return `${prefix}-${String(max + 1).padStart(3, '0')}`
1063
+ }
1064
+
1065
+ /** 需求编号按年递增:REQ-2026-001。 */
1066
+ export function nextRequirementCode(requirements) {
1067
+ const year = new Date().getFullYear()
1068
+ const pattern = new RegExp(`^REQ-${year}-(\\d+)$`)
1069
+ let max = 0
1070
+ for (const req of requirements) {
1071
+ const match = pattern.exec(String(req.code ?? ''))
1072
+ if (match !== null) max = Math.max(max, Number(match[1]))
1073
+ }
1074
+ return `REQ-${year}-${String(max + 1).padStart(3, '0')}`
1075
+ }
1076
+
1077
+ /** 验收清单的一条:写了什么、谁勾的、什么时候。 */
1078
+ export function sanitizeChecklistItem(raw, index) {
1079
+ if (raw === null || typeof raw !== 'object') return undefined
1080
+ const text = String(raw.text ?? '').trim().slice(0, 200)
1081
+ if (text.length === 0) return undefined
1082
+ const id = String(raw.id ?? '').trim() || `c${index + 1}-${randomBytes(2).toString('hex')}`
1083
+ return {
1084
+ id,
1085
+ text,
1086
+ done: raw.done === true,
1087
+ by: String(raw.by ?? '').slice(0, 40),
1088
+ at: Number(raw.at) || 0,
1089
+ note: String(raw.note ?? '').slice(0, 400),
1090
+ }
1091
+ }
1092
+
1093
+ /** 验收清单:最多 30 条,去重。 */
1094
+ export function sanitizeChecklist(input) {
1095
+ if (!Array.isArray(input)) return []
1096
+ const seen = new Set()
1097
+ const out = []
1098
+ for (const [index, raw] of input.slice(0, 30).entries()) {
1099
+ const item = sanitizeChecklistItem(raw, index)
1100
+ if (item === undefined || seen.has(item.id)) continue
1101
+ seen.add(item.id)
1102
+ out.push(item)
1103
+ }
1104
+ return out
1105
+ }
1106
+ /** 用例与验收清单同理:轮询只给计数,抽屉打开时按卡取正文。 */
1107
+ export function publicCases(task) {
1108
+ const cases = Array.isArray(task?.cases) ? task.cases : []
1109
+ const pass = cases.filter(item => item.result === 'pass').length
1110
+ const fail = cases.filter(item => item.result === 'fail').length
1111
+ return { cases: [], caseSummary: { total: cases.length, pass, fail } }
1112
+ }
1113
+
1114
+ export function publicChecklist(task) {
1115
+ const checklist = Array.isArray(task?.checklist) ? task.checklist : []
1116
+ return { checklist: [], checkSummary: { done: checklist.filter(item => item.done).length, total: checklist.length } }
1117
+ }
1118
+
1119
+ /** 评论正文也不随轮询走:一条讨论动辄几百字,抽屉打开时按卡取一次,轮询只给条数。 */
1120
+ export function publicComments(task) {
1121
+ const comments = Array.isArray(task?.comments) ? task.comments : []
1122
+ return { comments: [], commentCount: comments.length }
1123
+ }
1124
+
1125
+ /** 轮询里只给长度:交付物正文能到几十 KB(实测占整包 57%),抽屉点「阅读 / 正文」时再单独取。 */
1126
+ export function publicArtifact(artifact) {
1127
+ const content = String(artifact?.content ?? '')
1128
+ return { ...artifact, content: '', contentLength: content.length }
1129
+ }
1130
+
1131
+
1132
+ /** 交付物:老数据可能是纯字符串,统一升级成一条记录。 */
1133
+ export function sanitizeArtifact(raw, index) {
1134
+ if (typeof raw === 'string') {
1135
+ const title = raw.trim().slice(0, 200)
1136
+ if (title.length === 0) return undefined
1137
+ return { id: `a${index + 1}-${randomBytes(2).toString('hex')}`, kind: 'other', title, ref: '', stage: '', status: 'delivered', by: '', at: 0 }
1138
+ }
1139
+ if (raw === null || typeof raw !== 'object') return undefined
1140
+ const rawTitle = String(raw.title ?? raw.ref ?? '').trim().slice(0, 200)
1141
+ // 老数据里 title 常常是文件路径,看板上显示成「docs/t-xxx-...」很难读,
1142
+ // 这里退化成文件名(去扩展名、连字符换空格)。
1143
+ let title = rawTitle
1144
+ let ref = String(raw.ref ?? '').trim().slice(0, 500)
1145
+ if (/\.[a-z0-9]{1,8}$/i.test(rawTitle) && (rawTitle.includes('/') || rawTitle.includes('\\'))) {
1146
+ // 路径变成了标题,但 ref 是空的:把路径留在 ref 里,归档时才能回读正文。
1147
+ if (ref === '') ref = rawTitle.slice(0, 500)
1148
+ const base = rawTitle.split(/[\\/]/).filter(Boolean).pop() ?? rawTitle
1149
+ const cleaned = base.replace(/\.[a-z0-9]{1,8}$/i, '').replace(/[-_]+/g, ' ').trim()
1150
+ if (cleaned.length >= 2) title = cleaned.slice(0, 200)
1151
+ }
1152
+ if (title.length === 0) return undefined
1153
+ const kind = ARTIFACT_KINDS.includes(String(raw.kind)) ? String(raw.kind) : 'other'
1154
+ const status = ARTIFACT_STATUS.includes(String(raw.status)) ? String(raw.status) : 'delivered'
1155
+ const stage = STATUS_IDS.includes(String(raw.stage)) ? String(raw.stage) : ''
1156
+ const docRaw = raw.doc !== null && typeof raw.doc === 'object' ? raw.doc : {}
1157
+ return {
1158
+ id: String(raw.id ?? '').trim() || `a${index + 1}-${randomBytes(2).toString('hex')}`,
1159
+ kind,
1160
+ title,
1161
+ ref: ref,
1162
+ content: String(raw.content ?? '').slice(0, 60000),
1163
+ stage,
1164
+ status,
1165
+ by: String(raw.by ?? '').slice(0, 40),
1166
+ at: Number(raw.at) || 0,
1167
+ doc: {
1168
+ token: String(docRaw.token ?? '').trim().slice(0, 80),
1169
+ url: String(docRaw.url ?? '').trim().slice(0, 500),
1170
+ name: String(docRaw.name ?? '').trim().slice(0, 200),
1171
+ at: Number(docRaw.at) || 0,
1172
+ error: String(docRaw.error ?? '').slice(0, 300),
1173
+ },
1174
+ }
1175
+ }
1176
+
1177
+ /** 交付物清单:一张卡最多 40 件。 */
1178
+ export function sanitizeArtifacts(input) {
1179
+ if (!Array.isArray(input)) return []
1180
+ const seen = new Set()
1181
+ const out = []
1182
+ for (const [index, raw] of input.slice(0, 40).entries()) {
1183
+ const item = sanitizeArtifact(raw, index)
1184
+ if (item === undefined || seen.has(item.id)) continue
1185
+ seen.add(item.id)
1186
+ out.push(item)
1187
+ }
1188
+ return out
1189
+ }
1190
+
1191
+ /** 用例执行结果:还没跑 / 通过 / 不通过 / 不适用。 */
1192
+ export const CASE_RESULTS = ['pending', 'pass', 'fail', 'skip']
1193
+ /** 用例的类型与优先级:让测试计划能按类型/优先级挑,而不是只有一句标题。 */
1194
+ export const CASE_TYPES = ['functional', 'api', 'ui', 'performance', 'security', 'other']
1195
+ export const CASE_TYPE_LABEL = { functional: '功能', api: '接口', ui: '界面', performance: '性能', security: '安全', other: '其它' }
1196
+ export const CASE_PRIORITIES = ['p0', 'p1', 'p2', 'p3']
1197
+
1198
+ /** 第一版执行器只跑 HTTP 请求:方法白名单里没有的按 GET 处理。 */
1199
+ export const CASE_HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD']
1200
+
1201
+ /**
1202
+ * 一条用例的可执行定义。相对 URL 拼环境里的 baseUrl,绝对 URL 必须命中允许的 host
1203
+ * (执行器跑在服务器上,不能让它变成打内网的跳板)。
1204
+ */
1205
+ export function sanitizeCaseHttp(raw) {
1206
+ if (raw === null || typeof raw !== 'object') return null
1207
+ const url = String(raw.url ?? '').trim().slice(0, 500)
1208
+ const headers = {}
1209
+ if (raw.headers !== null && typeof raw.headers === 'object' && !Array.isArray(raw.headers)) {
1210
+ for (const [key, value] of Object.entries(raw.headers).slice(0, 20)) {
1211
+ const name = String(key).trim().slice(0, 60)
1212
+ if (name.length === 0) continue
1213
+ headers[name] = String(value ?? '').slice(0, 500)
1214
+ }
1215
+ }
1216
+ const method = String(raw.method ?? 'GET').trim().toUpperCase()
1217
+ const pickVars = input => {
1218
+ const out = {}
1219
+ if (input !== null && typeof input === 'object' && !Array.isArray(input)) {
1220
+ for (const [key, value] of Object.entries(input).slice(0, 20)) {
1221
+ const name = String(key).trim().slice(0, 40)
1222
+ if (name.length === 0) continue
1223
+ out[name] = String(value ?? '').slice(0, 500)
1224
+ }
1225
+ }
1226
+ return out
1227
+ }
1228
+ // 多步用例:登录拿 token → 用 token 调业务接口 → 用返回的 id 查详情,一条用例串起来。
1229
+ const steps = Array.isArray(raw.steps)
1230
+ ? raw.steps.slice(0, 20).map(step => {
1231
+ const target = step !== null && typeof step === 'object' ? step : {}
1232
+ const stepMethod = String(target.method ?? 'GET').trim().toUpperCase()
1233
+ return {
1234
+ method: CASE_HTTP_METHODS.includes(stepMethod) ? stepMethod : 'GET',
1235
+ url: String(target.url ?? '').trim().slice(0, 500),
1236
+ body: String(target.body ?? '').slice(0, 8000),
1237
+ expectStatus: Number.isInteger(Number(target.expectStatus)) ? Math.max(0, Math.min(599, Number(target.expectStatus))) : 0,
1238
+ expectContains: String(target.expectContains ?? '').slice(0, 300),
1239
+ extract: pickVars(target.extract),
1240
+ }
1241
+ }).filter(step => step.url !== '')
1242
+ : []
1243
+ if (url.length === 0 && steps.length === 0) return null
1244
+ return {
1245
+ method: CASE_HTTP_METHODS.includes(method) ? method : 'GET',
1246
+ url,
1247
+ headers,
1248
+ body: String(raw.body ?? '').slice(0, 8000),
1249
+ /** 0 = 任意 2xx/3xx 都算通过。 */
1250
+ expectStatus: Number.isInteger(Number(raw.expectStatus)) ? Math.max(0, Math.min(599, Number(raw.expectStatus))) : 0,
1251
+ /** 响应体里必须出现的字符串,空则不比。 */
1252
+ expectContains: String(raw.expectContains ?? '').slice(0, 300),
1253
+ timeoutMs: Math.max(0, Math.min(120000, Number(raw.timeoutMs) || 0)),
1254
+ steps,
1255
+ vars: pickVars(raw.vars),
1256
+ }
1257
+ }
1258
+
1259
+ /** 一条测试用例:做什么、期望什么、实际结果,以及最近一次执行的样子。 */
1260
+ export function sanitizeCase(raw, index) {
1261
+ if (typeof raw === 'string') {
1262
+ const title = raw.trim().slice(0, 200)
1263
+ if (title.length === 0) return undefined
1264
+ return {
1265
+ id: `c${index + 1}-${randomBytes(2).toString('hex')}`,
1266
+ title,
1267
+ expected: '',
1268
+ pointId: '',
1269
+ result: 'pending',
1270
+ type: 'functional',
1271
+ priority: 'p2',
1272
+ preconditions: '',
1273
+ steps: [],
1274
+ note: '',
1275
+ by: '',
1276
+ at: 0,
1277
+ http: null,
1278
+ lastRun: null,
1279
+ }
1280
+ }
1281
+ if (raw === null || typeof raw !== 'object') return undefined
1282
+ const title = String(raw.title ?? '').trim().slice(0, 200)
1283
+ if (title.length === 0) return undefined
1284
+ const result = CASE_RESULTS.includes(String(raw.result)) ? String(raw.result) : 'pending'
1285
+ const type = CASE_TYPES.includes(String(raw.type)) ? String(raw.type) : 'functional'
1286
+ const priority = CASE_PRIORITIES.includes(String(raw.priority).toLowerCase()) ? String(raw.priority).toLowerCase() : 'p2'
1287
+ return {
1288
+ id: String(raw.id ?? '').trim() || `c${index + 1}-${randomBytes(2).toString('hex')}`,
1289
+ title,
1290
+ expected: String(raw.expected ?? '').trim().slice(0, 300),
1291
+ pointId: String(raw.pointId ?? '').trim().slice(0, 24),
1292
+ result,
1293
+ /** 用例类型:功能 / 接口 / 界面 / 性能 / 安全。 */
1294
+ type,
1295
+ /** 用例优先级:p0 必测、p3 有余力再测。 */
1296
+ priority,
1297
+ /** 前置条件:跑这条用例之前系统该是什么状态。 */
1298
+ preconditions: String(raw.preconditions ?? '').slice(0, 800),
1299
+ /** 操作步骤:一行一步,最多 20 步。 */
1300
+ steps: (Array.isArray(raw.steps) ? raw.steps : [])
1301
+ .map(item => String(item ?? '').trim().slice(0, 200))
1302
+ .filter(item => item !== '')
1303
+ .slice(0, 20),
1304
+ /** 不稳定用例:失败时不自动开缺陷,避免把环境抖动当成产品缺陷。 */
1305
+ flaky: raw.flaky === true,
1306
+ note: String(raw.note ?? '').trim().slice(0, 400),
1307
+ by: String(raw.by ?? '').slice(0, 40),
1308
+ at: Number(raw.at) || 0,
1309
+ http: sanitizeCaseHttp(raw.http),
1310
+ lastRun: sanitizeCaseRun(raw.lastRun),
1311
+ }
1312
+ }
1313
+
1314
+ /** 一次用例执行的结果摘要(放在用例上,最多保留这几个字段)。 */
1315
+ export function sanitizeCaseRun(raw) {
1316
+ if (raw === null || typeof raw !== 'object') return null
1317
+ const steps = Array.isArray(raw.steps) ? raw.steps.slice(0, 20).map((step, index) => ({
1318
+ i: Number(step?.i) || index + 1,
1319
+ method: String(step?.method ?? 'GET').slice(0, 10),
1320
+ url: String(step?.url ?? '').slice(0, 500),
1321
+ ok: step?.ok === true,
1322
+ status: Number(step?.status) || 0,
1323
+ ms: Number(step?.ms) || 0,
1324
+ error: String(step?.error ?? '').slice(0, 300),
1325
+ preview: String(step?.preview ?? '').slice(0, 300),
1326
+ })) : []
1327
+ return {
1328
+ at: Number(raw.at) || 0,
1329
+ ok: raw.ok === true,
1330
+ status: Number(raw.status) || 0,
1331
+ ms: Number(raw.ms) || 0,
1332
+ error: String(raw.error ?? '').slice(0, 400),
1333
+ preview: String(raw.preview ?? '').slice(0, 600),
1334
+ response: String(raw.response ?? '').slice(0, 2000),
1335
+ env: String(raw.env ?? '').slice(0, 40),
1336
+ steps,
1337
+ }
1338
+ }
1339
+
1340
+ /** 一次批量执行:哪张卡、哪个环境、跑了几条、几条不过。 */
1341
+ export function sanitizeTestRun(raw, index) {
1342
+ if (raw === null || typeof raw !== 'object') return undefined
1343
+ const results = (Array.isArray(raw.results) ? raw.results : []).slice(0, 60).map(item => ({
1344
+ id: String(item?.id ?? '').slice(0, 60),
1345
+ title: String(item?.title ?? '').slice(0, 200),
1346
+ ok: item?.ok === true,
1347
+ status: Number(item?.status) || 0,
1348
+ ms: Number(item?.ms) || 0,
1349
+ error: String(item?.error ?? '').slice(0, 400),
1350
+ }))
1351
+ return {
1352
+ id: String(raw.id ?? '').trim() || `r${index + 1}-${randomBytes(2).toString('hex')}`,
1353
+ at: Number(raw.at) || 0,
1354
+ by: String(raw.by ?? '').slice(0, 40),
1355
+ env: String(raw.env ?? '').slice(0, 40),
1356
+ total: results.length,
1357
+ passed: results.filter(item => item.ok).length,
1358
+ failed: results.filter(item => item.ok !== true).length,
1359
+ results,
1360
+ }
1361
+ }
1362
+
1363
+ /** 一张卡最多保留 10 次执行记录。 */
1364
+ export function sanitizeTestRuns(input) {
1365
+ if (!Array.isArray(input)) return []
1366
+ const out = []
1367
+ for (const [index, raw] of input.slice(-10).entries()) {
1368
+ const item = sanitizeTestRun(raw, index)
1369
+ if (item !== undefined) out.push(item)
1370
+ }
1371
+ return out
1372
+ }
1373
+
1374
+ /** 测试环境:一个名字 + baseUrl + 默认请求头。 */
1375
+ export function sanitizeEnvs(input) {
1376
+ if (!Array.isArray(input)) return []
1377
+ const seen = new Set()
1378
+ const out = []
1379
+ for (const raw of input.slice(0, 12)) {
1380
+ if (raw === null || typeof raw !== 'object') continue
1381
+ const name = String(raw.name ?? '').trim().slice(0, 40)
1382
+ const baseUrl = String(raw.baseUrl ?? '').trim().slice(0, 300)
1383
+ if (name.length === 0 || baseUrl.length === 0) continue
1384
+ const id = cleanId(raw.id ?? name, 'env')
1385
+ if (seen.has(id)) continue
1386
+ seen.add(id)
1387
+ const headers = {}
1388
+ if (raw.headers !== null && typeof raw.headers === 'object' && !Array.isArray(raw.headers)) {
1389
+ for (const [key, value] of Object.entries(raw.headers).slice(0, 20)) {
1390
+ const head = String(key).trim().slice(0, 60)
1391
+ if (head.length === 0) continue
1392
+ headers[head] = String(value ?? '').slice(0, 500)
1393
+ }
1394
+ }
1395
+ out.push({ id, name, baseUrl, headers })
1396
+ }
1397
+ return out
1398
+ }
1399
+
1400
+ /**
1401
+ * 部署环境:只是「记录发布去过哪」的清单,平台不触发任何部署。
1402
+ * dev / staging / prod 是出厂默认,名字、说明都能改,id 用来挂部署记录。
1403
+ */
1404
+ export const DEPLOY_ENV_KINDS = ['dev', 'staging', 'prod', 'other']
1405
+
1406
+ export function defaultDeployEnvs() {
1407
+ return [
1408
+ { id: 'dev', name: '开发', kind: 'dev', note: '开发自测环境' },
1409
+ { id: 'staging', name: '预发', kind: 'staging', note: '上线前最后一关' },
1410
+ { id: 'prod', name: '生产', kind: 'prod', note: '真实用户' },
1411
+ ]
1412
+ }
1413
+
1414
+ export function sanitizeDeployEnvs(input) {
1415
+ if (!Array.isArray(input)) return []
1416
+ const seen = new Set()
1417
+ const out = []
1418
+ for (const raw of input.slice(0, 12)) {
1419
+ if (raw === null || typeof raw !== 'object') continue
1420
+ const id = cleanId(raw.id ?? raw.name, 'env')
1421
+ if (id === '' || seen.has(id)) continue
1422
+ seen.add(id)
1423
+ out.push({
1424
+ id,
1425
+ name: String(raw.name ?? '').trim().slice(0, 40) || id,
1426
+ kind: DEPLOY_ENV_KINDS.includes(String(raw.kind)) ? String(raw.kind) : 'other',
1427
+ note: String(raw.note ?? '').slice(0, 200),
1428
+ })
1429
+ }
1430
+ return out
1431
+ }
1432
+
1433
+ /** 执行器只允许打这些 host(防止被提示词带着去扫内网)。 */
1434
+ export function sanitizeAllowHosts(input) {
1435
+ const list = Array.isArray(input) ? input : String(input ?? '').split(/[,,\s]+/)
1436
+ const out = []
1437
+ for (const raw of list.slice(0, 40)) {
1438
+ const host = String(raw ?? '').trim().toLowerCase().slice(0, 120)
1439
+ if (host.length === 0) continue
1440
+ if (!/^[a-z0-9.*-]+$/.test(host)) continue
1441
+ if (!out.includes(host)) out.push(host)
1442
+ }
1443
+ return out
1444
+ }
1445
+
1446
+ /** 测试执行配置:环境、默认环境、超时、允许的域名。 */
1447
+ export function sanitizeTesting(raw) {
1448
+ const base = { envs: [], defaultEnv: '', timeoutMs: 15000, allowHosts: ['127.0.0.1', 'localhost'], concurrency: 4 }
1449
+ if (raw === null || typeof raw !== 'object') return base
1450
+ const envs = sanitizeEnvs(raw.envs)
1451
+ const defaultEnv = String(raw.defaultEnv ?? '').trim()
1452
+ return {
1453
+ envs,
1454
+ defaultEnv: envs.some(env => env.id === defaultEnv) ? defaultEnv : (envs[0]?.id ?? ''),
1455
+ timeoutMs: Math.max(1000, Math.min(120000, Number(raw.timeoutMs) || base.timeoutMs)),
1456
+ allowHosts: sanitizeAllowHosts(raw.allowHosts ?? base.allowHosts),
1457
+ concurrency: Math.max(1, Math.min(8, Number(raw.concurrency) || base.concurrency)),
1458
+ }
1459
+ }
1460
+
1461
+
1462
+ /** 一张卡最多 60 条用例。 */
1463
+ export function sanitizeCases(input) {
1464
+ if (!Array.isArray(input)) return []
1465
+ const seen = new Set()
1466
+ const out = []
1467
+ for (const [index, raw] of input.slice(0, 60).entries()) {
1468
+ const item = sanitizeCase(raw, index)
1469
+ if (item === undefined || seen.has(item.id)) continue
1470
+ seen.add(item.id)
1471
+ out.push(item)
1472
+ }
1473
+ return out
1474
+ }
1475
+
1476
+ // ------------------------------------------------------- Bug 自动闭环(用例失败 → 缺陷卡)
1477
+
1478
+ /** 缺陷卡的「来源」:哪张测试卡、哪条用例、当时的证据。 */
1479
+ export function sanitizeBugSource(raw) {
1480
+ if (raw === null || typeof raw !== 'object') return null
1481
+ const taskId = String(raw.taskId ?? '').trim()
1482
+ if (taskId === '') return null
1483
+ return {
1484
+ taskId: taskId.slice(0, 60),
1485
+ code: String(raw.code ?? '').trim().slice(0, 40),
1486
+ caseId: String(raw.caseId ?? '').trim().slice(0, 60),
1487
+ caseTitle: String(raw.caseTitle ?? '').trim().slice(0, 200),
1488
+ expected: String(raw.expected ?? '').trim().slice(0, 300),
1489
+ note: String(raw.note ?? '').trim().slice(0, 400),
1490
+ by: String(raw.by ?? '').slice(0, 40),
1491
+ at: Number(raw.at) || 0,
1492
+ }
1493
+ }
1494
+
1495
+ // ---------------------------------------------------------------- AI Coding 执行记录
1496
+
1497
+ export const CODING_REVIEW_STATUS = ['pending', 'approved', 'changes_requested']
1498
+ export const CODING_FILE_CHANGES = ['added', 'modified', 'deleted']
1499
+
1500
+ /** 一次提交:谁改的、改了哪些文件、多少行。 */
1501
+ export function sanitizeCommit(raw, index) {
1502
+ if (raw === null || typeof raw !== 'object') return undefined
1503
+ const sha = String(raw.sha ?? '').trim().slice(0, 64)
1504
+ if (sha === '') return undefined
1505
+ return {
1506
+ id: `${sha.slice(0, 12)}-${index}`,
1507
+ sha,
1508
+ message: String(raw.message ?? '').trim().slice(0, 300),
1509
+ at: Number(raw.at) || Date.now(),
1510
+ files: Math.max(0, Math.min(9999, Number(raw.files) || 0)),
1511
+ additions: Math.max(0, Number(raw.additions) || 0),
1512
+ deletions: Math.max(0, Number(raw.deletions) || 0),
1513
+ }
1514
+ }
1515
+
1516
+ export function sanitizeCommits(input) {
1517
+ if (!Array.isArray(input)) return []
1518
+ const out = []
1519
+ for (const [index, raw] of input.slice(0, 60).entries()) {
1520
+ const item = sanitizeCommit(raw, index)
1521
+ if (item !== undefined) out.push(item)
1522
+ }
1523
+ return out
1524
+ }
1525
+
1526
+ /** 一个被改动的文件。 */
1527
+ export function sanitizeChangedFile(raw, index) {
1528
+ if (raw === null || typeof raw !== 'object') return undefined
1529
+ const path = String(raw.path ?? '').trim().slice(0, 300)
1530
+ if (path === '') return undefined
1531
+ return {
1532
+ id: `${path}-${index}`.slice(0, 340),
1533
+ path,
1534
+ change: CODING_FILE_CHANGES.includes(String(raw.change)) ? String(raw.change) : 'modified',
1535
+ additions: Math.max(0, Number(raw.additions) || 0),
1536
+ deletions: Math.max(0, Number(raw.deletions) || 0),
1537
+ }
1538
+ }
1539
+
1540
+ export function sanitizeChangedFiles(input) {
1541
+ if (!Array.isArray(input)) return []
1542
+ const out = []
1543
+ for (const [index, raw] of input.slice(0, 300).entries()) {
1544
+ const item = sanitizeChangedFile(raw, index)
1545
+ if (item !== undefined) out.push(item)
1546
+ }
1547
+ return out
1548
+ }
1549
+
1550
+ /**
1551
+ * AI Coding 执行记录:这一棒到底是谁、照着哪版需求、改了哪些代码、单测过没过、谁准的合并。
1552
+ * 文档里「AI 执行记录」列的正是这七项。
1553
+ */
1554
+ export function sanitizeCoding(raw) {
1555
+ if (raw === null || raw === undefined || typeof raw !== 'object') return null
1556
+ const tests = raw.unitTests !== null && typeof raw.unitTests === 'object' ? raw.unitTests : {}
1557
+ const review = raw.review !== null && typeof raw.review === 'object' ? raw.review : {}
1558
+ const total = Math.max(0, Math.min(99999, Number(tests.total) || 0))
1559
+ const passed = Math.max(0, Math.min(total, Number(tests.passed) || 0))
1560
+ const failed = Math.max(0, Math.min(total - passed, Number(tests.failed) || (total - passed)))
1561
+ return {
1562
+ agent: String(raw.agent ?? '').trim().slice(0, 80),
1563
+ model: String(raw.model ?? '').trim().slice(0, 80),
1564
+ branch: String(raw.branch ?? '').trim().slice(0, 200),
1565
+ context: String(raw.context ?? '').slice(0, 4000),
1566
+ commits: sanitizeCommits(raw.commits),
1567
+ files: sanitizeChangedFiles(raw.files),
1568
+ unitTests: { total, passed, failed, at: Number(tests.at) || 0 },
1569
+ review: {
1570
+ status: CODING_REVIEW_STATUS.includes(String(review.status)) ? String(review.status) : 'pending',
1571
+ by: String(review.by ?? '').slice(0, 40),
1572
+ at: Number(review.at) || 0,
1573
+ note: String(review.note ?? '').slice(0, 600),
1574
+ },
1575
+ mergedBy: String(raw.mergedBy ?? '').slice(0, 40),
1576
+ mergedAt: Number(raw.mergedAt) || 0,
1577
+ // 最近一次「从 GitLab 核对」的结果(MR 状态 / 流水线 / 文件数与提交数);没核对过就是 null。
1578
+ gitlab: raw.gitlab === null || raw.gitlab === undefined || typeof raw.gitlab !== 'object'
1579
+ ? null
1580
+ : {
1581
+ at: Number(raw.gitlab.at) || 0,
1582
+ state: String(raw.gitlab.state ?? '').slice(0, 20),
1583
+ title: String(raw.gitlab.title ?? '').slice(0, 200),
1584
+ pipeline: raw.gitlab.pipeline === null || raw.gitlab.pipeline === undefined ? null : String(raw.gitlab.pipeline).slice(0, 20),
1585
+ files: {
1586
+ declared: Math.max(0, Number(raw.gitlab.files?.declared) || 0),
1587
+ actual: Math.max(0, Number(raw.gitlab.files?.actual) || 0),
1588
+ },
1589
+ commits: {
1590
+ declared: Math.max(0, Number(raw.gitlab.commits?.declared) || 0),
1591
+ actual: Math.max(0, Number(raw.gitlab.commits?.actual) || 0),
1592
+ },
1593
+ mismatches: (Array.isArray(raw.gitlab.mismatches) ? raw.gitlab.mismatches : []).map(item => String(item).slice(0, 120)).slice(0, 6),
1594
+ },
1595
+ // 最近一次「执行记录 vs 代码事实」的核对结果;没核对过就是 null。
1596
+ verified: raw.verified === null || raw.verified === undefined || typeof raw.verified !== 'object'
1597
+ ? null
1598
+ : {
1599
+ at: Number(raw.verified.at) || 0,
1600
+ branch: String(raw.verified.branch ?? '').slice(0, 200),
1601
+ declaredBranch: String(raw.verified.declaredBranch ?? '').slice(0, 200),
1602
+ branchOk: raw.verified.branchOk === true,
1603
+ commitCount: {
1604
+ declared: Math.max(0, Number(raw.verified.commitCount?.declared) || 0),
1605
+ actual: Math.max(0, Number(raw.verified.commitCount?.actual) || 0),
1606
+ },
1607
+ fileCount: {
1608
+ declared: Math.max(0, Number(raw.verified.fileCount?.declared) || 0),
1609
+ actual: Math.max(0, Number(raw.verified.fileCount?.actual) || 0),
1610
+ },
1611
+ missingCommits: (Array.isArray(raw.verified.missingCommits) ? raw.verified.missingCommits : []).map(item => String(item).slice(0, 80)).slice(0, 10),
1612
+ missingFiles: (Array.isArray(raw.verified.missingFiles) ? raw.verified.missingFiles : []).map(item => String(item).slice(0, 300)).slice(0, 20),
1613
+ dirty: (Array.isArray(raw.verified.dirty) ? raw.verified.dirty : []).map(item => String(item).slice(0, 300)).slice(0, 10),
1614
+ ok: raw.verified.ok === true,
1615
+ },
1616
+ updatedAt: Number(raw.updatedAt) || Date.now(),
1617
+ }
1618
+ }
1619
+
1620
+ /** 把外部传进来的 patch 合到已有记录上(数组整段替换,不追加重叠)。 */
1621
+ export function mergeCoding(current, patch) {
1622
+ const base = sanitizeCoding(current ?? {}) ?? sanitizeCoding({})
1623
+ const merged = { ...base, ...(patch ?? {}), updatedAt: Date.now() }
1624
+ if (patch?.commits !== undefined) merged.commits = patch.commits
1625
+ if (patch?.files !== undefined) merged.files = patch.files
1626
+ if (patch?.unitTests !== undefined) merged.unitTests = { ...base.unitTests, ...patch.unitTests }
1627
+ if (patch?.review !== undefined) merged.review = { ...base.review, ...patch.review }
1628
+ return sanitizeCoding(merged)
1629
+ }
1630
+
1631
+ /** 单测通过率文案。 */
1632
+ export function codingSummary(coding) {
1633
+ if (coding === null || coding === undefined) return ''
1634
+ const parts = []
1635
+ if (coding.agent !== '') parts.push(coding.agent)
1636
+ if (coding.files.length > 0) parts.push(`${coding.files.length} 个文件`)
1637
+ if (coding.commits.length > 0) parts.push(`${coding.commits.length} 次提交`)
1638
+ if (coding.unitTests.total > 0) parts.push(`单测 ${coding.unitTests.passed}/${coding.unitTests.total}`)
1639
+ if (coding.review.status === 'approved') parts.push('评审通过')
1640
+ else if (coding.review.status === 'changes_requested') parts.push('评审待改')
1641
+ if (coding.mergedAt > 0) parts.push(`已合并 ${coding.mergedBy || ''}`.trim())
1642
+ return parts.join(' · ')
1643
+ }
1644
+
1645
+ /** 清单模板:每种卡片类型一组条目。 */
1646
+ export function sanitizeTemplates(input) {
1647
+ const base = defaultTemplates()
1648
+ if (input === null || typeof input !== 'object' || Array.isArray(input)) return base
1649
+ const out = { ...base }
1650
+ for (const type of TASK_TYPES) {
1651
+ const raw = input[type]
1652
+ if (!Array.isArray(raw)) continue
1653
+ const items = raw
1654
+ .map(item => String(item ?? '').trim().slice(0, 200))
1655
+ .filter(item => item.length > 0)
1656
+ .slice(0, 12)
1657
+ out[type] = items
1658
+ }
1659
+ return out
1660
+ }
1661
+
1662
+ /** Replace the entry with the same id, or append a new one. */
1663
+
1664
+ export function sanitizeAccess(raw) {
1665
+ const input = raw !== null && typeof raw === 'object' ? raw : {}
1666
+ const members = []
1667
+ const seen = new Set()
1668
+ if (Array.isArray(input.members)) {
1669
+ for (const item of input.members) {
1670
+ if (members.length >= 50) break
1671
+ const entry = item !== null && typeof item === 'object' ? item : {}
1672
+ const name = String(entry.name ?? '').trim().slice(0, 40)
1673
+ if (name === '') continue
1674
+ const key = name.toLowerCase()
1675
+ if (seen.has(key)) continue
1676
+ seen.add(key)
1677
+ const id = String(entry.id ?? entry.name ?? '').trim().slice(0, 60)
1678
+ members.push({ id, name, role: ACCESS_ROLES.includes(entry.role) ? entry.role : 'member' })
1679
+ }
1680
+ }
1681
+ return {
1682
+ enabled: input.enabled === true,
1683
+ agents: input.agents === 'bypass' ? 'bypass' : 'inherit',
1684
+ members,
1685
+ }
1686
+ }
1687
+
1688
+ export function worktreeRoot(config) {
1689
+ const explicit = String(config?.agents?.worktreeRoot ?? '').trim()
1690
+ if (explicit.length > 0) return explicit
1691
+ const workspace = String(config?.agents?.workspaceDir ?? '').trim() || join(homedir(), 'dsh-workspace')
1692
+ return join(dirname(workspace), 'dsh-worktrees')
1693
+ }
1694
+
1695
+ export function text(value, max = 2000) {
1696
+ if (typeof value !== 'string') return undefined
1697
+ const trimmed = value.trim()
1698
+ return trimmed.length === 0 ? undefined : trimmed.slice(0, max)
1699
+ }
1700
+
1701
+ /** Coerce one arbitrary input object into a well-formed task patch. */
1702
+ export function normalizePatch(input) {
1703
+ const patch = {}
1704
+ if (input.title !== undefined) {
1705
+ const title = text(input.title, 200)
1706
+ if (title === undefined) throw new Error('title must not be empty')
1707
+ patch.title = title
1708
+ }
1709
+ if (input.requirement !== undefined) patch.requirement = text(input.requirement, 20000) ?? ''
1710
+ if (input.type !== undefined) {
1711
+ const type = String(input.type)
1712
+ if (!TASK_TYPES.includes(type)) throw new Error(`unknown type ${type}`)
1713
+ patch.type = type
1714
+ }
1715
+ if (input.severity !== undefined) {
1716
+ const severity = String(input.severity).toLowerCase()
1717
+ patch.severity = severity === '' ? '' : (SEVERITIES.includes(severity) ? severity : 'p2')
1718
+ }
1719
+ if (input.role !== undefined) {
1720
+ const role = String(input.role)
1721
+ if (!ROLE_IDS.includes(role)) throw new Error(`unknown role ${role}`)
1722
+ patch.role = role
1723
+ }
1724
+ if (input.status !== undefined) {
1725
+ const status = String(input.status)
1726
+ if (!STATUS_SET.has(status)) throw new Error(`unknown status ${status}`)
1727
+ // 旧客户端仍把「阻塞」当一列:翻译成标记,车道保持不动。
1728
+ if (status === LEGACY_BLOCKED_STATUS) patch.blocked = true
1729
+ else patch.status = status
1730
+ }
1731
+ if (input.blocked !== undefined) patch.blocked = input.blocked === true
1732
+ if (input.priority !== undefined) {
1733
+ const priority = String(input.priority)
1734
+ if (!PRIORITIES.includes(priority)) throw new Error(`unknown priority ${priority}`)
1735
+ patch.priority = priority
1736
+ }
1737
+ for (const key of ['gitlabIssue', 'gitlabMR', 'owner', 'blockedReason', 'feishuThread']) {
1738
+ if (input[key] !== undefined) patch[key] = text(input[key], 500) ?? ''
1739
+ }
1740
+ if (input.expert !== undefined) patch.expert = text(input.expert, 40) ?? ''
1741
+ if (input.domain !== undefined) patch.domain = text(input.domain, 40) ?? ''
1742
+ if (input.gitlabProject !== undefined) patch.gitlabProject = text(input.gitlabProject, 60) ?? ''
1743
+ for (const key of ['project', 'iteration', 'parent', 'service']) {
1744
+ if (input[key] !== undefined) patch[key] = text(input[key], 60) ?? ''
1745
+ }
1746
+ if (input.requirementId !== undefined) patch.requirementId = text(input.requirementId, 60) ?? ''
1747
+ if (input.pointId !== undefined) patch.pointId = text(input.pointId, 24) ?? ''
1748
+ if (input.override !== undefined) patch.override = text(input.override, 300) ?? ''
1749
+ if (input.releaseId !== undefined) patch.releaseId = text(input.releaseId, 60) ?? ''
1750
+ if (input.dueAt !== undefined) {
1751
+ const dueAt = Number(input.dueAt)
1752
+ patch.dueAt = Number.isFinite(dueAt) && dueAt > 0 ? dueAt : 0
1753
+ }
1754
+ if (input.artifacts !== undefined) {
1755
+ patch.artifacts = sanitizeArtifacts(Array.isArray(input.artifacts) ? input.artifacts : [])
1756
+ }
1757
+ if (input.checklist !== undefined) {
1758
+ patch.checklist = sanitizeChecklist(Array.isArray(input.checklist) ? input.checklist : [])
1759
+ }
1760
+ if (input.cases !== undefined) {
1761
+ patch.cases = Array.isArray(input.cases)
1762
+ ? sanitizeCases(input.cases.map(item => ({
1763
+ ...item,
1764
+ http: item?.http ?? (item?.url !== undefined && String(item.url).trim().length > 0
1765
+ ? { method: item.method, url: item.url, body: item.body, expectStatus: item.expectStatus, expectContains: item.expectContains }
1766
+ : undefined),
1767
+ })))
1768
+ : []
1769
+ }
1770
+ if (input.tags !== undefined) {
1771
+ patch.tags = Array.isArray(input.tags) ? input.tags.map(t => String(t).slice(0, 40)).slice(0, 20) : []
1772
+ }
1773
+ if (input.bugSource !== undefined) patch.bugSource = sanitizeBugSource(input.bugSource)
1774
+ return patch
1775
+ }
1776
+
1777
+
1778
+
1779
+ export function sanitizeAuditEntry(raw, index) {
1780
+ const input = raw !== null && typeof raw === 'object' ? raw : {}
1781
+ const route = String(input.route ?? '').slice(0, 120)
1782
+ if (route === '') return null
1783
+ return {
1784
+ id: String(input.id ?? `au-${index}`).slice(0, 60),
1785
+ at: Number.isFinite(input.at) ? Number(input.at) : 0,
1786
+ by: String(input.by ?? '').slice(0, 40) || '人',
1787
+ route,
1788
+ level: ['read', 'write', 'admin'].includes(input.level) ? input.level : 'write',
1789
+ status: Number.isFinite(input.status) ? Number(input.status) : 0,
1790
+ ok: input.ok === true,
1791
+ summary: String(input.summary ?? '').slice(0, 200),
1792
+ }
1793
+ }
1794
+
1795
+ export function sanitizeAudit(input) {
1796
+ if (!Array.isArray(input)) return []
1797
+ const out = []
1798
+ for (const raw of input.slice(-1000)) {
1799
+ const item = sanitizeAuditEntry(raw, out.length)
1800
+ if (item !== null) out.push(item)
1801
+ }
1802
+ return out
1803
+ }
1804
+
1805
+ export function sanitizeReleases(input) {
1806
+ if (!Array.isArray(input)) return []
1807
+ const out = []
1808
+ const seen = new Set()
1809
+ input.slice(0, 200).forEach((raw, index) => {
1810
+ const item = sanitizeRelease(raw, index)
1811
+ if (item === null) return
1812
+ let id = item.id
1813
+ let suffix = 1
1814
+ while (seen.has(id)) {
1815
+ id = `${item.id}-${suffix++}`
1816
+ }
1817
+ seen.add(id)
1818
+ out.push({ ...item, id })
1819
+ })
1820
+ return out
1821
+ }
1822
+
1823
+ export function sanitizeRelease(raw, index = 0) {
1824
+ if (raw === null || typeof raw !== 'object') return null
1825
+ const version = String(raw.version ?? '').trim().slice(0, 40)
1826
+ const name = String(raw.name ?? '').trim().slice(0, 120)
1827
+ if (version === '' && name === '') return null
1828
+ return {
1829
+ id: cleanId(raw.id ?? (version === '' ? name : version), `rel-${index}`),
1830
+ version,
1831
+ name,
1832
+ project: String(raw.project ?? '').slice(0, 60),
1833
+ status: RELEASE_STATUSES.includes(raw.status) ? raw.status : 'draft',
1834
+ owner: String(raw.owner ?? '').slice(0, 60),
1835
+ plannedAt: Number(raw.plannedAt) || 0,
1836
+ releasedAt: Number(raw.releasedAt) || 0,
1837
+ notes: String(raw.notes ?? '').slice(0, 60000),
1838
+ approveBy: String(raw.approveBy ?? '').slice(0, 120),
1839
+ approveAt: Number(raw.approveAt) || 0,
1840
+ rollbackReason: String(raw.rollbackReason ?? '').slice(0, 600),
1841
+ /** 发布时绑定的制品:Git 标签 + 提交,出问题能顺着 tag 找到当时的代码。 */
1842
+ tagName: String(raw.tagName ?? '').slice(0, 120),
1843
+ commitSha: String(raw.commitSha ?? '').slice(0, 64),
1844
+ smoke: sanitizeSmoke(raw.smoke),
1845
+ /** 审批留痕:谁在什么时候、基于什么结论批的(不记就等于没人负责)。 */
1846
+ approvals: (Array.isArray(raw.approvals) ? raw.approvals : []).slice(0, 20).map((entry, at) => ({
1847
+ id: String(entry?.id ?? '').trim() || `ap${at + 1}`,
1848
+ by: String(entry?.by ?? '').slice(0, 120),
1849
+ role: String(entry?.role ?? '').slice(0, 40),
1850
+ at: Number(entry?.at) || 0,
1851
+ note: String(entry?.note ?? '').slice(0, 600),
1852
+ })).filter(entry => entry.by !== ''),
1853
+ /** 灰度批次:先 10% 再 50% 再全量,每批谁确认的都要留下。 */
1854
+ grayBatches: (Array.isArray(raw.grayBatches) ? raw.grayBatches : []).slice(0, 20).map((entry, at) => ({
1855
+ id: String(entry?.id ?? '').trim() || `g${at + 1}`,
1856
+ name: String(entry?.name ?? '').slice(0, 120) || `第 ${at + 1} 批`,
1857
+ percent: Math.max(0, Math.min(100, Number(entry?.percent) || 0)),
1858
+ status: ['pending', 'running', 'passed', 'rolled_back'].includes(String(entry?.status)) ? String(entry.status) : 'pending',
1859
+ by: String(entry?.by ?? '').slice(0, 120),
1860
+ at: Number(entry?.at) || 0,
1861
+ note: String(entry?.note ?? '').slice(0, 600),
1862
+ })),
1863
+ /** 部署记录:发布、回滚、灰度推进都写在这里,时间线不用问人。 */
1864
+ deployLog: (Array.isArray(raw.deployLog) ? raw.deployLog : []).slice(-200).map(entry => ({
1865
+ at: Number(entry?.at) || 0,
1866
+ by: String(entry?.by ?? '').slice(0, 120),
1867
+ action: String(entry?.action ?? '').slice(0, 40),
1868
+ /** 部署到的环境 id(只登记,不触发部署)。 */
1869
+ env: String(entry?.env ?? '').slice(0, 40),
1870
+ note: String(entry?.note ?? '').slice(0, 600),
1871
+ })),
1872
+ createdAt: Number(raw.createdAt) || Date.now(),
1873
+ updatedAt: Number(raw.updatedAt) || Date.now(),
1874
+ }
1875
+ }
1876
+
1877
+ export const RELEASE_STATUSES = ['draft', 'candidate', 'approved', 'released', 'rolled_back']
1878
+
1879
+ export const RELEASE_STATUS_LABEL = {
1880
+ draft: '草稿',
1881
+ candidate: '待发布',
1882
+ approved: '已审批',
1883
+ released: '已发布',
1884
+ rolled_back: '已回滚',
1885
+ }
1886
+
1887
+ export function sanitizeSmokeItem(raw, index) {
1888
+ if (raw === null || typeof raw !== 'object') {
1889
+ const plain = String(raw ?? '').trim().slice(0, 200)
1890
+ return plain === '' ? null : { id: `s${index + 1}`, text: plain, done: false, by: '', at: 0 }
1891
+ }
1892
+ const label = String(raw.text ?? '').trim().slice(0, 200)
1893
+ if (label === '') return null
1894
+ return {
1895
+ id: String(raw.id ?? '').trim().slice(0, 24) || `s${index + 1}`,
1896
+ text: label,
1897
+ done: raw.done === true,
1898
+ by: String(raw.by ?? '').slice(0, 60),
1899
+ at: Number(raw.at) || 0,
1900
+ }
1901
+ }
1902
+
1903
+ export function sanitizeSmoke(input) {
1904
+ if (!Array.isArray(input)) return []
1905
+ const out = []
1906
+ const seen = new Set()
1907
+ input.slice(0, 40).forEach((raw, index) => {
1908
+ const item = sanitizeSmokeItem(raw, index)
1909
+ if (item === null) return
1910
+ let id = item.id
1911
+ let suffix = 1
1912
+ while (seen.has(id)) id = `${item.id}-${suffix++}`
1913
+ seen.add(id)
1914
+ out.push({ ...item, id })
1915
+ })
1916
+ return out
1917
+ }
1918
+
1919
+ export function canonicalAuthor(who, config) {
1920
+ const raw = String(who ?? '').trim()
1921
+ if (raw === '') return raw
1922
+ // 去掉 emoji / 符号前缀,只留下人类可读的名字部分
1923
+ const plain = raw.replace(/^[^\p{L}\p{N}]+/u, '').trim() || raw
1924
+ const experts = (config?.experts ?? []).filter(entry => entry.enabled !== false)
1925
+ const exact = experts.find(entry => entry.name === plain || entry.name === raw)
1926
+ if (exact !== undefined) return exact.name
1927
+ const contains = experts.find(entry => {
1928
+ const name = String(entry.name ?? '')
1929
+ if (name === '') return false
1930
+ // 只允许一方完整包含另一方,避免「产品」误匹配「支付产品经理」以外的短名
1931
+ return plain.includes(name) || (plain.length >= 2 && name.includes(plain))
1932
+ })
1933
+ if (contains !== undefined) return contains.name
1934
+ const byRole = experts.find(entry => {
1935
+ const label = ROLE_LABELS[entry.role] ?? ''
1936
+ if (label === '') return false
1937
+ return label === plain || label.startsWith(plain) || plain.startsWith(label)
1938
+ })
1939
+ return byRole === undefined ? raw : byRole.name
1940
+ }
1941
+
1942
+ export const ROLE_LABELS = Object.fromEntries(ROLE_IDS.map(role => [role, DEFAULT_ROLES[role].label]))
1943
+ /** Compile the author-facing parameter spec into a registry-ready tool. */
1944
+ export function defineStudioTool(spec) {
1945
+ const properties = {}
1946
+ const required = []
1947
+ for (const [key, value] of Object.entries(spec.parameters ?? {})) {
1948
+ const { required: isRequired, ...schema } = value
1949
+ properties[key] = schema
1950
+ if (isRequired === true) required.push(key)
1951
+ }
1952
+ const parameters = { type: 'object', properties }
1953
+ if (required.length > 0) parameters.required = required
1954
+ return {
1955
+ name: spec.name,
1956
+ description: spec.description,
1957
+ parameters,
1958
+ output: {
1959
+ schema: {},
1960
+ render(_args, value) {
1961
+ return [{ type: 'text', text: typeof value === 'string' ? value : JSON.stringify(value, null, 2) }]
1962
+ },
1963
+ },
1964
+ execute: spec.execute,
1965
+ }
1966
+ }