@marsken/dsh-studio 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1396 @@
1
+ // -------------------------------------------------- 执行记录与接口测试执行器(P4)
2
+ // 代码事实核对、用例执行、缺陷回流、健康检查。宿主级依赖通过 configureExecutor() 注入。
3
+
4
+ import { CODE_PREFIX, ROLE_IDS, STATUS_IDS, STATUSES, nextRequirementCode, nextTaskCode, normalizePatch, sanitizeBaseline, sanitizeReqDoc, sanitizeRequirement, sanitizeTestRun, text,
5
+ canonicalAuthor,
6
+ } from './domain.js'
7
+ import { casesOfTasks, impactSummary } from './impact.js'
8
+ import { designGate } from './design.js'
9
+ import { RUN_LOG, parseMentions } from './dispatcher.js'
10
+ import { appendTeamMemory } from './team-memory.js'
11
+ import { homedir } from 'node:os'
12
+ import { join, resolve } from 'node:path'
13
+ import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
14
+ import { spawn } from 'node:child_process'
15
+ import { randomBytes } from 'node:crypto'
16
+
17
+ let host = {}
18
+
19
+ export function configureExecutor(services) {
20
+ host = services ?? {}
21
+ }
22
+
23
+ // ------------------------------------------------------- 执行记录与代码事实核对
24
+
25
+ /**
26
+ * 核对执行记录和代码事实:agent 自报的提交/文件,分支上到底有没有。
27
+ * 没有 worktree(隔离没开)或还没写执行记录时返回 skipped,不拦流程。
28
+ */
29
+ export async function verifyCodingRecord(state, task) {
30
+ const coding = task?.coding ?? null
31
+ if (coding === null || coding === undefined) return { ok: false, skipped: '这张卡还没有执行记录' }
32
+ const info = task?.worktree ?? null
33
+ if (info === null || String(info.path ?? '').trim() === '') {
34
+ return { ok: false, skipped: '这张卡没有 worktree(隔离没开或还没建),读不到代码事实' }
35
+ }
36
+ const dir = String(info.path)
37
+ const git = async (...args) => runCommand('git', ['-C', dir, ...args], 30000)
38
+ const head = await git('rev-parse', '--abbrev-ref', 'HEAD')
39
+ if (head.ok !== true) {
40
+ const reason = (head.stderr || '').trim().split('\n')[0] ?? ''
41
+ return { ok: false, skipped: `读不到这个 worktree 的 git 仓库${reason === '' ? '' : `:${reason}`}`.slice(0, 300) }
42
+ }
43
+ const branch = String(head.stdout ?? '').trim()
44
+ const log = await git('log', '--format=%H', '-n', '200')
45
+ const actualCommits = String(log.stdout ?? '').trim().split('\n').filter(item => item !== '')
46
+ const base = String(info.base ?? 'main').trim() || 'main'
47
+ const diff = await git('diff', '--name-only', `${base}...HEAD`)
48
+ let actualFiles = String(diff.stdout ?? '').trim().split('\n').filter(item => item !== '')
49
+ if (diff.ok !== true || actualFiles.length === 0) {
50
+ // 基线分支名对不上时退回到「最近 20 次提交涉及的文件」,免得误报「一个文件都没改」。
51
+ const fallback = await git('diff', '--name-only', 'HEAD~20', 'HEAD')
52
+ if (fallback.ok === true) actualFiles = String(fallback.stdout ?? '').trim().split('\n').filter(item => item !== '')
53
+ }
54
+ const status = await git('status', '--porcelain')
55
+ const dirty = String(status.stdout ?? '').trim().split('\n').filter(item => item !== '')
56
+ const declaredBranch = String(coding.branch ?? '').trim()
57
+ const declaredCommits = (coding.commits ?? []).map(item => String(item.sha ?? '').trim()).filter(item => item !== '')
58
+ const declaredFiles = (coding.files ?? []).map(item => String(item.path ?? '').trim()).filter(item => item !== '')
59
+ const commitHit = sha => actualCommits.some(full => full.startsWith(sha.toLowerCase()) || sha.toLowerCase().startsWith(full.slice(0, 8)))
60
+ const fileHit = path => actualFiles.some(item => item === path || item.endsWith(`/${path}`) || path.endsWith(`/${item}`))
61
+ const missingCommits = declaredCommits.filter(sha => commitHit(sha) !== true)
62
+ const missingFiles = declaredFiles.filter(path => fileHit(path) !== true)
63
+ const branchOk = declaredBranch === '' || declaredBranch === branch
64
+ return {
65
+ at: Date.now(),
66
+ branch,
67
+ declaredBranch,
68
+ branchOk,
69
+ commitCount: { declared: declaredCommits.length, actual: actualCommits.length },
70
+ fileCount: { declared: declaredFiles.length, actual: actualFiles.length },
71
+ missingCommits: missingCommits.slice(0, 10),
72
+ missingFiles: missingFiles.slice(0, 20),
73
+ dirty: dirty.slice(0, 10),
74
+ ok: branchOk && missingCommits.length === 0 && missingFiles.length === 0,
75
+ }
76
+ }
77
+
78
+ /** 把核对结果写成一句人话。 */
79
+ export function codingVerifyLine(report) {
80
+ if (report === null || report === undefined) return ''
81
+ if (String(report.skipped ?? '') !== '') return String(report.skipped)
82
+ const bits = [`分支 ${report.branch}`]
83
+ if (report.branchOk !== true) bits.push(`记录里写的是 ${report.declaredBranch}`)
84
+ bits.push(`提交 ${report.commitCount.actual} 个、改动 ${report.fileCount.actual} 个文件`)
85
+ if ((report.missingCommits ?? []).length > 0) bits.push(`${report.missingCommits.length} 个记录的提交在分支上找不到`)
86
+ if ((report.missingFiles ?? []).length > 0) bits.push(`${report.missingFiles.length} 个记录的文件没有实际改动`)
87
+ if ((report.dirty ?? []).length > 0) bits.push(`还有 ${report.dirty.length} 处未提交的改动`)
88
+ return bits.join(';')
89
+ }
90
+
91
+ // ------------------------------------------------------- 接口测试执行器(P4)
92
+
93
+ /** 允许打的 host:精确匹配,或 `*.example.com` 这种子域通配。 */
94
+ export function hostAllowed(host, allowHosts) {
95
+ const target = String(host ?? '').toLowerCase()
96
+ if (target.length === 0) return false
97
+ for (const rule of allowHosts) {
98
+ if (rule === '*') return true
99
+ if (rule.startsWith('*.')) {
100
+ const tail = rule.slice(1)
101
+ if (target.endsWith(tail) && target.length > tail.length) return true
102
+ continue
103
+ }
104
+ if (target === rule) return true
105
+ }
106
+ return false
107
+ }
108
+
109
+ /** 把用例里的相对 URL 拼成绝对地址;不允许的 host 直接抛。 */
110
+ export function resolveCaseUrl(http, env, config) {
111
+ const raw = String(http?.url ?? '').trim()
112
+ if (raw.length === 0) throw new Error('这条用例没有配请求地址')
113
+ let url
114
+ try {
115
+ url = new URL(raw, env.baseUrl)
116
+ } catch {
117
+ throw new Error(`请求地址不合法:${raw}`)
118
+ }
119
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
120
+ throw new Error(`只允许 http/https:${url.protocol}`)
121
+ }
122
+ if (!hostAllowed(url.hostname, config.testing.allowHosts)) {
123
+ throw new Error(`host ${url.hostname} 不在允许列表里(设置 → 测试执行 → 允许访问的域名)`)
124
+ }
125
+ return url
126
+ }
127
+
128
+ /** 跑一条接口用例:发请求、比状态码、比响应体片段。 */
129
+ /** 把 {{变量}} 换成当前上下文里的值;没有的变量原样留着,方便一眼看出漏配。 */
130
+ export function substituteVars(text, vars) {
131
+ return String(text ?? '').replace(/\{\{\s*([A-Za-z0-9_.-]{1,40})\s*\}\}/g, (whole, name) => (
132
+ vars[name] === undefined || vars[name] === null ? whole : String(vars[name])
133
+ ))
134
+ }
135
+
136
+ /** 从 JSON 响应里取一个值:`data.token`、`items.0.id`;`header:x-request-id` 取响应头。 */
137
+ export function pickJsonPath(payload, path) {
138
+ const text = String(path ?? '').trim()
139
+ if (text === '') return ''
140
+ if (text.toLowerCase().startsWith('header:')) return ''
141
+ let cursor = payload
142
+ for (const part of text.split('.')) {
143
+ if (part === '') continue
144
+ if (cursor === null || cursor === undefined) return ''
145
+ cursor = Array.isArray(cursor) ? cursor[Number(part)] : cursor[part]
146
+ }
147
+ return cursor === null || cursor === undefined ? '' : String(cursor)
148
+ }
149
+
150
+ /**
151
+ * 开跑之前先探一下环境。环境没起的时候,一整套用例会被刷成"失败",
152
+ * 需求准出还会因此被卡住——那不是用例坏了,是环境没开。
153
+ * 任何 HTTP 响应(含 404/500)都算"活着",只有连不上/超时才算不可达。
154
+ */
155
+ export async function probeEnv(env, config, timeoutMs = 5000) {
156
+ const baseUrl = String(env?.baseUrl ?? '').trim()
157
+ const id = String(env?.id ?? '')
158
+ if (baseUrl === '') return { ok: false, env: id, baseUrl: '', error: '环境没有填 baseUrl' }
159
+ let url
160
+ try {
161
+ url = new URL(baseUrl)
162
+ } catch {
163
+ return { ok: false, env: id, baseUrl, error: 'baseUrl 不是合法地址' }
164
+ }
165
+ const allow = Array.isArray(config?.testing?.allowHosts) ? config.testing.allowHosts : []
166
+ if (!hostAllowed(url.hostname, allow)) {
167
+ return { ok: false, env: id, baseUrl, error: `域名 ${url.hostname} 不在白名单(设置 → 测试执行 → 允许访问的域名)` }
168
+ }
169
+ const controller = new AbortController()
170
+ const timer = setTimeout(() => controller.abort(), Math.max(1000, Math.min(Number(timeoutMs) || 5000, 15000)))
171
+ const started = Date.now()
172
+ try {
173
+ const response = await fetch(baseUrl, { method: 'GET', signal: controller.signal, redirect: 'manual' })
174
+ return { ok: true, env: id, baseUrl, status: response.status, ms: Date.now() - started }
175
+ } catch (error) {
176
+ const code = error?.cause?.code ?? error?.code ?? error?.name
177
+ const reason = String(code ?? error?.message ?? error).slice(0, 120)
178
+ return { ok: false, env: id, baseUrl, error: reason, ms: Date.now() - started }
179
+ } finally {
180
+ clearTimeout(timer)
181
+ }
182
+ }
183
+
184
+ export async function runOneCase(item, env, config, seedVars) {
185
+ const started = Date.now()
186
+ if (item.http == null) {
187
+ return { ok: false, status: 0, ms: 0, error: '这条用例还没有可执行的请求定义(method/url)', preview: '' }
188
+ }
189
+ const vars = { ...(env.vars ?? {}), ...(item.http.vars ?? {}), ...(seedVars ?? {}) }
190
+ const steps = (item.http.steps ?? []).length > 0
191
+ ? item.http.steps
192
+ : [{
193
+ method: item.http.method,
194
+ url: item.http.url,
195
+ body: item.http.body,
196
+ expectStatus: item.http.expectStatus,
197
+ expectContains: item.http.expectContains,
198
+ extract: {},
199
+ }]
200
+ const timeout = item.http.timeoutMs > 0 ? item.http.timeoutMs : config.testing.timeoutMs
201
+ const stepResults = []
202
+ let preview = ''
203
+ let response = ''
204
+ let status = 0
205
+ let failure = ''
206
+ for (let index = 0; index < steps.length; index += 1) {
207
+ const step = steps[index]
208
+ const stepStarted = Date.now()
209
+ const renderedUrl = substituteVars(step.url, vars)
210
+ const renderedBody = step.body === '' ? '' : substituteVars(step.body, vars)
211
+ let url
212
+ try {
213
+ url = resolveCaseUrl({ url: renderedUrl }, env, config)
214
+ } catch (error) {
215
+ failure = `第 ${index + 1} 步:${error.message}`
216
+ stepResults.push({ i: index + 1, method: step.method, url: renderedUrl, ok: false, status: 0, ms: Date.now() - stepStarted, error: error.message, preview: '' })
217
+ break
218
+ }
219
+ const headers = { ...env.headers }
220
+ for (const [key, value] of Object.entries(item.http.headers ?? {})) headers[key] = substituteVars(value, vars)
221
+ const init = { method: step.method, headers, signal: AbortSignal.timeout(timeout) }
222
+ if (renderedBody !== '' && step.method !== 'GET' && step.method !== 'HEAD') {
223
+ init.body = renderedBody
224
+ if (Object.keys(headers).every(key => key.toLowerCase() !== 'content-type')) headers['content-type'] = 'application/json'
225
+ }
226
+ let resp
227
+ let text = ''
228
+ try {
229
+ resp = await fetch(url, init)
230
+ text = await resp.text()
231
+ } catch (error) {
232
+ const message = error?.name === 'TimeoutError' ? `超时(>${timeout}ms)` : `${error?.cause?.code ?? error?.message ?? error}`
233
+ failure = `第 ${index + 1} 步:${message}`
234
+ stepResults.push({ i: index + 1, method: step.method, url, ok: false, status: 0, ms: Date.now() - stepStarted, error: message, preview: '' })
235
+ break
236
+ }
237
+ status = resp.status
238
+ preview = text.slice(0, 600)
239
+ response = text.slice(0, 2000)
240
+ for (const [name, path] of Object.entries(step.extract ?? {})) {
241
+ if (String(path).toLowerCase().startsWith('header:')) {
242
+ vars[name] = resp.headers.get(String(path).slice(7)) ?? ''
243
+ continue
244
+ }
245
+ let parsed = null
246
+ try { parsed = JSON.parse(text) } catch { parsed = null }
247
+ vars[name] = parsed === null ? '' : pickJsonPath(parsed, path)
248
+ }
249
+ const statusOk = step.expectStatus > 0 ? status === step.expectStatus : (status >= 200 && status < 400)
250
+ const containsOk = step.expectContains === '' ? true : text.includes(step.expectContains)
251
+ const reasons = []
252
+ if (!statusOk) reasons.push(step.expectStatus > 0 ? `期望 ${step.expectStatus},实际 ${status}` : `期望 2xx/3xx,实际 ${status}`)
253
+ if (!containsOk) reasons.push(`响应里没有「${step.expectContains}」`)
254
+ const stepOk = reasons.length === 0
255
+ stepResults.push({
256
+ i: index + 1,
257
+ method: step.method,
258
+ url,
259
+ ok: stepOk,
260
+ status,
261
+ ms: Date.now() - stepStarted,
262
+ error: reasons.join(';'),
263
+ preview: text.slice(0, 300),
264
+ })
265
+ // 多步之间是有依赖的(下一步用这一步的 token/id),前一步废了就没有继续的意义。
266
+ if (!stepOk) {
267
+ failure = `第 ${index + 1} 步:${reasons.join(';')}`
268
+ break
269
+ }
270
+ }
271
+ const ok = failure === ''
272
+ return {
273
+ ok,
274
+ status,
275
+ ms: Date.now() - started,
276
+ error: failure,
277
+ preview,
278
+ response,
279
+ steps: stepResults,
280
+ }
281
+ }
282
+
283
+ /**
284
+ * 跑一张卡上的用例(不传 caseIds 就跑全部有请求定义的用例):
285
+ * 结果写回用例、追加一条执行记录、失败的用例自动开缺陷卡。
286
+ */
287
+ export async function runTaskCases(store, taskId, options = {}) {
288
+ const state = store.snapshot()
289
+ const task = state.tasks.find(entry => entry.id === String(taskId ?? ''))
290
+ if (task === undefined) throw new Error('任务不存在')
291
+ const config = state.config
292
+ const envs = config.testing.envs
293
+ if (envs.length === 0) throw new Error('还没有配置测试环境(设置 → 测试执行)')
294
+ const envId = String(options.env ?? config.testing.defaultEnv ?? '')
295
+ const env = envs.find(item => item.id === envId) ?? envs[0]
296
+ const wanted = Array.isArray(options.caseIds) && options.caseIds.length > 0
297
+ ? new Set(options.caseIds.map(id => String(id)))
298
+ : null
299
+ const targets = (Array.isArray(task.cases) ? task.cases : [])
300
+ .filter(item => (wanted === null ? item.http != null : wanted.has(item.id)))
301
+ if (targets.length === 0) throw new Error('没有可执行的用例:用例上要填 method + url(相对地址会拼环境 baseUrl)')
302
+
303
+ const probe = await probeEnv(env, config)
304
+ if (probe.ok !== true) {
305
+ throw new Error(`测试环境「${env.name || env.id}」不可达(${probe.error}):一条用例都没跑,先把 ${probe.baseUrl || 'baseUrl'} 起来`)
306
+ }
307
+
308
+ const by = String(options.by ?? '系统').slice(0, 40)
309
+ const results = []
310
+ // 串行跑,环境不稳时并发只会让失败更难归因。
311
+ for (const item of targets) {
312
+ const outcome = await runOneCase(item, env, config)
313
+ results.push({ id: item.id, title: item.title, ...outcome })
314
+ await store.mutate(draft => {
315
+ const card = draft.tasks.find(entry => entry.id === task.id)
316
+ if (card === undefined) return
317
+ card.cases = (Array.isArray(card.cases) ? card.cases : []).map(entry => {
318
+ if (entry.id !== item.id) return entry
319
+ const note = outcome.ok
320
+ ? `${String(item.http?.method ?? 'GET')} ${String(item.http?.url ?? '')} → ${outcome.status}(${outcome.ms}ms)`
321
+ : `${String(item.http?.method ?? 'GET')} ${String(item.http?.url ?? '')} → ${outcome.error}`
322
+ return {
323
+ ...entry,
324
+ result: outcome.ok ? 'pass' : 'fail',
325
+ note: note.slice(0, 400),
326
+ by,
327
+ at: Date.now(),
328
+ lastRun: { at: Date.now(), ok: outcome.ok, status: outcome.status, ms: outcome.ms, error: outcome.error, preview: outcome.preview, response: outcome.response ?? '', env: env.id, steps: outcome.steps ?? [] },
329
+ }
330
+ })
331
+ card.updatedAt = Date.now()
332
+ })
333
+ }
334
+
335
+ const run = sanitizeTestRun({
336
+ id: `run-${randomBytes(3).toString('hex')}`,
337
+ at: Date.now(),
338
+ by,
339
+ env: env.id,
340
+ results,
341
+ })
342
+ await store.mutate(draft => {
343
+ const card = draft.tasks.find(entry => entry.id === task.id)
344
+ if (card === undefined) return
345
+ card.testRuns = [...(Array.isArray(card.testRuns) ? card.testRuns : []), run].slice(-10)
346
+ card.updatedAt = Date.now()
347
+ })
348
+
349
+ // 失败即开缺陷:复用 P3 的去重逻辑(同一条用例重复失败只加评论)。
350
+ const bugs = []
351
+ for (const outcome of results) {
352
+ if (outcome.ok) continue
353
+ const latest = store.snapshot().tasks.find(entry => entry.id === task.id)
354
+ const item = (latest?.cases ?? []).find(entry => entry.id === outcome.id)
355
+ if (item === undefined) continue
356
+ // 不稳定用例失败不自动建缺陷:环境抖动、脚本不稳、数据异常造成的红灯不该污染缺陷池。
357
+ if (item.flaky === true) {
358
+ bugs.push({ caseId: outcome.id, title: outcome.title, bug: '', created: false, skipped: 'flaky' })
359
+ continue
360
+ }
361
+ try {
362
+ const bug = await host.autoBugFromCase(store, latest, item, by)
363
+ bugs.push({ caseId: outcome.id, title: outcome.title, bug: bug?.bug?.code || bug?.bug?.id || '', created: bug?.created === true, skipped: String(bug?.skipped ?? '') })
364
+ } catch (error) {
365
+ bugs.push({ caseId: outcome.id, title: outcome.title, bug: '', created: false, error: String(error?.message ?? error) })
366
+ }
367
+ }
368
+
369
+ const passed = results.filter(item => item.ok).length
370
+ await commentTask(
371
+ store,
372
+ task.id,
373
+ `接口测试已在环境「${env.name}」跑完:${results.length} 条,通过 ${passed},不通过 ${results.length - passed}。`
374
+ + (results.length - passed > 0 ? `\n\n不通过的:\n${results.filter(item => !item.ok).map(item => `- ${item.title}:${item.error}`).join('\n')}` : ''),
375
+ by,
376
+ 'agent',
377
+ )
378
+ return { run, env: { id: env.id, name: env.name, baseUrl: env.baseUrl }, bugs, state: host.publicState(store) }
379
+ }
380
+
381
+ /**
382
+ * 批量跑一批卡上的用例:卡与卡之间并发(互不依赖),同一张卡内部串行。
383
+ * 默认对象是「待测试」列——测试这一列本来就该是流水线里最自动的一段。
384
+ */
385
+ export async function runSuite(store, options = {}) {
386
+ const state = store.snapshot()
387
+ const ids = Array.isArray(options.taskIds) && options.taskIds.length > 0
388
+ ? options.taskIds.map(String)
389
+ : state.tasks.filter(task => task.status === 'test').map(task => task.id)
390
+ const targets = ids.filter(id => state.tasks.some(task => task.id === id)).slice(0, 20)
391
+ if (targets.length === 0) throw new Error('没有可跑的卡:传 taskIds,或者先让卡进入「待测试」列')
392
+ const limit = Math.max(1, Math.min(Number(options.concurrency) || state.config.testing.concurrency || 4, 8))
393
+ const results = []
394
+ let cursor = 0
395
+ const workers = Array.from({ length: Math.min(limit, targets.length) }, async () => {
396
+ while (cursor < targets.length) {
397
+ const mine = cursor
398
+ cursor += 1
399
+ const id = targets[mine]
400
+ try {
401
+ const outcome = await runTaskCases(store, id, { env: options.env, by: options.by })
402
+ results.push({ taskId: id, ok: true, total: outcome.run.total, passed: outcome.run.passed, failed: outcome.run.failed, bugs: outcome.bugs.length })
403
+ } catch (error) {
404
+ results.push({ taskId: id, ok: false, error: String(error?.message ?? error).slice(0, 300) })
405
+ }
406
+ }
407
+ })
408
+ await Promise.all(workers)
409
+ return { concurrency: limit, tasks: results, state: host.publicState(store) }
410
+ }
411
+
412
+ /** 新建或修改一条需求;编号只在新建时分配,之后不再变。 */
413
+ /** 功能点的指纹:只比能跟卡片对上号的几列,避免时间戳之类的噪音触发变更。 */
414
+ export function requirementPointsFingerprint(points) {
415
+ return JSON.stringify((points ?? []).map(point => [point.id, point.text, point.priority, point.owner ?? '']))
416
+ }
417
+
418
+ /** 这张卡是不是已经产出过东西:到了测试/验收/完成、签过字、跑过用例、有提交。 */
419
+ export function requirementTaskTouched(task) {
420
+ if (['test', 'accept', 'done'].includes(String(task.status))) return true
421
+ if (task.signoff !== null && task.signoff !== undefined) return true
422
+ if ((task.cases ?? []).some(item => item.result === 'pass')) return true
423
+ if ((task.coding?.commits ?? []).length > 0) return true
424
+ return false
425
+ }
426
+
427
+ export async function saveRequirement(store, input) {
428
+ // 部分更新(只带 id + 改动的字段)时,先把库里那条垫在下面,免得被判成「标题为空」。
429
+ if (input.id !== undefined && input.id !== null) {
430
+ const known = store.snapshot().requirements.find(item => item.id === String(input.id))
431
+ if (known !== undefined) input = { ...known, ...input }
432
+ }
433
+ const draft = sanitizeRequirement({ ...input, id: input.id ?? '' })
434
+ if (draft === undefined) throw new Error('需求的标题不能为空')
435
+ let saved
436
+ await store.mutate(state => {
437
+ const existing = state.requirements.find(item => item.id === draft.id)
438
+ if (existing === undefined) {
439
+ const now = Date.now()
440
+ const needsProduct = String(draft.links?.prd ?? '').trim() === '' && (draft.docs ?? []).every(doc => String(doc.content ?? '').trim() === '') && draft.baseline === null
441
+ const created = {
442
+ ...draft,
443
+ status: needsProduct === true ? 'review' : draft.status,
444
+ review: needsProduct === true ? { status: 'pending', by: String(input.by ?? input.operator ?? '系统').slice(0, 40), at: now, note: '新建需求:保存后自动转产品补 PRD' } : draft.review,
445
+ code: draft.code.length > 0 ? draft.code : nextRequirementCode(state.requirements),
446
+ createdAt: now,
447
+ updatedAt: now,
448
+ }
449
+ state.requirements.unshift(created)
450
+ saved = created
451
+ return
452
+ }
453
+ // 变更影响面:先记下旧值,覆盖之后再比,才能说清楚「改了哪几处」。
454
+ const before = {
455
+ title: existing.title,
456
+ description: existing.description,
457
+ points: requirementPointsFingerprint(existing.points),
458
+ links: JSON.stringify(existing.links ?? {}),
459
+ }
460
+ for (const key of ['title', 'description', 'project', 'iteration', 'owner', 'domain', 'priority', 'status', 'links']) {
461
+ existing[key] = draft[key]
462
+ }
463
+ // 排序与依赖单独处理:显式给了才覆盖,避免详情页保存别的字段时把它们清空。
464
+ for (const key of ['order', 'dependsOn']) {
465
+ if (input[key] !== undefined) existing[key] = draft[key]
466
+ }
467
+ // 功能点是需求的骨架:显式给了才覆盖,避免别的保存动作把矩阵清空。
468
+ if (input.points !== undefined) existing.points = draft.points
469
+ if (existing.code.length === 0) existing.code = nextRequirementCode(state.requirements)
470
+ const changedParts = []
471
+ if (existing.title !== before.title) changedParts.push('标题')
472
+ if (existing.description !== before.description) changedParts.push('需求明细')
473
+ if (requirementPointsFingerprint(existing.points) !== before.points) changedParts.push('功能点')
474
+ if (JSON.stringify(existing.links ?? {}) !== before.links) changedParts.push('文档链接')
475
+ if (changedParts.length > 0) {
476
+ // 只有内容(标题/明细/功能点)变了才算影响交付;改个文档链接不动签字。
477
+ const substantive = changedParts.some(part => part !== '文档链接')
478
+ const touched = substantive
479
+ ? state.tasks.filter(task => task.requirementId === existing.id && requirementTaskTouched(task))
480
+ : []
481
+ const hadSignoff = existing.signoff !== null && existing.signoff !== undefined
482
+ existing.change = {
483
+ at: Date.now(),
484
+ by: String(input.by ?? input.operator ?? '').trim().slice(0, 60) || '人',
485
+ parts: changedParts,
486
+ affected: touched.slice(0, 60).map(task => ({ id: task.id, code: task.code, title: task.title, status: task.status })),
487
+ // 用例跟卡走:需求改了,这些卡上的用例都得按新版重跑一遍。
488
+ affectedCases: casesOfTasks(touched, 40),
489
+ impact: impactSummary(touched),
490
+ invalidated: substantive && hadSignoff,
491
+ }
492
+ // 卡还指着旧需求,签字就不作数了,要人重新确认一遍。
493
+ if (existing.change.invalidated) existing.signoff = null
494
+ }
495
+ existing.updatedAt = Date.now()
496
+ saved = existing
497
+ })
498
+ // 新建的需求顺手在云盘里建一个同名文件夹;失败只记录,不影响保存。
499
+ if (saved !== undefined && String(saved.folder?.token ?? '').trim() === '') {
500
+ try {
501
+ await ensureReqFolder(store, saved.id)
502
+ } catch {
503
+ /* 文件夹建不出来时,需求详情里会显示原因,人补一次即可 */
504
+ }
505
+ }
506
+ return saved
507
+ }
508
+
509
+ /** 一个需求的体检:按列/按类型分布、用例通过率、交付物、MR。 */
510
+ export function requirementRollup(state, id) {
511
+ const requirement = state.requirements.find(item => item.id === id)
512
+ if (requirement === undefined) return undefined
513
+ const tasks = state.tasks.filter(task => task.requirementId === id)
514
+ const byStatus = {}
515
+ for (const status of STATUS_IDS) byStatus[status] = 0
516
+ const byType = {}
517
+ for (const task of tasks) {
518
+ byStatus[task.status] = (byStatus[task.status] ?? 0) + 1
519
+ byType[task.type] = (byType[task.type] ?? 0) + 1
520
+ }
521
+ const cases = { total: 0, pass: 0, fail: 0, pending: 0, skip: 0 }
522
+ let checklistTotal = 0
523
+ let checklistDone = 0
524
+ const artifacts = []
525
+ const mrs = []
526
+ for (const task of tasks) {
527
+ for (const item of task.cases ?? []) {
528
+ cases.total += 1
529
+ cases[item.result] = (cases[item.result] ?? 0) + 1
530
+ }
531
+ for (const item of task.checklist ?? []) {
532
+ checklistTotal += 1
533
+ if (item.done) checklistDone += 1
534
+ }
535
+ for (const artifact of task.artifacts ?? []) artifacts.push({ ...artifact, taskId: task.id, taskCode: task.code })
536
+ if (task.gitlabMR !== undefined && task.gitlabMR !== null && String(task.gitlabMR.url ?? '').length > 0) {
537
+ mrs.push({ ...task.gitlabMR, taskId: task.id, taskCode: task.code })
538
+ }
539
+ }
540
+ return {
541
+ requirement,
542
+ tasks,
543
+ artifacts,
544
+ mrs,
545
+ byStatus,
546
+ byType,
547
+ metrics: {
548
+ total: tasks.length,
549
+ blocked: tasks.filter(task => task.blocked !== undefined && task.blocked !== null).length,
550
+ done: tasks.filter(task => task.status === 'done').length,
551
+ bugs: tasks.filter(task => task.type === 'bug' && task.status !== 'done').length,
552
+ checklist: { total: checklistTotal, done: checklistDone },
553
+ cases,
554
+ artifacts: artifacts.length,
555
+ mrs: mrs.length,
556
+ },
557
+ }
558
+ }
559
+
560
+ /**
561
+ * 可追溯矩阵:需求的功能点 → 卡片(含状态)→ 用例(通过/失败/没跑)→ 交付物。
562
+ * 一条需求做没做完,看的就是这张表,而不是“我印象里做过了”。
563
+ */
564
+ export function requirementMatrix(state, requirement) {
565
+ const tasks = state.tasks.filter(task => task.requirementId === requirement.id)
566
+ const points = (requirement.points ?? []).map(point => {
567
+ const cards = tasks.filter(task => task.pointId === point.id)
568
+ const cardIds = new Set(cards.map(card => card.id))
569
+ const cases = []
570
+ for (const card of cards) {
571
+ for (const item of card.cases ?? []) {
572
+ if (item.pointId !== '' && item.pointId !== point.id) continue
573
+ cases.push({ ...item, taskId: card.id, taskCode: card.code || card.id, taskTitle: card.title })
574
+ }
575
+ }
576
+ // 用例可能挂在别的卡上(比如测试卡),按 pointId 全局找一遍。
577
+ for (const card of tasks) {
578
+ if (cardIds.has(card.id)) continue
579
+ for (const item of card.cases ?? []) {
580
+ if (item.pointId !== point.id) continue
581
+ cases.push({ ...item, taskId: card.id, taskCode: card.code || card.id, taskTitle: card.title })
582
+ }
583
+ }
584
+ const artifacts = cards.flatMap(card => (card.artifacts ?? []).map(item => ({ ...item, taskCode: card.code || card.id })))
585
+ const acceptance = (point.acceptance ?? []).map(item => {
586
+ const matched = cases.filter(entry => entry.result === 'pass')
587
+ return { ...item, covered: matched.length > 0 }
588
+ })
589
+ const pass = cases.filter(item => item.result === 'pass').length
590
+ const fail = cases.filter(item => item.result === 'fail').length
591
+ const pending = cases.length - pass - fail
592
+ const doneCards = cards.filter(card => card.status === 'done').length
593
+ const covered = acceptance.length === 0
594
+ ? pass > 0
595
+ : acceptance.every(item => item.covered)
596
+ return {
597
+ id: point.id,
598
+ title: point.title,
599
+ description: point.description,
600
+ priority: point.priority,
601
+ status: point.status,
602
+ acceptance,
603
+ cards: cards.map(card => ({ id: card.id, code: card.code, title: card.title, status: card.status, date: card.status === 'done' ? card.updatedAt : 0 })),
604
+ cases: { total: cases.length, pass, fail, pending },
605
+ artifacts: artifacts.length,
606
+ doneCards,
607
+ covered,
608
+ }
609
+ })
610
+ const loose = tasks.filter(task => task.pointId === '' || (requirement.points ?? []).every(point => point.id !== task.pointId))
611
+ const summary = {
612
+ points: points.length,
613
+ coveredPoints: points.filter(point => point.covered).length,
614
+ /** 一个用例都没有的功能点:覆盖率最容易漏的口子。 */
615
+ pointsWithoutCases: points.filter(point => point.cases.total === 0).length,
616
+ cards: tasks.length,
617
+ looseCards: loose.length,
618
+ cases: points.reduce((sum, point) => sum + point.cases.total, 0),
619
+ pass: points.reduce((sum, point) => sum + point.cases.pass, 0),
620
+ fail: points.reduce((sum, point) => sum + point.cases.fail, 0),
621
+ artifacts: points.reduce((sum, point) => sum + point.artifacts, 0),
622
+ }
623
+ summary.passRate = summary.cases === 0 ? 0 : Math.round((summary.pass / summary.cases) * 100)
624
+ summary.coverageRate = summary.points === 0 ? 0 : Math.round(((summary.points - summary.pointsWithoutCases) / summary.points) * 100)
625
+ return {
626
+ points,
627
+ loose: loose.map(card => ({ id: card.id, code: card.code, title: card.title, status: card.status })),
628
+ summary,
629
+ }
630
+ }
631
+
632
+ /**
633
+ * 一条需求能不能算「交付完成」:卡全完成、功能点全覆盖、没有阻塞/未结缺陷/失败用例,
634
+ * 并且有人做过需求级验收签字。这是需求维度的门禁,和单卡的 taskExitGate 分开。
635
+ */
636
+ export function requirementGate(state, requirement) {
637
+ const tasks = state.tasks.filter(task => task.requirementId === requirement.id)
638
+ const matrix = requirementMatrix(state, requirement)
639
+ const blockers = []
640
+ const warnings = []
641
+ const pool = requirement.pool ?? { score: 0 }
642
+ if (!(pool.score > 0)) warnings.push('还没做需求池打分(RICE):影响面、影响度、置信度、成本填一下就能排序')
643
+ const openQuestions = (requirement.questions ?? []).filter(item => item.status === 'open')
644
+ if (String(requirement.links?.prd ?? '').trim() === '' && (requirement.docs ?? []).every(doc => String(doc.content ?? '').trim() === '') && requirement.baseline === null) {
645
+ blockers.push('需求还没有产品输出:先补 PRD 链接或正文,再拆卡')
646
+ }
647
+ if (openQuestions.length > 0) blockers.push(`还有 ${openQuestions.length} 个待确认问题没回答:${openQuestions.slice(0, 3).map(item => item.text.slice(0, 18)).join('、')}`)
648
+ if (tasks.length === 0) blockers.push('这条需求下还没有任何任务卡')
649
+ if ((requirement.points ?? []).length === 0) warnings.push('还没有拆功能点,可追溯矩阵是空的')
650
+ const open = tasks.filter(task => task.status !== 'done')
651
+ if (open.length > 0) {
652
+ const names = open.slice(0, 5).map(task => task.code || task.id).join('、')
653
+ blockers.push(`${open.length} 张卡还没完成:${names}${open.length > 5 ? ' 等' : ''}`)
654
+ }
655
+ const blocked = tasks.filter(task => task.blocked !== null && task.blocked !== undefined)
656
+ if (blocked.length > 0) blockers.push(`${blocked.length} 张卡还卡在阻塞:${blocked.slice(0, 3).map(task => task.code || task.id).join('、')}`)
657
+ const bugs = tasks.filter(task => task.type === 'bug' && task.status !== 'done')
658
+ if (bugs.length > 0) blockers.push(`还有 ${bugs.length} 个缺陷没关闭:${bugs.slice(0, 3).map(task => task.code || task.id).join('、')}`)
659
+ const failing = []
660
+ for (const task of tasks) {
661
+ for (const item of task.cases ?? []) {
662
+ if (item.result === 'fail') failing.push(`${task.code || task.id}·${item.title}`)
663
+ }
664
+ }
665
+ if (failing.length > 0) blockers.push(`${failing.length} 条用例不通过:${failing.slice(0, 3).join(';')}`)
666
+ const pendingDeps = (requirement.dependsOn ?? [])
667
+ .map(id => state.requirements.find(item => item.id === id))
668
+ .filter(item => item !== undefined && item.status !== 'delivered' && item.status !== 'closed')
669
+ if (pendingDeps.length > 0) {
670
+ blockers.push(`依赖的需求还没交付:${pendingDeps.map(item => item.code || item.title).join('、')}`)
671
+ }
672
+ const uncovered = matrix.points.filter(point => !point.covered)
673
+ if (uncovered.length > 0) warnings.push(`${uncovered.length} 个功能点还没有通过的用例覆盖:${uncovered.slice(0, 3).map(point => point.id).join('、')}`)
674
+ if (matrix.summary.pointsWithoutCases > 0) warnings.push(`${matrix.summary.pointsWithoutCases} 个功能点还没有任何用例(测试覆盖率 ${matrix.summary.coverageRate}%)`)
675
+ if (matrix.summary.looseCards > 0) warnings.push(`${matrix.summary.looseCards} 张卡还没归到功能点`)
676
+ const artifacts = tasks.reduce((sum, task) => sum + (task.artifacts ?? []).length, 0)
677
+ if (artifacts === 0) warnings.push('这条需求下还没有登记任何交付物')
678
+ else {
679
+ const empty = tasks.reduce((sum, task) => sum + (task.artifacts ?? []).filter(item => String(item.content ?? '').trim() === '').length, 0)
680
+ if (empty > 0) warnings.push(`${empty} 件交付物还没有正文`)
681
+ }
682
+ if (requirement.signoff === null || requirement.signoff === undefined) blockers.push('还没有需求级验收签字')
683
+ // 评审没通过的草稿/待评审需求不该往下拆卡:先过评审,再干活。
684
+ const review = requirement.review ?? null
685
+ const reviewApproved = review !== null && review.status === 'approved'
686
+ const started = requirement.status === 'active' || requirement.status === 'delivered' || requirement.status === 'closed'
687
+ if (!reviewApproved && !started) {
688
+ blockers.push(requirement.status === 'review'
689
+ ? '需求还在评审中:等评审人给出结论(通过之后才能拆卡)'
690
+ : '需求还没评审通过:先点「提交评审」,评审通过后再拆卡')
691
+ }
692
+ if (review !== null && review.status === 'changes_requested') {
693
+ blockers.push(`需求评审被打回${review.note === '' ? '' : `:${review.note.slice(0, 80)}`}`)
694
+ }
695
+ // 需求中途改过内容:已经产出过的卡是按旧版做的,签字作废、必须重新走一遍确认。
696
+ const change = requirement.change ?? null
697
+ if (change !== null && change.invalidated) {
698
+ const names = change.affected.slice(0, 3).map(item => item.code || item.id).join('、')
699
+ blockers.push(`需求改过(${change.parts.join('、')}):${change.affected.length} 张已产出的卡要复核${names === '' ? '' : `(${names})`},验收签字已作废`)
700
+ } else if (change !== null && change.affected.length > 0) {
701
+ warnings.push(`需求上一次变更影响 ${change.affected.length} 张卡,建议逐张复核再签字`)
702
+ }
703
+ return {
704
+ ready: blockers.length === 0,
705
+ blockers,
706
+ warnings,
707
+ stats: {
708
+ cards: tasks.length,
709
+ done: tasks.length - open.length,
710
+ open: open.length,
711
+ blocked: blocked.length,
712
+ bugs: bugs.length,
713
+ failing: failing.length,
714
+ points: matrix.points.length,
715
+ coveredPoints: matrix.summary.coveredPoints,
716
+ cases: matrix.summary.cases,
717
+ pass: matrix.summary.pass,
718
+ artifacts,
719
+ signoff: requirement.signoff !== null && requirement.signoff !== undefined,
720
+ },
721
+ }
722
+ }
723
+
724
+ /**
725
+ * 需求进度:给列表和详情共用。derived 是**算出来的**状态,
726
+ * 手动状态只在「关闭」时优先(人明确关掉的需求不该被自动拉回进行中)。
727
+ */
728
+ export function requirementProgress(state, requirement) {
729
+ const tasks = state.tasks.filter(task => task.requirementId === requirement.id)
730
+ const gate = requirementGate(state, requirement)
731
+ const done = tasks.filter(task => task.status === 'done').length
732
+ const blocked = tasks.filter(task => task.blocked !== null && task.blocked !== undefined).length
733
+ const progress = tasks.length === 0 ? 0 : Math.round((done / tasks.length) * 100)
734
+ const derived = requirement.status === 'closed'
735
+ ? 'closed'
736
+ : (tasks.length > 0 && done === tasks.length && gate.ready ? 'delivered' : (tasks.length > 0 ? 'active' : 'draft'))
737
+ return {
738
+ gate,
739
+ cards: { total: tasks.length, done, open: tasks.length - done, blocked },
740
+ progress,
741
+ derived,
742
+ review: requirement.review ?? null,
743
+ reviewStatus: (requirement.review ?? {}).status ?? 'none',
744
+ }
745
+ }
746
+
747
+ /**
748
+ * 卡能不能进「已完成」:验收清单全勾、没有失败用例、不处在阻塞中、并且有人签字。
749
+ * 没过就抛错,除非显式带 override 理由(会记进状态历史)。
750
+ */
751
+ export function taskExitGate(state, task) {
752
+ const blockers = []
753
+ const warnings = []
754
+ const checklist = task.checklist ?? []
755
+ const unchecked = checklist.filter(item => item.done !== true)
756
+ if (unchecked.length > 0) {
757
+ blockers.push(`验收清单还有 ${unchecked.length} 项没勾:${unchecked.slice(0, 3).map(item => item.text).join('、')}`)
758
+ }
759
+ const cases = task.cases ?? []
760
+ const failed = cases.filter(item => item.result === 'fail')
761
+ if (failed.length > 0) blockers.push(`${failed.length} 条用例不通过:${failed.slice(0, 3).map(item => item.title).join('、')}`)
762
+ // CI 门禁:真跑过流水线且失败,不允许收口(没接 GitLab 时这个字段是空的,不会误伤)。
763
+ if (task.gitlabMR?.pipeline?.status === 'failed') blockers.push(`CI 流水线没通过:${task.gitlabMR.pipeline.id ? `#${task.gitlabMR.pipeline.id}` : '最近一次运行'}`)
764
+ if (task.blocked !== undefined && task.blocked !== null) blockers.push('这张卡还挂着阻塞标记')
765
+ if (task.signoff === undefined || task.signoff === null) blockers.push('还没有验收签字')
766
+ if (cases.length === 0) warnings.push('这张卡没有测试用例,建议至少补一条主流程用例')
767
+ else {
768
+ const pending = cases.filter(item => item.result === 'pending' || item.result === 'skip').length
769
+ if (pending > 0) warnings.push(`${pending} 条用例还没跑`)
770
+ }
771
+ const artifacts = task.artifacts ?? []
772
+ if (artifacts.length === 0) warnings.push('没有登记任何交付物')
773
+ else {
774
+ const empty = artifacts.filter(item => String(item.content ?? '').trim() === '').length
775
+ if (empty > 0) warnings.push(`${empty} 件交付物没有正文`)
776
+ }
777
+ // 服务依赖:上游服务还有没做完的卡时,这张卡先别收口(接口契约/联调顺序)。
778
+ const deps = host.serviceDependencies(state, task)
779
+ if (deps !== undefined && deps.pending > 0) {
780
+ const waiting = deps.rows.filter(row => row.open > 0)
781
+ blockers.push(`依赖服务还没完成:${waiting.map(row => `${row.name}(${row.open} 张:${row.titles.join('、')})`).join(';')}`)
782
+ }
783
+ // 执行记录 vs 代码事实:agent 自报的提交/文件跟分支上的真实情况对不上,就不许收口。
784
+ const verified = task.coding?.verified ?? null
785
+ if (verified !== null && verified.ok !== true) {
786
+ const bits = []
787
+ if (verified.branchOk !== true) bits.push(`分支对不上(记录里是 ${verified.declaredBranch},实际是 ${verified.branch})`)
788
+ if ((verified.missingCommits ?? []).length > 0) bits.push(`${verified.missingCommits.length} 个记录的提交在分支上找不到`)
789
+ if ((verified.missingFiles ?? []).length > 0) bits.push(`${verified.missingFiles.length} 个记录的文件没有实际改动`)
790
+ if (bits.length > 0) blockers.push(`执行记录和代码不一致:${bits.join(';')}`)
791
+ } else if (verified === null && task.coding !== null && task.coding !== undefined && task.worktree !== null && task.worktree !== undefined) {
792
+ warnings.push('执行记录还没和代码事实核对过(执行记录页里有「核对代码事实」)')
793
+ }
794
+ // 同服务并行:几张卡同时改一个服务,合并时要人盯着。
795
+ const parallel = host.serviceConflicts(state, task)
796
+ if (parallel !== undefined) {
797
+ warnings.push(`同一个服务「${parallel.name}」还有 ${parallel.count} 张卡在改(${parallel.tasks.slice(0, 3).map(item => item.code).join('、')}),合并前先对齐,避免互相覆盖`)
798
+ }
799
+ return { ready: blockers.length === 0, blockers, warnings }
800
+ }
801
+
802
+ /**
803
+ * 工作台:一眼看清「哪里着火了」。
804
+ * 只做汇总,不改任何数据;列表最多各给 6 条,点进去看卡片。
805
+ */
806
+ export function buildDashboard(state) {
807
+ const now = Date.now()
808
+ const tasks = state.tasks
809
+ const open = tasks.filter(task => task.status !== 'done')
810
+ const listOf = (items, limit = 6) => items.slice(0, limit).map(task => ({
811
+ id: task.id,
812
+ code: task.code,
813
+ title: task.title,
814
+ status: task.status,
815
+ statusLabel: (STATUSES.find(entry => entry.id === task.status)?.label) ?? task.status,
816
+ owner: task.owner || '',
817
+ dueAt: task.dueAt || 0,
818
+ sla: host.bugSla(task, state.config),
819
+ }))
820
+
821
+ const overdue = open.filter(task => task.dueAt > 0 && task.dueAt < now)
822
+ const blocked = open.filter(task => task.blocked != null)
823
+ const slaOverdue = open.filter(task => task.type === 'bug' && host.bugSla(task, state.config).state === 'overdue')
824
+ const gateFailed = open.filter(task => {
825
+ if (task.status !== 'test' && task.status !== 'accept') return false
826
+ const gate = taskExitGate(state, task)
827
+ return gate.blockers.length > 0
828
+ })
829
+ const pipelineFailed = open.filter(task => task.gitlabMR?.pipeline?.status === 'failed')
830
+
831
+ const requirementBlocked = state.requirements.filter(item => {
832
+ const progress = requirementProgress(state, item)
833
+ return progress.derived !== 'delivered' && progress.derived !== 'closed' && progress.gate.blockers.length > 0
834
+ })
835
+ const releaseBlocked = state.releases.filter(item => item.status !== 'released' && item.status !== 'rolled_back' && !host.releaseGate(state, item).ready)
836
+
837
+ const done = tasks.filter(task => task.status === 'done')
838
+ const dayStart = new Date()
839
+ dayStart.setHours(0, 0, 0, 0)
840
+ const doneToday = done.filter(task => (task.updatedAt || 0) >= dayStart.getTime())
841
+ const doneWeek = done.filter(task => (task.updatedAt || 0) >= now - 7 * 24 * 60 * 60 * 1000)
842
+ const aiRunning = host.activeDispatcher === null ? 0 : host.activeDispatcher.running.size
843
+ const runs = typeof RUN_LOG !== 'undefined' && Array.isArray(RUN_LOG) ? RUN_LOG : []
844
+
845
+ return {
846
+ at: now,
847
+ scope: { tasks: tasks.length, open: open.length, requirements: state.requirements.length, releases: state.releases.length },
848
+ risks: {
849
+ overdue: overdue.length,
850
+ blocked: blocked.length,
851
+ slaOverdue: slaOverdue.length,
852
+ gateFailed: gateFailed.length,
853
+ pipelineFailed: pipelineFailed.length,
854
+ requirementBlocked: requirementBlocked.length,
855
+ releaseBlocked: releaseBlocked.length,
856
+ integrationsBroken: host.healthReport().broken,
857
+ },
858
+ flow: {
859
+ review: tasks.filter(task => task.status === 'review').length,
860
+ test: tasks.filter(task => task.status === 'test').length,
861
+ accept: tasks.filter(task => task.status === 'accept').length,
862
+ doneToday: doneToday.length,
863
+ doneWeek: doneWeek.length,
864
+ },
865
+ ai: { running: aiRunning, todayRuns: runs.filter(item => (item.at || 0) >= dayStart.getTime()).length },
866
+ list: {
867
+ overdue: listOf(overdue),
868
+ blocked: listOf(blocked),
869
+ slaOverdue: listOf(slaOverdue),
870
+ gateFailed: listOf(gateFailed),
871
+ pipelineFailed: listOf(pipelineFailed),
872
+ requirementBlocked: requirementBlocked.slice(0, 6).map(item => ({
873
+ id: item.id,
874
+ code: item.code,
875
+ title: item.title,
876
+ blockers: requirementProgress(state, item).gate.blockers.slice(0, 3),
877
+ })),
878
+ releaseBlocked: releaseBlocked.slice(0, 6).map(item => ({
879
+ id: item.id,
880
+ version: item.version,
881
+ name: item.name,
882
+ blockers: host.releaseGate(state, item).blockers.slice(0, 3),
883
+ })),
884
+ },
885
+ }
886
+ }
887
+
888
+ /** 跑一个外部命令,有超时、不抛异常;lark-cli 之类没有时返回 ok:false。 */
889
+ export function runCommand(command, args, timeoutMs = 20000) {
890
+ return new Promise(resolvePromise => {
891
+ let child
892
+ try {
893
+ child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] })
894
+ } catch (error) {
895
+ resolvePromise({ ok: false, code: -1, stdout: '', stderr: String(error?.message ?? error) })
896
+ return
897
+ }
898
+ let stdout = ''
899
+ let stderr = ''
900
+ const timer = setTimeout(() => { child.kill('SIGKILL') }, timeoutMs)
901
+ child.stdout.on('data', chunk => { stdout += chunk })
902
+ child.stderr.on('data', chunk => { stderr += chunk })
903
+ child.on('error', error => {
904
+ clearTimeout(timer)
905
+ resolvePromise({ ok: false, code: -1, stdout, stderr: String(error?.message ?? error) })
906
+ })
907
+ child.on('close', code => {
908
+ clearTimeout(timer)
909
+ resolvePromise({ ok: code === 0, code, stdout, stderr })
910
+ })
911
+ })
912
+ }
913
+
914
+ /** 从飞书文档拉正文;没有 lark-cli 就如实说明,让人改成粘贴。 */
915
+ export async function pullFeishuDoc(url) {
916
+ const target = String(url ?? '').trim()
917
+ if (target === '') return { ok: false, tool: false, text: '', error: '请先填飞书文档链接' }
918
+ if (!/feishu\.cn|larksuite\.com/i.test(target)) return { ok: false, tool: false, text: '', error: '这不是飞书文档链接' }
919
+ const probe = await runCommand('lark-cli', ['--version'], 8000)
920
+ if (!probe.ok) {
921
+ const hint = String(probe.stderr || probe.stdout || 'command not found').split('\n')[0].slice(0, 140)
922
+ return {
923
+ ok: false,
924
+ tool: false,
925
+ text: '',
926
+ error: `服务器上没有可用的 lark-cli(${hint})。可以自己打开文档,把正文粘进「文档正文」再保存。`,
927
+ }
928
+ }
929
+ const pulled = await runCommand('lark-cli', ['docs', '+fetch', '--doc', target, '--doc-format', 'markdown'], 45000)
930
+ if (!pulled.ok) {
931
+ return { ok: false, tool: true, text: '', error: `拉取失败:${(pulled.stderr || pulled.stdout).slice(0, 300)}` }
932
+ }
933
+ const raw = pulled.stdout.trim()
934
+ let text = raw
935
+ try {
936
+ const parsed = JSON.parse(raw)
937
+ if (parsed.ok === false) {
938
+ const message = parsed.error?.message ?? '未知错误'
939
+ return { ok: false, tool: true, text: '', error: `lark-cli 报错:${String(message).slice(0, 260)}(可能是这个应用还没被加进文档协作者)` }
940
+ }
941
+ text = String(parsed.data?.document?.content ?? '')
942
+ if (text.trim() === '') return { ok: false, tool: true, text: '', error: '拉到了空正文:这个文档可能是空的,或者应用没有阅读权限' }
943
+ const title = /<title>([\s\S]*?)<\/title>/.exec(text)?.[1]?.trim() ?? ''
944
+ const body = text.replace(/<title>[\s\S]*?<\/title>\s*/, '')
945
+ return { ok: true, tool: true, title, text: body.slice(0, 60000), error: '' }
946
+ } catch {
947
+ return { ok: true, tool: true, title: '', text: raw.slice(0, 60000), error: '' }
948
+ }
949
+ }
950
+
951
+ /** lark-cli 探活:60 秒内复用结果,别每次调用都起进程。 */
952
+ export let larkProbeCache = { at: 0, ok: false, hint: '' }
953
+
954
+ /** Drops the cached probe so the next larkCliReady() shells out again. */
955
+ export function invalidateLarkProbe() {
956
+ larkProbeCache = { at: 0, ok: false, hint: '' }
957
+ }
958
+
959
+ export async function larkCliReady() {
960
+ const now = Date.now()
961
+ if (now - larkProbeCache.at < 60000) return larkProbeCache
962
+ const probe = await runCommand('lark-cli', ['--version'], 8000)
963
+ larkProbeCache = {
964
+ at: now,
965
+ ok: probe.ok,
966
+ hint: probe.ok ? '' : String(probe.stderr || probe.stdout || 'command not found').split('\n')[0].slice(0, 140),
967
+ }
968
+ return larkProbeCache
969
+ }
970
+
971
+ /** 以「用户身份」跑一条 lark-cli,返回解析后的 data;失败给一句人话。 */
972
+ export async function larkJson(args, timeoutMs = 45000) {
973
+ const probe = await larkCliReady()
974
+ if (!probe.ok) {
975
+ host.markHealth('drive', 'fail', `服务器上没有可用的 lark-cli(${probe.hint})`)
976
+ return { ok: false, error: `服务器上没有可用的 lark-cli(${probe.hint})` }
977
+ }
978
+ const result = await runCommand('lark-cli', [...args, '--as', 'user', '--json'], timeoutMs)
979
+ const raw = String(result.stdout || '').trim()
980
+ if (raw === '') {
981
+ const message = (result.stderr || 'lark-cli 没有输出').slice(0, 300)
982
+ host.markHealth('drive', 'fail', message)
983
+ return { ok: false, error: message }
984
+ }
985
+ let parsed
986
+ try {
987
+ parsed = JSON.parse(raw)
988
+ } catch {
989
+ host.markHealth('drive', 'fail', `lark-cli 输出不是 JSON:${raw.slice(0, 160)}`)
990
+ return { ok: false, error: raw.slice(0, 300) }
991
+ }
992
+ if (parsed.ok === false) {
993
+ const message = `lark-cli 报错:${String(parsed.error?.message ?? '未知错误').slice(0, 260)}`
994
+ host.markHealth('drive', 'fail', message)
995
+ return { ok: false, error: message }
996
+ }
997
+ host.markHealth('drive', 'ok')
998
+ return { ok: true, data: parsed.data ?? {} }
999
+ }
1000
+
1001
+ export const ARTIFACT_FILE_LABEL = {
1002
+ requirement: '需求', design: '方案', mr: '合并请求', cases: '用例', report: '报告',
1003
+ release: '发布', doc: '文档', screenshot: '截图', other: '交付物',
1004
+ }
1005
+
1006
+ export function safeFileName(text) {
1007
+ return String(text ?? '').replace(/[\\/:*?"<>|\n\r\t]/g, '-').replace(/\s+/g, ' ').trim().slice(0, 120)
1008
+ }
1009
+
1010
+ /** 需求在云盘里的文件夹名:`REQ-2026-001 会员积分抵扣`。 */
1011
+ export function requirementFolderName(requirement) {
1012
+ const code = String(requirement.code ?? '').trim()
1013
+ return safeFileName(`${code.length > 0 ? code : requirement.id} ${requirement.title}`.trim())
1014
+ }
1015
+
1016
+ /** 给需求建一个云盘文件夹;已经建过就直接返回。 */
1017
+ export async function ensureReqFolder(store, requirementId) {
1018
+ const state = store.snapshot()
1019
+ const requirement = state.requirements.find(item => item.id === requirementId)
1020
+ if (requirement === undefined) throw new Error(`no requirement ${requirementId}`)
1021
+ const current = requirement.folder ?? {}
1022
+ if (String(current.token ?? '').trim().length > 0) return current
1023
+ const parent = String(state.config.feishu.driveFolder ?? '').trim()
1024
+ if (parent === '') throw new Error('还没配置飞书云盘根文件夹(设置 → 飞书 · 云盘根文件夹)')
1025
+ const name = requirementFolderName(requirement)
1026
+ const created = await larkJson(['drive', '+create-folder', '--folder-token', parent, '--name', name], 45000)
1027
+ if (!created.ok) {
1028
+ await store.mutate(draft => {
1029
+ const target = draft.requirements.find(item => item.id === requirementId)
1030
+ if (target !== undefined) target.folder = { ...(target.folder ?? {}), error: created.error.slice(0, 300) }
1031
+ })
1032
+ return { token: '', url: '', name, at: 0, error: created.error.slice(0, 300) }
1033
+ }
1034
+ const folder = {
1035
+ token: String(created.data.folder_token ?? ''),
1036
+ url: String(created.data.url ?? ''),
1037
+ name: String(created.data.name ?? name),
1038
+ at: Date.now(),
1039
+ error: '',
1040
+ }
1041
+ await store.mutate(draft => {
1042
+ const target = draft.requirements.find(item => item.id === requirementId)
1043
+ if (target !== undefined) target.folder = folder
1044
+ })
1045
+ return folder
1046
+ }
1047
+
1048
+ /** 把一条交付物渲染成 Markdown:谁、在哪个需求下、哪张卡、什么内容。 */
1049
+ export function artifactMarkdown(task, artifact, requirement) {
1050
+ const lines = []
1051
+ lines.push(`# ${artifact.title}`)
1052
+ lines.push('')
1053
+ if (requirement !== undefined) lines.push(`- 需求:${requirement.code || requirement.id} · ${requirement.title}`)
1054
+ lines.push(`- 卡片:${task.code || task.id} · ${task.title}`)
1055
+ lines.push(`- 类型:${ARTIFACT_FILE_LABEL[artifact.kind] ?? artifact.kind} 状态:${artifact.status}`)
1056
+ if (String(artifact.by ?? '').length > 0) lines.push(`- 登记人:${artifact.by}`)
1057
+ if (Number(artifact.at) > 0) lines.push(`- 登记时间:${new Date(Number(artifact.at)).toISOString().slice(0, 16).replace('T', ' ')}`)
1058
+ if (String(artifact.ref ?? '').length > 0) lines.push(`- 参考:${artifact.ref}`)
1059
+ lines.push('')
1060
+ const body = String(artifact.content ?? '').trim()
1061
+ lines.push(body.length > 0 ? body : '_这条交付物还没有正文。补充正文后点「同步到云盘」,这份文档会被覆盖更新。_')
1062
+ lines.push('')
1063
+ return lines.join('\n')
1064
+ }
1065
+
1066
+ /** 交付物正文:优先用登记的 content;没有就从 ref 指向的本地文件里读回来。 */
1067
+ export async function artifactBody(task, artifact, config) {
1068
+ const inline = String(artifact.content ?? '').trim()
1069
+ if (inline.length > 0) return inline
1070
+ const raw = String(artifact.ref ?? '').trim()
1071
+ if (raw.length === 0) return ''
1072
+ const root = String(config?.agents?.workspaceDir ?? '').trim() || join(homedir(), 'dsh-workspace')
1073
+ const target = raw.startsWith('/') ? raw : resolve(root, raw)
1074
+ try {
1075
+ const info = await stat(target)
1076
+ if (!info.isFile() || info.size > 2_000_000) return ''
1077
+ const text = await readFile(target, 'utf8')
1078
+ return String(text).slice(0, 60000)
1079
+ } catch {
1080
+ return ''
1081
+ }
1082
+ }
1083
+
1084
+ /** 没有挂需求的卡也要能归档:统一放进根目录下的「未挂需求的产物」。 */
1085
+ export async function ensureArtifactFolder(store, task) {
1086
+ const state = store.snapshot()
1087
+ const requirement = state.requirements.find(item => item.id === task.requirementId)
1088
+ if (requirement !== undefined) {
1089
+ return { folder: await ensureReqFolder(store, requirement.id), requirement }
1090
+ }
1091
+ const cached = state.fallbackFolder ?? {}
1092
+ if (String(cached.token ?? '').trim().length > 0) return { folder: cached, requirement: undefined }
1093
+ const parent = String(state.config.feishu.driveFolder ?? '').trim()
1094
+ if (parent === '') throw new Error('还没配置飞书云盘根文件夹(设置 → 飞书 · 云盘根文件夹)')
1095
+ const name = '未挂需求的产物'
1096
+ const created = await larkJson(['drive', '+create-folder', '--folder-token', parent, '--name', name], 45000)
1097
+ if (!created.ok) throw new Error(created.error)
1098
+ const folder = {
1099
+ token: String(created.data.folder_token ?? ''),
1100
+ url: String(created.data.url ?? ''),
1101
+ name: String(created.data.name ?? name),
1102
+ at: Date.now(),
1103
+ error: '',
1104
+ }
1105
+ await store.mutate(draft => { draft.fallbackFolder = folder })
1106
+ return { folder, requirement: undefined }
1107
+ }
1108
+
1109
+ /** 把一条交付物写进需求的云盘文件夹(首次创建、之后覆盖)。 */
1110
+ export async function syncArtifact(store, taskId, artifactId) {
1111
+ const state = store.snapshot()
1112
+ const task = state.tasks.find(item => item.id === taskId)
1113
+ if (task === undefined) throw new Error(`no task ${taskId}`)
1114
+ const artifact = (task.artifacts ?? []).find(item => item.id === artifactId)
1115
+ if (artifact === undefined) throw new Error(`no artifact ${artifactId}`)
1116
+ const { folder, requirement } = await ensureArtifactFolder(store, task)
1117
+ if (String(folder.token ?? '').trim() === '') throw new Error(folder.error ?? '创建云盘文件夹失败')
1118
+ const prefix = task.code.length > 0 ? `${task.code}-` : `${task.id}-`
1119
+ const name = safeFileName(`${prefix}${ARTIFACT_FILE_LABEL[artifact.kind] ?? '交付物'}-${artifact.title}.md`)
1120
+ const filled = { ...artifact, content: await artifactBody(task, artifact, state.config) }
1121
+ const body = artifactMarkdown(task, filled, requirement)
1122
+ const tmpDir = join(host.dshHomePath('tmp'), `artifact-${artifact.id}`)
1123
+ await mkdir(tmpDir, { recursive: true })
1124
+ // lark-cli 用本地文件名当远端文件名(--name 在 --file 模式下会被忽略),
1125
+ // 所以这里把临时文件直接命名成最终文档名。
1126
+ const tmpFile = join(tmpDir, name)
1127
+ await writeFile(tmpFile, body, 'utf8')
1128
+ try {
1129
+ const existing = String(artifact.doc?.token ?? '').trim()
1130
+ const result = existing.length > 0
1131
+ ? await larkJson(['markdown', '+overwrite', '--file-token', existing, '--file', tmpFile], 45000)
1132
+ : await larkJson(['markdown', '+create', '--folder-token', folder.token, '--file', tmpFile, '--name', name], 45000)
1133
+ if (!result.ok) {
1134
+ await store.mutate(draft => {
1135
+ const target = draft.tasks.find(item => item.id === taskId)?.artifacts?.find(item => item.id === artifactId)
1136
+ if (target !== undefined) target.doc = { ...(target.doc ?? {}), error: result.error.slice(0, 300) }
1137
+ })
1138
+ throw new Error(result.error)
1139
+ }
1140
+ const doc = {
1141
+ token: String(result.data.file_token ?? existing),
1142
+ url: String(result.data.url ?? artifact.doc?.url ?? ''),
1143
+ name: String(result.data.file_name ?? artifact.doc?.name ?? name),
1144
+ at: Date.now(),
1145
+ error: '',
1146
+ }
1147
+ await store.mutate(draft => {
1148
+ const target = draft.tasks.find(item => item.id === taskId)?.artifacts?.find(item => item.id === artifactId)
1149
+ if (target !== undefined) {
1150
+ target.doc = doc
1151
+ // 正文是从 ref 文件读回来的话,落回交付物本身:
1152
+ // 看板上要能看到「正文 N 字」,下次同步也不必再读一次磁盘。
1153
+ if (String(target.content ?? '').trim() === '' && filled.content.trim() !== '') {
1154
+ target.content = filled.content.slice(0, 60000)
1155
+ }
1156
+ }
1157
+ })
1158
+ return doc
1159
+ } finally {
1160
+ await rm(tmpDir, { recursive: true, force: true })
1161
+ }
1162
+ }
1163
+
1164
+ /** 一条需求下所有没归档(或归档失败的)交付物,批量写进云盘。 */
1165
+ export async function syncRequirementArtifacts(store, requirementId) {
1166
+ const state = store.snapshot()
1167
+ const tasks = state.tasks.filter(task => task.requirementId === requirementId)
1168
+ const report = { total: 0, written: 0, failed: 0, errors: [] }
1169
+ for (const task of tasks) {
1170
+ for (const artifact of task.artifacts ?? []) {
1171
+ report.total += 1
1172
+ try {
1173
+ await syncArtifact(store, task.id, artifact.id)
1174
+ report.written += 1
1175
+ } catch (error) {
1176
+ report.failed += 1
1177
+ if (report.errors.length < 5) report.errors.push(`${task.code || task.id} / ${artifact.title}:${String(error?.message ?? error).slice(0, 160)}`)
1178
+ }
1179
+ }
1180
+ }
1181
+ return report
1182
+ }
1183
+
1184
+ /** 需求文档的增删改与「评审通过、冻结为基线」。 */
1185
+ export async function mutateReqDoc(store, body) {
1186
+ const requirementId = String(body.id ?? '')
1187
+ const action = String(body.action ?? 'add')
1188
+ const docId = String(body.docId ?? '')
1189
+ let saved
1190
+ await store.mutate(state => {
1191
+ const requirement = state.requirements.find(item => item.id === requirementId)
1192
+ if (requirement === undefined) throw new Error(`no requirement ${requirementId}`)
1193
+ if (!Array.isArray(requirement.docs)) requirement.docs = []
1194
+ if (action === 'add') {
1195
+ const doc = sanitizeReqDoc(body.doc ?? {}, requirement.docs.length)
1196
+ if (doc === undefined) throw new Error('文档要有标题')
1197
+ requirement.docs.push(doc)
1198
+ } else if (action === 'update') {
1199
+ const target = requirement.docs.find(item => item.id === docId)
1200
+ if (target === undefined) throw new Error(`no doc ${docId}`)
1201
+ Object.assign(target, sanitizeReqDoc({ ...target, ...(body.doc ?? {}), id: target.id }))
1202
+ } else if (action === 'remove') {
1203
+ requirement.docs = requirement.docs.filter(item => item.id !== docId)
1204
+ if (requirement.baseline !== null && requirement.baseline !== undefined && requirement.baseline.docId === docId) {
1205
+ requirement.baseline = null
1206
+ }
1207
+ } else if (action === 'baseline') {
1208
+ const target = requirement.docs.find(item => item.id === docId)
1209
+ if (target === undefined) throw new Error(`no doc ${docId}`)
1210
+ for (const doc of requirement.docs) doc.status = doc.id === docId ? 'baseline' : (doc.status === 'baseline' ? 'draft' : doc.status)
1211
+ requirement.baseline = sanitizeBaseline({
1212
+ docId: target.id,
1213
+ title: target.title,
1214
+ version: target.version,
1215
+ content: target.content,
1216
+ at: Date.now(),
1217
+ by: String(body.by ?? '人').slice(0, 40),
1218
+ })
1219
+ if (requirement.status === 'draft') requirement.status = 'active'
1220
+ } else if (action === 'clear-baseline') {
1221
+ requirement.baseline = null
1222
+ for (const doc of requirement.docs) if (doc.status === 'baseline') doc.status = 'review'
1223
+ } else {
1224
+ throw new Error(`unknown action ${action}`)
1225
+ }
1226
+ requirement.updatedAt = Date.now()
1227
+ saved = structuredClone(requirement)
1228
+ })
1229
+ return saved
1230
+ }
1231
+
1232
+ /** Update one task. */
1233
+ export async function updateTask(store, id, patch) {
1234
+ const normalized = normalizePatch(patch)
1235
+ const blockFlag = normalized.blocked
1236
+ delete normalized.blocked
1237
+ const override = normalized.override ?? ''
1238
+ delete normalized.override
1239
+ const blockReason = text(patch.blockedReason, 500)
1240
+ if (normalized.status === 'done' && override === '') {
1241
+ const current = store.snapshot().tasks.find(entry => entry.id === id)
1242
+ if (current !== undefined) {
1243
+ const simulated = { ...current, ...normalized, blocked: current.blocked }
1244
+ const gate = taskExitGate(store.snapshot(), simulated)
1245
+ if (!gate.ready) {
1246
+ throw new Error(`准出没过:${gate.blockers.join(';')}。确实要收口就在推进时填 override 写明原因。`)
1247
+ }
1248
+ }
1249
+ }
1250
+ if (normalized.status !== undefined && override === '') {
1251
+ // 设计闸门:开了开关的项目,进开发列之前设计必须评审通过。
1252
+ const current = store.snapshot().tasks.find(entry => entry.id === id)
1253
+ if (current !== undefined) {
1254
+ const gate = designGate(store.snapshot().config, current, normalized.status)
1255
+ if (gate.needed === true && gate.ready !== true) throw new Error(`进开发被拦:${gate.blockers.join(';')}`)
1256
+ }
1257
+ }
1258
+ let result
1259
+ let moved = null
1260
+ let blocking = null
1261
+ const by = text(patch.by, 60) ?? 'agent'
1262
+ await store.mutate(state => {
1263
+ const task = state.tasks.find(t => t.id === id)
1264
+ if (task === undefined) throw new Error(`no task ${id}`)
1265
+ const previous = task.status
1266
+ const previousRequirement = task.requirementId ?? ''
1267
+ Object.assign(task, normalized)
1268
+ task.updatedAt = Date.now()
1269
+ // 服务必须属于它挂的项目:换项目或服务被删掉时,把失效的服务 id 清掉。
1270
+ if (String(task.service ?? '') !== '') {
1271
+ const owner = (state.config.projects ?? []).find(entry => entry.id === task.project)
1272
+ if (owner === undefined || !(owner.services ?? []).some(entry => entry.id === task.service)) task.service = ''
1273
+ }
1274
+ // 换需求就要换编号:同一需求下 DEV-001 只出现一次。
1275
+ if (normalized.requirementId !== undefined && normalized.requirementId !== previousRequirement) {
1276
+ task.code = normalized.requirementId === ''
1277
+ ? ''
1278
+ : nextTaskCode(state.tasks, normalized.requirementId, CODE_PREFIX[task.type] ?? 'TASK')
1279
+ }
1280
+ if (blockFlag === true && (task.blocked === null || task.blocked === undefined)) {
1281
+ task.blocked = {
1282
+ at: task.updatedAt,
1283
+ by,
1284
+ reason: blockReason ?? text(task.blockedReason, 500) ?? '',
1285
+ }
1286
+ task.blockedReason = task.blocked.reason
1287
+ task.history.push({ at: task.updatedAt, by, status: task.status, note: 'blocked' })
1288
+ blocking = 'blocked'
1289
+ } else if (blockFlag === false && task.blocked !== null && task.blocked !== undefined) {
1290
+ task.blocked = null
1291
+ task.blockedReason = ''
1292
+ task.history.push({ at: task.updatedAt, by, status: task.status, note: 'unblocked' })
1293
+ blocking = 'unblocked'
1294
+ }
1295
+ // 换列时负责人会被这一列的默认负责人覆盖,团队记忆要用「换列前」的那个人去认团。
1296
+ const expertBefore = task.expert
1297
+ if (normalized.status !== undefined) {
1298
+ task.history.push({ at: task.updatedAt, by, status: normalized.status, ...(override !== '' ? { note: `override:${override}` } : {}) })
1299
+ // 缺陷从「已完成」被打回去 = 重开,重开次数是质量信号(谁的老毛病又犯了)。
1300
+ if (task.type === 'bug' && previous === 'done' && normalized.status !== 'done') {
1301
+ task.reopenCount = (Number(task.reopenCount) || 0) + 1
1302
+ }
1303
+ // 人把卡推进到别的列,就意味着「解除阻塞、继续走」。
1304
+ if (task.blocked !== null && task.blocked !== undefined) {
1305
+ task.blocked = null
1306
+ task.blockedReason = ''
1307
+ task.history.push({ at: task.updatedAt, by, status: normalized.status, note: 'unblocked' })
1308
+ blocking = 'unblocked'
1309
+ }
1310
+ // The role and the owning expert follow the column unless named explicitly.
1311
+ const column = STATUSES.find(entry => entry.id === normalized.status)
1312
+ if (column !== undefined) {
1313
+ if (normalized.role === undefined) task.role = column.role
1314
+ if (normalized.expert === undefined) {
1315
+ const owner = state.config.columns?.[column.id]
1316
+ if (owner !== undefined && typeof owner.id === 'string') task.expert = owner.id
1317
+ }
1318
+ }
1319
+ if (previous !== task.status) moved = { from: previous, to: task.status, task: structuredClone(task), by, expertBefore }
1320
+ }
1321
+ result = task
1322
+ })
1323
+ if (moved !== null) {
1324
+ // 卡进「完成」:把这条经验记进它所属专家团的团队记忆(下次派活会带进提示词)。
1325
+ if (moved.to === 'done') {
1326
+ try {
1327
+ const snapshot = store.snapshot()
1328
+ const owner = snapshot.config?.columns?.[moved.to] ?? {}
1329
+ const teams = snapshot.config?.teams ?? []
1330
+ const ownerId = owner.kind === 'team' ? String(owner.id ?? '') : ''
1331
+ const expertId = String(moved.expertBefore ?? result.expert ?? '')
1332
+ const hit = ownerId !== ''
1333
+ ? teams.find(team => team.id === ownerId)
1334
+ : teams.find(team => (team.members ?? []).includes(expertId) || team.lead === expertId)
1335
+ if (hit !== undefined) {
1336
+ await appendTeamMemory(store, hit.id, {
1337
+ taskId: result.id,
1338
+ title: result.title,
1339
+ outcome: result.type === 'bug' ? '修完的缺陷' : '做完的卡',
1340
+ note: String(result.signoff?.note ?? '').slice(0, 200),
1341
+ })
1342
+ }
1343
+ } catch {
1344
+ /* 记不下团队记忆不能影响卡推进 */
1345
+ }
1346
+ }
1347
+ try { host.broadcastChange(store, moved.task, moved.from, moved.to, moved.by) } catch (error) {
1348
+ host.INTEGRATION.lastPushError = String(error?.message ?? error).slice(0, 300)
1349
+ }
1350
+ // 缺陷卡修好进「待测试」=触发回归:把来源用例重置成待执行。
1351
+ if (moved.to === 'test' && result.bugSource != null) {
1352
+ try { await host.resetOriginCase(store, result) } catch (error) {
1353
+ host.INTEGRATION.lastPushError = String(error?.message ?? error).slice(0, 300)
1354
+ }
1355
+ }
1356
+ }
1357
+ if (blocking !== null) {
1358
+ try { host.broadcastChange(store, result, result.status, result.status, by, blocking) } catch (error) {
1359
+ host.INTEGRATION.lastPushError = String(error?.message ?? error).slice(0, 300)
1360
+ }
1361
+ }
1362
+ return result
1363
+ }
1364
+
1365
+ /** Append a comment and report who it @mentioned. */
1366
+ /** 把「架构」「🏛️ 架构师」「研发」这类历史写法收敛成专家本名,老数据与新写入都受益。 */
1367
+
1368
+ export async function commentTask(store, id, body, author, kind) {
1369
+ const content = text(body, 20000)
1370
+ if (content === undefined) throw new Error('body is required')
1371
+ const explicit = kind === 'human' || kind === 'agent' ? kind : undefined
1372
+ let result
1373
+ let mentions = []
1374
+ await store.mutate(state => {
1375
+ const task = state.tasks.find(t => t.id === id)
1376
+ if (task === undefined) throw new Error(`no task ${id}`)
1377
+ const who = canonicalAuthor(text(author, 60) ?? 'agent', state.config)
1378
+ // 没显式声明来源时,按作者判断:命中专家名册或像角色名的是 AI,其余算人。
1379
+ const origin = explicit ?? ((state.config?.experts ?? []).some(entry => {
1380
+ const name = String(entry.name ?? '')
1381
+ return name !== '' && (name === who || who.includes(name) || name.includes(who))
1382
+ }) || /产品|架构|研发|开发|测试|运维|专家/.test(who) ? 'agent' : 'human')
1383
+ task.comments.push({
1384
+ id: `c-${randomBytes(3).toString('hex')}`,
1385
+ at: Date.now(),
1386
+ author: who,
1387
+ kind: origin,
1388
+ body: content,
1389
+ })
1390
+ task.updatedAt = Date.now()
1391
+ mentions = parseMentions(content, state.config)
1392
+ result = task
1393
+ })
1394
+ return { task: result, mentions }
1395
+ }
1396
+