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.
@@ -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
+ }