sillyspec 3.12.8 → 3.14.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/package.json +2 -1
- package/src/index.js +173 -0
- package/src/modules.js +427 -0
- package/src/progress.js +51 -0
- package/src/run.js +161 -0
- package/src/stages/archive.js +66 -84
- package/src/stages/brainstorm.js +17 -4
- package/src/stages/doctor.js +30 -1
- package/src/stages/execute.js +5 -1
- package/src/stages/plan.js +6 -1
- package/src/stages/scan.js +277 -125
- package/src/stages/verify.js +2 -1
- package/src/workflow.js +670 -0
package/src/workflow.js
ADDED
|
@@ -0,0 +1,670 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SillySpec Workflow Engine
|
|
3
|
+
*
|
|
4
|
+
* 定义、检查和执行结构化工作流。
|
|
5
|
+
* 职责:
|
|
6
|
+
* - 加载 .sillyspec/workflows/*.yaml
|
|
7
|
+
* - 运行 post_check 验证产物
|
|
8
|
+
* - 按角色定位失败 + 生成重试 prompt
|
|
9
|
+
* - 根据 role 定义生成 role prompts(Level 2)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { readFileSync, existsSync, readdirSync, writeFileSync, mkdirSync } from 'fs'
|
|
13
|
+
import { join, resolve, basename } from 'path'
|
|
14
|
+
import jsYaml from 'js-yaml'
|
|
15
|
+
|
|
16
|
+
// ─── Workflow 加载 ───
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 查找并加载指定名称的 workflow YAML
|
|
20
|
+
* @param {string} cwd - 项目根目录
|
|
21
|
+
* @param {string} name - workflow 名称(如 'scan-docs')
|
|
22
|
+
* @returns {object|null} workflow 定义,或 null
|
|
23
|
+
*/
|
|
24
|
+
export function loadWorkflow(cwd, name, validate = true) {
|
|
25
|
+
const wfDir = join(cwd, '.sillyspec', 'workflows')
|
|
26
|
+
if (!existsSync(wfDir)) return null
|
|
27
|
+
|
|
28
|
+
// 优先找 <name>.yaml,其次找 <name>.yml
|
|
29
|
+
for (const ext of ['.yaml', '.yml']) {
|
|
30
|
+
const f = join(wfDir, `${name}${ext}`)
|
|
31
|
+
if (existsSync(f)) {
|
|
32
|
+
const raw = readFileSync(f, 'utf8')
|
|
33
|
+
const wf = jsYaml.load(raw)
|
|
34
|
+
if (validate) {
|
|
35
|
+
const errors = validateWorkflow(wf)
|
|
36
|
+
if (errors.length > 0) return { _validationErrors: errors, ...wf }
|
|
37
|
+
}
|
|
38
|
+
return wf
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 校验 workflow YAML 结构
|
|
46
|
+
* @param {object} wf - workflow 定义
|
|
47
|
+
* @returns {string[]} 错误列表,空数组表示通过
|
|
48
|
+
*/
|
|
49
|
+
export function validateWorkflow(wf) {
|
|
50
|
+
const errors = []
|
|
51
|
+
const roles = wf.roles || []
|
|
52
|
+
const roleIds = new Set(roles.map(r => r.id))
|
|
53
|
+
|
|
54
|
+
for (const role of roles) {
|
|
55
|
+
if (!role.id) {
|
|
56
|
+
errors.push(`role 缺少 id 字段`)
|
|
57
|
+
continue
|
|
58
|
+
}
|
|
59
|
+
// depends_on 校验
|
|
60
|
+
const deps = role.depends_on || []
|
|
61
|
+
for (const depId of deps) {
|
|
62
|
+
if (!roleIds.has(depId)) {
|
|
63
|
+
errors.push(`role "${role.id}" 的 depends_on 引用了不存在的 role "${depId}"`)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// depends_on 循环检测(简单两层:A→B→A)
|
|
67
|
+
for (const depId of deps) {
|
|
68
|
+
const depRole = roles.find(r => r.id === depId)
|
|
69
|
+
if (depRole && (depRole.depends_on || []).includes(role.id)) {
|
|
70
|
+
errors.push(`循环依赖:"${role.id}" ↔ "${depId}"`)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// from_role 校验
|
|
74
|
+
const inputs = role.inputs || {}
|
|
75
|
+
if (!Array.isArray(inputs)) {
|
|
76
|
+
// inputs is a mapping
|
|
77
|
+
if (inputs.from_role) {
|
|
78
|
+
if (!roleIds.has(inputs.from_role)) {
|
|
79
|
+
errors.push(`role "${role.id}" 的 inputs.from_role 引用了不存在的 role "${inputs.from_role}"`)
|
|
80
|
+
}
|
|
81
|
+
if (inputs.output) {
|
|
82
|
+
const sourceRole = roles.find(r => r.id === inputs.from_role)
|
|
83
|
+
if (sourceRole) {
|
|
84
|
+
const outputExists = (sourceRole.outputs || []).some(o => {
|
|
85
|
+
const outputName = o.name || o.path?.split('/').pop()?.replace(/\.md$/, '') || ''
|
|
86
|
+
return outputName === inputs.output
|
|
87
|
+
})
|
|
88
|
+
if (!outputExists) {
|
|
89
|
+
errors.push(`role "${role.id}" 的 inputs.from_role "${inputs.from_role}" 没有名为 "${inputs.output}" 的 output`)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (!deps.includes(inputs.from_role)) {
|
|
94
|
+
errors.push(`role "${role.id}" 的 inputs.from_role "${inputs.from_role}" 未在 depends_on 中声明`)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
} else {
|
|
98
|
+
// inputs is an array (legacy format)
|
|
99
|
+
for (const input of inputs) {
|
|
100
|
+
if (input.from_role) {
|
|
101
|
+
if (!roleIds.has(input.from_role)) {
|
|
102
|
+
errors.push(`role "${role.id}" 的 inputs.from_role 引用了不存在的 role "${input.from_role}"`)
|
|
103
|
+
}
|
|
104
|
+
if (!deps.includes(input.from_role)) {
|
|
105
|
+
errors.push(`role "${role.id}" 的 inputs.from_role "${input.from_role}" 未在 depends_on 中声明`)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return errors
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* 列出所有可用 workflow
|
|
116
|
+
*/
|
|
117
|
+
export function listWorkflows(cwd) {
|
|
118
|
+
const wfDir = join(cwd, '.sillyspec', 'workflows')
|
|
119
|
+
if (!existsSync(wfDir)) return []
|
|
120
|
+
const files = readdirSync(wfDir).filter(f => f.endsWith('.yaml') || f.endsWith('.yml'))
|
|
121
|
+
return files.map(f => f.replace(/\.(yaml|yml)$/, ''))
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ─── 占位符替换 ───
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* 替换 workflow YAML 中的 <project> 占位符
|
|
128
|
+
* @param {object} wf - workflow 定义(会被修改)
|
|
129
|
+
* @param {string} projectName - 项目名
|
|
130
|
+
*/
|
|
131
|
+
function replaceProjectPlaceholder(wf, projectName) {
|
|
132
|
+
const json = JSON.stringify(wf)
|
|
133
|
+
const replaced = json.replace(/<project>/g, projectName)
|
|
134
|
+
return JSON.parse(replaced)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ─── Post Check ───
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* 检查结果项
|
|
141
|
+
* @typedef {{ role: string, output: string, path: string, check: string, passed: boolean, detail?: string }} CheckResult
|
|
142
|
+
*/
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* 对单个 output 运行检查
|
|
146
|
+
* @param {object} outputDef - output 定义
|
|
147
|
+
* @param {string} basePath - 被检查的文件所在目录
|
|
148
|
+
* @param {string} cwd - 项目根目录
|
|
149
|
+
* @returns {CheckResult}
|
|
150
|
+
*/
|
|
151
|
+
function checkOutput(outputDef, projectName, cwd) {
|
|
152
|
+
// 将 <project> 替换为实际项目名
|
|
153
|
+
const rawPath = (outputDef.path || '').replace(/<project>/g, projectName)
|
|
154
|
+
const fullPath = resolve(cwd, rawPath)
|
|
155
|
+
const checks = outputDef.checks || []
|
|
156
|
+
const results = []
|
|
157
|
+
|
|
158
|
+
for (const check of checks) {
|
|
159
|
+
switch (check.type) {
|
|
160
|
+
case 'file_exists': {
|
|
161
|
+
const exists = existsSync(fullPath)
|
|
162
|
+
results.push({ passed: exists, check: 'file_exists', detail: exists ? '' : `文件不存在: ${rawPath}` })
|
|
163
|
+
break
|
|
164
|
+
}
|
|
165
|
+
case 'no_empty_files': {
|
|
166
|
+
if (existsSync(fullPath)) {
|
|
167
|
+
const content = readFileSync(fullPath, 'utf8')
|
|
168
|
+
const empty = content.trim().length === 0
|
|
169
|
+
results.push({ passed: !empty, check: 'no_empty_files', detail: empty ? `文件为空: ${rawPath}` : '' })
|
|
170
|
+
} else {
|
|
171
|
+
results.push({ passed: false, check: 'no_empty_files', detail: `文件不存在: ${rawPath}` })
|
|
172
|
+
}
|
|
173
|
+
break
|
|
174
|
+
}
|
|
175
|
+
case 'min_lines': {
|
|
176
|
+
if (existsSync(fullPath)) {
|
|
177
|
+
const content = readFileSync(fullPath, 'utf8')
|
|
178
|
+
const lines = content.split('\n').length
|
|
179
|
+
const min = check.min || 1
|
|
180
|
+
results.push({ passed: lines >= min, check: `min_lines(${min})`, detail: lines >= min ? '' : `文件只有 ${lines} 行,要求至少 ${min} 行: ${rawPath}` })
|
|
181
|
+
} else {
|
|
182
|
+
results.push({ passed: false, check: `min_lines(${check.min || 1})`, detail: `文件不存在: ${rawPath}` })
|
|
183
|
+
}
|
|
184
|
+
break
|
|
185
|
+
}
|
|
186
|
+
case 'contains_sections': {
|
|
187
|
+
if (existsSync(fullPath)) {
|
|
188
|
+
const content = readFileSync(fullPath, 'utf8')
|
|
189
|
+
const sections = check.sections || []
|
|
190
|
+
const missing = sections.filter(s => !content.includes(`## ${s}`))
|
|
191
|
+
results.push({ passed: missing.length === 0, check: 'contains_sections', detail: missing.length > 0 ? `缺少章节: ${missing.join(', ')} — ${rawPath}` : '' })
|
|
192
|
+
} else {
|
|
193
|
+
results.push({ passed: false, check: 'contains_sections', detail: `文件不存在: ${rawPath}` })
|
|
194
|
+
}
|
|
195
|
+
break
|
|
196
|
+
}
|
|
197
|
+
case 'no_placeholder': {
|
|
198
|
+
if (existsSync(fullPath)) {
|
|
199
|
+
const content = readFileSync(fullPath, 'utf8')
|
|
200
|
+
const patterns = check.patterns || ['待补充', 'TODO', 'TBD', '未分析', '根据项目情况', '根据实际情况', '按需填写']
|
|
201
|
+
// 只匹配独立成行的占位文本,不匹配行内引用
|
|
202
|
+
const lineMatches = patterns.filter(p => {
|
|
203
|
+
const regex = new RegExp(`^\s*[-*]?\s*${p}\s*$`, 'm')
|
|
204
|
+
return regex.test(content)
|
|
205
|
+
})
|
|
206
|
+
results.push({ passed: lineMatches.length === 0, check: 'no_placeholder', detail: lineMatches.length > 0 ? `包含占位文本: ${lineMatches.map(m => `"${m}"`).join(', ')} — ${rawPath}` : '' })
|
|
207
|
+
} else {
|
|
208
|
+
results.push({ passed: false, check: 'no_placeholder', detail: `文件不存在: ${rawPath}` })
|
|
209
|
+
}
|
|
210
|
+
break
|
|
211
|
+
}
|
|
212
|
+
default:
|
|
213
|
+
results.push({ passed: true, check: check.type, detail: `未知检查类型,跳过: ${check.type}` })
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return results
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* 运行 workflow 的 post_check
|
|
222
|
+
* @param {object} wf - workflow 定义
|
|
223
|
+
* @param {string} cwd - 项目根目录
|
|
224
|
+
* @param {string} projectName - 项目名
|
|
225
|
+
* @returns {{ passed: boolean, roleResults: Array<{ roleId: string, roleName: string, passed: boolean, failures: string[] }>, workflowFailures: string[] }}
|
|
226
|
+
*/
|
|
227
|
+
/**
|
|
228
|
+
* 统一的 Workflow Check 结果协议
|
|
229
|
+
* CLI 和 run.js 共用同一份结构化结果
|
|
230
|
+
*
|
|
231
|
+
* 返回结构:
|
|
232
|
+
* {
|
|
233
|
+
* workflow: string, // workflow 名称
|
|
234
|
+
* project: string, // 项目名
|
|
235
|
+
* status: 'pass'|'fail', // 总体状态
|
|
236
|
+
* spec_version: number, // spec 版本
|
|
237
|
+
* roles: [{ id, name, status, outputs: [{ path, status, checks: [{ type, status, detail }] }] }],
|
|
238
|
+
* workflow_checks: [{ type, status, detail }],
|
|
239
|
+
* failures: [{ level: 'role'|'workflow', role_id?, output?, check, message }],
|
|
240
|
+
* retry_prompts: [{ role_id, role_name, prompt }]
|
|
241
|
+
* }
|
|
242
|
+
*/
|
|
243
|
+
export function runPostCheck(wf, cwd, projectName, placeholders = {}) {
|
|
244
|
+
let resolved = replaceProjectPlaceholder(wf, projectName)
|
|
245
|
+
if (Object.keys(placeholders).length > 0) {
|
|
246
|
+
let json = JSON.stringify(resolved)
|
|
247
|
+
for (const [key, value] of Object.entries(placeholders)) {
|
|
248
|
+
json = json.replace(new RegExp(`<${key}>`, 'g'), value)
|
|
249
|
+
}
|
|
250
|
+
resolved = JSON.parse(json)
|
|
251
|
+
}
|
|
252
|
+
return _checkWorkflow(resolved, cwd, projectName)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function _checkWorkflow(wf, cwd, projectName) {
|
|
256
|
+
const workflowName = wf.name || 'unknown'
|
|
257
|
+
const specVersion = wf.spec_version || wf.version || 0
|
|
258
|
+
const workflowChecks = wf.checks?.workflow_level || []
|
|
259
|
+
const roles = []
|
|
260
|
+
const failures = []
|
|
261
|
+
const workflowCheckResults = []
|
|
262
|
+
|
|
263
|
+
// 1. 角色级别检查
|
|
264
|
+
for (const role of wf.roles || []) {
|
|
265
|
+
const roleId = role.id
|
|
266
|
+
const roleName = role.name || roleId
|
|
267
|
+
const outputDefs = role.outputs || []
|
|
268
|
+
const outputs = []
|
|
269
|
+
|
|
270
|
+
for (const outputDef of outputDefs) {
|
|
271
|
+
const rawPath = (outputDef.path || '').replace(/<project>/g, projectName)
|
|
272
|
+
const checkResults = checkOutput(outputDef, projectName, cwd)
|
|
273
|
+
const outputPassed = checkResults.every(c => c.passed)
|
|
274
|
+
|
|
275
|
+
outputs.push({
|
|
276
|
+
path: rawPath,
|
|
277
|
+
status: outputPassed ? 'pass' : 'fail',
|
|
278
|
+
checks: checkResults.map(c => ({
|
|
279
|
+
type: c.check,
|
|
280
|
+
status: c.passed ? 'pass' : 'fail',
|
|
281
|
+
detail: c.detail
|
|
282
|
+
}))
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
for (const cr of checkResults) {
|
|
286
|
+
if (!cr.passed) {
|
|
287
|
+
failures.push({
|
|
288
|
+
level: 'role',
|
|
289
|
+
role_id: roleId,
|
|
290
|
+
output: rawPath,
|
|
291
|
+
check: cr.check,
|
|
292
|
+
message: cr.detail
|
|
293
|
+
})
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const rolePassed = outputs.every(o => o.status === 'pass')
|
|
299
|
+
roles.push({
|
|
300
|
+
id: roleId,
|
|
301
|
+
name: roleName,
|
|
302
|
+
status: rolePassed ? 'pass' : 'fail',
|
|
303
|
+
outputs
|
|
304
|
+
})
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// 2. 工作流级别检查
|
|
308
|
+
for (const check of workflowChecks) {
|
|
309
|
+
switch (check.type) {
|
|
310
|
+
case 'file_count': {
|
|
311
|
+
const scanDir = join(cwd, '.sillyspec', 'docs', projectName, check.path || 'scan/')
|
|
312
|
+
if (existsSync(scanDir)) {
|
|
313
|
+
const files = readdirSync(scanDir).filter(f => f.endsWith('.md'))
|
|
314
|
+
const min = check.min || 0
|
|
315
|
+
if (files.length < min) {
|
|
316
|
+
const detail = `文件数不足: ${scanDir} 有 ${files.length} 个 .md 文件,要求至少 ${min} 个`
|
|
317
|
+
workflowCheckResults.push({ type: 'file_count', status: 'fail', detail })
|
|
318
|
+
failures.push({ level: 'workflow', check: 'file_count', message: detail })
|
|
319
|
+
} else {
|
|
320
|
+
workflowCheckResults.push({ type: 'file_count', status: 'pass', detail: '' })
|
|
321
|
+
}
|
|
322
|
+
} else {
|
|
323
|
+
const detail = `目录不存在: ${scanDir}`
|
|
324
|
+
workflowCheckResults.push({ type: 'file_count', status: 'fail', detail })
|
|
325
|
+
failures.push({ level: 'workflow', check: 'file_count', message: detail })
|
|
326
|
+
}
|
|
327
|
+
break
|
|
328
|
+
}
|
|
329
|
+
case 'no_empty_files': {
|
|
330
|
+
const scanDir = join(cwd, '.sillyspec', 'docs', projectName, check.path || 'scan/')
|
|
331
|
+
if (existsSync(scanDir)) {
|
|
332
|
+
const files = readdirSync(scanDir).filter(f => f.endsWith('.md'))
|
|
333
|
+
let anyEmpty = false
|
|
334
|
+
for (const f of files) {
|
|
335
|
+
const content = readFileSync(join(scanDir, f), 'utf8')
|
|
336
|
+
if (content.trim().length === 0) {
|
|
337
|
+
const detail = `空文件: ${join(scanDir, f)}`
|
|
338
|
+
workflowCheckResults.push({ type: 'no_empty_files', status: 'fail', detail })
|
|
339
|
+
failures.push({ level: 'workflow', check: 'no_empty_files', message: detail })
|
|
340
|
+
anyEmpty = true
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
if (!anyEmpty) {
|
|
344
|
+
workflowCheckResults.push({ type: 'no_empty_files', status: 'pass', detail: '' })
|
|
345
|
+
}
|
|
346
|
+
} else {
|
|
347
|
+
const detail = `目录不存在: ${scanDir}`
|
|
348
|
+
workflowCheckResults.push({ type: 'no_empty_files', status: 'fail', detail })
|
|
349
|
+
failures.push({ level: 'workflow', check: 'no_empty_files', message: detail })
|
|
350
|
+
}
|
|
351
|
+
break
|
|
352
|
+
}
|
|
353
|
+
default:
|
|
354
|
+
workflowCheckResults.push({ type: check.type, status: 'pass', detail: '' })
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const allPassed = roles.every(r => r.status === 'pass') && workflowCheckResults.every(c => c.status === 'pass')
|
|
359
|
+
|
|
360
|
+
// 生成 retry prompts
|
|
361
|
+
const retryPrompts = []
|
|
362
|
+
if (!allPassed) {
|
|
363
|
+
for (const role of roles.filter(r => r.status === 'fail')) {
|
|
364
|
+
const roleFailures = failures.filter(f => f.role_id === role.id)
|
|
365
|
+
const targetFiles = [...new Set(roleFailures.map(f => f.output).filter(Boolean))]
|
|
366
|
+
const roleDef = (wf.roles || []).find(r => r.id === role.id)
|
|
367
|
+
const constraints = roleDef?.constraints || []
|
|
368
|
+
let prompt = `上一次 workflow 执行存在失败项,请重试。\n\n`
|
|
369
|
+
prompt += `### 失败角色:${role.name} (${role.id})\n失败原因:\n`
|
|
370
|
+
for (const f of roleFailures) {
|
|
371
|
+
prompt += `- ${f.message}\n`
|
|
372
|
+
}
|
|
373
|
+
prompt += `\n`
|
|
374
|
+
for (const fp of targetFiles) {
|
|
375
|
+
prompt += `目标文件:\`${fp}\`\n`
|
|
376
|
+
}
|
|
377
|
+
if (constraints.length > 0) {
|
|
378
|
+
prompt += `约束:\n`
|
|
379
|
+
for (const c of constraints) {
|
|
380
|
+
prompt += `- ${c}\n`
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
prompt += `\n⚠️ 你必须确保文件写入指定路径。不要只报告完成,请用 write 工具实际写入。`
|
|
384
|
+
retryPrompts.push({ role_id: role.id, role_name: role.name, prompt })
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return {
|
|
389
|
+
workflow: workflowName,
|
|
390
|
+
project: projectName,
|
|
391
|
+
status: allPassed ? 'pass' : 'fail',
|
|
392
|
+
spec_version: specVersion,
|
|
393
|
+
roles,
|
|
394
|
+
workflow_checks: workflowCheckResults,
|
|
395
|
+
failures,
|
|
396
|
+
retry_prompts: retryPrompts
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* 格式化检查结果为人类可读报告(兼容旧接口)
|
|
402
|
+
*/
|
|
403
|
+
export function formatCheckReport(result) {
|
|
404
|
+
const lines = []
|
|
405
|
+
lines.push('\n📋 Workflow Post-Check 报告\n')
|
|
406
|
+
|
|
407
|
+
for (const r of (result.roles || [])) {
|
|
408
|
+
const icon = r.status === 'pass' ? '✅' : '❌'
|
|
409
|
+
lines.push(`${icon} ${r.name} (${r.id})`)
|
|
410
|
+
// 兼容新旧格式
|
|
411
|
+
const outputFailures = (r.outputs || []).flatMap(o =>
|
|
412
|
+
(o.checks || []).filter(c => c.status === 'fail').map(c => c.detail)
|
|
413
|
+
)
|
|
414
|
+
for (const f of outputFailures) {
|
|
415
|
+
lines.push(` └─ ${f}`)
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if ((result.workflow_checks || []).some(c => c.status === 'fail')) {
|
|
420
|
+
lines.push('')
|
|
421
|
+
for (const c of result.workflow_checks) {
|
|
422
|
+
if (c.status === 'fail') {
|
|
423
|
+
lines.push(`❌ 全局检查失败: ${c.detail}`)
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (result.status === 'pass') {
|
|
429
|
+
lines.push('\n✅ 全部检查通过')
|
|
430
|
+
} else {
|
|
431
|
+
lines.push('\n❌ 存在失败项,请根据以下重试提示修复:')
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
return lines.join('\n')
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// ─── 兼容适配层 ───
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* 兼容旧接口:generateRetryPrompt
|
|
441
|
+
* @deprecated 直接用 runPostCheck 返回的 retry_prompts
|
|
442
|
+
*/
|
|
443
|
+
export function generateRetryPrompt(wf, checkResult, projectName) {
|
|
444
|
+
const resolved = replaceProjectPlaceholder(wf, projectName)
|
|
445
|
+
const lines = []
|
|
446
|
+
lines.push('上一次 workflow 执行存在失败项,请重试。\n')
|
|
447
|
+
|
|
448
|
+
const roles = resolved.roles || []
|
|
449
|
+
const roleResults = checkResult.roles || []
|
|
450
|
+
for (const r of roleResults) {
|
|
451
|
+
if (r.status === 'pass') continue
|
|
452
|
+
const role = roles.find(rl => rl.id === r.id)
|
|
453
|
+
if (!role) continue
|
|
454
|
+
|
|
455
|
+
lines.push(`### 失败角色:${r.name} (${r.id})`)
|
|
456
|
+
lines.push(`失败原因:`)
|
|
457
|
+
const roleFailures = (checkResult.failures || []).filter(f => f.role_id === r.id)
|
|
458
|
+
for (const f of roleFailures) {
|
|
459
|
+
lines.push(`- ${f.message}`)
|
|
460
|
+
}
|
|
461
|
+
lines.push('')
|
|
462
|
+
|
|
463
|
+
for (const output of (role.outputs || [])) {
|
|
464
|
+
lines.push(`目标文件:\`${output.path}\``)
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if (role.constraints && role.constraints.length > 0) {
|
|
468
|
+
lines.push('约束:')
|
|
469
|
+
for (const c of role.constraints) {
|
|
470
|
+
lines.push(`- ${c}`)
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
lines.push('')
|
|
475
|
+
lines.push('⚠️ 你必须确保文件写入指定路径。不要只报告完成,请用 write 工具实际写入。')
|
|
476
|
+
lines.push('')
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
return lines.join('\n')
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// ─── Role Prompt 生成(Level 2)───
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* 根据 workflow role 定义生成子代理 prompt
|
|
486
|
+
* @param {object} wf - workflow 定义
|
|
487
|
+
* @param {string} roleId - 角色ID
|
|
488
|
+
* @param {string} projectName - 项目名
|
|
489
|
+
* @param {object} context - 额外上下文(envSummary, missingDocs 等)
|
|
490
|
+
* @returns {string|null} 生成的 prompt,或 null(角色不存在)
|
|
491
|
+
*/
|
|
492
|
+
export function generateRolePrompt(wf, roleId, projectName, context = {}) {
|
|
493
|
+
const resolved = replaceProjectPlaceholder(wf, projectName)
|
|
494
|
+
const role = (resolved.roles || []).find(r => r.id === roleId)
|
|
495
|
+
if (!role) return null
|
|
496
|
+
|
|
497
|
+
const lines = []
|
|
498
|
+
lines.push(`## 子代理任务:${role.name} (${roleId})`)
|
|
499
|
+
lines.push('')
|
|
500
|
+
lines.push(`项目:${projectName}`)
|
|
501
|
+
|
|
502
|
+
// 任务描述
|
|
503
|
+
if (role.task) {
|
|
504
|
+
lines.push(`任务:${role.task}`)
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// 依赖角色的输出(depends_on + from_role)
|
|
508
|
+
const deps = role.depends_on || []
|
|
509
|
+
if (deps.length > 0) {
|
|
510
|
+
lines.push('')
|
|
511
|
+
lines.push('前置依赖(已完成角色的输出):')
|
|
512
|
+
const inputs = role.inputs || {}
|
|
513
|
+
for (const depId of deps) {
|
|
514
|
+
const depRole = (resolved.roles || []).find(r => r.id === depId)
|
|
515
|
+
if (!depRole) continue
|
|
516
|
+
if (inputs.from_role === depId) {
|
|
517
|
+
lines.push(`- ${depRole.name}(${depId}):${inputs.output_description || ''}`)
|
|
518
|
+
if (inputs.output) {
|
|
519
|
+
const depOutput = (depRole.outputs || []).find(o => {
|
|
520
|
+
const outputName = o.name || o.path?.split('/').pop()?.replace(/\.md$/, '') || ''
|
|
521
|
+
return outputName === inputs.output
|
|
522
|
+
})
|
|
523
|
+
if (depOutput) {
|
|
524
|
+
lines.push(` 输出文件:${depOutput.path}`)
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
} else {
|
|
528
|
+
lines.push(`- ${depRole.name}(${depId})`)
|
|
529
|
+
for (const o of (depRole.outputs || [])) {
|
|
530
|
+
lines.push(` 输出:${o.path}`)
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// 输入提示
|
|
537
|
+
const inputs = role.inputs || {}
|
|
538
|
+
const inputPaths = inputs.paths || []
|
|
539
|
+
const inputHints = inputs.hints || {}
|
|
540
|
+
if (inputPaths.length > 0) {
|
|
541
|
+
lines.push('')
|
|
542
|
+
lines.push('搜索范围:')
|
|
543
|
+
for (const p of inputPaths) {
|
|
544
|
+
lines.push(`- ${p}`)
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
if (inputHints.grep_patterns && inputHints.grep_patterns.length > 0) {
|
|
548
|
+
lines.push('')
|
|
549
|
+
lines.push('搜索关键词:')
|
|
550
|
+
lines.push(`- ${inputHints.grep_patterns.join(', ')}`)
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// 额外上下文
|
|
554
|
+
if (context.envSummary) {
|
|
555
|
+
lines.push('')
|
|
556
|
+
lines.push('环境探测结果:')
|
|
557
|
+
lines.push(context.envSummary)
|
|
558
|
+
}
|
|
559
|
+
if (context.missingDocs) {
|
|
560
|
+
lines.push('')
|
|
561
|
+
lines.push('缺失文档列表:')
|
|
562
|
+
lines.push(context.missingDocs)
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// 输出目标
|
|
566
|
+
const outputs = role.outputs || []
|
|
567
|
+
lines.push('')
|
|
568
|
+
lines.push('目标文件:')
|
|
569
|
+
for (const o of outputs) {
|
|
570
|
+
lines.push(`- \`${o.path}\``)
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// 约束
|
|
574
|
+
if (role.constraints && role.constraints.length > 0) {
|
|
575
|
+
lines.push('')
|
|
576
|
+
lines.push('约束:')
|
|
577
|
+
for (const c of role.constraints) {
|
|
578
|
+
lines.push(`- ${c}`)
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// 检查要求(告诉子代理需要满足什么)
|
|
583
|
+
for (const o of outputs) {
|
|
584
|
+
const checks = o.checks || []
|
|
585
|
+
for (const check of checks) {
|
|
586
|
+
if (check.type === 'contains_sections' && check.sections) {
|
|
587
|
+
lines.push('')
|
|
588
|
+
lines.push(`必须包含章节:${check.sections.map(s => `"## ${s}"`).join(', ')}`)
|
|
589
|
+
}
|
|
590
|
+
if (check.type === 'min_lines') {
|
|
591
|
+
lines.push(`文件长度要求:至少 ${check.min} 行`)
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
lines.push('')
|
|
597
|
+
lines.push('⚠️ 必须用 write 工具将文件写入磁盘!写完后用 read 工具确认文件存在!')
|
|
598
|
+
|
|
599
|
+
return lines.join('\n')
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* 为 workflow 的所有角色生成 role prompts
|
|
604
|
+
* @param {object} wf - workflow 定义
|
|
605
|
+
* @param {string} projectName - 项目名
|
|
606
|
+
* @param {object} context - 额外上下文
|
|
607
|
+
* @returns {Array<{ roleId: string, roleName: string, prompt: string }>}
|
|
608
|
+
*/
|
|
609
|
+
export function generateAllRolePrompts(wf, projectName, context = {}) {
|
|
610
|
+
const resolved = replaceProjectPlaceholder(wf, projectName)
|
|
611
|
+
const roles = resolved.roles || []
|
|
612
|
+
return roles.map(role => ({
|
|
613
|
+
roleId: role.id,
|
|
614
|
+
roleName: role.name || role.id,
|
|
615
|
+
prompt: generateRolePrompt(resolved, role.id, projectName, context)
|
|
616
|
+
}))
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// ─── Workflow Run 归档 ───
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* 将 workflow check 结果归档到 .sillyspec/.runtime/workflow-runs/
|
|
624
|
+
* @param {object} result - runPostCheck 返回的结构化结果
|
|
625
|
+
* @param {object} options
|
|
626
|
+
* @param {string} options.cwd - 项目根目录
|
|
627
|
+
* @param {string} [options.source] - 调用来源('run.js' / 'cli')
|
|
628
|
+
* @param {string} [options.stage] - 阶段名(scan/archive)
|
|
629
|
+
* @param {string} [options.step] - 步骤名
|
|
630
|
+
* @returns {string|null} 保存路径,失败返回 null
|
|
631
|
+
*/
|
|
632
|
+
export function saveWorkflowRun(result, options = {}) {
|
|
633
|
+
const { cwd = '.', source = 'unknown', stage, step } = options
|
|
634
|
+
const runDir = join(cwd, '.sillyspec', '.runtime', 'workflow-runs')
|
|
635
|
+
try {
|
|
636
|
+
mkdirSync(runDir, { recursive: true })
|
|
637
|
+
} catch (e) {
|
|
638
|
+
console.warn('⚠️ 无法创建 workflow-runs 目录:', e.message)
|
|
639
|
+
return null
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
const now = new Date()
|
|
643
|
+
const ts = now.toISOString().replace(/[-:T]/g, '').slice(0, 14)
|
|
644
|
+
const filename = `${ts}-${result.workflow || 'unknown'}-${result.project || 'default'}-${result.status}.json`
|
|
645
|
+
const filepath = join(runDir, filename)
|
|
646
|
+
|
|
647
|
+
const record = {
|
|
648
|
+
run_id: filename.replace('.json', ''),
|
|
649
|
+
created_at: now.toISOString(),
|
|
650
|
+
source,
|
|
651
|
+
...(stage ? { stage } : {}),
|
|
652
|
+
...(step ? { step } : {}),
|
|
653
|
+
workflow: result.workflow,
|
|
654
|
+
project: result.project,
|
|
655
|
+
status: result.status,
|
|
656
|
+
spec_version: result.spec_version,
|
|
657
|
+
roles: result.roles,
|
|
658
|
+
workflow_checks: result.workflow_checks,
|
|
659
|
+
failures: result.failures,
|
|
660
|
+
retry_prompts: result.retry_prompts
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
try {
|
|
664
|
+
writeFileSync(filepath, JSON.stringify(record, null, 2), 'utf8')
|
|
665
|
+
return filepath
|
|
666
|
+
} catch (e) {
|
|
667
|
+
console.warn('⚠️ 保存 workflow run 失败:', e.message)
|
|
668
|
+
return null
|
|
669
|
+
}
|
|
670
|
+
}
|