@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.
- package/AGENTS.md +148 -0
- package/LICENSE +202 -0
- package/README.md +1356 -3
- package/client.js +952 -0
- package/client.src.js +2466 -0
- package/cordis.patch.yml +9 -0
- package/dist/client.js +952 -0
- package/docs/compatibility.md +99 -0
- package/docs/publish.md +86 -0
- package/docs/quickstart.md +53 -0
- package/icon.svg +6 -0
- package/index.js +2674 -0
- package/locale/en.json +11 -0
- package/locale/zh.json +11 -0
- package/package.json +63 -4
- package/scripts/smoke-store.mjs +76 -0
- package/src/host/cases.js +135 -0
- package/src/host/design.js +87 -0
- package/src/host/dispatcher.js +936 -0
- package/src/host/domain.js +1966 -0
- package/src/host/executor.js +1396 -0
- package/src/host/gitlab-facts.js +22 -0
- package/src/host/impact.js +32 -0
- package/src/host/integrations.js +587 -0
- package/src/host/quality.js +106 -0
- package/src/host/routes/admin.js +100 -0
- package/src/host/routes/agents.js +71 -0
- package/src/host/routes/auth.js +155 -0
- package/src/host/routes/coding.js +203 -0
- package/src/host/routes/connect.js +166 -0
- package/src/host/routes/exports.js +122 -0
- package/src/host/routes/insights.js +97 -0
- package/src/host/routes/releases.js +335 -0
- package/src/host/routes/requirements.js +430 -0
- package/src/host/routes/skills.js +75 -0
- package/src/host/routes/tasks.js +573 -0
- package/src/host/store.js +522 -0
- package/src/host/team-memory.js +46 -0
- package/src/host/tools.js +1211 -0
|
@@ -0,0 +1,1211 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto'
|
|
2
|
+
import {
|
|
3
|
+
CASE_RESULTS, CODING_REVIEW_STATUS, RELEASE_STATUS_LABEL, REQ_STATUS_LABEL, ROLE_IDS, STATUSES, STATUS_IDS,
|
|
4
|
+
TASK_TYPES, cleanId, codingSummary, defineStudioTool, mergeCoding, sanitizeArtifact, sanitizeCaseHttp,
|
|
5
|
+
sanitizeCases, sanitizeCoding, sanitizeRelease, text,
|
|
6
|
+
} from './domain.js'
|
|
7
|
+
import {
|
|
8
|
+
buildDashboard, commentTask, ensureReqFolder, requirementMatrix, requirementProgress, requirementRollup,
|
|
9
|
+
runSuite, runTaskCases, saveRequirement, syncArtifact, syncRequirementArtifacts, taskExitGate, updateTask,
|
|
10
|
+
} from './executor.js'
|
|
11
|
+
import { runSummary } from './dispatcher.js'
|
|
12
|
+
const INJECTED = ["activeDispatcher","activeSkills","addDraftCases","applyConfig","auditSummary","buildAnalytics","buildMetrics","burndownReport","checkToolAccess","createTask","releaseGate","releaseNotes","skillsRoot","taskLine","toolLevel","toolOperator","upsertById","workloadReport"]
|
|
13
|
+
let host = {
|
|
14
|
+
activeDispatcher: () => {},
|
|
15
|
+
activeSkills: () => {},
|
|
16
|
+
addDraftCases: () => {},
|
|
17
|
+
applyConfig: () => {},
|
|
18
|
+
auditSummary: () => {},
|
|
19
|
+
buildAnalytics: () => {},
|
|
20
|
+
buildMetrics: () => {},
|
|
21
|
+
burndownReport: () => {},
|
|
22
|
+
checkToolAccess: () => {},
|
|
23
|
+
createTask: () => {},
|
|
24
|
+
releaseGate: () => {},
|
|
25
|
+
releaseNotes: () => {},
|
|
26
|
+
skillsRoot: () => {},
|
|
27
|
+
taskLine: () => {},
|
|
28
|
+
toolLevel: () => {},
|
|
29
|
+
toolOperator: () => {},
|
|
30
|
+
upsertById: () => {},
|
|
31
|
+
workloadReport: () => {},
|
|
32
|
+
getActiveDispatcher: () => undefined,
|
|
33
|
+
getActiveSkills: () => undefined,
|
|
34
|
+
}
|
|
35
|
+
/** Agent-facing tools. 宿主能力由 configureToolHost() 注入(避免反向 import index.js 成环)。 */
|
|
36
|
+
export function configureToolHost(services) {
|
|
37
|
+
host = { ...host, ...(services ?? {}) }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 注册 39 个 studio_* 工具。 */
|
|
41
|
+
export function registerTools(ctx, store) {
|
|
42
|
+
const disposers = []
|
|
43
|
+
const register = tool => {
|
|
44
|
+
const body = tool.execute
|
|
45
|
+
disposers.push(ctx.tools.register({
|
|
46
|
+
...tool,
|
|
47
|
+
// 专家 agent 调工具也要过成员名单:读操作放行,写要成员、危险的只有管理员。
|
|
48
|
+
async execute(args, exec) {
|
|
49
|
+
await store.ready
|
|
50
|
+
const config = store.snapshot().config
|
|
51
|
+
const operator = host.toolOperator(store, exec)
|
|
52
|
+
const { level, denial } = operator === null
|
|
53
|
+
? { level: host.toolLevel(tool.name), denial: null }
|
|
54
|
+
: host.checkToolAccess(config, operator.name, tool.name)
|
|
55
|
+
if (denial !== null) {
|
|
56
|
+
await recordAudit(store, {
|
|
57
|
+
by: `${operator.name}(AI)`, route: `tool:${tool.name}`, level,
|
|
58
|
+
status: 403, ok: false, summary: denial,
|
|
59
|
+
})
|
|
60
|
+
throw new Error(`权限不足:${denial}`)
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
const value = await body(args, exec)
|
|
64
|
+
// 只给写操作留痕,读太多会把审计刷成流水账。
|
|
65
|
+
if (operator !== null && level !== 'read') {
|
|
66
|
+
await recordAudit(store, {
|
|
67
|
+
by: `${operator.name}(AI)`, route: `tool:${tool.name}`, level,
|
|
68
|
+
status: 200, ok: true, summary: host.auditSummary(args),
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
return value
|
|
72
|
+
} catch (error) {
|
|
73
|
+
if (operator !== null && level !== 'read') {
|
|
74
|
+
await recordAudit(store, {
|
|
75
|
+
by: `${operator.name}(AI)`, route: `tool:${tool.name}`, level,
|
|
76
|
+
status: 500, ok: false, summary: String(error?.message ?? error).slice(0, 200),
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
throw error
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
}))
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
register(defineStudioTool({
|
|
86
|
+
name: 'studio_config_get',
|
|
87
|
+
description: 'Read the Studio settings (GitLab / Feishu endpoints and the role→model mapping). Secrets are returned as presence flags, never values.',
|
|
88
|
+
parameters: {},
|
|
89
|
+
async execute() {
|
|
90
|
+
await store.ready
|
|
91
|
+
return { ok: true, config: publicConfig(store.snapshot().config), statuses: STATUSES, roles: ROLE_IDS }
|
|
92
|
+
},
|
|
93
|
+
}))
|
|
94
|
+
|
|
95
|
+
register(defineStudioTool({
|
|
96
|
+
name: 'studio_config_set',
|
|
97
|
+
description: 'Update Studio settings. Only the fields you pass change; an empty token/secret leaves the stored secret untouched. Use clearToken / clearAppSecret to erase one.',
|
|
98
|
+
parameters: {
|
|
99
|
+
gitlabUrl: { type: 'string', description: 'GitLab base URL, e.g. https://gitlab.example.com' },
|
|
100
|
+
gitlabToken: { type: 'string', description: 'GitLab personal/project access token (written once, never returned).' },
|
|
101
|
+
gitlabGroup: { type: 'string', description: 'Default GitLab group or namespace.' },
|
|
102
|
+
gitlabProject: { type: 'string', description: 'Default GitLab project path (group/name).' },
|
|
103
|
+
feishuAppId: { type: 'string', description: 'Feishu self-built app App ID.' },
|
|
104
|
+
feishuAppSecret: { type: 'string', description: 'Feishu app secret (written once, never returned).' },
|
|
105
|
+
feishuChatId: { type: 'string', description: 'Default Feishu chat id for notifications.' },
|
|
106
|
+
roleModels: {
|
|
107
|
+
type: 'object',
|
|
108
|
+
additionalProperties: true,
|
|
109
|
+
description: 'Role → model overrides, e.g. {"dev":"deepseek-v4.1-flash","qa":"deepseek-v4.1-flash"}',
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
async execute(args) {
|
|
113
|
+
const patch = { gitlab: {}, feishu: {}, roles: {} }
|
|
114
|
+
if (args.gitlabUrl !== undefined) patch.gitlab.url = args.gitlabUrl
|
|
115
|
+
if (args.gitlabToken !== undefined) patch.gitlab.token = args.gitlabToken
|
|
116
|
+
if (args.gitlabGroup !== undefined) patch.gitlab.group = args.gitlabGroup
|
|
117
|
+
if (args.gitlabProject !== undefined) patch.gitlab.project = args.gitlabProject
|
|
118
|
+
if (args.feishuAppId !== undefined) patch.feishu.appId = args.feishuAppId
|
|
119
|
+
if (args.feishuAppSecret !== undefined) patch.feishu.appSecret = args.feishuAppSecret
|
|
120
|
+
if (args.feishuChatId !== undefined) patch.feishu.chatId = args.feishuChatId
|
|
121
|
+
if (args.roleModels !== undefined && typeof args.roleModels === 'object') {
|
|
122
|
+
for (const [role, model] of Object.entries(args.roleModels)) {
|
|
123
|
+
if (ROLE_IDS.includes(role) && typeof model === 'string') patch.roles[role] = { model }
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
await host.applyConfig(store, patch)
|
|
127
|
+
return { ok: true, config: publicConfig(store.snapshot().config) }
|
|
128
|
+
},
|
|
129
|
+
}))
|
|
130
|
+
|
|
131
|
+
register(defineStudioTool({
|
|
132
|
+
name: 'studio_requirement_list',
|
|
133
|
+
description: 'List product requirements (REQ-...). A requirement is the anchor of the whole delivery chain: every task, artifact, test case, bug and MR hangs under it. Filter by project or status.',
|
|
134
|
+
parameters: {
|
|
135
|
+
project: { type: 'string', description: '业务项目 id。' },
|
|
136
|
+
status: { type: 'string', description: `draft | active | delivered | closed` },
|
|
137
|
+
},
|
|
138
|
+
async execute(args) {
|
|
139
|
+
await store.ready
|
|
140
|
+
const state = store.snapshot()
|
|
141
|
+
const rows = state.requirements
|
|
142
|
+
.filter(req => args.project === undefined || req.project === args.project)
|
|
143
|
+
.filter(req => args.status === undefined || req.status === args.status)
|
|
144
|
+
if (rows.length === 0) return '还没有登记任何需求。用 studio_requirement_save 建一条。'
|
|
145
|
+
const lines = rows.map(req => {
|
|
146
|
+
const tasks = state.tasks.filter(task => task.requirementId === req.id)
|
|
147
|
+
const done = tasks.filter(task => task.status === 'done').length
|
|
148
|
+
return `${req.code || req.id}|${req.title}|${REQ_STATUS_LABEL[req.status] ?? req.status}|卡 ${done}/${tasks.length}|${req.owner || '未指定负责人'}`
|
|
149
|
+
})
|
|
150
|
+
return [`共 ${rows.length} 条需求:`, ...lines].join('\n')
|
|
151
|
+
},
|
|
152
|
+
}))
|
|
153
|
+
|
|
154
|
+
register(defineStudioTool({
|
|
155
|
+
name: 'studio_requirement_get',
|
|
156
|
+
description: 'Read one requirement with its full rollup: tasks by column and type, checklist progress, test cases, artifacts and merge requests.',
|
|
157
|
+
parameters: { id: { type: 'string', required: true, description: '需求 id' } },
|
|
158
|
+
async execute(args) {
|
|
159
|
+
await store.ready
|
|
160
|
+
const rollup = requirementRollup(store.snapshot(), args.id)
|
|
161
|
+
if (rollup === undefined) throw new Error(`no requirement ${args.id}`)
|
|
162
|
+
return {
|
|
163
|
+
...rollup,
|
|
164
|
+
matrix: requirementMatrix(store.snapshot(), rollup.requirement),
|
|
165
|
+
progress: requirementProgress(store.snapshot(), rollup.requirement),
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
}))
|
|
169
|
+
|
|
170
|
+
register(defineStudioTool({
|
|
171
|
+
name: 'studio_requirement_save',
|
|
172
|
+
description: 'Create or update a requirement (PRD anchor). Omit id to create one; its code REQ-YYYY-NNN is assigned automatically.',
|
|
173
|
+
parameters: {
|
|
174
|
+
id: { type: 'string', description: '需求 id(省略则新建)。' },
|
|
175
|
+
title: { type: 'string', description: '需求标题。' },
|
|
176
|
+
description: { type: 'string', description: '需求正文 / PRD 摘要。' },
|
|
177
|
+
project: { type: 'string', description: '业务项目 id。' },
|
|
178
|
+
iteration: { type: 'string', description: '迭代 id。' },
|
|
179
|
+
service: { type: 'string', description: '服务 id(决定 worktree 用哪个 git 路径)。' },
|
|
180
|
+
owner: { type: 'string', description: '业务负责人。' },
|
|
181
|
+
priority: { type: 'string', description: 'urgent | normal | relaxed' },
|
|
182
|
+
status: { type: 'string', description: 'draft | active | delivered | closed' },
|
|
183
|
+
prd: { type: 'string', description: 'PRD 文档链接(如飞书云文档)。' },
|
|
184
|
+
proto: { type: 'string', description: '原型链接。' },
|
|
185
|
+
acceptance: { type: 'string', description: '验收标准链接。' },
|
|
186
|
+
},
|
|
187
|
+
async execute(args) {
|
|
188
|
+
const input = { ...args }
|
|
189
|
+
if (args.prd !== undefined || args.proto !== undefined || args.acceptance !== undefined) {
|
|
190
|
+
input.links = { prd: args.prd ?? '', proto: args.proto ?? '', acceptance: args.acceptance ?? '' }
|
|
191
|
+
}
|
|
192
|
+
const requirement = await saveRequirement(store, input)
|
|
193
|
+
return `已保存需求 ${requirement.code}(${REQ_STATUS_LABEL[requirement.status] ?? requirement.status}):${requirement.title}`
|
|
194
|
+
},
|
|
195
|
+
}))
|
|
196
|
+
|
|
197
|
+
register(defineStudioTool({
|
|
198
|
+
name: 'studio_requirement_signoff',
|
|
199
|
+
description: 'Sign off (or clear) a requirement after delivery review; the note is the acceptance conclusion.',
|
|
200
|
+
parameters: {
|
|
201
|
+
id: { type: 'string', required: true, description: '需求 id。' },
|
|
202
|
+
note: { type: 'string', description: '验收结论:交付了什么、遗留什么(签字时必填)。' },
|
|
203
|
+
by: { type: 'string', description: '签字人。' },
|
|
204
|
+
action: { type: 'string', description: 'sign(默认)| clear' },
|
|
205
|
+
},
|
|
206
|
+
async execute(args) {
|
|
207
|
+
const id = String(args.id ?? '')
|
|
208
|
+
const action = args.action === 'clear' ? 'clear' : 'sign'
|
|
209
|
+
const note = String(args.note ?? '').trim().slice(0, 600)
|
|
210
|
+
if (action === 'sign' && note === '') throw new Error('需求验收签字要写一句结论:交付了什么、遗留什么')
|
|
211
|
+
const snapshot = await store.mutate(state => {
|
|
212
|
+
const target = state.requirements.find(item => item.id === id)
|
|
213
|
+
if (target === undefined) throw new Error('需求不存在')
|
|
214
|
+
if (action === 'sign') {
|
|
215
|
+
target.signoff = { by: String(args.by ?? 'agent').slice(0, 60), at: Date.now(), note }
|
|
216
|
+
const open = state.tasks.filter(task => task.requirementId === id && task.status !== 'done').length
|
|
217
|
+
const total = state.tasks.filter(task => task.requirementId === id).length
|
|
218
|
+
if (open === 0 && total > 0) target.status = 'delivered'
|
|
219
|
+
} else {
|
|
220
|
+
target.signoff = null
|
|
221
|
+
if (target.status === 'delivered') target.status = 'active'
|
|
222
|
+
}
|
|
223
|
+
target.updatedAt = Date.now()
|
|
224
|
+
})
|
|
225
|
+
const requirement = snapshot.requirements.find(item => item.id === id)
|
|
226
|
+
const progress = requirementProgress(snapshot, requirement)
|
|
227
|
+
const head = action === 'sign' ? `已给 ${requirement.code} 签字` : `已取消 ${requirement.code} 的签字`
|
|
228
|
+
const tail = progress.gate.ready
|
|
229
|
+
? '需求准出:通过'
|
|
230
|
+
: `需求准出还差:${progress.gate.blockers.join(';')}`
|
|
231
|
+
return `${head}|${tail}(进度 ${progress.progress}%:${progress.cards.done}/${progress.cards.total} 张卡已完成)`
|
|
232
|
+
},
|
|
233
|
+
}))
|
|
234
|
+
|
|
235
|
+
register(defineStudioTool({
|
|
236
|
+
name: 'studio_requirement_folder',
|
|
237
|
+
description: 'Create (or reuse) the Feishu Drive folder of a requirement and optionally sync every deliverable under it into that folder as Markdown documents.',
|
|
238
|
+
parameters: {
|
|
239
|
+
id: { type: 'string', required: true, description: '需求 id 或编号(REQ-2026-001)。' },
|
|
240
|
+
sync: { type: 'boolean', description: '是否顺便把所有交付物写进这个文件夹(默认 true)。' },
|
|
241
|
+
},
|
|
242
|
+
async execute(args) {
|
|
243
|
+
const state = store.snapshot()
|
|
244
|
+
const key = String(args.id ?? '')
|
|
245
|
+
const requirement = state.requirements.find(item => item.id === key || item.code === key)
|
|
246
|
+
if (requirement === undefined) throw new Error(`no requirement ${key}`)
|
|
247
|
+
const folder = await ensureReqFolder(store, requirement.id)
|
|
248
|
+
if (String(folder.token ?? '').length === 0) {
|
|
249
|
+
return `建文件夹失败:${folder.error ?? '未知原因'}`
|
|
250
|
+
}
|
|
251
|
+
const lines = [`需求 ${requirement.code} 的云盘文件夹:${folder.name}`, folder.url]
|
|
252
|
+
if (args.sync !== false) {
|
|
253
|
+
const report = await syncRequirementArtifacts(store, requirement.id)
|
|
254
|
+
lines.push(`交付物归档:共 ${report.total} 件,成功 ${report.written} 件,失败 ${report.failed} 件。`)
|
|
255
|
+
for (const message of report.errors) lines.push(`- ${message}`)
|
|
256
|
+
}
|
|
257
|
+
return lines.join('\n')
|
|
258
|
+
},
|
|
259
|
+
}))
|
|
260
|
+
|
|
261
|
+
register(defineStudioTool({
|
|
262
|
+
name: 'studio_task_list',
|
|
263
|
+
description: 'List work-board tasks, newest first. Filter by status or role. Read this before claiming work; the board is the shared truth between product, architect, developer and QA agents.',
|
|
264
|
+
parameters: {
|
|
265
|
+
status: { type: 'string', description: `Filter by status: ${STATUS_IDS.join(' / ')}` },
|
|
266
|
+
role: { type: 'string', description: `Filter by role: ${ROLE_IDS.join(' / ')}` },
|
|
267
|
+
type: { type: 'string', description: `Filter by type: ${TASK_TYPES.join(' / ')}` },
|
|
268
|
+
project: { type: 'string', description: 'Filter by 业务项目 id.' },
|
|
269
|
+
requirementId: { type: 'string', description: 'Filter by 需求 id(只看一条需求下的卡)。' },
|
|
270
|
+
limit: { type: 'number', description: 'Max tasks to return (default 50).' },
|
|
271
|
+
},
|
|
272
|
+
async execute(args) {
|
|
273
|
+
await store.ready
|
|
274
|
+
const tasks = store.snapshot().tasks
|
|
275
|
+
.filter(t => args.status === undefined || t.status === args.status)
|
|
276
|
+
.filter(t => args.role === undefined || t.role === args.role)
|
|
277
|
+
.filter(t => args.type === undefined || t.type === args.type)
|
|
278
|
+
.filter(t => args.project === undefined || t.project === args.project)
|
|
279
|
+
.filter(t => args.requirementId === undefined || t.requirementId === '' || t.requirementId === args.requirementId)
|
|
280
|
+
.slice(0, Number.isFinite(args.limit) ? Math.max(1, Math.min(200, args.limit)) : 50)
|
|
281
|
+
if (tasks.length === 0) return '工作看板暂无匹配任务。'
|
|
282
|
+
return [`工作看板共 ${store.snapshot().tasks.length} 条,匹配 ${tasks.length} 条:`, ...tasks.map(taskLine)].join('\n')
|
|
283
|
+
},
|
|
284
|
+
}))
|
|
285
|
+
|
|
286
|
+
register(defineStudioTool({
|
|
287
|
+
name: 'studio_task_get',
|
|
288
|
+
description: 'Read one work-board task in full: requirement, artifacts, GitLab links, comments and status history.',
|
|
289
|
+
parameters: { id: { type: 'string', required: true, description: 'Task id, e.g. t-1a2b3c4d' } },
|
|
290
|
+
async execute(args) {
|
|
291
|
+
await store.ready
|
|
292
|
+
const task = store.snapshot().tasks.find(t => t.id === args.id)
|
|
293
|
+
if (task === undefined) throw new Error(`no task ${args.id}`)
|
|
294
|
+
return task
|
|
295
|
+
},
|
|
296
|
+
}))
|
|
297
|
+
|
|
298
|
+
register(defineStudioTool({
|
|
299
|
+
name: 'studio_task_create',
|
|
300
|
+
description: 'Create a work-board task. Use one task per unit of work a role agent can finish and be reviewed on.',
|
|
301
|
+
parameters: {
|
|
302
|
+
title: { type: 'string', required: true, description: 'Short imperative title.' },
|
|
303
|
+
requirement: { type: 'string', description: 'What the task involves: background, acceptance criteria, links.' },
|
|
304
|
+
type: { type: 'string', description: `requirement | bug | design | dev | test | ops (default requirement). Use bug for a defect: it starts in 待开发 and the owning agent is told to reproduce, find the root cause and fix it.` },
|
|
305
|
+
severity: { type: 'string', description: 'For bug tasks: p0 | p1 | p2 | p3 (default p2, p0 auto-sets urgency to urgent).' },
|
|
306
|
+
role: { type: 'string', description: 'Owning role: product | architect | dev | qa (default product)' },
|
|
307
|
+
status: { type: 'string', description: `Initial status (default clarify): ${STATUS_IDS.join(' / ')}` },
|
|
308
|
+
priority: { type: 'string', description: 'urgent | normal | relaxed (default normal)' },
|
|
309
|
+
gitlabIssue: { type: 'string', description: 'Related GitLab issue URL or iid.' },
|
|
310
|
+
domain: { type: 'string', description: '业务领域(如 支付 / 风控 / 会员),用于把卡路由给同领域专家。' },
|
|
311
|
+
owner: { type: 'string', description: 'Human or agent accountable for the next step.' },
|
|
312
|
+
project: { type: 'string', description: '业务项目 id(studio_project_list 可查)。多项目并行时每张卡都要挂项目。' },
|
|
313
|
+
iteration: { type: 'string', description: '迭代 id(属于上面那个项目)。' },
|
|
314
|
+
service: { type: 'string', description: '服务 id:一个项目里有多个服务时指定这次改哪个服务(决定用哪个 git 路径切 worktree)。' },
|
|
315
|
+
parent: { type: 'string', description: '父任务 id:缺陷挂到它发现自的那张卡上。' },
|
|
316
|
+
requirementId: { type: 'string', description: '挂到哪条需求(REQ 的 id)。挂上后自动分配编号 DEV-001 / TEST-001 等。' },
|
|
317
|
+
pointId: { type: 'string', description: '挂到需求的哪个功能点 id(可追溯矩阵靠它对齐)。' },
|
|
318
|
+
dueAt: { type: 'number', description: '期望完成时间(epoch 毫秒)。' },
|
|
319
|
+
},
|
|
320
|
+
async execute(args) {
|
|
321
|
+
const task = await host.createTask(store, args)
|
|
322
|
+
return `已创建 ${task.id}:${task.title}(${task.status},${task.role})`
|
|
323
|
+
},
|
|
324
|
+
}))
|
|
325
|
+
|
|
326
|
+
register(defineStudioTool({
|
|
327
|
+
name: 'studio_task_update',
|
|
328
|
+
description: 'Move a task forward: change status, owning role, priority, artifacts or GitLab links, or flag it as blocked. A blocked card stays in its column and waits for a human; every change is recorded in the task history.',
|
|
329
|
+
parameters: {
|
|
330
|
+
id: { type: 'string', required: true, description: 'Task id.' },
|
|
331
|
+
status: { type: 'string', description: `New column: ${STATUS_IDS.join(' / ')}` },
|
|
332
|
+
role: { type: 'string', description: 'New owning role.' },
|
|
333
|
+
priority: { type: 'string', description: 'urgent | normal | relaxed' },
|
|
334
|
+
blocked: { type: 'boolean', description: 'true = 干不下去了,把卡标成阻塞(留在原列,等人处理);false = 解除阻塞。' },
|
|
335
|
+
blockedReason: { type: 'string', description: 'Why the work cannot continue (used together with blocked=true).' },
|
|
336
|
+
gitlabIssue: { type: 'string', description: 'GitLab issue URL or iid.' },
|
|
337
|
+
gitlabMR: { type: 'string', description: 'GitLab merge-request URL or iid.' },
|
|
338
|
+
artifacts: { type: 'array', items: { type: 'string' }, description: '交付物:路径、文档或链接(也可以用 studio_task_artifact 单独登记)。' },
|
|
339
|
+
project: { type: 'string', description: '业务项目 id。' },
|
|
340
|
+
iteration: { type: 'string', description: '迭代 id。' },
|
|
341
|
+
service: { type: 'string', description: '服务 id(决定 worktree 用哪个 git 路径)。' },
|
|
342
|
+
parent: { type: 'string', description: '父任务 id。' },
|
|
343
|
+
requirementId: { type: 'string', description: '改挂到另一条需求(会自动重新分配编号)。' },
|
|
344
|
+
pointId: { type: 'string', description: '挂到需求的哪个功能点(可追溯矩阵靠它对齐)。' },
|
|
345
|
+
override: { type: 'string', description: '推到「已完成」但准出没过时,写清原因(会记进状态历史)。' },
|
|
346
|
+
dueAt: { type: 'number', description: '期望完成时间(epoch 毫秒)。' },
|
|
347
|
+
releaseId: { type: 'string', description: '挂到哪个版本(版本 id;空字符串 = 脱离版本)。' },
|
|
348
|
+
title: { type: 'string', description: 'Replace the title.' },
|
|
349
|
+
},
|
|
350
|
+
async execute(args) {
|
|
351
|
+
const { id, ...rest } = args
|
|
352
|
+
const task = await updateTask(store, id, { ...rest, by: rest.by })
|
|
353
|
+
return `已更新 ${task.id}:${task.status}|${task.title}`
|
|
354
|
+
},
|
|
355
|
+
}))
|
|
356
|
+
|
|
357
|
+
register(defineStudioTool({
|
|
358
|
+
name: 'studio_task_signoff',
|
|
359
|
+
description: 'Sign off acceptance for a card (or clear a previous sign-off). A card cannot reach 已完成 without a sign-off unless a human overrides with a reason.',
|
|
360
|
+
parameters: {
|
|
361
|
+
id: { type: 'string', required: true, description: 'Task id.' },
|
|
362
|
+
note: { type: 'string', description: '验收结论:验了什么、结果如何、遗留什么(签字必填)。' },
|
|
363
|
+
action: { type: 'string', description: 'sign(默认)| clear' },
|
|
364
|
+
by: { type: 'string', description: '谁验的(人)。' },
|
|
365
|
+
},
|
|
366
|
+
async execute(args) {
|
|
367
|
+
const id = String(args.id ?? '')
|
|
368
|
+
const action = String(args.action ?? 'sign')
|
|
369
|
+
const by = String(args.by ?? '人').slice(0, 40)
|
|
370
|
+
const note = String(args.note ?? '').slice(0, 600)
|
|
371
|
+
await store.mutate(state => {
|
|
372
|
+
const entry = state.tasks.find(item => item.id === id)
|
|
373
|
+
if (entry === undefined) throw new Error(`no task ${id}`)
|
|
374
|
+
if (action === 'clear') {
|
|
375
|
+
entry.signoff = null
|
|
376
|
+
entry.history.push({ at: Date.now(), by, status: entry.status, note: 'signoff-cleared' })
|
|
377
|
+
} else {
|
|
378
|
+
if (note.trim() === '') throw new Error('验收签字要写一句结论:验了什么、遗留什么')
|
|
379
|
+
entry.signoff = { by, at: Date.now(), note }
|
|
380
|
+
entry.history.push({ at: Date.now(), by, status: entry.status, note: `signoff:${note.slice(0, 80)}` })
|
|
381
|
+
}
|
|
382
|
+
entry.updatedAt = Date.now()
|
|
383
|
+
})
|
|
384
|
+
const task = store.snapshot().tasks.find(item => item.id === id)
|
|
385
|
+
const gate = taskExitGate(store.snapshot(), task)
|
|
386
|
+
return `已${action === 'clear' ? '取消签字' : '签字'} ${task.code || task.id}|准出:${gate.ready ? '通过' : `还差 ${gate.blockers.join(';')}`}`
|
|
387
|
+
},
|
|
388
|
+
}))
|
|
389
|
+
|
|
390
|
+
register(defineStudioTool({
|
|
391
|
+
name: 'studio_task_generate_cases',
|
|
392
|
+
description: '按需求的「功能点 + 验收条件」为这张卡生成测试用例草稿(按标题去重,不覆盖已有用例)。',
|
|
393
|
+
parameters: {
|
|
394
|
+
id: { type: 'string', required: true, description: 'Task id.' },
|
|
395
|
+
by: { type: 'string', description: '谁生成的,默认人。' },
|
|
396
|
+
},
|
|
397
|
+
async execute(args) {
|
|
398
|
+
const result = await host.addDraftCases(store, String(args.id ?? ''), String(args.by ?? '人').slice(0, 40))
|
|
399
|
+
if (result.added === 0 && result.reason !== '') return `没生成用例:${result.reason}`
|
|
400
|
+
return `已生成 ${result.added} 条用例,这张卡现在共 ${result.total} 条。`
|
|
401
|
+
},
|
|
402
|
+
}))
|
|
403
|
+
|
|
404
|
+
register(defineStudioTool({
|
|
405
|
+
name: 'studio_bug_duplicate',
|
|
406
|
+
description: '把一张缺陷标成另一张的重复(targetId 传空则取消重复标记)。',
|
|
407
|
+
parameters: {
|
|
408
|
+
id: { type: 'string', required: true, description: '重复的那张缺陷。' },
|
|
409
|
+
targetId: { type: 'string', description: '保留的主缺陷 id。' },
|
|
410
|
+
by: { type: 'string', description: '谁判断的。' },
|
|
411
|
+
},
|
|
412
|
+
async execute(args) {
|
|
413
|
+
const task = await markDuplicate(store, String(args.id ?? ''), String(args.targetId ?? ''), String(args.by ?? '人').slice(0, 40))
|
|
414
|
+
return task.duplicateOf === '' ? `${task.code || task.id} 已取消重复标记。` : `${task.code || task.id} 标为重复,主缺陷是 ${task.duplicateOf}。`
|
|
415
|
+
},
|
|
416
|
+
}))
|
|
417
|
+
|
|
418
|
+
register(defineStudioTool({
|
|
419
|
+
name: 'studio_dashboard',
|
|
420
|
+
description: '工作台体检:逾期、阻塞、缺陷超时、准出被拦、流水线失败、需求/版本卡住、集成异常,一次看全。',
|
|
421
|
+
parameters: {},
|
|
422
|
+
async execute() {
|
|
423
|
+
const board = buildDashboard(store.snapshot())
|
|
424
|
+
const risks = board.risks
|
|
425
|
+
const lines = [
|
|
426
|
+
`在办 ${board.scope.open} 张 / 共 ${board.scope.tasks} 张;待评审 ${board.flow.review}、待测试 ${board.flow.test}、待验收 ${board.flow.accept};今天完成 ${board.flow.doneToday}、近 7 天 ${board.flow.doneWeek}。`,
|
|
427
|
+
`风险:逾期 ${risks.overdue} · 阻塞 ${risks.blocked} · 缺陷超时 ${risks.slaOverdue} · 准出被拦 ${risks.gateFailed} · 流水线失败 ${risks.pipelineFailed} · 需求卡住 ${risks.requirementBlocked} · 版本卡住 ${risks.releaseBlocked} · 集成异常 ${risks.integrationsBroken}`,
|
|
428
|
+
`AI 正在跑 ${board.ai.running} 个会话,今天派单 ${board.ai.todayRuns} 次。`,
|
|
429
|
+
]
|
|
430
|
+
for (const [key, label] of [['overdue', '逾期'], ['blocked', '阻塞'], ['slaOverdue', '缺陷超时'], ['gateFailed', '准出被拦'], ['pipelineFailed', '流水线失败']]) {
|
|
431
|
+
const items = board.list[key] ?? []
|
|
432
|
+
if (items.length === 0) continue
|
|
433
|
+
lines.push(`${label}:${items.map(item => `${item.code || item.id} ${item.title}`).join(';')}`)
|
|
434
|
+
}
|
|
435
|
+
return lines.join('\n')
|
|
436
|
+
},
|
|
437
|
+
}))
|
|
438
|
+
|
|
439
|
+
register(defineStudioTool({
|
|
440
|
+
name: 'studio_workload',
|
|
441
|
+
description: '每个专家手上还有多少活:正在跑、在办、近 7 天完成、逾期缺陷。',
|
|
442
|
+
parameters: {},
|
|
443
|
+
async execute() {
|
|
444
|
+
const dispatcher = host.getActiveDispatcher()
|
|
445
|
+
const running = dispatcher === undefined ? [] : [...dispatcher.running.values()]
|
|
446
|
+
const report = host.workloadReport(store.snapshot(), running)
|
|
447
|
+
const lines = report.experts.filter(item => item.enabled).map(item => `${item.emoji} ${item.name}:在跑 ${item.running} · 在办 ${item.open} · 近7天完成 ${item.done7d}${item.overdue > 0 ? ` · 逾期缺陷 ${item.overdue}` : ''}`)
|
|
448
|
+
return lines.length === 0 ? '还没有配置专家。' : [`同时有 ${report.busy} 位专家在跑。`, ...lines].join('\n')
|
|
449
|
+
},
|
|
450
|
+
}))
|
|
451
|
+
|
|
452
|
+
register(defineStudioTool({
|
|
453
|
+
name: 'studio_burndown',
|
|
454
|
+
description: '近 N 天每天的剩余卡数(按创建与完成时间回推),带项目过滤。',
|
|
455
|
+
parameters: {
|
|
456
|
+
days: { type: 'string', description: '7 / 14 / 30,默认 14。' },
|
|
457
|
+
project: { type: 'string', description: '业务项目 id,留空看全部。' },
|
|
458
|
+
},
|
|
459
|
+
async execute(args) {
|
|
460
|
+
const report = host.burndownReport(store.snapshot(), { days: args.days ?? '', project: args.project ?? '' })
|
|
461
|
+
const lines = report.series.map(point => `${point.day}:还剩 ${point.remaining}(累计建 ${point.created} · 累计完成 ${point.done})`)
|
|
462
|
+
return lines.join('\n')
|
|
463
|
+
},
|
|
464
|
+
}))
|
|
465
|
+
|
|
466
|
+
register(defineStudioTool({
|
|
467
|
+
name: 'studio_costs',
|
|
468
|
+
description: 'AI 执行成本:派单次数、平均时长、按专家/模型/提示词版本分组(宿主不回 token,按次数计时)。',
|
|
469
|
+
parameters: {},
|
|
470
|
+
async execute() {
|
|
471
|
+
const summary = runSummary()
|
|
472
|
+
if (summary.total === 0) return '还没有派单记录。'
|
|
473
|
+
const lines = [
|
|
474
|
+
`共 ${summary.total} 次派单(${summary.finished} 次已结束),平均 ${Math.round(summary.avgMs / 1000)} 秒。`,
|
|
475
|
+
`按专家:${summary.byExpert.map(item => `${item.key} ${item.runs}`).join(' · ')}`,
|
|
476
|
+
`按模型:${summary.byModel.map(item => `${item.key} ${item.runs}`).join(' · ')}`,
|
|
477
|
+
`按提示词版本:${summary.byPromptVersion.map(item => `${item.key} ${item.runs}`).join(' · ')}`,
|
|
478
|
+
]
|
|
479
|
+
return lines.join('\n')
|
|
480
|
+
},
|
|
481
|
+
}))
|
|
482
|
+
|
|
483
|
+
register(defineStudioTool({
|
|
484
|
+
name: 'studio_release_list',
|
|
485
|
+
description: 'List releases with their gate status (can it ship? what is still open). Use this before publishing anything.',
|
|
486
|
+
parameters: {},
|
|
487
|
+
async execute() {
|
|
488
|
+
const state = store.snapshot()
|
|
489
|
+
if (state.releases.length === 0) return '还没有版本。先建一个版本,再把卡挂上去。'
|
|
490
|
+
const lines = state.releases.map(item => {
|
|
491
|
+
const gate = host.releaseGate(state, item)
|
|
492
|
+
const label = RELEASE_STATUS_LABEL[item.status] ?? item.status
|
|
493
|
+
const head = `${item.version || item.id} ${item.name}|${label}|卡 ${gate.stats.done}/${gate.stats.tasks}`
|
|
494
|
+
const tail = gate.blockers.length > 0 ? `|阻塞项:${gate.blockers.join(';')}` : '|门禁通过'
|
|
495
|
+
return `- ${head}${tail}`
|
|
496
|
+
})
|
|
497
|
+
return lines.join('\n')
|
|
498
|
+
},
|
|
499
|
+
}))
|
|
500
|
+
|
|
501
|
+
register(defineStudioTool({
|
|
502
|
+
name: 'studio_release_get',
|
|
503
|
+
description: 'One release in detail: its cards, gate blockers and the generated release notes.',
|
|
504
|
+
parameters: {
|
|
505
|
+
id: { type: 'string', required: true, description: 'Release id or version.' },
|
|
506
|
+
},
|
|
507
|
+
async execute(args) {
|
|
508
|
+
const state = store.snapshot()
|
|
509
|
+
const wanted = String(args.id ?? '')
|
|
510
|
+
const release = state.releases.find(item => item.id === wanted || item.version === wanted)
|
|
511
|
+
if (release === undefined) throw new Error(`no release ${wanted}`)
|
|
512
|
+
const gate = host.releaseGate(state, release)
|
|
513
|
+
const tasks = state.tasks.filter(task => task.releaseId === release.id)
|
|
514
|
+
const lines = [
|
|
515
|
+
`${release.version || release.id} ${release.name}|${RELEASE_STATUS_LABEL[release.status] ?? release.status}`,
|
|
516
|
+
`卡 ${tasks.length} 张:${tasks.map(task => `${task.code || task.id}(${task.status})`).join('、') || '无'}`,
|
|
517
|
+
gate.blockers.length === 0 ? '门禁:通过' : `门禁:不通过 —— ${gate.blockers.join(';')}`,
|
|
518
|
+
gate.warnings.length === 0 ? '' : `提醒:${gate.warnings.join(';')}`,
|
|
519
|
+
release.notes === '' ? '发布说明:还没生成' : `发布说明:\n${release.notes.slice(0, 2000)}`,
|
|
520
|
+
].filter(line => line !== '')
|
|
521
|
+
return lines.join('\n')
|
|
522
|
+
},
|
|
523
|
+
}))
|
|
524
|
+
|
|
525
|
+
register(defineStudioTool({
|
|
526
|
+
name: 'studio_release_save',
|
|
527
|
+
description: 'Create or update a release (version). Version strings are locked once released or rolled back.',
|
|
528
|
+
parameters: {
|
|
529
|
+
id: { type: 'string', description: 'Existing release id (omit to create).' },
|
|
530
|
+
version: { type: 'string', description: '例如 v1.3.0' },
|
|
531
|
+
name: { type: 'string', description: '版本名,例如 积分抵扣首个版本' },
|
|
532
|
+
project: { type: 'string', description: '属于哪个业务项目(项目 id)。' },
|
|
533
|
+
owner: { type: 'string', description: '发布负责人。' },
|
|
534
|
+
plannedAt: { type: 'number', description: '计划发布时间(epoch 毫秒)。' },
|
|
535
|
+
status: { type: 'string', description: `draft | candidate | approved(${RELEASE_STATUS_LABEL.draft} / ${RELEASE_STATUS_LABEL.candidate} / ${RELEASE_STATUS_LABEL.approved})` },
|
|
536
|
+
},
|
|
537
|
+
async execute(args) {
|
|
538
|
+
const incoming = sanitizeRelease({ ...args, id: args.id ?? args.version ?? args.name })
|
|
539
|
+
if (incoming === null) throw new Error('版本至少要填 version 或 name')
|
|
540
|
+
let saved = null
|
|
541
|
+
await store.mutate(state => {
|
|
542
|
+
const existing = state.releases.find(item => item.id === incoming.id)
|
|
543
|
+
if (existing === undefined) {
|
|
544
|
+
saved = { ...incoming, createdAt: Date.now(), updatedAt: Date.now() }
|
|
545
|
+
state.releases.push(saved)
|
|
546
|
+
return
|
|
547
|
+
}
|
|
548
|
+
existing.name = incoming.name === '' ? existing.name : incoming.name
|
|
549
|
+
existing.project = incoming.project
|
|
550
|
+
existing.owner = incoming.owner
|
|
551
|
+
existing.plannedAt = incoming.plannedAt
|
|
552
|
+
if (existing.status !== 'released' && existing.status !== 'rolled_back') existing.status = incoming.status
|
|
553
|
+
existing.updatedAt = Date.now()
|
|
554
|
+
saved = { ...existing }
|
|
555
|
+
})
|
|
556
|
+
return `已保存版本 ${saved.version || saved.id}(${RELEASE_STATUS_LABEL[saved.status] ?? saved.status})`
|
|
557
|
+
},
|
|
558
|
+
}))
|
|
559
|
+
|
|
560
|
+
register(defineStudioTool({
|
|
561
|
+
name: 'studio_release_publish',
|
|
562
|
+
description: 'Publish a release. Refuses when the gate is not clean: open cards, blocked cards, open bugs or failing cases all block it.',
|
|
563
|
+
parameters: {
|
|
564
|
+
id: { type: 'string', required: true, description: 'Release id or version.' },
|
|
565
|
+
by: { type: 'string', description: 'Who publishes it.' },
|
|
566
|
+
},
|
|
567
|
+
async execute(args) {
|
|
568
|
+
const wanted = String(args.id ?? '')
|
|
569
|
+
const by = String(args.by ?? 'agent').slice(0, 120)
|
|
570
|
+
let saved = null
|
|
571
|
+
await store.mutate(state => {
|
|
572
|
+
const release = state.releases.find(item => item.id === wanted || item.version === wanted)
|
|
573
|
+
if (release === undefined) throw new Error(`no release ${wanted}`)
|
|
574
|
+
if (release.status === 'released') throw new Error('这个版本已经发布过了')
|
|
575
|
+
const gate = host.releaseGate(state, release)
|
|
576
|
+
if (!gate.ready) throw new Error(`门禁没过:${gate.blockers.join(';')}`)
|
|
577
|
+
release.status = 'released'
|
|
578
|
+
release.releasedAt = Date.now()
|
|
579
|
+
release.approveBy = release.approveBy === '' ? by : release.approveBy
|
|
580
|
+
release.approveAt = release.approveAt === 0 ? Date.now() : release.approveAt
|
|
581
|
+
release.notes = release.notes === '' ? host.releaseNotes(state, release) : release.notes
|
|
582
|
+
release.deployLog = [...(release.deployLog ?? []), {
|
|
583
|
+
at: Date.now(), by, action: 'publish', note: `tag ${release.tagName || '—'}`,
|
|
584
|
+
}].slice(-200)
|
|
585
|
+
release.updatedAt = Date.now()
|
|
586
|
+
saved = { ...release }
|
|
587
|
+
})
|
|
588
|
+
return `已发布 ${saved.version || saved.id}:\n${saved.notes.slice(0, 1500)}`
|
|
589
|
+
},
|
|
590
|
+
}))
|
|
591
|
+
|
|
592
|
+
register(defineStudioTool({
|
|
593
|
+
name: 'studio_release_gray',
|
|
594
|
+
description: 'Manage gradual rollout batches on a release: add a batch (percent), mark one as running/passed, or roll a batch back (which rolls the whole release back). Use it instead of claiming "灰度中" without a record.',
|
|
595
|
+
parameters: {
|
|
596
|
+
id: { type: 'string', required: true, description: 'Release id or version.' },
|
|
597
|
+
action: { type: 'string', enum: ['add', 'start', 'advance', 'rollback'], description: 'add a batch / mark it running / mark it passed / roll back.' },
|
|
598
|
+
batchId: { type: 'string', description: 'Target batch id (needed except when adding).' },
|
|
599
|
+
name: { type: 'string', description: 'Batch name when adding, e.g. 内测用户 10%.' },
|
|
600
|
+
percent: { type: 'number', description: 'Traffic percent when adding (1-100).' },
|
|
601
|
+
note: { type: 'string', description: 'Conclusion or rollback reason.' },
|
|
602
|
+
by: { type: 'string', description: 'Who did it.' },
|
|
603
|
+
},
|
|
604
|
+
async execute(args) {
|
|
605
|
+
const wanted = String(args.id ?? '')
|
|
606
|
+
const action = String(args.action ?? 'add')
|
|
607
|
+
const by = String(args.by ?? 'agent').slice(0, 120)
|
|
608
|
+
const note = String(args.note ?? '').slice(0, 600)
|
|
609
|
+
let saved = null
|
|
610
|
+
let gate = null
|
|
611
|
+
await store.mutate(state => {
|
|
612
|
+
const release = state.releases.find(item => item.id === wanted || item.version === wanted)
|
|
613
|
+
if (release === undefined) throw new Error(`no release ${wanted}`)
|
|
614
|
+
const batches = release.grayBatches ?? []
|
|
615
|
+
if (action === 'add') {
|
|
616
|
+
if (batches.length >= 20) throw new Error('灰度批次最多 20 批')
|
|
617
|
+
const percent = Math.max(0, Math.min(100, Number(args.percent) || 0))
|
|
618
|
+
if (percent <= 0) throw new Error('灰度比例要大于 0,例如 10')
|
|
619
|
+
batches.push({
|
|
620
|
+
id: `g-${Date.now().toString(36)}`,
|
|
621
|
+
name: String(args.name ?? '').slice(0, 120) || `第 ${batches.length + 1} 批 ${percent}%`,
|
|
622
|
+
percent,
|
|
623
|
+
status: 'pending',
|
|
624
|
+
by,
|
|
625
|
+
at: Date.now(),
|
|
626
|
+
note,
|
|
627
|
+
})
|
|
628
|
+
} else {
|
|
629
|
+
const target = batches.find(item => item.id === String(args.batchId ?? ''))
|
|
630
|
+
if (target === undefined) throw new Error('找不到这一批灰度')
|
|
631
|
+
if (action === 'advance') {
|
|
632
|
+
target.status = 'passed'
|
|
633
|
+
target.at = Date.now()
|
|
634
|
+
} else if (action === 'start') {
|
|
635
|
+
target.status = 'running'
|
|
636
|
+
target.at = Date.now()
|
|
637
|
+
} else if (action === 'rollback') {
|
|
638
|
+
if (note === '') throw new Error('灰度回滚要写原因')
|
|
639
|
+
target.status = 'rolled_back'
|
|
640
|
+
target.at = Date.now()
|
|
641
|
+
target.note = note
|
|
642
|
+
release.status = 'rolled_back'
|
|
643
|
+
release.rollbackReason = `灰度「${target.name}」回滚:${note}`
|
|
644
|
+
} else {
|
|
645
|
+
throw new Error(`unknown gray action: ${action}`)
|
|
646
|
+
}
|
|
647
|
+
target.by = by
|
|
648
|
+
}
|
|
649
|
+
release.grayBatches = batches
|
|
650
|
+
release.deployLog = [...(release.deployLog ?? []), {
|
|
651
|
+
at: Date.now(), by, action: `gray-${action}`, note: note || `${batches.length} 批`,
|
|
652
|
+
}].slice(-200)
|
|
653
|
+
release.updatedAt = Date.now()
|
|
654
|
+
saved = { ...release }
|
|
655
|
+
gate = host.releaseGate(state, release)
|
|
656
|
+
})
|
|
657
|
+
return [
|
|
658
|
+
`${saved.version || saved.id}:灰度 ${saved.grayBatches.length} 批(已过 ${saved.grayBatches.filter(item => item.status === 'passed').length})`,
|
|
659
|
+
...saved.grayBatches.map(item => `- ${item.name} ${item.percent}% · ${item.status}`),
|
|
660
|
+
gate.warnings.length > 0 ? `提醒:${gate.warnings.join(';')}` : '门禁无提醒',
|
|
661
|
+
].join('\n')
|
|
662
|
+
},
|
|
663
|
+
}))
|
|
664
|
+
|
|
665
|
+
register(defineStudioTool({
|
|
666
|
+
name: 'studio_task_block',
|
|
667
|
+
description: 'Flag a task as blocked: it stays in its current column, stops being auto-dispatched, and waits for a human to decide how to continue. Always give a concrete reason.',
|
|
668
|
+
parameters: {
|
|
669
|
+
id: { type: 'string', required: true, description: 'Task id.' },
|
|
670
|
+
reason: { type: 'string', required: true, description: 'What exactly is blocking this (missing info, permission, dependency, failing test...).' },
|
|
671
|
+
},
|
|
672
|
+
async execute(args) {
|
|
673
|
+
const reason = text(args.reason, 500) ?? ''
|
|
674
|
+
if (reason === '') throw new Error('reason is required')
|
|
675
|
+
const task = await updateTask(store, args.id, { blocked: true, blockedReason: reason, by: 'agent' })
|
|
676
|
+
host.getActiveDispatcher()?.abortTask?.(task.id)
|
|
677
|
+
return `已标记阻塞 ${task.id}(停在「${STATUSES.find(s => s.id === task.status)?.label ?? task.status}」):${reason}`
|
|
678
|
+
},
|
|
679
|
+
}))
|
|
680
|
+
|
|
681
|
+
register(defineStudioTool({
|
|
682
|
+
name: 'studio_task_unblock',
|
|
683
|
+
description: 'Clear the blocked flag after a human decided how to continue.',
|
|
684
|
+
parameters: {
|
|
685
|
+
id: { type: 'string', required: true, description: 'Task id.' },
|
|
686
|
+
note: { type: 'string', description: 'How to continue, for the record.' },
|
|
687
|
+
},
|
|
688
|
+
async execute(args) {
|
|
689
|
+
const task = await updateTask(store, args.id, { blocked: false, by: 'agent' })
|
|
690
|
+
return `已解除阻塞 ${task.id}:${task.title}`
|
|
691
|
+
},
|
|
692
|
+
}))
|
|
693
|
+
|
|
694
|
+
register(defineStudioTool({
|
|
695
|
+
name: 'studio_task_comment',
|
|
696
|
+
description: 'Append a comment to a task: progress notes, review findings, hand-off details. Comments are the durable conversation between role agents and humans.',
|
|
697
|
+
parameters: {
|
|
698
|
+
id: { type: 'string', required: true, description: 'Task id.' },
|
|
699
|
+
body: { type: 'string', required: true, description: 'Comment text (markdown).' },
|
|
700
|
+
author: { type: 'string', description: 'Author label, e.g. 研发 or 张三 (default agent).' },
|
|
701
|
+
},
|
|
702
|
+
async execute(args) {
|
|
703
|
+
const { task, mentions } = await commentTask(store, args.id, args.body, args.author, 'agent')
|
|
704
|
+
const dispatched = []
|
|
705
|
+
if (host.getActiveDispatcher() !== undefined) {
|
|
706
|
+
for (const mention of mentions) {
|
|
707
|
+
try {
|
|
708
|
+
await host.getActiveDispatcher().dispatchMention(task.id, mention, String(args.body ?? ''))
|
|
709
|
+
dispatched.push(`${mention.kind}:${mention.id}`)
|
|
710
|
+
} catch (error) {
|
|
711
|
+
dispatched.push(`${mention.kind}:${mention.id} failed: ${error?.message ?? error}`)
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
const tail = dispatched.length > 0 ? ` 已通知 ${dispatched.join('、')}。` : ''
|
|
716
|
+
return `已评论 ${task.id},当前 ${task.comments.length} 条评论。${tail}`
|
|
717
|
+
},
|
|
718
|
+
}))
|
|
719
|
+
|
|
720
|
+
register(defineStudioTool({
|
|
721
|
+
name: 'studio_project_save',
|
|
722
|
+
description: 'Create or update a business project and its services. A project groups cards; a service is one deployable thing with its own local git path and base branch, and a card picks a service so its worktree is cut from that service. Partial update: only the fields you pass are changed. Pass services to replace the whole service list (each needs a name; repoPath is the local checkout used for worktrees).',
|
|
723
|
+
parameters: {
|
|
724
|
+
id: { type: 'string', description: 'Project id; omit when creating (derived from the name).' },
|
|
725
|
+
name: { type: 'string', description: 'Project name, e.g. 订单中心.' },
|
|
726
|
+
key: { type: 'string', description: 'Short code shown on cards, e.g. ORD.' },
|
|
727
|
+
description: { type: 'string', description: 'What this project does, for whom.' },
|
|
728
|
+
lead: { type: 'string', description: 'Business owner.' },
|
|
729
|
+
status: { type: 'string', description: 'active | archived.' },
|
|
730
|
+
repoPath: { type: 'string', description: 'Default local git path when cards do not pick a service.' },
|
|
731
|
+
baseBranch: { type: 'string', description: 'Default base branch, e.g. main.' },
|
|
732
|
+
services: {
|
|
733
|
+
type: 'array',
|
|
734
|
+
description: 'Replace the service list. Example: [{"name":"order-service","repoPath":"/srv/repos/order","baseBranch":"main"}].',
|
|
735
|
+
items: {
|
|
736
|
+
type: 'object',
|
|
737
|
+
properties: {
|
|
738
|
+
name: { type: 'string', description: 'Service name, e.g. order-service.' },
|
|
739
|
+
repoPath: { type: 'string', description: 'Local git checkout for this service (worktree base).' },
|
|
740
|
+
baseBranch: { type: 'string', description: 'Base branch, default main.' },
|
|
741
|
+
repoId: { type: 'string', description: 'Optional GitLab project id from the code-repo settings.' },
|
|
742
|
+
dependsOn: {
|
|
743
|
+
type: 'array',
|
|
744
|
+
description: 'Service ids in this project that must be finished first, e.g. ["order-service"]. Cards on this service cannot pass the exit gate while those services still have open cards.',
|
|
745
|
+
items: { type: 'string' },
|
|
746
|
+
},
|
|
747
|
+
},
|
|
748
|
+
required: ['name'],
|
|
749
|
+
},
|
|
750
|
+
},
|
|
751
|
+
},
|
|
752
|
+
async execute(args) {
|
|
753
|
+
await store.ready
|
|
754
|
+
const input = args ?? {}
|
|
755
|
+
const existing = store.snapshot().config.projects ?? []
|
|
756
|
+
const wantedId = cleanId(input.id ?? input.name, 'proj')
|
|
757
|
+
const before = existing.find(item => item.id === wantedId)
|
|
758
|
+
const merged = {
|
|
759
|
+
...(before ?? {}),
|
|
760
|
+
id: wantedId,
|
|
761
|
+
...(input.name !== undefined ? { name: input.name } : {}),
|
|
762
|
+
...(input.key !== undefined ? { key: input.key } : {}),
|
|
763
|
+
...(input.description !== undefined ? { description: input.description } : {}),
|
|
764
|
+
...(input.lead !== undefined ? { lead: input.lead } : {}),
|
|
765
|
+
...(input.status !== undefined ? { status: input.status } : {}),
|
|
766
|
+
...(input.repoPath !== undefined ? { repoPath: input.repoPath } : {}),
|
|
767
|
+
...(input.baseBranch !== undefined ? { baseBranch: input.baseBranch } : {}),
|
|
768
|
+
...(Array.isArray(input.services) ? { services: input.services } : {}),
|
|
769
|
+
}
|
|
770
|
+
if (before === undefined && (merged.name === undefined || String(merged.name).trim() === '')) {
|
|
771
|
+
throw new Error('新建项目要给 name')
|
|
772
|
+
}
|
|
773
|
+
await host.applyConfig(store, { projects: host.upsertById(existing, merged) })
|
|
774
|
+
const saved = (store.snapshot().config.projects ?? []).find(item => item.id === wantedId)
|
|
775
|
+
const lines = [`已保存项目 ${saved?.id} ${saved?.name}(${saved?.key || '无代号'})`]
|
|
776
|
+
const services = saved?.services ?? []
|
|
777
|
+
if (services.length === 0) lines.push(`服务:无(worktree 用默认目录 ${saved?.repoPath || '未配置'})`)
|
|
778
|
+
else {
|
|
779
|
+
lines.push(`服务(${services.length}):`)
|
|
780
|
+
for (const service of services) {
|
|
781
|
+
lines.push(`- ${service.id} ${service.name}:${service.repoPath || '未配路径'} · 基线 ${service.baseBranch}${service.repoId ? ` · GitLab ${service.repoId}` : ''}${(service.dependsOn ?? []).length > 0 ? ` · 依赖 ${(service.dependsOn ?? []).join('、')}` : ''}`)
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
return lines.join('\n')
|
|
785
|
+
},
|
|
786
|
+
}))
|
|
787
|
+
|
|
788
|
+
register(defineStudioTool({
|
|
789
|
+
name: 'studio_project_list',
|
|
790
|
+
description: 'List the company projects (with their iterations) and the per-project workload: how many cards in each column, how many blocked, overdue, open defects, checklist progress and average cycle time. Read this when you need to know which project a card belongs to or how loaded a project is.',
|
|
791
|
+
parameters: {},
|
|
792
|
+
async execute() {
|
|
793
|
+
await store.ready
|
|
794
|
+
const state = store.snapshot()
|
|
795
|
+
const metrics = host.buildMetrics(state)
|
|
796
|
+
const lines = ['业务项目:']
|
|
797
|
+
for (const item of metrics.projects) {
|
|
798
|
+
const m = item.metrics
|
|
799
|
+
const iters = (item.iterations ?? []).map(iter => `${iter.name}${iter.status === 'active' ? '(进行中)' : ''}`).join('、') || '无'
|
|
800
|
+
const svcs = (item.services ?? []).map(svc => `${svc.name}${svc.repoPath ? `→${svc.repoPath}` : '(未配路径)'}`).join('、') || (item.repoPath ? `默认目录 ${item.repoPath}` : '无')
|
|
801
|
+
lines.push(`- ${item.id} ${item.name}${item.key ? ` [${item.key}]` : ''}:${m.total} 张卡(待澄清 ${m.byStatus.clarify}/待方案 ${m.byStatus.design}/待开发 ${m.byStatus.dev_ready}/开发中 ${m.byStatus.dev}/待测试 ${m.byStatus.test}/待验收 ${m.byStatus.accept}/已完成 ${m.byStatus.done})· 阻塞 ${m.blocked} · 逾期 ${m.overdue} · 未结缺陷 ${m.bugs} · 清单 ${m.checklist.done}/${m.checklist.total} · 平均周期 ${m.avgCycleMs > 0 ? `${Math.round(m.avgCycleMs / 3600000)} 小时` : '—'} · 迭代:${iters} · 服务:${svcs}`)
|
|
802
|
+
}
|
|
803
|
+
if (metrics.projects.length === 0) lines.push('(还没有配置项目,所有卡都在「未分配项目」里)')
|
|
804
|
+
lines.push(`未分配项目:${metrics.unassigned.total} 张卡`)
|
|
805
|
+
return lines.join('\n')
|
|
806
|
+
},
|
|
807
|
+
}))
|
|
808
|
+
|
|
809
|
+
register(defineStudioTool({
|
|
810
|
+
name: 'studio_analytics',
|
|
811
|
+
description: 'Read the delivery analytics: first-pass rate, rework rate (how often cards go backwards), cycle-time distribution and median/p90, how long cards sit in each column, throughput of the last 14 days, and how much AI output was accepted (deliverables accepted vs registered, coding reviews approved, unit-test pass rate). Use it when someone asks how the team is doing, not for a single card.',
|
|
812
|
+
parameters: {
|
|
813
|
+
days: { type: 'number', description: 'Window in days: 7, 30 or 90. Default 30.' },
|
|
814
|
+
project: { type: 'string', description: 'Business project id; omit for the whole board.' },
|
|
815
|
+
},
|
|
816
|
+
async execute(args) {
|
|
817
|
+
await store.ready
|
|
818
|
+
const analytics = host.buildAnalytics(store.snapshot(), { days: args?.days, project: args?.project })
|
|
819
|
+
const lines = [
|
|
820
|
+
`统计口径:最近 ${analytics.days} 天${analytics.project === '' ? '' : ` · 项目 ${analytics.project}`},${analytics.scope.tasks} 张卡(${analytics.scope.done} 张已完成)`,
|
|
821
|
+
`一次通过率:${Math.round(analytics.rework.firstPassRate * 100)}%(已完成卡里从没倒退过的比例)`,
|
|
822
|
+
`返工率:${Math.round(analytics.rework.rate * 100)}%(${analytics.rework.reworked}/${analytics.rework.moved} 张卡倒退过;评审打回 ${analytics.rework.reviewRework} 次、测试打回 ${analytics.rework.testRework} 次)`,
|
|
823
|
+
`周期:平均 ${analytics.cycle.avgDays.toFixed(1)} 天 · 中位 ${analytics.cycle.medianDays.toFixed(1)} 天 · P90 ${analytics.cycle.p90Days.toFixed(1)} 天`,
|
|
824
|
+
`周期分布:${analytics.cycle.buckets.map(bucket => `${bucket.label} ${bucket.count} 张`).join(' / ')}`,
|
|
825
|
+
`AI 交付物:${analytics.ai.artifactsAi}/${analytics.ai.artifactsTotal} 件由专家登记(${Math.round(analytics.ai.aiShare * 100)}%),其中 ${Math.round(analytics.ai.acceptedRate * 100)}% 已被人验收;缺正文 ${analytics.ai.emptyBody} 件`,
|
|
826
|
+
`AI Coding:${analytics.ai.codingTasks} 张卡有执行记录,评审通过 ${analytics.ai.codingApproved} 张、要求修改 ${analytics.ai.codingChanges} 张、已合并 ${analytics.ai.merged} 张;单测 ${analytics.ai.unitPassed}/${analytics.ai.unitTests}`,
|
|
827
|
+
`列停留(平均):${analytics.columns.filter(column => column.cards > 0).map(column => `${column.label} ${column.avgDays.toFixed(1)} 天`).join(' / ')}`,
|
|
828
|
+
]
|
|
829
|
+
if (analytics.rework.worst.length > 0) {
|
|
830
|
+
lines.push(`返工最多的卡:${analytics.rework.worst.map(item => `${item.code || item.id}(倒退 ${item.backward} 次)`).join(';')}`)
|
|
831
|
+
}
|
|
832
|
+
return lines.join('\n')
|
|
833
|
+
},
|
|
834
|
+
}))
|
|
835
|
+
|
|
836
|
+
register(defineStudioTool({
|
|
837
|
+
name: 'studio_task_artifact',
|
|
838
|
+
description: 'Register or update a deliverable on a task: the design doc, the MR, the test report, the release note. Every stage must leave something the next person can open and check.',
|
|
839
|
+
parameters: {
|
|
840
|
+
id: { type: 'string', required: true, description: 'Task id.' },
|
|
841
|
+
action: { type: 'string', description: 'add | update | remove (default add)' },
|
|
842
|
+
artifactId: { type: 'string', description: 'Required for update / remove.' },
|
|
843
|
+
kind: { type: 'string', description: `requirement | design | mr | cases | report | release | doc | screenshot | other` },
|
|
844
|
+
title: { type: 'string', description: 'What this deliverable is.' },
|
|
845
|
+
ref: { type: 'string', description: 'Path or URL (e.g. /home/lyf/dsh-workspace/docs/x.md or the MR link).' },
|
|
846
|
+
content: { type: 'string', description: '交付物正文(Markdown)。写到云盘文档里的就是这段;不填就只有标题和元信息。' },
|
|
847
|
+
by: { type: 'string', description: '登记人:填你自己的专家名字(例如「架构师」)。看板靠它区分 AI 产出和人手工登记。' },
|
|
848
|
+
sync: { type: 'boolean', description: '登记后是否立刻同步到飞书云盘(需求文件夹下的 .md 文档)。默认 true。' },
|
|
849
|
+
stage: { type: 'string', description: `Which column produced it: ${STATUS_IDS.join(' / ')}` },
|
|
850
|
+
status: { type: 'string', description: 'draft | delivered | accepted' },
|
|
851
|
+
},
|
|
852
|
+
async execute(args) {
|
|
853
|
+
const action = String(args.action ?? 'add')
|
|
854
|
+
const artifact = {
|
|
855
|
+
kind: args.kind,
|
|
856
|
+
title: args.title,
|
|
857
|
+
ref: args.ref,
|
|
858
|
+
content: args.content,
|
|
859
|
+
stage: args.stage,
|
|
860
|
+
status: args.status,
|
|
861
|
+
by: String(args.by ?? 'agent'),
|
|
862
|
+
}
|
|
863
|
+
const task = await store.mutate(state => {
|
|
864
|
+
const target = state.tasks.find(t => t.id === args.id)
|
|
865
|
+
if (target === undefined) throw new Error(`no task ${args.id}`)
|
|
866
|
+
if (action === 'add') {
|
|
867
|
+
const created = sanitizeArtifact({ ...artifact, at: Date.now() }, target.artifacts.length)
|
|
868
|
+
if (created === undefined) throw new Error('交付物至少要有标题')
|
|
869
|
+
created.at = Date.now()
|
|
870
|
+
target.artifacts = [...target.artifacts, created].slice(0, 40)
|
|
871
|
+
} else if (action === 'remove') {
|
|
872
|
+
target.artifacts = target.artifacts.filter(item => item.id !== args.artifactId)
|
|
873
|
+
} else {
|
|
874
|
+
target.artifacts = target.artifacts.map(item => (item.id === args.artifactId ? { ...item, ...sanitizeArtifact({ ...item, ...artifact }, 0) } : item))
|
|
875
|
+
}
|
|
876
|
+
target.updatedAt = Date.now()
|
|
877
|
+
return target
|
|
878
|
+
})
|
|
879
|
+
const wanted = action === 'remove' ? [] : (task.artifacts ?? []).filter(item => args.artifactId === undefined || item.id === args.artifactId)
|
|
880
|
+
if (args.sync !== false && wanted.length > 0) {
|
|
881
|
+
const notes = []
|
|
882
|
+
for (const item of wanted) {
|
|
883
|
+
try {
|
|
884
|
+
const doc = await syncArtifact(store, task.id, item.id)
|
|
885
|
+
notes.push(`已归档到云盘:${doc.url}`)
|
|
886
|
+
} catch (error) {
|
|
887
|
+
notes.push(`归档失败:${String(error?.message ?? error).slice(0, 160)}`)
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
return [`已更新 ${task.id} 的交付物,当前 ${task.artifacts.length} 件。`, ...notes].join('\n')
|
|
891
|
+
}
|
|
892
|
+
return `已更新 ${task.id} 的交付物,当前 ${task.artifacts.length} 件。`
|
|
893
|
+
},
|
|
894
|
+
}))
|
|
895
|
+
|
|
896
|
+
register(defineStudioTool({
|
|
897
|
+
name: 'studio_task_check',
|
|
898
|
+
description: 'Tick off (or reopen) an item on the task acceptance checklist, and add new items. Do this as you actually produce each deliverable — the checklist is what "done" means.',
|
|
899
|
+
parameters: {
|
|
900
|
+
id: { type: 'string', required: true, description: 'Task id.' },
|
|
901
|
+
action: { type: 'string', description: 'toggle | add (default toggle)' },
|
|
902
|
+
itemId: { type: 'string', description: 'Checklist item id (from studio_task_get).' },
|
|
903
|
+
text: { type: 'string', description: 'For action=add: the item text.' },
|
|
904
|
+
done: { type: 'boolean', description: 'Explicit state; omit to toggle.' },
|
|
905
|
+
note: { type: 'string', description: 'Evidence: command output, file path, test result.' },
|
|
906
|
+
by: { type: 'string', description: 'Who ticked it (your expert name).' },
|
|
907
|
+
},
|
|
908
|
+
async execute(args) {
|
|
909
|
+
const action = String(args.action ?? 'toggle')
|
|
910
|
+
const snapshot = await store.mutate(state => {
|
|
911
|
+
const target = state.tasks.find(t => t.id === args.id)
|
|
912
|
+
if (target === undefined) throw new Error(`no task ${args.id}`)
|
|
913
|
+
if (action === 'add') {
|
|
914
|
+
const text = String(args.text ?? '').trim().slice(0, 200)
|
|
915
|
+
if (text.length === 0) throw new Error('text is required')
|
|
916
|
+
target.checklist.push({ id: `c${target.checklist.length + 1}-${randomBytes(2).toString('hex')}`, text, done: false, by: '', at: 0, note: '' })
|
|
917
|
+
} else {
|
|
918
|
+
target.checklist = target.checklist.map(item => {
|
|
919
|
+
if (item.id !== args.itemId) return item
|
|
920
|
+
const done = typeof args.done === 'boolean' ? args.done : !item.done
|
|
921
|
+
return { ...item, done, by: done ? String(args.by ?? 'agent') : '', at: done ? Date.now() : 0, note: String(args.note ?? item.note ?? '').slice(0, 400) }
|
|
922
|
+
})
|
|
923
|
+
}
|
|
924
|
+
target.updatedAt = Date.now()
|
|
925
|
+
return target
|
|
926
|
+
})
|
|
927
|
+
const task = snapshot.tasks.find(entry => entry.id === args.id)
|
|
928
|
+
const done = task.checklist.filter(item => item.done).length
|
|
929
|
+
return `${task.id} 验收清单 ${done}/${task.checklist.length}:\n${task.checklist.map(item => `${item.done ? '☑' : '☐'} ${item.id} ${item.text}${item.note ? `(${item.note})` : ''}`).join('\n')}`
|
|
930
|
+
},
|
|
931
|
+
}))
|
|
932
|
+
|
|
933
|
+
register(defineStudioTool({
|
|
934
|
+
name: 'studio_task_coding',
|
|
935
|
+
description: 'Record the AI-coding execution record on a task: which coding agent/model, which branch, the commits, the changed files, unit-test results, and the review decision. Call this when you finish implementing (dev column) and when you finish reviewing (review column) — the board shows it as the coding trail, and "done" without it is not auditable.',
|
|
936
|
+
parameters: {
|
|
937
|
+
id: { type: 'string', required: true, description: 'Task id.' },
|
|
938
|
+
action: { type: 'string', description: 'update (default) | review | merge | clear' },
|
|
939
|
+
agent: { type: 'string', description: 'Coding agent you used, e.g. "claude-code", "deepseek-v4.1-flash 直接改文件".' },
|
|
940
|
+
model: { type: 'string', description: 'Model name if different from the agent.' },
|
|
941
|
+
branch: { type: 'string', description: 'Git branch, e.g. feat/member-points-deduct.' },
|
|
942
|
+
context: { type: 'string', description: 'Which requirement baseline / technical context you worked from (file paths, design doc).' },
|
|
943
|
+
commits: {
|
|
944
|
+
type: 'array',
|
|
945
|
+
description: 'Commits you made (newest first).',
|
|
946
|
+
items: {
|
|
947
|
+
type: 'object',
|
|
948
|
+
properties: {
|
|
949
|
+
sha: { type: 'string', description: 'Commit sha (short or full).' },
|
|
950
|
+
message: { type: 'string', description: 'Commit message.' },
|
|
951
|
+
files: { type: 'string', description: 'How many files changed.' },
|
|
952
|
+
additions: { type: 'string', description: 'Added lines.' },
|
|
953
|
+
deletions: { type: 'string', description: 'Deleted lines.' },
|
|
954
|
+
},
|
|
955
|
+
},
|
|
956
|
+
},
|
|
957
|
+
files: {
|
|
958
|
+
type: 'array',
|
|
959
|
+
description: 'Changed files (send the important ones if there are many).',
|
|
960
|
+
items: {
|
|
961
|
+
type: 'object',
|
|
962
|
+
properties: {
|
|
963
|
+
path: { type: 'string', description: 'Repo-relative path.' },
|
|
964
|
+
change: { type: 'string', description: 'added | modified | deleted' },
|
|
965
|
+
additions: { type: 'string', description: 'Added lines.' },
|
|
966
|
+
deletions: { type: 'string', description: 'Deleted lines.' },
|
|
967
|
+
},
|
|
968
|
+
},
|
|
969
|
+
},
|
|
970
|
+
testsTotal: { type: 'string', description: 'How many unit tests ran.' },
|
|
971
|
+
testsPassed: { type: 'string', description: 'How many passed.' },
|
|
972
|
+
reviewStatus: { type: 'string', description: 'For action=review: approved | changes_requested | pending' },
|
|
973
|
+
reviewNote: { type: 'string', description: 'For action=review: what you checked and what still needs fixing.' },
|
|
974
|
+
by: { type: 'string', description: 'Who changed it (your expert name).' },
|
|
975
|
+
},
|
|
976
|
+
async execute(args) {
|
|
977
|
+
const action = String(args.action ?? 'update')
|
|
978
|
+
const by = String(args.by ?? 'agent').slice(0, 40)
|
|
979
|
+
const snapshot = await store.mutate(state => {
|
|
980
|
+
const task = state.tasks.find(t => t.id === args.id)
|
|
981
|
+
if (task === undefined) throw new Error(`no task ${args.id}`)
|
|
982
|
+
if (action === 'clear') {
|
|
983
|
+
task.coding = null
|
|
984
|
+
} else if (action === 'review') {
|
|
985
|
+
const base = sanitizeCoding(task.coding ?? {})
|
|
986
|
+
task.coding = sanitizeCoding({
|
|
987
|
+
...base,
|
|
988
|
+
review: {
|
|
989
|
+
status: CODING_REVIEW_STATUS.includes(String(args.reviewStatus)) ? String(args.reviewStatus) : 'approved',
|
|
990
|
+
by,
|
|
991
|
+
at: Date.now(),
|
|
992
|
+
note: String(args.reviewNote ?? '').slice(0, 600),
|
|
993
|
+
},
|
|
994
|
+
})
|
|
995
|
+
} else if (action === 'merge') {
|
|
996
|
+
const base = sanitizeCoding(task.coding ?? {})
|
|
997
|
+
task.coding = sanitizeCoding({ ...base, mergedBy: by, mergedAt: Date.now() })
|
|
998
|
+
} else {
|
|
999
|
+
const patch = {}
|
|
1000
|
+
for (const key of ['agent', 'model', 'branch', 'context']) {
|
|
1001
|
+
if (args[key] !== undefined) patch[key] = String(args[key]).slice(0, key === 'context' ? 4000 : 200)
|
|
1002
|
+
}
|
|
1003
|
+
if (Array.isArray(args.commits)) patch.commits = args.commits
|
|
1004
|
+
if (Array.isArray(args.files)) patch.files = args.files
|
|
1005
|
+
if (args.testsTotal !== undefined || args.testsPassed !== undefined) {
|
|
1006
|
+
patch.unitTests = { total: Number(args.testsTotal) || 0, passed: Number(args.testsPassed) || 0, at: Date.now() }
|
|
1007
|
+
}
|
|
1008
|
+
task.coding = mergeCoding(task.coding ?? null, patch)
|
|
1009
|
+
}
|
|
1010
|
+
task.updatedAt = Date.now()
|
|
1011
|
+
return task
|
|
1012
|
+
})
|
|
1013
|
+
const task = snapshot.tasks.find(entry => entry.id === args.id)
|
|
1014
|
+
const summary = codingSummary(task.coding)
|
|
1015
|
+
return `${task.id} 执行记录:${summary === '' ? '(空)' : summary}`
|
|
1016
|
+
},
|
|
1017
|
+
}))
|
|
1018
|
+
|
|
1019
|
+
register(defineStudioTool({
|
|
1020
|
+
name: 'studio_task_case',
|
|
1021
|
+
description: 'Record test cases on a task and their execution results. Test engineers write cases here before running them, then set each result to pass/fail/skip with evidence — this is the test deliverable the board shows.',
|
|
1022
|
+
parameters: {
|
|
1023
|
+
id: { type: 'string', required: true, description: 'Task id.' },
|
|
1024
|
+
action: { type: 'string', description: 'add | update | remove (default add)' },
|
|
1025
|
+
cases: {
|
|
1026
|
+
type: 'array',
|
|
1027
|
+
description: 'For action=add: one or more cases.',
|
|
1028
|
+
items: {
|
|
1029
|
+
type: 'object',
|
|
1030
|
+
properties: {
|
|
1031
|
+
title: { type: 'string', description: 'What you do, e.g. 用错误密码登录 5 次' },
|
|
1032
|
+
expected: { type: 'string', description: 'Expected result, e.g. 第 5 次返回 429 并锁定 10 分钟' },
|
|
1033
|
+
pointId: { type: 'string', description: 'Optional: 对应的需求功能点 id(可追溯矩阵靠它对齐)。' },
|
|
1034
|
+
method: { type: 'string', description: 'Optional HTTP method so the platform can run this case itself: GET | POST | PUT | PATCH | DELETE | HEAD.' },
|
|
1035
|
+
url: { type: 'string', description: 'Optional request path (relative to the test env baseUrl) or absolute URL on an allowed host.' },
|
|
1036
|
+
body: { type: 'string', description: 'Optional request body (JSON string) for POST/PUT/PATCH.' },
|
|
1037
|
+
expectStatus: { type: 'number', description: 'Optional expected HTTP status; 0 or omitted means any 2xx/3xx passes.' },
|
|
1038
|
+
expectContains: { type: 'string', description: 'Optional substring the response body must contain.' },
|
|
1039
|
+
},
|
|
1040
|
+
},
|
|
1041
|
+
},
|
|
1042
|
+
caseId: { type: 'string', description: 'For update/remove: the case id from studio_task_get.' },
|
|
1043
|
+
result: { type: 'string', description: 'pending | pass | fail | skip' },
|
|
1044
|
+
title: { type: 'string', description: 'For update: new case title.' },
|
|
1045
|
+
expected: { type: 'string', description: 'For update: new expected result.' },
|
|
1046
|
+
note: { type: 'string', description: 'Evidence or failure detail: command, output, environment.' },
|
|
1047
|
+
by: { type: 'string', description: 'Who recorded it (your expert name).' },
|
|
1048
|
+
},
|
|
1049
|
+
async execute(args) {
|
|
1050
|
+
const action = String(args.action ?? 'add')
|
|
1051
|
+
const by = String(args.by ?? 'agent').slice(0, 40)
|
|
1052
|
+
const http = args.url !== undefined && String(args.url).trim().length > 0
|
|
1053
|
+
? { method: args.method, url: args.url, body: args.body, expectStatus: args.expectStatus, expectContains: args.expectContains }
|
|
1054
|
+
: undefined
|
|
1055
|
+
const snapshot = await store.mutate(state => {
|
|
1056
|
+
const target = state.tasks.find(t => t.id === args.id)
|
|
1057
|
+
if (target === undefined) throw new Error(`no task ${args.id}`)
|
|
1058
|
+
const list = Array.isArray(target.cases) ? target.cases : []
|
|
1059
|
+
if (action === 'remove') {
|
|
1060
|
+
target.cases = list.filter(item => item.id !== String(args.caseId ?? ''))
|
|
1061
|
+
} else if (action === 'update') {
|
|
1062
|
+
target.cases = list.map(item => {
|
|
1063
|
+
if (item.id !== String(args.caseId ?? '')) return item
|
|
1064
|
+
const merged = { ...item, by, at: Date.now() }
|
|
1065
|
+
if (args.result !== undefined && CASE_RESULTS.includes(String(args.result))) merged.result = String(args.result)
|
|
1066
|
+
if (args.title !== undefined) merged.title = String(args.title).trim().slice(0, 200) || item.title
|
|
1067
|
+
if (args.expected !== undefined) merged.expected = String(args.expected).trim().slice(0, 300)
|
|
1068
|
+
if (args.note !== undefined) merged.note = String(args.note).trim().slice(0, 400)
|
|
1069
|
+
if (http !== undefined) merged.http = sanitizeCaseHttp(http)
|
|
1070
|
+
return merged
|
|
1071
|
+
})
|
|
1072
|
+
} else {
|
|
1073
|
+
const raw = Array.isArray(args.cases) ? args.cases : (args.title !== undefined ? [args] : [])
|
|
1074
|
+
if (raw.length === 0) throw new Error('cases is required for action=add')
|
|
1075
|
+
for (const item of sanitizeCases(raw.map(entry => ({
|
|
1076
|
+
...entry,
|
|
1077
|
+
http: entry.http ?? (entry.url !== undefined && String(entry.url).trim().length > 0
|
|
1078
|
+
? { method: entry.method, url: entry.url, body: entry.body, expectStatus: entry.expectStatus, expectContains: entry.expectContains }
|
|
1079
|
+
: undefined),
|
|
1080
|
+
by,
|
|
1081
|
+
at: Date.now(),
|
|
1082
|
+
})))) {
|
|
1083
|
+
if (list.length >= 60) break
|
|
1084
|
+
list.push({ ...item, id: `c${list.length + 1}-${randomBytes(2).toString('hex')}` })
|
|
1085
|
+
}
|
|
1086
|
+
target.cases = list.slice(0, 60)
|
|
1087
|
+
}
|
|
1088
|
+
target.updatedAt = Date.now()
|
|
1089
|
+
return target
|
|
1090
|
+
})
|
|
1091
|
+
const task = snapshot.tasks.find(entry => entry.id === args.id)
|
|
1092
|
+
const total = task.cases.length
|
|
1093
|
+
const passed = task.cases.filter(item => item.result === 'pass').length
|
|
1094
|
+
const failed = task.cases.filter(item => item.result === 'fail').length
|
|
1095
|
+
return [
|
|
1096
|
+
`${task.id} 测试用例 ${total} 条(通过 ${passed} / 不通过 ${failed} / 未跑 ${total - passed - failed}):`,
|
|
1097
|
+
...task.cases.map(item => {
|
|
1098
|
+
const mark = item.result === 'pass' ? '✅' : item.result === 'fail' ? '❌' : item.result === 'skip' ? '⏭️' : '⬜'
|
|
1099
|
+
return `${mark} ${item.id} ${item.title}${item.expected ? `(期望:${item.expected})` : ''}${item.note ? ` — ${item.note}` : ''}`
|
|
1100
|
+
}),
|
|
1101
|
+
].join('\n')
|
|
1102
|
+
},
|
|
1103
|
+
}))
|
|
1104
|
+
|
|
1105
|
+
register(defineStudioTool({
|
|
1106
|
+
name: 'studio_test_suite',
|
|
1107
|
+
description: 'Run the HTTP test cases for a batch of cards at once (defaults to every card in the test column). Cards run in parallel, cases inside one card run in sequence. Use it for regression before a release.',
|
|
1108
|
+
parameters: {
|
|
1109
|
+
taskIds: { type: 'string', description: 'Comma separated task ids; omit to run every card sitting in the test column.' },
|
|
1110
|
+
env: { type: 'string', description: 'Test env id or name; omit to use the default env.' },
|
|
1111
|
+
concurrency: { type: 'number', description: 'How many cards to run at once (1-8).' },
|
|
1112
|
+
by: { type: 'string', description: 'Who triggered the run (your expert name).' },
|
|
1113
|
+
},
|
|
1114
|
+
async execute(args) {
|
|
1115
|
+
const ids = typeof args.taskIds === 'string' && args.taskIds.trim() !== ''
|
|
1116
|
+
? args.taskIds.split(',').map(item => item.trim()).filter(item => item !== '')
|
|
1117
|
+
: undefined
|
|
1118
|
+
const result = await runSuite(store, { taskIds: ids, env: args.env, by: String(args.by ?? 'agent'), concurrency: args.concurrency })
|
|
1119
|
+
const totalCases = result.tasks.reduce((sum, item) => sum + (item.total ?? 0), 0)
|
|
1120
|
+
const passCases = result.tasks.reduce((sum, item) => sum + (item.passed ?? 0), 0)
|
|
1121
|
+
const bugs = result.tasks.reduce((sum, item) => sum + (item.bugs ?? 0), 0)
|
|
1122
|
+
return [
|
|
1123
|
+
`并发 ${result.concurrency},跑了 ${result.tasks.length} 张卡:用例 ${totalCases} 条,通过 ${passCases},开缺陷 ${bugs} 张`,
|
|
1124
|
+
...result.tasks.map(item => item.ok
|
|
1125
|
+
? `- ${item.taskId}:${item.passed}/${item.total} 通过${item.failed > 0 ? `,${item.failed} 条失败` : ''}${item.bugs > 0 ? `,开缺陷 ${item.bugs} 张` : ''}`
|
|
1126
|
+
: `- ${item.taskId}:跑挂了——${item.error}`),
|
|
1127
|
+
].join('\n')
|
|
1128
|
+
},
|
|
1129
|
+
}))
|
|
1130
|
+
|
|
1131
|
+
register(defineStudioTool({
|
|
1132
|
+
name: 'studio_test_run',
|
|
1133
|
+
description: 'Run the HTTP test cases on a task for real: the platform sends the requests, compares status/body, writes the results back, records a run, and opens a bug card for every failing case. Use this instead of guessing whether the API works.',
|
|
1134
|
+
parameters: {
|
|
1135
|
+
id: { type: 'string', required: true, description: 'Test task id.' },
|
|
1136
|
+
caseId: { type: 'string', description: 'Run just this case; omit to run every case that has a request definition.' },
|
|
1137
|
+
env: { type: 'string', description: 'Test env id or name; omit to use the default env.' },
|
|
1138
|
+
by: { type: 'string', description: 'Who triggered the run (your expert name).' },
|
|
1139
|
+
},
|
|
1140
|
+
async execute(args) {
|
|
1141
|
+
const result = await runTaskCases(store, String(args.id ?? ''), {
|
|
1142
|
+
caseIds: args.caseId !== undefined && String(args.caseId).length > 0 ? [String(args.caseId)] : undefined,
|
|
1143
|
+
env: args.env,
|
|
1144
|
+
by: String(args.by ?? 'agent').slice(0, 40),
|
|
1145
|
+
})
|
|
1146
|
+
const run = result.run
|
|
1147
|
+
return [
|
|
1148
|
+
`环境 ${result.env.name}(${result.env.baseUrl}):${run.total} 条,通过 ${run.passed},不通过 ${run.failed}`,
|
|
1149
|
+
...run.results.map(item => `${item.ok ? '✅' : '❌'} ${item.title}${item.status > 0 ? ` → ${item.status}` : ''}${item.error !== '' ? `:${item.error}` : ''}(${item.ms}ms)`),
|
|
1150
|
+
...result.bugs.map(item => `${item.created ? '已开缺陷' : '已有缺陷'} ${item.bug}:${item.title}`),
|
|
1151
|
+
].join('\n')
|
|
1152
|
+
},
|
|
1153
|
+
}))
|
|
1154
|
+
|
|
1155
|
+
register(defineStudioTool({
|
|
1156
|
+
name: 'studio_skill_list',
|
|
1157
|
+
description: 'List the company skill library: every skill you can load with the skill tool. Check this before inventing a procedure, and after saving a new one.',
|
|
1158
|
+
parameters: {},
|
|
1159
|
+
async execute() {
|
|
1160
|
+
if (host.getActiveSkills() === undefined) throw new Error('技能库尚未就绪')
|
|
1161
|
+
const skills = host.getActiveSkills().list()
|
|
1162
|
+
if (skills.length === 0) return '技能库还是空的。可以用 studio_skill_save 把这次摸索出来的做法写成一条技能。'
|
|
1163
|
+
return [
|
|
1164
|
+
`技能库共 ${skills.length} 条(落盘在 ${host.skillsRoot()}/<名字>/SKILL.md):`,
|
|
1165
|
+
...skills.map(skill => `- ${skill.name}${skill.enabled === false ? '(已停用)' : ''}:${skill.description}${skill.tags.length > 0 ? ` [${skill.tags.join('/')}]` : ''}`),
|
|
1166
|
+
].join('\n')
|
|
1167
|
+
},
|
|
1168
|
+
}))
|
|
1169
|
+
|
|
1170
|
+
register(defineStudioTool({
|
|
1171
|
+
name: 'studio_skill_get',
|
|
1172
|
+
description: 'Read one skill body in full (the same text the skill tool would load).',
|
|
1173
|
+
parameters: { name: { type: 'string', required: true, description: 'Skill name, kebab-case.' } },
|
|
1174
|
+
async execute(args) {
|
|
1175
|
+
if (host.getActiveSkills() === undefined) throw new Error('技能库尚未就绪')
|
|
1176
|
+
const skill = host.getActiveSkills().get(String(args.name ?? ''))
|
|
1177
|
+
if (skill === undefined) throw new Error(`no skill ${args.name}`)
|
|
1178
|
+
return `# ${skill.name}\n\n${skill.description}\n\n${skill.content}`
|
|
1179
|
+
},
|
|
1180
|
+
}))
|
|
1181
|
+
|
|
1182
|
+
register(defineStudioTool({
|
|
1183
|
+
name: 'studio_skill_save',
|
|
1184
|
+
description: 'Create or update a skill in the company library. Saving writes ~/.dsh/skills/<name>/SKILL.md, so every agent can load it with the skill tool from the next session on. Use it to keep hard-won procedures instead of rediscovering them.',
|
|
1185
|
+
parameters: {
|
|
1186
|
+
name: { type: 'string', required: true, description: 'Kebab-case skill name, e.g. gitlab-mr-review.' },
|
|
1187
|
+
description: { type: 'string', required: true, description: 'One line: when should an agent load this?' },
|
|
1188
|
+
content: { type: 'string', required: true, description: 'Markdown body: the actual procedure, commands and pitfalls.' },
|
|
1189
|
+
whenToUse: { type: 'string', description: 'Extra routing guidance, e.g. 收到 MR 评审任务时。' },
|
|
1190
|
+
tags: { type: 'array', items: { type: 'string' }, description: 'A few tags: gitlab / review / feishu …' },
|
|
1191
|
+
},
|
|
1192
|
+
async execute(args) {
|
|
1193
|
+
if (host.getActiveSkills() === undefined) throw new Error('技能库尚未就绪')
|
|
1194
|
+
const { skill, file } = await host.getActiveSkills().save(args)
|
|
1195
|
+
return `已保存技能 ${skill.name}${file === undefined ? '(已停用,未落盘)' : `,已写入 ${file}`}`
|
|
1196
|
+
},
|
|
1197
|
+
}))
|
|
1198
|
+
|
|
1199
|
+
register(defineStudioTool({
|
|
1200
|
+
name: 'studio_skill_delete',
|
|
1201
|
+
description: 'Remove a skill from the company library and from disk. Only do this when the skill is obsolete or wrong — prefer fixing it with studio_skill_save.',
|
|
1202
|
+
parameters: { name: { type: 'string', required: true, description: 'Skill name.' } },
|
|
1203
|
+
async execute(args) {
|
|
1204
|
+
if (host.getActiveSkills() === undefined) throw new Error('技能库尚未就绪')
|
|
1205
|
+
const { name } = await host.getActiveSkills().remove(String(args.name ?? ''))
|
|
1206
|
+
return `已删除技能 ${name}`
|
|
1207
|
+
},
|
|
1208
|
+
}))
|
|
1209
|
+
|
|
1210
|
+
return disposers
|
|
1211
|
+
}
|