sillyspec 3.13.0 → 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 +134 -0
- package/src/run.js +159 -0
- package/src/stages/archive.js +12 -34
- package/src/stages/scan.js +80 -86
- package/src/workflow.js +670 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sillyspec",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.14.0",
|
|
4
4
|
"description": "SillySpec CLI — 流程状态机,让 AI 严格按步骤来",
|
|
5
5
|
"icon": "logo.jpg",
|
|
6
6
|
"homepage": "https://sillyspec.ppdmq.top/",
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
"@inquirer/prompts": "^7.10.1",
|
|
28
28
|
"chalk": "^5.6.2",
|
|
29
29
|
"chokidar": "^4.0",
|
|
30
|
+
"js-yaml": "^4.2.0",
|
|
30
31
|
"open": "^10.1",
|
|
31
32
|
"ora": "^9.3.0",
|
|
32
33
|
"sql.js": "^1.14.1",
|
package/src/index.js
CHANGED
|
@@ -94,6 +94,7 @@ async function main() {
|
|
|
94
94
|
|
|
95
95
|
// 解析全局选项
|
|
96
96
|
let json = false;
|
|
97
|
+
let saveWorkflowRunFlag = false;
|
|
97
98
|
let targetDir = process.cwd();
|
|
98
99
|
let tool = null;
|
|
99
100
|
let interactive = false;
|
|
@@ -102,6 +103,8 @@ async function main() {
|
|
|
102
103
|
for (let i = 0; i < args.length; i++) {
|
|
103
104
|
if (args[i] === '--json') {
|
|
104
105
|
json = true;
|
|
106
|
+
} else if (args[i] === '--save') {
|
|
107
|
+
saveWorkflowRunFlag = true;
|
|
105
108
|
} else if (args[i] === '--dir' && args[i + 1]) {
|
|
106
109
|
targetDir = resolve(args[i + 1]);
|
|
107
110
|
i++;
|
|
@@ -485,6 +488,137 @@ SillySpec platform — SillyHub 平台同步
|
|
|
485
488
|
await pm.renameChange(dir, oldName, newName);
|
|
486
489
|
break;
|
|
487
490
|
}
|
|
491
|
+
case 'workflow': {
|
|
492
|
+
const wfSub = filteredArgs[1];
|
|
493
|
+
if (!wfSub || wfSub === 'help' || wfSub === '--help') {
|
|
494
|
+
console.log(`
|
|
495
|
+
SillySpec workflow — 工作流管理
|
|
496
|
+
|
|
497
|
+
用法:
|
|
498
|
+
sillyspec workflow check <name> [--project <project>] [--json]
|
|
499
|
+
sillyspec workflow list
|
|
500
|
+
`);
|
|
501
|
+
break;
|
|
502
|
+
}
|
|
503
|
+
if (wfSub === 'list') {
|
|
504
|
+
const { listWorkflows } = await import('./workflow.js');
|
|
505
|
+
const names = listWorkflows(dir);
|
|
506
|
+
if (names.length === 0) {
|
|
507
|
+
console.log('未找到 workflow 定义(.sillyspec/workflows/*.yaml)');
|
|
508
|
+
} else {
|
|
509
|
+
console.log(`可用 workflow:`);
|
|
510
|
+
for (const name of names) {
|
|
511
|
+
const { loadWorkflow } = await import('./workflow.js');
|
|
512
|
+
const wf = loadWorkflow(dir, name);
|
|
513
|
+
const specVer = wf?.spec_version || wf?.version || '?';
|
|
514
|
+
const mode = wf?.orchestration?.mode || '?';
|
|
515
|
+
const roles = wf?.roles?.length || 0;
|
|
516
|
+
console.log(` ${name} (spec v${specVer}, ${mode}, ${roles} roles)`);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
break;
|
|
520
|
+
}
|
|
521
|
+
if (wfSub === 'check') {
|
|
522
|
+
const { loadWorkflow, runPostCheck, listWorkflows, saveWorkflowRun } = await import('./workflow.js');
|
|
523
|
+
const wfName = filteredArgs[2];
|
|
524
|
+
if (!wfName) {
|
|
525
|
+
console.error('❌ 请指定 workflow 名称,例如:sillyspec workflow check scan-docs --project sillyspec');
|
|
526
|
+
process.exit(2);
|
|
527
|
+
}
|
|
528
|
+
const wf = loadWorkflow(dir, wfName);
|
|
529
|
+
if (!wf) {
|
|
530
|
+
console.error(`❌ 未找到 workflow: ${wfName}`);
|
|
531
|
+
console.error(`可用 workflow:${listWorkflows(dir).join(', ') || '无'}`);
|
|
532
|
+
process.exit(2);
|
|
533
|
+
}
|
|
534
|
+
// depends_on 校验
|
|
535
|
+
if (wf._validationErrors && wf._validationErrors.length > 0) {
|
|
536
|
+
console.error('❌ workflow YAML 校验失败:');
|
|
537
|
+
for (const err of wf._validationErrors) {
|
|
538
|
+
console.error(` ${err}`);
|
|
539
|
+
}
|
|
540
|
+
process.exit(2);
|
|
541
|
+
}
|
|
542
|
+
// spec_version 校验
|
|
543
|
+
const specVer = wf.spec_version || wf.version;
|
|
544
|
+
if (!specVer) {
|
|
545
|
+
console.error('❌ workflow YAML 缺少 spec_version 字段');
|
|
546
|
+
process.exit(2);
|
|
547
|
+
}
|
|
548
|
+
const SUPPORTED_SPECS = [1];
|
|
549
|
+
if (!SUPPORTED_SPECS.includes(specVer)) {
|
|
550
|
+
console.error(`❌ 不支持的 spec_version: ${specVer}(支持: ${SUPPORTED_SPECS.join(', ')})`);
|
|
551
|
+
process.exit(2);
|
|
552
|
+
}
|
|
553
|
+
// 解析 --project
|
|
554
|
+
const projectIdx = filteredArgs.indexOf('--project');
|
|
555
|
+
const project = projectIdx !== -1 && filteredArgs[projectIdx + 1] ? filteredArgs[projectIdx + 1] : null;
|
|
556
|
+
// 解析 --json(已在顶层解析)
|
|
557
|
+
const isJson = json;
|
|
558
|
+
// 解析 --change
|
|
559
|
+
const changeIdx = filteredArgs.indexOf('--change');
|
|
560
|
+
const changeName = changeIdx !== -1 && filteredArgs[changeIdx + 1] ? filteredArgs[changeIdx + 1] : null;
|
|
561
|
+
|
|
562
|
+
if (!project && wfName !== 'archive-impact') {
|
|
563
|
+
console.error('❌ 请指定 --project,例如:--project sillyspec');
|
|
564
|
+
process.exit(2);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// 执行检查
|
|
568
|
+
let resolvedWf = wf;
|
|
569
|
+
const placeholders = {};
|
|
570
|
+
if (changeName) placeholders['change-name'] = changeName;
|
|
571
|
+
// 替换占位符
|
|
572
|
+
let jsonStr = JSON.stringify(resolvedWf);
|
|
573
|
+
if (changeName) jsonStr = jsonStr.replace(/<change-name>/g, changeName);
|
|
574
|
+
resolvedWf = JSON.parse(jsonStr);
|
|
575
|
+
|
|
576
|
+
const projectName = project || 'sillyspec';
|
|
577
|
+
const result = runPostCheck(resolvedWf, dir, projectName, placeholders);
|
|
578
|
+
|
|
579
|
+
if (isJson) {
|
|
580
|
+
console.log(JSON.stringify(result, null, 2));
|
|
581
|
+
} else {
|
|
582
|
+
// 带项目维度前缀的输出(从统一结果对象格式化)
|
|
583
|
+
const lines = [`\n📋 Workflow Post-Check: ${result.workflow} (project: ${result.project})\n`];
|
|
584
|
+
for (const r of (result.roles || [])) {
|
|
585
|
+
const icon = r.status === 'pass' ? '✅' : '❌';
|
|
586
|
+
lines.push(`${icon} [${result.project}] ${r.name} (${r.id})`);
|
|
587
|
+
const roleFailures = (result.failures || []).filter(f => f.role_id === r.id);
|
|
588
|
+
for (const f of roleFailures) {
|
|
589
|
+
lines.push(` └─ ${f.message}`);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
const wfFailures = (result.workflow_checks || []).filter(c => c.status === 'fail');
|
|
593
|
+
if (wfFailures.length > 0) {
|
|
594
|
+
lines.push('');
|
|
595
|
+
for (const f of wfFailures) {
|
|
596
|
+
lines.push(`❌ [${result.project}] 全局: ${f.detail}`);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
lines.push('');
|
|
600
|
+
if (result.status === 'pass') {
|
|
601
|
+
lines.push('✅ 全部检查通过');
|
|
602
|
+
} else {
|
|
603
|
+
lines.push('❌ 存在失败项');
|
|
604
|
+
}
|
|
605
|
+
console.log(lines.join('\n'));
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// exit code: 0=通过, 1=检查失败, 2=参数/YAML错误
|
|
609
|
+
if (saveWorkflowRunFlag) {
|
|
610
|
+
const saved = saveWorkflowRun(result, { cwd: dir, source: 'cli' });
|
|
611
|
+
if (saved) {
|
|
612
|
+
if (!isJson) console.log(`\n📁 结果已归档:${saved}`);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
process.exit(result.status === 'pass' ? 0 : 1);
|
|
616
|
+
} else {
|
|
617
|
+
console.error(`❌ 未知子命令: workflow ${wfSub}`);
|
|
618
|
+
process.exit(1);
|
|
619
|
+
}
|
|
620
|
+
break;
|
|
621
|
+
}
|
|
488
622
|
case 'modules': {
|
|
489
623
|
const modulesSub = filteredArgs[1];
|
|
490
624
|
if (!modulesSub || modulesSub === 'help' || modulesSub === '--help') {
|
package/src/run.js
CHANGED
|
@@ -584,6 +584,88 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
|
|
|
584
584
|
}
|
|
585
585
|
}
|
|
586
586
|
|
|
587
|
+
// scan 阶段 step 2 "构建扫描项目列表" 完成后,按项目展开 perProject 步骤
|
|
588
|
+
if (stageName === 'scan' && steps[currentIdx]?.name === '构建扫描项目列表') {
|
|
589
|
+
// 解析项目列表:从 step 2 输出提取,或回退读取 projects/*.yaml
|
|
590
|
+
let projectNames = []
|
|
591
|
+
if (outputText) {
|
|
592
|
+
// 匹配 "1. project-name" 格式
|
|
593
|
+
const matches = outputText.match(/^\s*\d+\.\s+(\S+)/gm)
|
|
594
|
+
if (matches) {
|
|
595
|
+
projectNames = matches.map(m => m.replace(/^\s*\d+\.\s+/, '').replace(/[—\-:].*$/, '').trim())
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
if (projectNames.length === 0) {
|
|
599
|
+
// 回退:读取所有已注册项目
|
|
600
|
+
console.warn('⚠️ 未能从 step 2 输出解析项目列表,回退扫描所有注册项目')
|
|
601
|
+
const projectsDir = join(cwd, '.sillyspec', 'projects')
|
|
602
|
+
if (existsSync(projectsDir)) {
|
|
603
|
+
projectNames = readdirSync(projectsDir)
|
|
604
|
+
.filter(f => f.endsWith('.yaml'))
|
|
605
|
+
.map(f => f.replace(/\.yaml$/, ''))
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
if (projectNames.length === 0) {
|
|
609
|
+
projectNames = ['sillyspec'] // 最终兜底
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// 保存到 runtime 供后续使用 + 防重复展开
|
|
613
|
+
const scanStatePath = join(cwd, '.sillyspec', '.runtime', 'scan-projects.json')
|
|
614
|
+
mkdirSync(join(cwd, '.sillyspec', '.runtime'), { recursive: true })
|
|
615
|
+
let scanState = { projects: projectNames, expanded: false }
|
|
616
|
+
if (existsSync(scanStatePath)) {
|
|
617
|
+
try { scanState = JSON.parse(readFileSync(scanStatePath, 'utf8')) } catch {}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// 收集当前步骤之后所有 perProject 步骤
|
|
621
|
+
const stageDef = stageRegistry[stageName]
|
|
622
|
+
const allSteps = stageDef?.steps || []
|
|
623
|
+
const perProjectSteps = allSteps.filter(s => s.perProject)
|
|
624
|
+
|
|
625
|
+
// 防重复展开:runtime 标记 或 steps 已含项目标识
|
|
626
|
+
const alreadyExpanded = scanState.expanded || steps.some(s => s.name?.match(/\[.+\]\s*$/))
|
|
627
|
+
if (!alreadyExpanded && perProjectSteps.length > 0) {
|
|
628
|
+
// 找到当前步骤(step 2)在动态 steps 中的位置
|
|
629
|
+
const insertBase = currentIdx + 1
|
|
630
|
+
let insertPos = insertBase
|
|
631
|
+
for (const pName of projectNames) {
|
|
632
|
+
// 读取项目配置获取 projectRoot
|
|
633
|
+
const projYaml = join(cwd, '.sillyspec', 'projects', `${pName}.yaml`)
|
|
634
|
+
let projectRoot = '.'
|
|
635
|
+
if (existsSync(projYaml)) {
|
|
636
|
+
const yamlContent = readFileSync(projYaml, 'utf8')
|
|
637
|
+
const pathMatch = yamlContent.match(/^path:\s*(.+)/m)
|
|
638
|
+
if (pathMatch) projectRoot = pathMatch[1].trim()
|
|
639
|
+
}
|
|
640
|
+
const docOutputDir = `.sillyspec/docs/${pName}`
|
|
641
|
+
const contextPrefix = `\n---\n## 当前项目\n- **项目名**: ${pName}\n- **项目路径**: ${projectRoot}\n- **文档输出**: ${docOutputDir}\n\n⚠️ 本步骤只处理上面这个项目,不要处理其他项目。\n---\n\n`
|
|
642
|
+
|
|
643
|
+
for (const ppStep of perProjectSteps) {
|
|
644
|
+
steps.splice(insertPos, 0, {
|
|
645
|
+
name: `${ppStep.name} [${pName}]`,
|
|
646
|
+
project: pName,
|
|
647
|
+
status: 'pending',
|
|
648
|
+
prompt: contextPrefix + ppStep.prompt,
|
|
649
|
+
outputHint: ppStep.outputHint,
|
|
650
|
+
optional: ppStep.optional
|
|
651
|
+
})
|
|
652
|
+
insertPos++
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
// 移除原始的 perProject 步骤(未展开的版本)
|
|
656
|
+
for (let i = steps.length - 1; i >= 0; i--) {
|
|
657
|
+
if (steps[i].perProject && !steps[i].name?.includes('[')) {
|
|
658
|
+
steps.splice(i, 1)
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
console.log(` 📝 已按项目展开 ${perProjectSteps.length} 个步骤 × ${projectNames.length} 个项目 = ${perProjectSteps.length * projectNames.length} 个项目步骤`)
|
|
662
|
+
console.log(` 📁 扫描项目:${projectNames.join(', ')}`)
|
|
663
|
+
// 标记已展开,防止 resume 重复插入
|
|
664
|
+
scanState.expanded = true
|
|
665
|
+
writeFileSync(scanStatePath, JSON.stringify(scanState))
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
587
669
|
const nextPendingIdx = steps.findIndex(s => s.status === 'pending')
|
|
588
670
|
|
|
589
671
|
if (nextPendingIdx === -1) {
|
|
@@ -687,6 +769,83 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
|
|
|
687
769
|
|
|
688
770
|
const defSteps = await getStageSteps(stageName, cwd, progress)
|
|
689
771
|
console.log(`✅ Step ${currentIdx + 1}/${steps.length} 完成:${steps[currentIdx].name}\n`)
|
|
772
|
+
|
|
773
|
+
// Workflow post_check:scan 深度扫描完成后自动检查产物
|
|
774
|
+
if (stageName === 'scan' && steps[currentIdx]?.name?.includes('深度扫描')) {
|
|
775
|
+
try {
|
|
776
|
+
const { loadWorkflow, runPostCheck, formatCheckReport, saveWorkflowRun } = await import('./workflow.js')
|
|
777
|
+
const wf = loadWorkflow(cwd, 'scan-docs')
|
|
778
|
+
if (wf) {
|
|
779
|
+
// 确定当前项目:优先从 step metadata 读取,回退从 display name 提取
|
|
780
|
+
const currentProjectName = steps[currentIdx].project
|
|
781
|
+
|| (steps[currentIdx].name.match(/\[([^\]]+)\]\s*$/) || [])[1]
|
|
782
|
+
|| null
|
|
783
|
+
|
|
784
|
+
// 确定要检查的项目列表
|
|
785
|
+
let projectsToCheck = []
|
|
786
|
+
if (currentProjectName) {
|
|
787
|
+
// 按项目展开模式:只检查当前项目
|
|
788
|
+
projectsToCheck = [currentProjectName]
|
|
789
|
+
} else {
|
|
790
|
+
// 兼容旧模式(未展开):检查所有项目
|
|
791
|
+
const projectsDir = join(cwd, '.sillyspec', 'projects')
|
|
792
|
+
const projectFiles = existsSync(projectsDir)
|
|
793
|
+
? readdirSync(projectsDir).filter(f => f.endsWith('.yaml'))
|
|
794
|
+
: []
|
|
795
|
+
projectsToCheck = projectFiles.map(f => f.replace(/\.yaml$/, ''))
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
let anyFailed = false
|
|
799
|
+
for (const pName of projectsToCheck) {
|
|
800
|
+
const result = runPostCheck(wf, cwd, pName)
|
|
801
|
+
const report = formatCheckReport(result)
|
|
802
|
+
console.log(report)
|
|
803
|
+
if (result.status === 'fail') {
|
|
804
|
+
anyFailed = true
|
|
805
|
+
// retry_prompts 由 _checkWorkflow 自动生成
|
|
806
|
+
for (const rp of (result.retry_prompts || [])) {
|
|
807
|
+
console.log(`\n🔄 重试提示(项目 ${pName}):\n`)
|
|
808
|
+
console.log(rp.prompt)
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
const saved = saveWorkflowRun(result, { cwd, source: 'run.js', stage: 'verify', step: steps[currentIdx]?.name })
|
|
812
|
+
if (saved) console.log(`📁 结果已归档:${saved}`)
|
|
813
|
+
}
|
|
814
|
+
if (anyFailed) {
|
|
815
|
+
console.log(`\n⚠️ 存在检查失败项,请按上面的重试提示修复后再继续。`)
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
} catch (e) {
|
|
819
|
+
console.warn(`⚠️ workflow 检查跳过:${e.message}`)
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// Workflow post_check:archive extract-module-impact 完成后检查产物
|
|
824
|
+
if (stageName === 'archive' && steps[currentIdx]?.name?.includes('extract-module-impact')) {
|
|
825
|
+
try {
|
|
826
|
+
const { loadWorkflow, runPostCheck, formatCheckReport, saveWorkflowRun } = await import('./workflow.js')
|
|
827
|
+
const wf = loadWorkflow(cwd, 'archive-impact')
|
|
828
|
+
if (wf && changeName) {
|
|
829
|
+
const raw = JSON.stringify(wf)
|
|
830
|
+
const resolved = JSON.parse(raw.replace(/<change-name>/g, changeName))
|
|
831
|
+
const result = runPostCheck(resolved, cwd, 'sillyspec')
|
|
832
|
+
// 只报告 impact-analyzer 的结果(doc-syncer 是后续步骤)
|
|
833
|
+
const impactResult = (result.roles || []).find(r => r.id === 'impact-analyzer')
|
|
834
|
+
if (impactResult) {
|
|
835
|
+
const icon = impactResult.status === 'pass' ? '✅' : '❌'
|
|
836
|
+
console.log(`${icon} module-impact.md 检查${impactResult.status === 'pass' ? '通过' : '失败'}`)
|
|
837
|
+
for (const f of (result.failures || []).filter(f => f.role_id === 'impact-analyzer')) {
|
|
838
|
+
console.log(` └─ ${f}`)
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
const saved = saveWorkflowRun(result, { cwd, source: 'run.js', stage: 'archive', step: steps[currentIdx]?.name })
|
|
842
|
+
if (saved) console.log(`📁 结果已归档:${saved}`)
|
|
843
|
+
}
|
|
844
|
+
} catch (e) {
|
|
845
|
+
console.warn(`⚠️ workflow 检查跳过:${e.message}`)
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
690
849
|
if (printNext) {
|
|
691
850
|
await outputStep(stageName, nextPendingIdx, defSteps, cwd, changeName, progress.project || null)
|
|
692
851
|
}
|
package/src/stages/archive.js
CHANGED
|
@@ -20,20 +20,21 @@ export const definition = {
|
|
|
20
20
|
},
|
|
21
21
|
{
|
|
22
22
|
name: 'extract-module-impact',
|
|
23
|
-
prompt:
|
|
23
|
+
prompt: `按照 \`.sillyspec/workflows/archive-impact.yaml\` 中定义的 \`impact-analyzer\` 角色规则,分析本次变更影响的模块。
|
|
24
24
|
|
|
25
25
|
### 操作
|
|
26
|
-
1.
|
|
27
|
-
2.
|
|
28
|
-
3.
|
|
26
|
+
1. 读取 \`.sillyspec/workflows/archive-impact.yaml\`,了解角色定义和检查规则
|
|
27
|
+
2. 读取变更目录下的 proposal.md、design.md、tasks.md
|
|
28
|
+
3. 运行 \`git diff --name-only HEAD~1\`(或 \`git diff --name-only --cached\`)获取真实修改文件列表
|
|
29
|
+
4. 读取 \`.sillyspec/docs/<project>/modules/_module-map.yaml\`
|
|
29
30
|
- **如果不存在**:提示"建议运行 scan 生成模块映射",但继续执行。跳到步骤 7 生成只有 unmapped 部分的 module-impact.md
|
|
30
|
-
|
|
31
|
+
5. 三重交叉验证:
|
|
31
32
|
- 声明范围:proposal.md / design.md 中的"变更范围"/"文件变更清单"
|
|
32
33
|
- 任务范围:tasks.md / plan.md 中的任务文件路径
|
|
33
34
|
- 真实变更:git diff 文件列表
|
|
34
35
|
- **以 git diff 为准**(真实 > 声明)
|
|
35
|
-
|
|
36
|
-
|
|
36
|
+
6. 将 git diff 文件按 \`_module-map.yaml\` 的 paths glob 匹配到模块
|
|
37
|
+
7. 生成模块影响矩阵:
|
|
37
38
|
|
|
38
39
|
| 模块 | 影响类型 | 相关文件 | 更新内容摘要 | needs_review |
|
|
39
40
|
|------|----------|----------|-------------|-------------|
|
|
@@ -41,33 +42,10 @@ export const definition = {
|
|
|
41
42
|
影响类型:逻辑变更 / 数据结构变更 / 接口变更 / 调用关系变更 / 配置变更 / 新增
|
|
42
43
|
needs_review:如果影响无法完全确定,标记为 true
|
|
43
44
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
# 模块影响分析
|
|
49
|
-
|
|
50
|
-
author: <git-user>
|
|
51
|
-
created_at: <now-datetime>
|
|
52
|
-
|
|
53
|
-
## 变更:<change-name>
|
|
54
|
-
|
|
55
|
-
## 模块影响矩阵
|
|
56
|
-
| 模块 | 影响类型 | 相关文件 | 更新内容摘要 | needs_review |
|
|
57
|
-
|------|----------|----------|-------------|-------------|
|
|
58
|
-
|
|
59
|
-
## 未匹配文件
|
|
60
|
-
| 文件路径 | 说明 |
|
|
61
|
-
|----------|------|
|
|
62
|
-
|
|
63
|
-
## 更新结果
|
|
64
|
-
(sync-module-docs 步骤完成后回填)
|
|
65
|
-
| 目标 | 操作 | 状态 |
|
|
66
|
-
|------|------|------|
|
|
67
|
-
\`\`\`
|
|
68
|
-
|
|
69
|
-
### 输出
|
|
70
|
-
module-impact.md 路径 + 影响模块数量 + 未匹配文件数量`,
|
|
45
|
+
8. 未匹配到任何模块的文件归入"未匹配文件"表格
|
|
46
|
+
9. 生成 \`.sillyspec/changes/<change-name>/module-impact.md\`
|
|
47
|
+
10. 完成后运行 workflow 检查:
|
|
48
|
+
\`node -e "import('./src/workflow.js').then(w => { /* 用 loadWorkflow 加载 archive-impact,用 runPostCheck 检查 */ })\``,
|
|
71
49
|
outputHint: 'module-impact.md 路径 + 影响摘要',
|
|
72
50
|
optional: false
|
|
73
51
|
},
|
package/src/stages/scan.js
CHANGED
|
@@ -37,117 +37,104 @@ export const definition = {
|
|
|
37
37
|
optional: false
|
|
38
38
|
},
|
|
39
39
|
{
|
|
40
|
-
name: '
|
|
41
|
-
prompt:
|
|
40
|
+
name: '构建扫描项目列表',
|
|
41
|
+
prompt: `确定本次要扫描的项目列表。
|
|
42
42
|
|
|
43
43
|
### 操作
|
|
44
|
-
1. \`ls .sillyspec/projects/*.yaml 2>/dev/null
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
44
|
+
1. \`ls .sillyspec/projects/*.yaml 2>/dev/null\` — 列出所有已注册项目
|
|
45
|
+
2. 对每个项目,检查已有的 scan 文档状态:\`ls .sillyspec/docs/<project>/scan/*.md 2>/dev/null\`
|
|
46
|
+
3. 按以下格式展示:
|
|
47
|
+
|
|
48
|
+
\`\`\`
|
|
49
|
+
扫描项目列表:
|
|
50
|
+
1. sillyspec(主项目)— scan 文档:0/7 已存在
|
|
51
|
+
2. dashboard(子项目)— scan 文档:0/7 已存在
|
|
52
|
+
\`\`\`
|
|
53
|
+
|
|
54
|
+
4. **必须停下来问用户**:
|
|
55
|
+
- 选择要扫描的项目(默认全部)
|
|
56
|
+
- 每个项目的扫描策略:**重新扫描(全部覆盖)** / **只补扫描缺失的文档** / **跳过**
|
|
57
|
+
|
|
58
|
+
### ⛔ 重要
|
|
53
59
|
- **不要自行决定跳过**,等用户选择后再继续
|
|
60
|
+
- 最终确定的项目列表将用于后续所有步骤
|
|
61
|
+
- **后续每个需要生成文档的步骤,都必须对列表中的每个项目分别执行**
|
|
54
62
|
|
|
55
63
|
### 输出
|
|
56
|
-
|
|
57
|
-
outputHint: '
|
|
64
|
+
确认后的扫描项目列表(项目名 + 扫描策略)`,
|
|
65
|
+
outputHint: '扫描项目列表',
|
|
58
66
|
optional: false
|
|
59
67
|
},
|
|
60
68
|
{
|
|
61
69
|
name: '构建环境探测',
|
|
62
|
-
|
|
70
|
+
perProject: true,
|
|
71
|
+
prompt: `探测当前项目的构建环境和依赖。
|
|
63
72
|
|
|
64
73
|
### 操作
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
74
|
+
对扫描列表中的每个项目重复以下操作:
|
|
75
|
+
1. 进入项目目录(子项目用其 path,如 \`packages/dashboard/\`)
|
|
76
|
+
2. \`cat package.json pom.xml build.gradle go.mod Cargo.toml requirements.txt pyproject.toml Gemfile composer.json 2>/dev/null\`
|
|
77
|
+
3. \`find <project-dir> -maxdepth 2 -name "*.config.*" -not -path "*/node_modules/*" -not -path "*/.git/*" | head -20 | xargs cat 2>/dev/null\`
|
|
78
|
+
4. 结果保存到 \`.sillyspec/docs/<project>/scan/_env-detect.md\`(临时文件,扫描完删除)
|
|
68
79
|
|
|
69
80
|
### 输出
|
|
70
|
-
|
|
81
|
+
每个项目的环境探测结果摘要`,
|
|
71
82
|
outputHint: '环境探测摘要',
|
|
72
83
|
optional: false
|
|
73
84
|
},
|
|
74
85
|
{
|
|
75
86
|
name: '断点续扫检测',
|
|
76
|
-
|
|
87
|
+
perProject: true,
|
|
88
|
+
prompt: `检测当前项目已有扫描文档,列出缺失的。
|
|
77
89
|
|
|
78
90
|
### 操作
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
91
|
+
对扫描列表中的每个项目分别执行:
|
|
92
|
+
1. 检查 7 份文档是否存在:ARCHITECTURE、STRUCTURE、CONVENTIONS、INTEGRATIONS、TESTING、CONCERNS、PROJECT
|
|
93
|
+
路径:\`.sillyspec/docs/<project>/scan/<DOC>.md\`
|
|
94
|
+
2. 列出已有 ✅ 和缺失 ⬜
|
|
83
95
|
|
|
84
96
|
### 输出
|
|
85
|
-
|
|
97
|
+
每个项目的已有/缺失文档列表`,
|
|
86
98
|
outputHint: '断点续扫状态',
|
|
87
99
|
optional: false
|
|
88
100
|
},
|
|
89
101
|
{
|
|
90
102
|
name: '深度扫描 — 7 份文档(子代理并行)',
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
-
|
|
114
|
-
-
|
|
115
|
-
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
搜索范围:目录树 + 模块说明 + 外部集成
|
|
120
|
-
- 用 find/ls/tree 和 grep,**禁止读源码全文**
|
|
121
|
-
- 搜索 API 调用、MQ 配置、缓存、第三方 SDK
|
|
122
|
-
- STRUCTURE.md:目录树+模块说明
|
|
123
|
-
- INTEGRATIONS.md:外部集成(按类型分组)
|
|
124
|
-
- 参考 _env-detect.md(如存在)
|
|
125
|
-
|
|
126
|
-
**子代理 D — 测试 + 债务 + 项目概览**
|
|
127
|
-
目标文件:\`.sillyspec/docs/<project>/scan/TESTING.md\` + \`.sillyspec/docs/<project>/scan/CONCERNS.md\` + \`.sillyspec/docs/<project>/scan/PROJECT.md\`
|
|
128
|
-
搜索范围:测试文件 + TODO/FIXME + 过时依赖 + 项目信息
|
|
129
|
-
- 用 grep 搜索测试文件、TODO/FIXME、过时依赖,**禁止读源码全文**
|
|
130
|
-
- TESTING.md:测试结构
|
|
131
|
-
- CONCERNS.md:技术债务(按严重程度分组)
|
|
132
|
-
- PROJECT.md:项目概览
|
|
133
|
-
- 参考 _env-detect.md(如存在)
|
|
134
|
-
|
|
135
|
-
### 每个子代理的共同要求
|
|
136
|
-
- **上下文注入**:主 agent 在启动子代理前,必须将以下信息拼入子代理 prompt:
|
|
137
|
-
- 项目名(直接用本次 prompt 中的实际项目名)
|
|
138
|
-
- 断点续扫步骤列出的缺失文档列表(哪些要生成、哪些跳过)
|
|
139
|
-
- 环境探测结果摘要(构建工具、语言框架、关键依赖)
|
|
140
|
-
- _env-detect.md 内容(如存在,直接贴入)
|
|
141
|
-
- 路径用反引号,不编造
|
|
142
|
-
- 目标文件不存在则创建,已存在则覆盖
|
|
143
|
-
- 只生成缺失文档(根据断点续扫结果)
|
|
103
|
+
perProject: true,
|
|
104
|
+
prompt: `按照 \`.sillyspec/workflows/scan-docs.yaml\` 中定义的角色和检查规则,使用子代理并行生成当前项目的 7 份扫描文档。
|
|
105
|
+
|
|
106
|
+
**你必须使用子代理执行,不要自己写文档。**
|
|
107
|
+
**对扫描列表中的每个项目分别执行以下流程。**
|
|
108
|
+
|
|
109
|
+
### 操作
|
|
110
|
+
1. 读取 \`.sillyspec/workflows/scan-docs.yaml\`,了解角色定义、输出要求和检查规则
|
|
111
|
+
2. 对每个项目(扫描列表中标记为需生成/覆盖的项目):
|
|
112
|
+
a. 将 \`<project>\` 替换为实际项目名,得到该项目的目标文件路径
|
|
113
|
+
b. 为每个角色启动独立子代理(可并行),每个子代理负责 1-2 份文档
|
|
114
|
+
c. 子代理的搜索范围限定在该项目目录内(子项目如 \`packages/dashboard/\`,不要搜索主项目源码)
|
|
115
|
+
d. 子代理直接用 grep/rg 搜索源码并写入文件,结果不回传到你的上下文
|
|
116
|
+
e. 等待该项目所有子代理完成后,验证文件是否生成且非空
|
|
117
|
+
f. 该项目完成后,继续下一个项目
|
|
118
|
+
3. 所有项目完成后,运行以下命令检查产物:
|
|
119
|
+
\`node -e "import('./src/workflow.js').then(w => { const r = w.runPostCheck(w.loadWorkflow('.', 'scan-docs'), '.', '<project>'); console.log(w.formatCheckReport(r)) })\`
|
|
120
|
+
对每个项目分别执行(将 \`<project>\` 替换为实际项目名)
|
|
121
|
+
4. 如果检查报告有失败项,按报告中的角色和文件重试失败的部分
|
|
122
|
+
|
|
123
|
+
### 子代理上下文注入
|
|
124
|
+
启动每个子代理前,将以下信息拼入子代理 prompt:
|
|
125
|
+
- 项目名(直接用实际项目名)
|
|
126
|
+
- 目标文件路径(从 workflow YAML 中 \`<project>\` 替换后的路径)
|
|
127
|
+
- 检查要求(从 workflow YAML 中该角色的 checks)
|
|
128
|
+
- 断点续扫步骤列出的缺失文档列表
|
|
129
|
+
- 环境探测结果摘要(如有 _env-detect.md,直接贴入)
|
|
130
|
+
- **⚠️ 必须强调:子代理必须用 write 工具将文件写入磁盘**
|
|
144
131
|
|
|
145
132
|
### 完成后
|
|
146
|
-
|
|
133
|
+
列出每个项目的 7 份文档状态:
|
|
147
134
|
- ✅ ARCHITECTURE.md / ❌ 缺失
|
|
148
135
|
- ✅ CONVENTIONS.md / ❌ 缺失
|
|
149
136
|
- ...`,
|
|
150
|
-
outputHint: '7
|
|
137
|
+
outputHint: '7 份文档生成状态(含 workflow 检查报告)',
|
|
151
138
|
optional: false
|
|
152
139
|
},
|
|
153
140
|
{
|
|
@@ -191,13 +178,15 @@ local.yaml 生成结果(已存在/已生成)`,
|
|
|
191
178
|
},
|
|
192
179
|
{
|
|
193
180
|
name: '生成模块映射',
|
|
194
|
-
|
|
181
|
+
perProject: true,
|
|
182
|
+
prompt: `生成当前项目的模块索引文件 \`_module-map.yaml\`。
|
|
195
183
|
|
|
196
184
|
### ⚠️ 重要:这个文件是唯一的结构化索引源
|
|
197
185
|
所有结构化事实(paths/tags/entrypoints/depends_on/used_by)只维护在这个文件里。
|
|
198
186
|
模块卡片(modules/*.md)只负责人类语义说明,不重复索引信息。
|
|
199
187
|
|
|
200
188
|
### 操作
|
|
189
|
+
对扫描列表中的每个项目分别执行:
|
|
201
190
|
1. 检查 \`.sillyspec/docs/<project>/modules/_module-map.yaml\` 是否已存在,已存在则跳过
|
|
202
191
|
2. 分析项目源码目录结构,识别模块划分:
|
|
203
192
|
- 用 \`find . -maxdepth 3 -type d -not -path "*/node_modules/*" -not -path "*/.git/*"\` 查看目录结构
|
|
@@ -307,13 +296,15 @@ _module-map.yaml 生成结果(已存在/已生成/模块列表)`,
|
|
|
307
296
|
},
|
|
308
297
|
{
|
|
309
298
|
name: '生成模块卡片文档',
|
|
310
|
-
|
|
299
|
+
perProject: true,
|
|
300
|
+
prompt: `根据当前项目的 \`_module-map.yaml\` 生成模块卡片文档。
|
|
311
301
|
|
|
312
302
|
### ⚠️ 重要:模块卡片只负责人类语义说明
|
|
313
303
|
结构化索引(paths/tags/entrypoints/depends_on/used_by)已经在 _module-map.yaml 里维护。
|
|
314
304
|
卡片里不要重复这些信息,只写 _module-map.yaml 无法表达的语义内容。
|
|
315
305
|
|
|
316
306
|
### 操作
|
|
307
|
+
对扫描列表中的每个项目分别执行:
|
|
317
308
|
1. 读取 \`.sillyspec/docs/<project>/modules/_module-map.yaml\`,获取模块列表和路径
|
|
318
309
|
2. 检查 \`.sillyspec/docs/<project>/modules/\` 下已有的模块文档(<module>.md)
|
|
319
310
|
3. 列出每个模块的状态:已有文档 / 缺失
|
|
@@ -383,7 +374,8 @@ module_id: <module-id>
|
|
|
383
374
|
},
|
|
384
375
|
{
|
|
385
376
|
name: '生成业务流程和术语表(可选)',
|
|
386
|
-
|
|
377
|
+
perProject: true,
|
|
378
|
+
prompt: `根据当前项目的模块依赖关系和源码,生成跨模块业务流程文档和术语表。
|
|
387
379
|
|
|
388
380
|
⚠️ 这一步是可选的。如果项目模块简单、流程不明显,可以跳过。
|
|
389
381
|
|
|
@@ -449,18 +441,20 @@ step1 → step2 → step3
|
|
|
449
441
|
},
|
|
450
442
|
{
|
|
451
443
|
name: '自检和提交',
|
|
452
|
-
|
|
444
|
+
perProject: true,
|
|
445
|
+
prompt: `验证当前项目的扫描完整性,清理并提交。
|
|
453
446
|
|
|
454
447
|
### 操作
|
|
455
|
-
|
|
456
|
-
|
|
448
|
+
对扫描列表中的每个项目分别执行:
|
|
449
|
+
1. 检查 7 份 scan 文档是否全部生成(\`.sillyspec/docs/<project>/scan/\`)
|
|
450
|
+
2. 检查模块文档状态(\`.sillyspec/docs/<project>/modules/\`)
|
|
457
451
|
3. 自检门控:ARCHITECTURE(技术栈+Schema摘要)、CONVENTIONS(隐形规则+代码风格)、STRUCTURE(目录结构)、INTEGRATIONS(外部依赖)、TESTING(测试现状)、CONCERNS(技术债务)、PROJECT(项目概览)
|
|
458
452
|
4. 检查 flows/ 和 glossary.md 是否已生成(如有)
|
|
459
453
|
5. 清理:\`rm -f .sillyspec/docs/<project>/scan/_env-detect.md\`
|
|
460
454
|
6. \`git add .sillyspec/\` — 暂存扫描结果(不要 commit,由用户通过统一提交工具处理)
|
|
461
455
|
|
|
462
456
|
### 输出
|
|
463
|
-
|
|
457
|
+
每个项目的扫描完整性报告
|
|
464
458
|
|
|
465
459
|
### 注意
|
|
466
460
|
- ❌ 修改代码 / 编造路径 / 读源码全文`,
|
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
|
+
}
|