sillyspec 3.15.2 → 3.16.1
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/.husky/pre-push +13 -0
- package/README.md +1 -1
- package/docs/sillyspec/file-lifecycle/known-implementation-gaps.md +99 -0
- package/docs/sillyspec/file-lifecycle/platform-workflows-sync.md +218 -0
- package/docs/sillyspec/file-lifecycle/stage-artifacts.md +167 -0
- package/docs/sillyspec/file-lifecycle/storage-and-state.md +148 -0
- package/docs/sillyspec/file-lifecycle/worktree-and-guard.md +193 -0
- package/docs/sillyspec/file-lifecycle.md +106 -1167
- package/docs/worktree-isolation.md +57 -2
- package/package.json +5 -1
- package/src/db.js +17 -0
- package/src/hooks/worktree-guard.js +166 -47
- package/src/index.js +44 -3
- package/src/progress.js +79 -0
- package/src/run.js +447 -55
- package/src/stage-contract.js +347 -0
- package/src/stages/archive.js +6 -10
- package/src/stages/brainstorm.js +4 -1
- package/src/stages/doctor.js +43 -2
- package/src/stages/execute.js +32 -5
- package/src/stages/propose.js +4 -1
- package/src/stages/quick.js +3 -3
- package/src/stages/scan.js +18 -18
- package/src/workflow.js +6 -2
- package/src/worktree-apply.js +14 -0
- package/src/worktree.js +201 -11
- package/test/check-syntax.mjs +26 -0
- package/test/run-tests.mjs +20 -0
- package/test/scan-paths.test.mjs +68 -0
- package/test/stage-contract.test.mjs +128 -0
- package/test/stage-definitions.test.mjs +43 -0
- package/test/worktree-guard.test.mjs +78 -0
package/src/run.js
CHANGED
|
@@ -8,6 +8,7 @@ import { basename, join } from 'path'
|
|
|
8
8
|
import { existsSync, readdirSync, mkdirSync, writeFileSync, appendFileSync, readFileSync, statSync } from 'fs'
|
|
9
9
|
import { ProgressManager } from './progress.js'
|
|
10
10
|
import { stageRegistry, auxiliaryStages } from './stages/index.js'
|
|
11
|
+
import { checkTransition, runValidators } from './stage-contract.js'
|
|
11
12
|
import { buildExecuteSteps } from './stages/execute.js'
|
|
12
13
|
import { buildPlanSteps } from './stages/plan.js'
|
|
13
14
|
import { formatExecuteSummary } from './worktree-apply.js'
|
|
@@ -15,10 +16,122 @@ import { formatExecuteSummary } from './worktree-apply.js'
|
|
|
15
16
|
/**
|
|
16
17
|
* 同步触发辅助函数:_write 后 best-effort 同步到平台
|
|
17
18
|
*/
|
|
19
|
+
/**
|
|
20
|
+
* quick 完成审计:对比 baseline 与实际变更
|
|
21
|
+
*/
|
|
22
|
+
async function auditQuickCompletion(cwd, guard, options = {}) {
|
|
23
|
+
const { baselineFiles, allowedFiles = [], allowNew = false, forceBaseline = false } = guard
|
|
24
|
+
const { isConfirm } = options
|
|
25
|
+
const result = { status: 'safe', reasons: [], changedFiles: [], newFiles: [], deletedFiles: [], baselineHit: [] }
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const { execSync } = await import('child_process')
|
|
29
|
+
const gitStatus = execSync('git status --porcelain', { cwd, encoding: 'utf8', timeout: 10000 })
|
|
30
|
+
const currentEntries = gitStatus.trim().split('\n').filter(Boolean)
|
|
31
|
+
|
|
32
|
+
const DANGEROUS_PATTERNS = [
|
|
33
|
+
'.sillyspec/',
|
|
34
|
+
'package.json',
|
|
35
|
+
'package-lock.json',
|
|
36
|
+
'yarn.lock',
|
|
37
|
+
'pnpm-lock.yaml',
|
|
38
|
+
'.eslintrc',
|
|
39
|
+
'tsconfig.json',
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
for (const entry of currentEntries) {
|
|
43
|
+
const status = entry.slice(0, 2).trim()
|
|
44
|
+
const file = entry.slice(3).trim()
|
|
45
|
+
if (!file || file.startsWith('??. ')) continue
|
|
46
|
+
|
|
47
|
+
result.changedFiles.push(file)
|
|
48
|
+
if (status === 'D' || status === ' D') result.deletedFiles.push(file)
|
|
49
|
+
if (status === '??') result.newFiles.push(file)
|
|
50
|
+
|
|
51
|
+
// 检查是否命中 baseline protected files
|
|
52
|
+
if (baselineFiles.includes(file)) {
|
|
53
|
+
result.baselineHit.push(file)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// 检查危险文件(除非 force-baseline)
|
|
57
|
+
if (DANGEROUS_PATTERNS.some(p => file.includes(p)) && !forceBaseline) {
|
|
58
|
+
result.reasons.push(`危险文件变更: ${file}`)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 检查 deleted files
|
|
63
|
+
for (const f of result.deletedFiles) {
|
|
64
|
+
result.reasons.push(`删除文件: ${f}`)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// 检查 baseline hit(除非 force-baseline)
|
|
68
|
+
if (!forceBaseline) {
|
|
69
|
+
for (const f of result.baselineHit) {
|
|
70
|
+
result.reasons.push(`覆盖 baseline 文件: ${f}`)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 检查 new files(除非 allow-new)
|
|
75
|
+
if (!allowNew) {
|
|
76
|
+
for (const f of result.newFiles) {
|
|
77
|
+
if (!f.startsWith('.sillyspec/quicklog/') && !f.startsWith('.sillyspec/.runtime/')) {
|
|
78
|
+
result.reasons.push(`新增文件(需 --allow-new): ${f}`)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// 检查 allowedFiles 范围
|
|
84
|
+
if (allowedFiles.length > 0) {
|
|
85
|
+
for (const f of result.changedFiles) {
|
|
86
|
+
if (!allowedFiles.includes(f) && !f.startsWith('.sillyspec/')) {
|
|
87
|
+
result.reasons.push(`超出 allowedFiles: ${f}`)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 判定结果
|
|
93
|
+
if (result.baselineHit.length > 0 || result.deletedFiles.length > 0 || result.reasons.some(r => r.startsWith('危险') || r.startsWith('删除'))) {
|
|
94
|
+
result.status = 'blocked'
|
|
95
|
+
} else if (result.newFiles.length > 0 || (allowedFiles.length > 0 && result.reasons.some(r => r.startsWith('超出')))) {
|
|
96
|
+
result.status = 'warning'
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// --confirm 模式:展示 diff 并等待确认
|
|
100
|
+
if (isConfirm && (result.status === 'warning' || result.status === 'blocked')) {
|
|
101
|
+
console.log(`\n📋 quick 变更概览:`)
|
|
102
|
+
console.log(` 新增: ${result.newFiles.length}, 修改: ${result.changedFiles.length - result.newFiles.length - result.deletedFiles.length}, 删除: ${result.deletedFiles.length}`)
|
|
103
|
+
if (result.changedFiles.length > 0) {
|
|
104
|
+
console.log(`\n 变更文件:`)
|
|
105
|
+
for (const f of result.changedFiles) {
|
|
106
|
+
const isBaseline = baselineFiles.includes(f)
|
|
107
|
+
const isDangerous = DANGEROUS_PATTERNS.some(p => f.includes(p))
|
|
108
|
+
const marker = isBaseline ? '🔴' : isDangerous ? '⚠️' : ' '
|
|
109
|
+
console.log(` ${marker} ${f}`)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
console.log(`\n 状态: ${result.status.toUpperCase()}`)
|
|
113
|
+
if (result.reasons.length > 0) {
|
|
114
|
+
console.log(` 原因:`)
|
|
115
|
+
for (const r of result.reasons) {
|
|
116
|
+
console.log(` - ${r}`)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
console.log(`\n 如确认接受这些变更,重新运行:sillyspec run quick --done --confirm --output "..."`)
|
|
120
|
+
console.log(` 或使用 --force-baseline 允许覆盖 baseline 文件,--allow-new 允许新增文件`)
|
|
121
|
+
}
|
|
122
|
+
} catch (e) {
|
|
123
|
+
result.reasons.push(`审计失败: ${e.message}`)
|
|
124
|
+
result.status = 'warning'
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return result
|
|
128
|
+
}
|
|
129
|
+
|
|
18
130
|
async function triggerSync(cwd, changeName) {
|
|
19
131
|
try {
|
|
132
|
+
if (changeName && !existsSync(join(cwd, '.sillyspec', 'changes', changeName))) return
|
|
20
133
|
const syncMod = await import('./sync.js')
|
|
21
|
-
await syncMod.sync(
|
|
134
|
+
await syncMod.sync(changeName, cwd)
|
|
22
135
|
} catch (e) {
|
|
23
136
|
// sync.js 不存在或同步失败,静默跳过
|
|
24
137
|
console.warn('⚠️ 同步失败:', e.message)
|
|
@@ -32,7 +145,7 @@ async function triggerSync(cwd, changeName) {
|
|
|
32
145
|
async function checkApproval(cwd, changeName) {
|
|
33
146
|
try {
|
|
34
147
|
const syncMod = await import('./sync.js')
|
|
35
|
-
return await syncMod.checkApproval(
|
|
148
|
+
return await syncMod.checkApproval(changeName, cwd)
|
|
36
149
|
} catch (e) {
|
|
37
150
|
// sync.js 不存在或检查失败,静默跳过
|
|
38
151
|
return null
|
|
@@ -147,7 +260,7 @@ async function ensureStageSteps(progress, stageName, cwd) {
|
|
|
147
260
|
/**
|
|
148
261
|
* 输出当前步骤的 prompt
|
|
149
262
|
*/
|
|
150
|
-
async function outputStep(stageName, stepIndex, steps, cwd, changeName, dbProjectName) {
|
|
263
|
+
async function outputStep(stageName, stepIndex, steps, cwd, changeName, dbProjectName, platformOpts = {}) {
|
|
151
264
|
const step = steps[stepIndex]
|
|
152
265
|
const total = steps.length
|
|
153
266
|
const projectName = dbProjectName || basename(cwd)
|
|
@@ -218,6 +331,43 @@ async function outputStep(stageName, stepIndex, steps, cwd, changeName, dbProjec
|
|
|
218
331
|
if (changeName && promptText.includes('<change-name>')) {
|
|
219
332
|
promptText = promptText.replace(/<change-name>/g, changeName)
|
|
220
333
|
}
|
|
334
|
+
// 平台模式:注入路径覆盖指令(仅 scan 阶段)
|
|
335
|
+
if (stageName === 'scan') {
|
|
336
|
+
const projectName = dbProjectName || basename(cwd)
|
|
337
|
+
const specSillyspec = platformOpts?.specRoot
|
|
338
|
+
? join(platformOpts.specRoot, '.sillyspec')
|
|
339
|
+
: join(cwd, '.sillyspec')
|
|
340
|
+
const docsRoot = join(specSillyspec, 'docs', projectName)
|
|
341
|
+
const projectsRoot = join(specSillyspec, 'projects')
|
|
342
|
+
|
|
343
|
+
promptText = promptText.replace(/\{DOCS_ROOT\}/g, docsRoot)
|
|
344
|
+
promptText = promptText.replace(/\{PROJECTS_ROOT\}/g, projectsRoot)
|
|
345
|
+
|
|
346
|
+
// 平台模式附加指令
|
|
347
|
+
if (platformOpts?.specRoot || platformOpts?.runtimeRoot) {
|
|
348
|
+
const platformDirectives = []
|
|
349
|
+
if (platformOpts.specRoot) {
|
|
350
|
+
platformDirectives.push(
|
|
351
|
+
`## ⚠️ 平台模式\n` +
|
|
352
|
+
`文档路径已参数化:\n` +
|
|
353
|
+
`- 文档根目录: \`${docsRoot}/\`\n` +
|
|
354
|
+
`- 项目注册表: \`${projectsRoot}/\`\n` +
|
|
355
|
+
`创建目录: \`mkdir -p ${docsRoot}/{scan,modules,flows} ${projectsRoot}\`\n`
|
|
356
|
+
)
|
|
357
|
+
}
|
|
358
|
+
if (platformOpts.runtimeRoot) {
|
|
359
|
+
const scanRunId = platformOpts.scanRunId || 'scan-' + new Date().toISOString().slice(0, 19).replace(/[T:]/g, '-')
|
|
360
|
+
platformDirectives.push(
|
|
361
|
+
`运行时产物写入: \`${platformOpts.runtimeRoot}/scan-runs/${scanRunId}/\`\n`
|
|
362
|
+
)
|
|
363
|
+
}
|
|
364
|
+
if (platformOpts.workspaceId) {
|
|
365
|
+
platformDirectives.push(`workspace_id: ${platformOpts.workspaceId}`)
|
|
366
|
+
}
|
|
367
|
+
promptText = platformDirectives.join('\n') + '\n\n' + promptText
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
221
371
|
console.log(promptText)
|
|
222
372
|
console.log(`\n### ⚠️ 铁律`)
|
|
223
373
|
console.log('- **文档是核心资产,代码是文档的产物。** 没有文档就没有代码——文档是 AI 的记忆,是团队协作的基础,是后续维护的唯一依据。任何代码产出必须先有对应的设计/规范文档支撑。')
|
|
@@ -265,6 +415,50 @@ export async function runCommand(args, cwd) {
|
|
|
265
415
|
const isConfirm = flags.includes('--confirm')
|
|
266
416
|
const isSkipApproval = flags.includes('--skip-approval')
|
|
267
417
|
|
|
418
|
+
// 平台模式参数(供 SillyHub 等平台调用)
|
|
419
|
+
const getFlagValue = (name) => {
|
|
420
|
+
const idx = flags.indexOf(name)
|
|
421
|
+
return idx !== -1 && flags[idx + 1] ? flags[idx + 1] : null
|
|
422
|
+
}
|
|
423
|
+
const platformOpts = {
|
|
424
|
+
specRoot: getFlagValue('--spec-root'),
|
|
425
|
+
runtimeRoot: getFlagValue('--runtime-root'),
|
|
426
|
+
workspaceId: getFlagValue('--workspace-id'),
|
|
427
|
+
scanRunId: getFlagValue('--scan-run-id'),
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// 跨 --done 生命周期:优先从 metadata 文件恢复 platformOpts
|
|
431
|
+
// 首次 scan 时写入,后续 --done 读取
|
|
432
|
+
const platformOptsFile = join(cwd, '.sillyspec', '.runtime', 'platform-scan.json')
|
|
433
|
+
if (isDone || isSkip) {
|
|
434
|
+
// --done/--skip 阶段:从文件恢复
|
|
435
|
+
try {
|
|
436
|
+
const { readFileSync } = await import('fs')
|
|
437
|
+
const saved = JSON.parse(readFileSync(platformOptsFile, 'utf8'))
|
|
438
|
+
if (saved.specRoot) platformOpts.specRoot = saved.specRoot
|
|
439
|
+
if (saved.runtimeRoot) platformOpts.runtimeRoot = saved.runtimeRoot
|
|
440
|
+
if (saved.workspaceId) platformOpts.workspaceId = saved.workspaceId
|
|
441
|
+
if (saved.scanRunId) platformOpts.scanRunId = saved.scanRunId
|
|
442
|
+
} catch {
|
|
443
|
+
// 文件不存在,说明不是平台模式,跳过
|
|
444
|
+
}
|
|
445
|
+
} else if (platformOpts.specRoot || platformOpts.runtimeRoot) {
|
|
446
|
+
// 首次 scan:持久化 platformOpts
|
|
447
|
+
try {
|
|
448
|
+
const { mkdirSync, writeFileSync } = await import('fs')
|
|
449
|
+
mkdirSync(join(cwd, '.sillyspec', '.runtime'), { recursive: true })
|
|
450
|
+
writeFileSync(platformOptsFile, JSON.stringify({
|
|
451
|
+
specRoot: platformOpts.specRoot,
|
|
452
|
+
runtimeRoot: platformOpts.runtimeRoot,
|
|
453
|
+
workspaceId: platformOpts.workspaceId,
|
|
454
|
+
scanRunId: platformOpts.scanRunId,
|
|
455
|
+
savedAt: new Date().toISOString(),
|
|
456
|
+
}, null, 2) + '\n')
|
|
457
|
+
} catch {
|
|
458
|
+
// 静默失败,不影响主流程
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
268
462
|
// 解析 --output
|
|
269
463
|
let outputText = null
|
|
270
464
|
const outputIdx = flags.indexOf('--output')
|
|
@@ -286,6 +480,37 @@ export async function runCommand(args, cwd) {
|
|
|
286
480
|
changeName = flags[changeIdx + 1]
|
|
287
481
|
}
|
|
288
482
|
|
|
483
|
+
// 解析 --files a.js,b.js(quick 专用:显式声明 allowedFiles)
|
|
484
|
+
let quickFiles = []
|
|
485
|
+
const filesIdx = flags.indexOf('--files')
|
|
486
|
+
if (filesIdx !== -1 && flags[filesIdx + 1]) {
|
|
487
|
+
quickFiles = flags[filesIdx + 1].split(',').map(f => f.trim()).filter(Boolean)
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const isAllowNew = flags.includes('--allow-new')
|
|
491
|
+
const isForceBaseline = flags.includes('--force-baseline')
|
|
492
|
+
|
|
493
|
+
// 未知参数 fail-fast
|
|
494
|
+
const knownFlags = new Set([
|
|
495
|
+
'--done', '--skip', '--status', '--reset', '--confirm', '--skip-approval',
|
|
496
|
+
'--output', '--input', '--change',
|
|
497
|
+
'--spec-root', '--runtime-root', '--workspace-id', '--scan-run-id',
|
|
498
|
+
'--files', '--allow-new', '--force-baseline',
|
|
499
|
+
'--json', '--dir', '--help',
|
|
500
|
+
])
|
|
501
|
+
for (let i = 0; i < flags.length; i++) {
|
|
502
|
+
const f = flags[i]
|
|
503
|
+
if (f.startsWith('--')) {
|
|
504
|
+
if (!knownFlags.has(f)) {
|
|
505
|
+
console.error(`❌ 未知参数: ${f}`)
|
|
506
|
+
console.error(`已知参数: ${[...knownFlags].sort().join(', ')}`)
|
|
507
|
+
process.exit(1)
|
|
508
|
+
}
|
|
509
|
+
// 跳过 value 参数
|
|
510
|
+
i++
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
289
514
|
const isAuxiliary = auxiliaryStages.includes(stageName)
|
|
290
515
|
|
|
291
516
|
const pm = new ProgressManager()
|
|
@@ -361,11 +586,11 @@ export async function runCommand(args, cwd) {
|
|
|
361
586
|
|
|
362
587
|
// --done
|
|
363
588
|
if (isDone) {
|
|
364
|
-
return await completeStep(pm, progress, stageName, cwd, outputText, inputText, { confirm: isConfirm, changeName: effectiveChange })
|
|
589
|
+
return await completeStep(pm, progress, stageName, cwd, outputText, inputText, { confirm: isConfirm, changeName: effectiveChange, platformOpts })
|
|
365
590
|
}
|
|
366
591
|
|
|
367
592
|
// 默认:输出当前步骤
|
|
368
|
-
return await runStage(pm, progress, stageName, cwd, effectiveChange, isSkipApproval)
|
|
593
|
+
return await runStage(pm, progress, stageName, cwd, effectiveChange, isSkipApproval, platformOpts)
|
|
369
594
|
}
|
|
370
595
|
|
|
371
596
|
/**
|
|
@@ -380,7 +605,19 @@ function resolveChangeNameAuto(cwd) {
|
|
|
380
605
|
return null
|
|
381
606
|
}
|
|
382
607
|
|
|
383
|
-
async function runStage(pm, progress, stageName, cwd, changeName, skipApproval = false) {
|
|
608
|
+
async function runStage(pm, progress, stageName, cwd, changeName, skipApproval = false, platformOpts = {}) {
|
|
609
|
+
// 状态转换校验
|
|
610
|
+
const prevStage = progress.currentStage || ''
|
|
611
|
+
const transition = checkTransition(prevStage, stageName)
|
|
612
|
+
if (!transition.allowed) {
|
|
613
|
+
console.error(`❌ 阶段转换不允许: ${prevStage || '(起始)'} → ${stageName}`)
|
|
614
|
+
console.error(` 原因: ${transition.reason}`)
|
|
615
|
+
console.error(` 提示: 使用 --skip-approval 绕过(需明确意图)`)
|
|
616
|
+
if (!skipApproval) {
|
|
617
|
+
process.exit(1)
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
384
621
|
// execute 阶段启动前检查审批
|
|
385
622
|
if (stageName === 'execute' && !skipApproval) {
|
|
386
623
|
const approval = await checkApproval(cwd, changeName)
|
|
@@ -435,6 +672,39 @@ async function runStage(pm, progress, stageName, cwd, changeName, skipApproval =
|
|
|
435
672
|
console.log(`🔄 ${stageName} 阶段已自动重置,重新开始。\n`)
|
|
436
673
|
}
|
|
437
674
|
|
|
675
|
+
// quick 阶段:记录 baselineFiles
|
|
676
|
+
if (stageName === 'quick' && !progress.quickGuard) {
|
|
677
|
+
try {
|
|
678
|
+
const { execSync } = await import('child_process')
|
|
679
|
+
const gitStatus = execSync('git status --porcelain', { cwd, encoding: 'utf8', timeout: 10000 })
|
|
680
|
+
const baselineFiles = gitStatus
|
|
681
|
+
.trim().split('\n').filter(Boolean)
|
|
682
|
+
.map(line => line.slice(3).trim())
|
|
683
|
+
.filter(f => !f.startsWith('.sillyspec/'))
|
|
684
|
+
const allowedFiles = quickFiles || []
|
|
685
|
+
const allowNew = isAllowNew || false
|
|
686
|
+
const forceBaseline = isForceBaseline || false
|
|
687
|
+
progress.quickGuard = {
|
|
688
|
+
baselineCommit: execSync('git rev-parse HEAD', { cwd, encoding: 'utf8', timeout: 5000 }).trim(),
|
|
689
|
+
baselineFiles,
|
|
690
|
+
allowedFiles,
|
|
691
|
+
allowNew,
|
|
692
|
+
forceBaseline,
|
|
693
|
+
startedAt: new Date().toISOString(),
|
|
694
|
+
}
|
|
695
|
+
// 写入 quick-guard.json 供 worktree-guard hook 读取
|
|
696
|
+
const guardFile = join(cwd, '.sillyspec', '.runtime', 'quick-guard.json')
|
|
697
|
+
writeFileSync(guardFile, JSON.stringify(progress.quickGuard, null, 2))
|
|
698
|
+
const parts = [`${baselineFiles.length} 个已有脏文件`]
|
|
699
|
+
if (allowedFiles.length > 0) parts.push(`${allowedFiles.length} 个 allowedFiles`)
|
|
700
|
+
if (allowNew) parts.push('允许新增文件')
|
|
701
|
+
console.log(`🛡️ quick 变更边界已记录: ${parts.join(', ')}`)
|
|
702
|
+
await pm._write(cwd, progress, changeName)
|
|
703
|
+
} catch (e) {
|
|
704
|
+
console.warn(`⚠️ baseline 记录失败: ${e.message}`)
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
438
708
|
if (currentIdx > 0) {
|
|
439
709
|
const completed = currentIdx
|
|
440
710
|
const total = steps.length
|
|
@@ -444,7 +714,7 @@ async function runStage(pm, progress, stageName, cwd, changeName, skipApproval =
|
|
|
444
714
|
|
|
445
715
|
const defSteps = await getStageSteps(stageName, cwd, progress)
|
|
446
716
|
if (defSteps && defSteps[currentIdx]) {
|
|
447
|
-
await outputStep(stageName, currentIdx, defSteps, cwd, changeName, progress.project || null)
|
|
717
|
+
await outputStep(stageName, currentIdx, defSteps, cwd, changeName, progress.project || null, platformOpts)
|
|
448
718
|
}
|
|
449
719
|
}
|
|
450
720
|
|
|
@@ -526,8 +796,41 @@ function validateFileLocations(cwd, stageName, progress, changeName) {
|
|
|
526
796
|
}
|
|
527
797
|
}
|
|
528
798
|
|
|
799
|
+
async function archiveChangeDirectory(pm, cwd, progress) {
|
|
800
|
+
const { renameSync } = await import('fs')
|
|
801
|
+
const archiveChangeName = progress.currentChange
|
|
802
|
+
if (!archiveChangeName) {
|
|
803
|
+
console.error('❌ 归档失败:未找到当前变更名(currentChange)')
|
|
804
|
+
process.exit(1)
|
|
805
|
+
}
|
|
806
|
+
const changesDir = join(cwd, '.sillyspec', 'changes')
|
|
807
|
+
const archiveDir = join(changesDir, 'archive')
|
|
808
|
+
const srcDir = join(changesDir, archiveChangeName)
|
|
809
|
+
const date = new Date().toISOString().slice(0, 10)
|
|
810
|
+
const destDir = join(archiveDir, `${date}-${archiveChangeName}`)
|
|
811
|
+
|
|
812
|
+
if (!existsSync(srcDir)) {
|
|
813
|
+
console.error(`❌ 归档失败:源目录不存在 ${srcDir}`)
|
|
814
|
+
process.exit(1)
|
|
815
|
+
}
|
|
816
|
+
if (existsSync(destDir)) {
|
|
817
|
+
console.error(`❌ 归档失败:目标目录已存在 ${destDir}`)
|
|
818
|
+
process.exit(1)
|
|
819
|
+
}
|
|
820
|
+
mkdirSync(archiveDir, { recursive: true })
|
|
821
|
+
renameSync(srcDir, destDir)
|
|
822
|
+
|
|
823
|
+
if (!existsSync(destDir) || existsSync(srcDir)) {
|
|
824
|
+
console.error('❌ 归档校验失败:移动操作异常')
|
|
825
|
+
process.exit(1)
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
await pm.unregisterChange(cwd, archiveChangeName)
|
|
829
|
+
console.log(`📦 已归档:${archiveChangeName} → archive/${date}-${archiveChangeName}/`)
|
|
830
|
+
}
|
|
831
|
+
|
|
529
832
|
async function completeStep(pm, progress, stageName, cwd, outputText, inputText = null, options = {}) {
|
|
530
|
-
const { printNext = true, confirm = false, changeName } = options
|
|
833
|
+
const { printNext = true, confirm = false, changeName, platformOpts = {} } = options
|
|
531
834
|
const stageData = progress.stages[stageName]
|
|
532
835
|
if (!stageData || !stageData.steps) {
|
|
533
836
|
console.error(`❌ 阶段 ${stageName} 未初始化`)
|
|
@@ -548,15 +851,48 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
|
|
|
548
851
|
const MAX_OUTPUT = 200
|
|
549
852
|
if (outputText.length > MAX_OUTPUT) {
|
|
550
853
|
steps[currentIdx].output = outputText.slice(0, MAX_OUTPUT) + '…'
|
|
551
|
-
|
|
552
|
-
|
|
854
|
+
// 平台模式:artifact 写入 runtime-root,否则写 .sillyspec/.runtime/artifacts
|
|
855
|
+
const artifactBase = platformOpts?.runtimeRoot
|
|
856
|
+
? join(platformOpts.runtimeRoot, 'scan-runs', platformOpts.scanRunId || 'unknown')
|
|
857
|
+
: join(cwd, '.sillyspec', '.runtime', 'artifacts')
|
|
858
|
+
mkdirSync(artifactBase, { recursive: true })
|
|
553
859
|
const ts = new Date().toISOString().slice(0,19).replace(/[-T:]/g, '')
|
|
554
|
-
writeFileSync(join(
|
|
860
|
+
writeFileSync(join(artifactBase, `${changeName || 'unknown'}-${stageName}-step${currentIdx + 1}-${ts}.txt`), outputText)
|
|
555
861
|
} else {
|
|
556
862
|
steps[currentIdx].output = outputText
|
|
557
863
|
}
|
|
558
864
|
}
|
|
559
865
|
|
|
866
|
+
if (stageName === 'archive' && steps[currentIdx]?.name === '确认归档') {
|
|
867
|
+
if (!confirm) {
|
|
868
|
+
steps[currentIdx].status = 'pending'
|
|
869
|
+
steps[currentIdx].completedAt = null
|
|
870
|
+
if (outputText) steps[currentIdx].output = null
|
|
871
|
+
await pm._write(cwd, progress, changeName)
|
|
872
|
+
console.log('⚠️ 请添加 --confirm 确认归档,例如:sillyspec run archive --done --confirm --output "确认归档"')
|
|
873
|
+
return { stageCompleted: false, currentIdx, nextPendingIdx: currentIdx }
|
|
874
|
+
}
|
|
875
|
+
await archiveChangeDirectory(pm, cwd, progress)
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// archive "确认归档" 步骤完成后,校验归档完整性
|
|
879
|
+
if (stageName === 'archive' && steps[currentIdx]?.name === '确认归档' && confirm) {
|
|
880
|
+
const projectName = progress.project || basename(cwd)
|
|
881
|
+
const contractResult = runValidators('archive', cwd, changeName, { projectName })
|
|
882
|
+
if (contractResult.errors.length > 0) {
|
|
883
|
+
console.error(`\n❌ 归档校验失败:`)
|
|
884
|
+
for (const err of contractResult.errors) {
|
|
885
|
+
console.error(` - ${err}`)
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
if (contractResult.warnings.length > 0) {
|
|
889
|
+
console.warn(`\n⚠️ 归档校验警告:`)
|
|
890
|
+
for (const w of contractResult.warnings) {
|
|
891
|
+
console.warn(` - ${w}`)
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
|
|
560
896
|
// plan 阶段 "展开任务" 完成后,动态插入任务蓝图协调器步骤
|
|
561
897
|
if (stageName === 'plan' && steps[currentIdx]?.name === '展开任务并分组') {
|
|
562
898
|
const changeDir = resolveChangeDir(cwd, progress)
|
|
@@ -683,50 +1019,61 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
|
|
|
683
1019
|
appendFileSync(inputsPath, entry)
|
|
684
1020
|
}
|
|
685
1021
|
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
const destDir = join(archiveDir, `${date}-${archiveChangeName}`)
|
|
705
|
-
|
|
706
|
-
if (!existsSync(srcDir)) {
|
|
707
|
-
console.error(`❌ 归档失败:源目录不存在 ${srcDir}`)
|
|
708
|
-
process.exit(1)
|
|
1022
|
+
// 平台模式:scan 完成后生成 manifest.json
|
|
1023
|
+
if (stageName === 'scan' && platformOpts.specRoot) {
|
|
1024
|
+
try {
|
|
1025
|
+
const { mkdirSync, writeFileSync } = await import('fs')
|
|
1026
|
+
const { join } = await import('path')
|
|
1027
|
+
const { execSync } = await import('child_process')
|
|
1028
|
+
const manifestDir = join(platformOpts.specRoot, '.sillyspec')
|
|
1029
|
+
mkdirSync(manifestDir, { recursive: true })
|
|
1030
|
+
let sourceCommit = null
|
|
1031
|
+
try {
|
|
1032
|
+
sourceCommit = execSync('git rev-parse HEAD', { cwd, encoding: 'utf8', timeout: 5000 }).trim()
|
|
1033
|
+
} catch {}
|
|
1034
|
+
const manifest = {
|
|
1035
|
+
workspace_id: platformOpts.workspaceId || null,
|
|
1036
|
+
scan_run_id: platformOpts.scanRunId || null,
|
|
1037
|
+
source_commit: sourceCommit,
|
|
1038
|
+
generated_at: new Date().toISOString(),
|
|
1039
|
+
schema_version: 1,
|
|
709
1040
|
}
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
1041
|
+
const manifestPath = join(manifestDir, 'manifest.json')
|
|
1042
|
+
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n')
|
|
1043
|
+
console.log(`📄 manifest.json 已写入: ${manifestPath}`)
|
|
1044
|
+
if (!sourceCommit) {
|
|
1045
|
+
console.log(`⚠️ source_commit 无法获取(可能非 git 目录),已设为 null`)
|
|
713
1046
|
}
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
1047
|
+
// 清理平台参数临时文件
|
|
1048
|
+
const { unlinkSync } = await import('fs')
|
|
1049
|
+
const platformOptsFile = join(cwd, '.sillyspec', '.runtime', 'platform-scan.json')
|
|
1050
|
+
try { unlinkSync(platformOptsFile) } catch {}
|
|
1051
|
+
|
|
1052
|
+
// 平台模式后置校验:检查 source_root 是否被污染
|
|
1053
|
+
if (platformOpts.specRoot) {
|
|
1054
|
+
const { readdirSync } = await import('fs')
|
|
1055
|
+
const localDocsDir = join(cwd, '.sillyspec', 'docs')
|
|
1056
|
+
try {
|
|
1057
|
+
if (existsSync(localDocsDir)) {
|
|
1058
|
+
const entries = readdirSync(localDocsDir, { recursive: true }).filter(e => e.endsWith('.md'))
|
|
1059
|
+
if (entries.length > 0) {
|
|
1060
|
+
console.warn(`⚠️ 平台模式后置校验:source_root 下存在 ${entries.length} 个文档文件:`)
|
|
1061
|
+
console.warn(` 路径:${localDocsDir}/`)
|
|
1062
|
+
console.warn(` 可能原因:agent 未遵守路径覆盖,将文档写入到了 cwd 而非 spec-root`)
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
} catch {}
|
|
720
1066
|
}
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
await pm.unregisterChange(cwd, archiveChangeName)
|
|
724
|
-
console.log(`📦 已归档:${archiveChangeName} → archive/${date}-${archiveChangeName}/`)
|
|
725
|
-
} else {
|
|
726
|
-
console.log('⚠️ 请添加 --confirm 确认归档,例如:sillyspec run archive --done --confirm --output "确认归档"')
|
|
1067
|
+
} catch (e) {
|
|
1068
|
+
console.warn(`⚠️ manifest.json 写入失败: ${e.message}`)
|
|
727
1069
|
}
|
|
728
1070
|
}
|
|
729
1071
|
|
|
1072
|
+
validateMetadata(cwd, stageName)
|
|
1073
|
+
|
|
1074
|
+
// 验证关键文件是否在正确的变更目录下
|
|
1075
|
+
validateFileLocations(cwd, stageName, progress, changeName)
|
|
1076
|
+
|
|
730
1077
|
// 辅助阶段完成后重置步骤
|
|
731
1078
|
const stageDef = stageRegistry[stageName]
|
|
732
1079
|
if (stageDef?.auxiliary) {
|
|
@@ -739,6 +1086,7 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
|
|
|
739
1086
|
stageData.steps = freshSteps
|
|
740
1087
|
stageData.status = 'pending'
|
|
741
1088
|
stageData.completedAt = null
|
|
1089
|
+
if (progress.currentStage === stageName) progress.currentStage = ''
|
|
742
1090
|
await pm._write(cwd, progress, changeName)
|
|
743
1091
|
}
|
|
744
1092
|
|
|
@@ -771,6 +1119,50 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
|
|
|
771
1119
|
return { stageCompleted: true, currentIdx, nextPendingIdx: -1 }
|
|
772
1120
|
}
|
|
773
1121
|
|
|
1122
|
+
// 阶段完成校验
|
|
1123
|
+
const projectName = progress.project || basename(cwd)
|
|
1124
|
+
const contractResult = runValidators(stageName, cwd, changeName, { projectName })
|
|
1125
|
+
if (contractResult.errors.length > 0) {
|
|
1126
|
+
console.error(`\n❌ 阶段 ${stageName} 校验失败:`)
|
|
1127
|
+
for (const err of contractResult.errors) {
|
|
1128
|
+
console.error(` - ${err}`)
|
|
1129
|
+
}
|
|
1130
|
+
console.error(`\n 提示:修复缺失产物后重新运行此步骤,或使用 --skip-approval 跳过校验`)
|
|
1131
|
+
}
|
|
1132
|
+
if (contractResult.warnings.length > 0) {
|
|
1133
|
+
console.warn(`\n⚠️ 阶段 ${stageName} 校验警告:`)
|
|
1134
|
+
for (const w of contractResult.warnings) {
|
|
1135
|
+
console.warn(` - ${w}`)
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
// quick 阶段完成审计
|
|
1140
|
+
if (stageName === 'quick' && progress.quickGuard) {
|
|
1141
|
+
const review = await auditQuickCompletion(cwd, progress.quickGuard, { isConfirm })
|
|
1142
|
+
progress.quickGuard.review = review
|
|
1143
|
+
progress.quickGuard.completedAt = new Date().toISOString()
|
|
1144
|
+
// 清理 quick-guard.json
|
|
1145
|
+
try {
|
|
1146
|
+
const { unlinkSync } = await import('fs')
|
|
1147
|
+
const guardFile = join(cwd, '.sillyspec', '.runtime', 'quick-guard.json')
|
|
1148
|
+
unlinkSync(guardFile)
|
|
1149
|
+
} catch {}
|
|
1150
|
+
if (review.status === 'blocked') {
|
|
1151
|
+
console.error(`\n🚫 quick 变更边界审计 — BLOCKED:`)
|
|
1152
|
+
for (const r of review.reasons) {
|
|
1153
|
+
console.error(` - ${r}`)
|
|
1154
|
+
}
|
|
1155
|
+
console.error(`\n 这些文件是 baseline 保护的,不应被修改。`)
|
|
1156
|
+
} else if (review.status === 'warning') {
|
|
1157
|
+
console.warn(`\n⚠️ quick 变更边界审计 — WARNING:`)
|
|
1158
|
+
for (const r of review.reasons) {
|
|
1159
|
+
console.warn(` - ${r}`)
|
|
1160
|
+
}
|
|
1161
|
+
} else {
|
|
1162
|
+
console.log(`\n✅ quick 变更边界审计 — SAFE (变更 ${review.changedFiles.length} 个文件)`)
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
|
|
774
1166
|
progress.lastActive = new Date().toLocaleString('zh-CN',{hour12:false})
|
|
775
1167
|
await pm._write(cwd, progress, changeName)
|
|
776
1168
|
triggerSync(cwd, changeName)
|
|
@@ -862,7 +1254,7 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
|
|
|
862
1254
|
}
|
|
863
1255
|
|
|
864
1256
|
if (printNext) {
|
|
865
|
-
await outputStep(stageName, nextPendingIdx, defSteps, cwd, changeName, progress.project || null)
|
|
1257
|
+
await outputStep(stageName, nextPendingIdx, defSteps, cwd, changeName, progress.project || null, platformOpts)
|
|
866
1258
|
}
|
|
867
1259
|
return { stageCompleted: false, currentIdx, nextPendingIdx }
|
|
868
1260
|
}
|
|
@@ -900,7 +1292,7 @@ async function skipStep(pm, progress, stageName, cwd, changeName) {
|
|
|
900
1292
|
const nextPendingIdx = steps.findIndex(s => s.status === 'pending')
|
|
901
1293
|
if (nextPendingIdx !== -1 && defSteps) {
|
|
902
1294
|
console.log('')
|
|
903
|
-
await outputStep(stageName, nextPendingIdx, defSteps, cwd, changeName, progress.project || null)
|
|
1295
|
+
await outputStep(stageName, nextPendingIdx, defSteps, cwd, changeName, progress.project || null, platformOpts)
|
|
904
1296
|
}
|
|
905
1297
|
}
|
|
906
1298
|
|
|
@@ -1045,7 +1437,7 @@ async function runAutoMode(pm, progress, cwd, flags, changeName) {
|
|
|
1045
1437
|
}
|
|
1046
1438
|
}
|
|
1047
1439
|
}
|
|
1048
|
-
await outputStep(currentStage, pendingIdx, defSteps, cwd, changeName, progress.project || null)
|
|
1440
|
+
await outputStep(currentStage, pendingIdx, defSteps, cwd, changeName, progress.project || null, platformOpts)
|
|
1049
1441
|
return
|
|
1050
1442
|
}
|
|
1051
1443
|
|
|
@@ -1054,7 +1446,7 @@ async function runAutoMode(pm, progress, cwd, flags, changeName) {
|
|
|
1054
1446
|
process.exit(1)
|
|
1055
1447
|
}
|
|
1056
1448
|
|
|
1057
|
-
const result = await completeStep(pm, progress, currentStage, cwd, outputText, inputText, { printNext: false, changeName })
|
|
1449
|
+
const result = await completeStep(pm, progress, currentStage, cwd, outputText, inputText, { printNext: false, changeName, platformOpts })
|
|
1058
1450
|
if (!result) return
|
|
1059
1451
|
progress = await pm.read(cwd, changeName)
|
|
1060
1452
|
|
|
@@ -1075,7 +1467,7 @@ async function runAutoMode(pm, progress, cwd, flags, changeName) {
|
|
|
1075
1467
|
}
|
|
1076
1468
|
}
|
|
1077
1469
|
}
|
|
1078
|
-
await outputStep(currentStage, nextPendingIdx, defSteps, cwd, changeName, progress.project || null)
|
|
1470
|
+
await outputStep(currentStage, nextPendingIdx, defSteps, cwd, changeName, progress.project || null, platformOpts)
|
|
1079
1471
|
return
|
|
1080
1472
|
}
|
|
1081
1473
|
|
|
@@ -1117,6 +1509,6 @@ async function runAutoMode(pm, progress, cwd, flags, changeName) {
|
|
|
1117
1509
|
}
|
|
1118
1510
|
}
|
|
1119
1511
|
}
|
|
1120
|
-
await outputStep(next, firstPending, nextSteps, cwd, changeName, progress.project || null)
|
|
1512
|
+
await outputStep(next, firstPending, nextSteps, cwd, changeName, progress.project || null, platformOpts)
|
|
1121
1513
|
}
|
|
1122
1514
|
}
|