sillyspec 3.18.0 → 3.18.2
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/.claude/skills/sillyspec-brainstorm/SKILL.md +24 -23
- package/.claude/skills/sillyspec-execute/SKILL.md +8 -1
- package/package.json +1 -1
- package/src/db.js +4 -0
- package/src/hooks/worktree-guard.js +97 -4
- package/src/index.js +1 -1
- package/src/progress.js +41 -14
- package/src/run.js +315 -83
- package/src/stage-contract.js +249 -12
- package/src/stages/brainstorm.js +228 -8
- package/src/stages/execute.js +12 -14
- package/src/stages/index.js +0 -2
- package/src/stages/plan.js +55 -18
- package/src/stages/propose.js +30 -4
- package/src/stages/quick.js +13 -10
- package/src/stages/scan.js +12 -0
- package/src/stages/verify.js +31 -13
- package/test/platform-artifacts.test.mjs +14 -5
- package/test/platform-failure-samples.test.mjs +3 -2
- package/test/platform-recovery-chain.test.mjs +10 -9
- package/test/platform-recovery.test.mjs +13 -5
- package/test/platform-scan-p0.test.mjs +3 -0
- package/test/scan-postcheck.test.mjs +3 -2
- package/test/spec-dir.test.mjs +2 -1
- package/test/stage-contract.test.mjs +119 -6
- package/test/stage-definitions.test.mjs +2 -6
- package/test/wait-gates.test.mjs +501 -0
- package/test/worktree-guard.test.mjs +58 -0
- package/.npmrc.bak +0 -0
package/src/run.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* CLI 成为流程引擎,AI 变成步骤执行器。
|
|
5
5
|
* 支持多变更并行:每个变更状态存储在 sillyspec.db 中。
|
|
6
6
|
*/
|
|
7
|
-
import { basename, join, resolve } from 'path'
|
|
7
|
+
import { basename, join, resolve, dirname } from 'path'
|
|
8
8
|
import { existsSync, readdirSync, mkdirSync, writeFileSync, appendFileSync, readFileSync, rmSync, statSync } from 'fs'
|
|
9
9
|
import { createRequire } from 'module'
|
|
10
10
|
const require = createRequire(import.meta.url)
|
|
@@ -48,16 +48,49 @@ function formatWaitOptions(raw) {
|
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* 格式化 repeatableWait 步骤的历史用户回答,注入到重新输出的 step prompt 前。
|
|
53
|
+
* @param {object} step - progress 中的 step 对象(含 waitAnswers 数组)
|
|
54
|
+
* @returns {string|null} 格式化的历史文本,或 null(无历史)
|
|
55
|
+
*/
|
|
56
|
+
function formatWaitHistory(step) {
|
|
57
|
+
const answers = Array.isArray(step.waitAnswers) ? step.waitAnswers : []
|
|
58
|
+
if (answers.length === 0) return null
|
|
59
|
+
let text = `本步骤历史用户回答(共 ${answers.length} 轮):\n`
|
|
60
|
+
for (const item of answers) {
|
|
61
|
+
text += `\n${item.round}. ${item.answer}`
|
|
62
|
+
if (item.question) {
|
|
63
|
+
text += `\n 对应问题/摘要:${item.question}`
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const maxRounds = step.maxWaitRounds || null
|
|
67
|
+
if (maxRounds && answers.length >= maxRounds) {
|
|
68
|
+
text += `\n\n已达到 maxWaitRounds=${maxRounds}。请基于以上回答总结需求;除非仍有阻塞问题,否则完成本步骤并进入方案讨论。`
|
|
69
|
+
} else {
|
|
70
|
+
text += `\n\n请判断信息是否足够:如果足够,完成本步骤;如果仍缺关键约束,再提出一个问题并 --wait。`
|
|
71
|
+
}
|
|
72
|
+
return text
|
|
73
|
+
}
|
|
74
|
+
|
|
51
75
|
/**
|
|
52
76
|
* 解析规范目录路径
|
|
53
|
-
*
|
|
77
|
+
* 向上查找含 .sillyspec 的祖先目录,类似 git 找 .git 的逻辑。
|
|
78
|
+
* @param {string} cwd - 项目根目录(或子目录)
|
|
54
79
|
* @param {object} [opts]
|
|
55
80
|
* @param {string} [opts.specDir] - 用户指定的 specDir(通过 --spec-dir 或 --spec-root)
|
|
56
81
|
* @returns {string} 规范目录的绝对路径
|
|
57
82
|
*/
|
|
58
83
|
function resolveSpecDir(cwd, opts = {}) {
|
|
59
84
|
if (opts.specDir) return resolve(opts.specDir)
|
|
60
|
-
|
|
85
|
+
let dir = resolve(cwd)
|
|
86
|
+
while (true) {
|
|
87
|
+
const candidate = join(dir, '.sillyspec')
|
|
88
|
+
if (existsSync(candidate)) return candidate
|
|
89
|
+
const parent = dirname(dir)
|
|
90
|
+
if (parent === dir) break
|
|
91
|
+
dir = parent
|
|
92
|
+
}
|
|
93
|
+
return join(resolve(cwd), '.sillyspec')
|
|
61
94
|
}
|
|
62
95
|
import { stageRegistry, auxiliaryStages } from './stages/index.js'
|
|
63
96
|
import { checkTransition, runValidators } from './stage-contract.js'
|
|
@@ -224,19 +257,34 @@ async function auditQuickCompletion(cwd, guard, options = {}) {
|
|
|
224
257
|
const gitStatus = execSync('git status --porcelain', { cwd, encoding: 'utf8', timeout: 10000 })
|
|
225
258
|
const currentEntries = gitStatus.trim().split('\n').filter(Boolean)
|
|
226
259
|
|
|
260
|
+
const normalizeGitPath = (p) => p.replace(/\\/g, '/')
|
|
261
|
+
const isQuickMetadata = (p) => {
|
|
262
|
+
const file = normalizeGitPath(p)
|
|
263
|
+
return file.startsWith('.sillyspec/quicklog/')
|
|
264
|
+
|| file.startsWith('.sillyspec/.runtime/')
|
|
265
|
+
|| file === '.sillyspec/knowledge/uncategorized.md'
|
|
266
|
+
|| (/^\.sillyspec\/docs\/[^/]+\/modules\/[^/]+\.md$/.test(file))
|
|
267
|
+
|| (/^\.sillyspec\/docs\/[^/]+\/modules\/_module-map\.yaml$/.test(file))
|
|
268
|
+
}
|
|
227
269
|
const DANGEROUS_PATTERNS = [
|
|
228
|
-
'.sillyspec/',
|
|
229
270
|
'package.json',
|
|
230
271
|
'package-lock.json',
|
|
231
272
|
'yarn.lock',
|
|
232
273
|
'pnpm-lock.yaml',
|
|
233
274
|
'.eslintrc',
|
|
234
275
|
'tsconfig.json',
|
|
276
|
+
'src/db.js',
|
|
277
|
+
'src/progress.js',
|
|
278
|
+
'src/run.js',
|
|
279
|
+
'src/stage-contract.js',
|
|
280
|
+
'src/worktree.js',
|
|
281
|
+
'src/worktree-apply.js',
|
|
282
|
+
'src/hooks/',
|
|
235
283
|
]
|
|
236
284
|
|
|
237
285
|
for (const entry of currentEntries) {
|
|
238
286
|
const status = entry.slice(0, 2).trim()
|
|
239
|
-
const file = entry.slice(3).trim()
|
|
287
|
+
const file = normalizeGitPath(entry.slice(3).trim())
|
|
240
288
|
if (!file || file.startsWith('??. ')) continue
|
|
241
289
|
|
|
242
290
|
result.changedFiles.push(file)
|
|
@@ -249,7 +297,11 @@ async function auditQuickCompletion(cwd, guard, options = {}) {
|
|
|
249
297
|
}
|
|
250
298
|
|
|
251
299
|
// 检查危险文件(除非 force-baseline)
|
|
252
|
-
if (
|
|
300
|
+
if (file.startsWith('.sillyspec/') && !isQuickMetadata(file) && !forceBaseline) {
|
|
301
|
+
result.reasons.push(`危险文件变更: ${file}`)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (DANGEROUS_PATTERNS.some(p => file === p || file.startsWith(p)) && !forceBaseline) {
|
|
253
305
|
result.reasons.push(`危险文件变更: ${file}`)
|
|
254
306
|
}
|
|
255
307
|
}
|
|
@@ -269,7 +321,7 @@ async function auditQuickCompletion(cwd, guard, options = {}) {
|
|
|
269
321
|
// 检查 new files(除非 allow-new)
|
|
270
322
|
if (!allowNew) {
|
|
271
323
|
for (const f of result.newFiles) {
|
|
272
|
-
if (!
|
|
324
|
+
if (!isQuickMetadata(f)) {
|
|
273
325
|
result.reasons.push(`新增文件(需 --allow-new): ${f}`)
|
|
274
326
|
}
|
|
275
327
|
}
|
|
@@ -278,7 +330,7 @@ async function auditQuickCompletion(cwd, guard, options = {}) {
|
|
|
278
330
|
// 检查 allowedFiles 范围
|
|
279
331
|
if (allowedFiles.length > 0) {
|
|
280
332
|
for (const f of result.changedFiles) {
|
|
281
|
-
if (!allowedFiles.includes(f) && !f
|
|
333
|
+
if (!allowedFiles.includes(f) && !isQuickMetadata(f)) {
|
|
282
334
|
result.reasons.push(`超出 allowedFiles: ${f}`)
|
|
283
335
|
}
|
|
284
336
|
}
|
|
@@ -337,6 +389,23 @@ async function auditQuickCompletion(cwd, guard, options = {}) {
|
|
|
337
389
|
return result
|
|
338
390
|
}
|
|
339
391
|
|
|
392
|
+
function printQuickAuditReview(review) {
|
|
393
|
+
if (review.status === 'blocked') {
|
|
394
|
+
console.error(`\n🚫 quick 变更边界审计 — BLOCKED:`)
|
|
395
|
+
for (const r of review.reasons) {
|
|
396
|
+
console.error(` - ${r}`)
|
|
397
|
+
}
|
|
398
|
+
console.error(`\n quick 已停止:请恢复/拆分这些变更,或重新运行 quick 并显式声明范围。`)
|
|
399
|
+
} else if (review.status === 'warning') {
|
|
400
|
+
console.warn(`\n⚠️ quick 变更边界审计 — WARNING:`)
|
|
401
|
+
for (const r of review.reasons) {
|
|
402
|
+
console.warn(` - ${r}`)
|
|
403
|
+
}
|
|
404
|
+
} else {
|
|
405
|
+
console.log(`\n✅ quick 变更边界审计 — SAFE (变更 ${review.changedFiles.length} 个文件)`)
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
340
409
|
async function triggerSync(cwd, changeName, platformOpts = {}) {
|
|
341
410
|
// 平台模式(SillyHub)走自己的回传链路,不走 CLI 内置 sync
|
|
342
411
|
if (platformOpts?.specRoot || platformOpts?.runtimeRoot) return
|
|
@@ -697,11 +766,22 @@ async function outputStep(stageName, stepIndex, steps, cwd, changeName, dbProjec
|
|
|
697
766
|
const changeFlag = changeName ? ` --change ${changeName}` : ''
|
|
698
767
|
// 检测当前 step prompt 是否包含 WAIT 指令(即可能需要等待用户)
|
|
699
768
|
const stepPrompt = promptText || ''
|
|
700
|
-
const
|
|
769
|
+
const requiresWait = step.requiresWait === true
|
|
770
|
+
const conditionalWait = step.conditionalWait === true
|
|
771
|
+
const mayNeedWait = WAIT_MARKER_RE.test(stepPrompt) || requiresWait || conditionalWait
|
|
772
|
+
|
|
701
773
|
console.log(`\n### 完成后执行`)
|
|
702
|
-
if (
|
|
774
|
+
if (requiresWait) {
|
|
775
|
+
console.log(`本步骤必须等待用户输入,不能直接 --done:`)
|
|
776
|
+
console.log(`sillyspec run ${stageName} --wait --reason "${step.waitReason || '等待用户输入'}" --options "${(step.waitOptions || ['确认']).join(',')}"${changeFlag} --output "你的问题/方案摘要"`)
|
|
777
|
+
console.log(``)
|
|
778
|
+
console.log(`用户回答后执行:`)
|
|
779
|
+
console.log(`sillyspec run ${stageName} --continue --answer "用户回答"${changeFlag}`)
|
|
780
|
+
console.log(``)
|
|
781
|
+
console.log(`收到回答并完成本步骤总结后,再执行:`)
|
|
782
|
+
} else if (mayNeedWait) {
|
|
703
783
|
console.log(`如果需要用户决策(选择方案/确认设计等):`)
|
|
704
|
-
console.log(`sillyspec run ${stageName} --wait --reason "等待原因" --options "选项1
|
|
784
|
+
console.log(`sillyspec run ${stageName} --wait --reason "${step.waitReason || '等待原因'}" --options "${(step.waitOptions || ['选项1', '选项2']).join(',')}"${changeFlag} --output "你的摘要"`)
|
|
705
785
|
console.log(``)
|
|
706
786
|
console.log(`如果不需要用户决策,正常完成:`)
|
|
707
787
|
}
|
|
@@ -873,9 +953,14 @@ async function executeScanPostcheck(cwd, platformOpts, scanProfile) {
|
|
|
873
953
|
const { execSync } = await import('child_process')
|
|
874
954
|
const manifestDir = platformOpts.specRoot
|
|
875
955
|
let sourceCommit = null
|
|
956
|
+
let sourceCommitError = null
|
|
876
957
|
try {
|
|
877
|
-
const
|
|
878
|
-
|
|
958
|
+
const gitResult = safeGit(cwd, ['rev-parse', 'HEAD'])
|
|
959
|
+
sourceCommit = gitResult.value
|
|
960
|
+
sourceCommitError = gitResult.error
|
|
961
|
+
} catch (e) {
|
|
962
|
+
sourceCommitError = e.message
|
|
963
|
+
}
|
|
879
964
|
mkdirSync(manifestDir, { recursive: true })
|
|
880
965
|
const manifest = {
|
|
881
966
|
scan_profile: {
|
|
@@ -888,7 +973,7 @@ async function executeScanPostcheck(cwd, platformOpts, scanProfile) {
|
|
|
888
973
|
workspace_id: platformOpts.workspaceId || null,
|
|
889
974
|
scan_run_id: platformOpts.scanRunId || null,
|
|
890
975
|
source_commit: sourceCommit,
|
|
891
|
-
source_commit_error: sourceCommit === null ? (
|
|
976
|
+
source_commit_error: sourceCommit === null ? (sourceCommitError || 'unknown') : undefined,
|
|
892
977
|
generated_at: new Date().toISOString(),
|
|
893
978
|
schema_version: 2,
|
|
894
979
|
}
|
|
@@ -1057,6 +1142,7 @@ export async function runCommand(args, cwd, specDir = null) {
|
|
|
1057
1142
|
|
|
1058
1143
|
const isAllowNew = flags.includes('--allow-new')
|
|
1059
1144
|
const isForceBaseline = flags.includes('--force-baseline')
|
|
1145
|
+
const isForceRescan = flags.includes('--force-rescan')
|
|
1060
1146
|
|
|
1061
1147
|
// 未知参数 fail-fast
|
|
1062
1148
|
const knownFlags = new Set([
|
|
@@ -1065,7 +1151,7 @@ export async function runCommand(args, cwd, specDir = null) {
|
|
|
1065
1151
|
'--reason', '--options', '--answer', '--confirm-mode',
|
|
1066
1152
|
'--output', '--input', '--change',
|
|
1067
1153
|
'--spec-dir', '--spec-root', '--runtime-root', '--workspace-id', '--scan-run-id',
|
|
1068
|
-
'--files', '--allow-new', '--force-baseline',
|
|
1154
|
+
'--files', '--allow-new', '--force-baseline', '--force-rescan',
|
|
1069
1155
|
'--json', '--dir', '--help',
|
|
1070
1156
|
])
|
|
1071
1157
|
for (let i = 0; i < flags.length; i++) {
|
|
@@ -1102,8 +1188,8 @@ export async function runCommand(args, cwd, specDir = null) {
|
|
|
1102
1188
|
progress = { currentStage: stageName, stages: {}, lastActive: new Date().toLocaleString('zh-CN', { hour12: false }), project: '' }
|
|
1103
1189
|
}
|
|
1104
1190
|
} else {
|
|
1105
|
-
// brainstorm
|
|
1106
|
-
if (stageName === 'brainstorm'
|
|
1191
|
+
// brainstorm 作为流程入口,自动生成变更名并初始化
|
|
1192
|
+
if (stageName === 'brainstorm') {
|
|
1107
1193
|
const date = new Date().toISOString().slice(0, 10)
|
|
1108
1194
|
const autoName = `${date}-new-change`
|
|
1109
1195
|
console.log(`🔄 自动创建变更:${autoName}`)
|
|
@@ -1167,11 +1253,12 @@ export async function runCommand(args, cwd, specDir = null) {
|
|
|
1167
1253
|
|
|
1168
1254
|
// --done
|
|
1169
1255
|
if (isDone) {
|
|
1170
|
-
|
|
1256
|
+
const doneAnswer = getFlagValue('--answer')
|
|
1257
|
+
return await completeStep(pm, progress, stageName, cwd, outputText, inputText, { confirm: isConfirm, changeName: effectiveChange, nonInteractive: isNonInteractive && !isInteractive, platformOpts, confirmMode, doneAnswer })
|
|
1171
1258
|
}
|
|
1172
1259
|
|
|
1173
1260
|
// 默认:输出当前步骤
|
|
1174
|
-
return await runStage(pm, progress, stageName, cwd, effectiveChange, isSkipApproval, platformOpts, { quickFiles, isAllowNew, isForceBaseline })
|
|
1261
|
+
return await runStage(pm, progress, stageName, cwd, effectiveChange, isSkipApproval, platformOpts, { quickFiles, isAllowNew, isForceBaseline, isForceRescan })
|
|
1175
1262
|
}
|
|
1176
1263
|
|
|
1177
1264
|
/**
|
|
@@ -1187,6 +1274,7 @@ function resolveChangeNameAuto(cwd, specDir = null) {
|
|
|
1187
1274
|
}
|
|
1188
1275
|
|
|
1189
1276
|
async function runStage(pm, progress, stageName, cwd, changeName, skipApproval = false, platformOpts = {}, quickOpts = {}) {
|
|
1277
|
+
const specBase = platformOpts.specRoot || join(cwd, '.sillyspec')
|
|
1190
1278
|
// 状态转换校验
|
|
1191
1279
|
const prevStage = progress.currentStage || ''
|
|
1192
1280
|
const transition = checkTransition(prevStage, stageName)
|
|
@@ -1214,6 +1302,26 @@ async function runStage(pm, progress, stageName, cwd, changeName, skipApproval =
|
|
|
1214
1302
|
}
|
|
1215
1303
|
}
|
|
1216
1304
|
|
|
1305
|
+
// execute 阶段:CLI 自动创建 worktree(不等 AI agent)
|
|
1306
|
+
if (stageName === 'execute' && changeName) {
|
|
1307
|
+
const effectiveChange = changeName
|
|
1308
|
+
const { WorktreeManager } = await import('./worktree.js')
|
|
1309
|
+
const wm = new WorktreeManager({ cwd })
|
|
1310
|
+
const existingMeta = wm.getMeta(effectiveChange)
|
|
1311
|
+
if (existingMeta) {
|
|
1312
|
+
console.log(`🔗 worktree 已存在: ${existingMeta.worktreePath} (${existingMeta.mode})`)
|
|
1313
|
+
} else {
|
|
1314
|
+
try {
|
|
1315
|
+
const result = wm.create(effectiveChange)
|
|
1316
|
+
console.log(`🔗 worktree 已创建: ${result.worktreePath} (分支: ${result.branch}, 模式: ${result.mode})`)
|
|
1317
|
+
} catch (e) {
|
|
1318
|
+
console.error(`❌ worktree 创建失败: ${e.message}`)
|
|
1319
|
+
console.error(` 继续执行前请解决上述问题,或使用 --no-worktree 跳过。`)
|
|
1320
|
+
process.exit(1)
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1217
1325
|
// 自动探测 currentChange
|
|
1218
1326
|
if (autoDetectChange(progress, cwd)) {
|
|
1219
1327
|
progress.lastActive = new Date().toLocaleString('zh-CN', { hour12: false })
|
|
@@ -1268,6 +1376,28 @@ async function runStage(pm, progress, stageName, cwd, changeName, skipApproval =
|
|
|
1268
1376
|
}
|
|
1269
1377
|
if (scanProfile) platformOpts.scanProfile = scanProfile
|
|
1270
1378
|
|
|
1379
|
+
if (stageName === 'scan') {
|
|
1380
|
+
try {
|
|
1381
|
+
const gitResult = safeGit(cwd, ['rev-parse', 'HEAD'])
|
|
1382
|
+
const scanGuard = {
|
|
1383
|
+
sourceCommit: gitResult.value,
|
|
1384
|
+
sourceCommitError: gitResult.error,
|
|
1385
|
+
startedAt: new Date().toISOString(),
|
|
1386
|
+
forceRescan: quickOpts?.isForceRescan || false,
|
|
1387
|
+
}
|
|
1388
|
+
const guardFile = join(specBase, '.runtime', 'scan-guard.json')
|
|
1389
|
+
mkdirSync(dirname(guardFile), { recursive: true })
|
|
1390
|
+
writeFileSync(guardFile, JSON.stringify(scanGuard, null, 2) + '\n')
|
|
1391
|
+
if (scanGuard.forceRescan) {
|
|
1392
|
+
console.log('🛡️ scan 覆盖保护已记录: --force-rescan 已开启')
|
|
1393
|
+
} else {
|
|
1394
|
+
console.log('🛡️ scan 覆盖保护已记录: existing scan docs require current source_commit/updated_at')
|
|
1395
|
+
}
|
|
1396
|
+
} catch (e) {
|
|
1397
|
+
console.warn(`⚠️ scan 覆盖保护记录失败: ${e.message}`)
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1271
1401
|
if (currentIdx === -1) {
|
|
1272
1402
|
// 已完成 → 自动重置,重新开始
|
|
1273
1403
|
const freshSteps = await getStageSteps(stageName, cwd, progress)
|
|
@@ -1400,9 +1530,8 @@ function validateFileLocations(cwd, stageName, progress, changeName, specBase) {
|
|
|
1400
1530
|
|
|
1401
1531
|
// 每个阶段完成后预期存在的文件
|
|
1402
1532
|
const expectedFiles = {
|
|
1403
|
-
|
|
1533
|
+
brainstorm: ['design.md', 'proposal.md', 'requirements.md', 'tasks.md'],
|
|
1404
1534
|
plan: ['plan.md'],
|
|
1405
|
-
verify: ['verify-result.md'],
|
|
1406
1535
|
archive: ['module-impact.md'],
|
|
1407
1536
|
}
|
|
1408
1537
|
|
|
@@ -1434,7 +1563,7 @@ function validateFileLocations(cwd, stageName, progress, changeName, specBase) {
|
|
|
1434
1563
|
}
|
|
1435
1564
|
}
|
|
1436
1565
|
|
|
1437
|
-
async function archiveChangeDirectory(pm, cwd, progress) {
|
|
1566
|
+
async function archiveChangeDirectory(pm, cwd, progress, specBase) {
|
|
1438
1567
|
const { renameSync } = await import('fs')
|
|
1439
1568
|
const archiveChangeName = progress.currentChange
|
|
1440
1569
|
if (!archiveChangeName) {
|
|
@@ -1486,6 +1615,28 @@ async function waitStep(pm, progress, stageName, cwd, outputText, waitReason, wa
|
|
|
1486
1615
|
process.exit(1)
|
|
1487
1616
|
}
|
|
1488
1617
|
|
|
1618
|
+
// 前置检查:不允许已有 waiting 步骤时再 --wait
|
|
1619
|
+
const existingWaitingIdx = stageData.steps.findIndex(s => s.status === 'waiting')
|
|
1620
|
+
if (existingWaitingIdx !== -1) {
|
|
1621
|
+
const ws = stageData.steps[existingWaitingIdx]
|
|
1622
|
+
console.error(`❌ 已有步骤处于等待状态:Step ${existingWaitingIdx + 1} "${ws.name}"`)
|
|
1623
|
+
console.error(` 请先 --continue 或 --reset 该步骤,再开始新的 --wait`)
|
|
1624
|
+
process.exit(1)
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
// maxWaitRounds 硬上限:达到后拒绝继续 --wait
|
|
1628
|
+
const currentStep = stageData.steps[currentIdx]
|
|
1629
|
+
const defSteps = await getStageSteps(stageName, cwd, progress, platformOpts?.specRoot || null)
|
|
1630
|
+
const stepDef = defSteps?.[currentIdx] || {}
|
|
1631
|
+
const maxWaitRounds = currentStep.maxWaitRounds ?? stepDef.maxWaitRounds
|
|
1632
|
+
const currentWaitRound = currentStep.waitRound || 0
|
|
1633
|
+
if (maxWaitRounds && currentWaitRound >= maxWaitRounds) {
|
|
1634
|
+
console.error(`❌ Step "${currentStep.name}" 已达到最大等待轮次(maxWaitRounds=${maxWaitRounds})`)
|
|
1635
|
+
console.error(` 请基于已有回答完成本步骤:`)
|
|
1636
|
+
console.error(` sillyspec run ${stageName} --done${changeName ? ` --change ${changeName}` : ''} --output "需求理解摘要"`)
|
|
1637
|
+
process.exit(1)
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1489
1640
|
// 非交互模式下拒绝等待
|
|
1490
1641
|
if (nonInteractive) {
|
|
1491
1642
|
console.error(`❌ Human decision required in non-interactive mode.`)
|
|
@@ -1560,38 +1711,82 @@ async function continueStep(pm, progress, stageName, cwd, answer, options = {})
|
|
|
1560
1711
|
process.exit(1)
|
|
1561
1712
|
}
|
|
1562
1713
|
const currentIdx = waitingSteps[0].idx
|
|
1714
|
+
const defSteps = await getStageSteps(stageName, cwd, progress, platformOpts?.specRoot || null)
|
|
1715
|
+
const currentStepDef = defSteps?.[currentIdx] || {}
|
|
1716
|
+
const currentStep = stageData.steps[currentIdx]
|
|
1717
|
+
const isRepeatableWait = currentStepDef.repeatableWait === true || currentStep.repeatableWait === true
|
|
1718
|
+
const requiresWait = currentStepDef.requiresWait === true || currentStep.requiresWait === true
|
|
1719
|
+
const shouldReturnToCurrentStep = isRepeatableWait || requiresWait
|
|
1563
1720
|
|
|
1564
1721
|
const now = new Date().toLocaleString('zh-CN', { hour12: false })
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1722
|
+
const prevOutput = currentStep.output || ''
|
|
1723
|
+
const waitRound = (currentStep.waitRound || 0) + 1
|
|
1724
|
+
currentStep.waitRound = waitRound
|
|
1725
|
+
currentStep.waitAnswer = answer
|
|
1726
|
+
currentStep.waitAnswers = Array.isArray(currentStep.waitAnswers) ? currentStep.waitAnswers : []
|
|
1727
|
+
currentStep.waitAnswers.push({
|
|
1728
|
+
round: waitRound,
|
|
1729
|
+
answer,
|
|
1730
|
+
question: prevOutput || null,
|
|
1731
|
+
answeredAt: now,
|
|
1732
|
+
})
|
|
1733
|
+
currentStep.maxWaitRounds = currentStepDef.maxWaitRounds ?? currentStep.maxWaitRounds
|
|
1568
1734
|
|
|
1569
1735
|
// 合并 waiting 信息到 output
|
|
1570
|
-
const
|
|
1571
|
-
const waitInfo = stageData.steps[currentIdx].waitReason || ''
|
|
1736
|
+
const waitInfo = currentStep.waitReason || ''
|
|
1572
1737
|
if (waitInfo) {
|
|
1573
|
-
|
|
1574
|
-
? `${prevOutput} |
|
|
1575
|
-
:
|
|
1738
|
+
currentStep.output = prevOutput
|
|
1739
|
+
? `${prevOutput} | 用户回答#${waitRound}:${answer}`
|
|
1740
|
+
: `用户回答#${waitRound}:${answer}`
|
|
1576
1741
|
}
|
|
1577
1742
|
|
|
1578
1743
|
// 清除等待状态
|
|
1579
|
-
delete
|
|
1580
|
-
delete
|
|
1581
|
-
delete
|
|
1744
|
+
delete currentStep.waitReason
|
|
1745
|
+
delete currentStep.waitOptions
|
|
1746
|
+
delete currentStep.waitedAt
|
|
1747
|
+
|
|
1748
|
+
if (shouldReturnToCurrentStep) {
|
|
1749
|
+
currentStep.status = 'pending'
|
|
1750
|
+
currentStep.completedAt = null
|
|
1751
|
+
} else {
|
|
1752
|
+
currentStep.status = 'completed'
|
|
1753
|
+
currentStep.completedAt = now
|
|
1754
|
+
}
|
|
1582
1755
|
|
|
1583
1756
|
progress.lastActive = now
|
|
1584
1757
|
await pm._write(cwd, progress, changeName)
|
|
1585
1758
|
triggerSync(cwd, changeName, platformOpts)
|
|
1586
1759
|
|
|
1587
|
-
console.log(`✅ Step ${currentIdx + 1}/${stageData.steps.length} 已继续:${
|
|
1760
|
+
console.log(`✅ Step ${currentIdx + 1}/${stageData.steps.length} 已继续:${currentStep.name}`)
|
|
1588
1761
|
console.log(` 回答:${answer}`)
|
|
1589
1762
|
|
|
1590
1763
|
// Append to user-inputs.md
|
|
1591
1764
|
const inputsPath = join(specBase, '.runtime', 'user-inputs.md')
|
|
1592
|
-
const entry = `\n## ${now} | ${changeName || '?'} | ${stageName}: ${
|
|
1765
|
+
const entry = `\n## ${now} | ${changeName || '?'} | ${stageName}: ${currentStep.name} [CONTINUED]\n- 回答:${answer}\n`
|
|
1593
1766
|
appendFileSync(inputsPath, entry)
|
|
1594
1767
|
|
|
1768
|
+
// shouldReturnToCurrentStep: 回到当前步骤继续执行(repeatable=多轮探索,requiresWait=确认后执行动作)
|
|
1769
|
+
if (shouldReturnToCurrentStep) {
|
|
1770
|
+
console.log(`\n🔁 Step ${currentIdx + 1}/${stageData.steps.length} 已收到用户输入,回到当前步骤继续执行。`)
|
|
1771
|
+
if (isRepeatableWait) {
|
|
1772
|
+
console.log(` 已收集回答轮次:${waitRound}${currentStep.maxWaitRounds ? `/${currentStep.maxWaitRounds}` : ''}`)
|
|
1773
|
+
}
|
|
1774
|
+
if (defSteps && defSteps[currentIdx]) {
|
|
1775
|
+
console.log('')
|
|
1776
|
+
await outputStep(
|
|
1777
|
+
stageName,
|
|
1778
|
+
currentIdx,
|
|
1779
|
+
defSteps,
|
|
1780
|
+
cwd,
|
|
1781
|
+
changeName,
|
|
1782
|
+
progress.project || null,
|
|
1783
|
+
platformOpts,
|
|
1784
|
+
formatWaitHistory(currentStep)
|
|
1785
|
+
)
|
|
1786
|
+
}
|
|
1787
|
+
return { stageCompleted: false, currentIdx, nextPendingIdx: currentIdx }
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1595
1790
|
// 检查阶段是否全部完成
|
|
1596
1791
|
const nextPendingIdx = stageData.steps.findIndex(s => s.status === 'pending')
|
|
1597
1792
|
const nextWaitingIdx = stageData.steps.findIndex(s => s.status === 'waiting')
|
|
@@ -1604,7 +1799,6 @@ async function continueStep(pm, progress, stageName, cwd, answer, options = {})
|
|
|
1604
1799
|
}
|
|
1605
1800
|
|
|
1606
1801
|
// 输出下一步
|
|
1607
|
-
const defSteps = await getStageSteps(stageName, cwd, progress)
|
|
1608
1802
|
if (nextPendingIdx !== -1 && defSteps) {
|
|
1609
1803
|
console.log('')
|
|
1610
1804
|
await outputStep(stageName, nextPendingIdx, defSteps, cwd, changeName, progress.project || null, platformOpts, answer)
|
|
@@ -1649,6 +1843,35 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
|
|
|
1649
1843
|
|
|
1650
1844
|
const steps = stageData.steps
|
|
1651
1845
|
const currentIdx = steps.findIndex(s => s.status === 'pending' || s.status === 'in-progress')
|
|
1846
|
+
if (currentIdx === -1) {
|
|
1847
|
+
console.error('没有待完成的步骤')
|
|
1848
|
+
process.exit(1)
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
// ── requiresWait 硬门控 ──
|
|
1852
|
+
const defStepsForCurrent = await getStageSteps(stageName, cwd, progress, platformOpts?.specRoot || null)
|
|
1853
|
+
const currentStepDef = defStepsForCurrent?.[currentIdx] || {}
|
|
1854
|
+
const currentStep = steps[currentIdx]
|
|
1855
|
+
if (currentStepDef.requiresWait === true && !currentStep.waitAnswer) {
|
|
1856
|
+
// 检查 --done 是否带了 --answer:如果是,自动补全 waitAnswer 状态,一步完成
|
|
1857
|
+
const doneAnswer = typeof options !== 'undefined' && options.doneAnswer ? options.doneAnswer : null
|
|
1858
|
+
if (doneAnswer) {
|
|
1859
|
+
currentStep.status = 'waiting'
|
|
1860
|
+
currentStep.waitAnswer = doneAnswer
|
|
1861
|
+
currentStep.waitReason = currentStepDef.waitReason || '等待用户输入'
|
|
1862
|
+
console.log(`⚠️ Step "${currentStep.name}" 需要 wait,但 --done 带了 --answer,自动补全 wait 状态。`)
|
|
1863
|
+
} else {
|
|
1864
|
+
console.error(`❌ Step "${currentStep.name}" 必须先等待用户输入,不能直接 --done。`)
|
|
1865
|
+
console.error(` 原因:${currentStepDef.waitReason || '该步骤需要人工确认/回答'}`)
|
|
1866
|
+
if (currentStepDef.waitOptions) {
|
|
1867
|
+
console.error(` 选项:${currentStepDef.waitOptions.join(', ')}`)
|
|
1868
|
+
}
|
|
1869
|
+
console.error(` 请先执行:`)
|
|
1870
|
+
console.error(` sillyspec run ${stageName} --wait --reason "${currentStepDef.waitReason || '等待用户输入'}" --options "${(currentStepDef.waitOptions || ['确认']).join(',')}"${changeName ? ` --change ${changeName}` : ''} --output "你的问题/方案摘要"`)
|
|
1871
|
+
console.error(` 或使用 --done --answer "用户回答" 一步完成 wait + done`)
|
|
1872
|
+
process.exit(1)
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1652
1875
|
|
|
1653
1876
|
steps[currentIdx].status = 'completed'
|
|
1654
1877
|
steps[currentIdx].completedAt = new Date().toLocaleString('zh-CN',{hour12:false})
|
|
@@ -1679,7 +1902,7 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
|
|
|
1679
1902
|
console.log('⚠️ 请添加 --confirm 确认归档,例如:sillyspec run archive --done --confirm --output "确认归档"')
|
|
1680
1903
|
return { stageCompleted: false, currentIdx, nextPendingIdx: currentIdx }
|
|
1681
1904
|
}
|
|
1682
|
-
await archiveChangeDirectory(pm, cwd, progress)
|
|
1905
|
+
await archiveChangeDirectory(pm, cwd, progress, specBase)
|
|
1683
1906
|
}
|
|
1684
1907
|
|
|
1685
1908
|
// archive "确认归档" 步骤完成后,校验归档完整性
|
|
@@ -1881,6 +2104,25 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
|
|
|
1881
2104
|
console.error(` 请先创建 quicklog 记录再 --done,或使用 --skip-approval 跳过此校验。`)
|
|
1882
2105
|
return { stageCompleted: false, currentIdx, nextPendingIdx: -1 }
|
|
1883
2106
|
}
|
|
2107
|
+
if (progress.quickGuard) {
|
|
2108
|
+
const review = await auditQuickCompletion(cwd, progress.quickGuard, { isConfirm })
|
|
2109
|
+
progress.quickGuard.review = review
|
|
2110
|
+
progress.quickGuard.completedAt = new Date().toISOString()
|
|
2111
|
+
printQuickAuditReview(review)
|
|
2112
|
+
if (review.status === 'blocked') {
|
|
2113
|
+
steps[currentIdx].status = 'pending'
|
|
2114
|
+
steps[currentIdx].completedAt = null
|
|
2115
|
+
if (outputText) steps[currentIdx].output = null
|
|
2116
|
+
process.exit(1)
|
|
2117
|
+
}
|
|
2118
|
+
try {
|
|
2119
|
+
const { unlinkSync } = await import('fs')
|
|
2120
|
+
const guardFile = join(specBase, '.runtime', 'quick-guard.json')
|
|
2121
|
+
unlinkSync(guardFile)
|
|
2122
|
+
} catch {}
|
|
2123
|
+
progress.lastQuickReview = review
|
|
2124
|
+
delete progress.quickGuard
|
|
2125
|
+
}
|
|
1884
2126
|
}
|
|
1885
2127
|
|
|
1886
2128
|
stageData.status = 'completed'
|
|
@@ -1906,9 +2148,14 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
|
|
|
1906
2148
|
const manifestDir = platformOpts.specRoot
|
|
1907
2149
|
mkdirSync(manifestDir, { recursive: true })
|
|
1908
2150
|
let sourceCommit = null
|
|
2151
|
+
let sourceCommitError = null
|
|
1909
2152
|
try {
|
|
1910
|
-
const
|
|
1911
|
-
|
|
2153
|
+
const gitResult = safeGit(cwd, ['rev-parse', 'HEAD'])
|
|
2154
|
+
sourceCommit = gitResult.value
|
|
2155
|
+
sourceCommitError = gitResult.error
|
|
2156
|
+
} catch (e) {
|
|
2157
|
+
sourceCommitError = e.message
|
|
2158
|
+
}
|
|
1912
2159
|
const manifest = {
|
|
1913
2160
|
workspace_id: platformOpts.workspaceId || null,
|
|
1914
2161
|
scan_run_id: platformOpts.scanRunId || null,
|
|
@@ -1916,7 +2163,7 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
|
|
|
1916
2163
|
spec_root: platformOpts.specRoot || null,
|
|
1917
2164
|
runtime_root: platformOpts.runtimeRoot || null,
|
|
1918
2165
|
source_commit: sourceCommit,
|
|
1919
|
-
source_commit_error: sourceCommit === null ? (
|
|
2166
|
+
source_commit_error: sourceCommit === null ? (sourceCommitError || 'unknown') : undefined,
|
|
1920
2167
|
generated_at: new Date().toISOString(),
|
|
1921
2168
|
schema_version: 1,
|
|
1922
2169
|
postcheck_result_path: null,
|
|
@@ -2018,10 +2265,16 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
|
|
|
2018
2265
|
}
|
|
2019
2266
|
}
|
|
2020
2267
|
|
|
2268
|
+
// 防御性守卫变量:确认所有步骤确实标记为 completed
|
|
2269
|
+
const actualCompleted = steps.filter(s => s.status === 'completed').length
|
|
2270
|
+
const actualTotal = steps.length
|
|
2271
|
+
|
|
2021
2272
|
validateMetadata(cwd, stageName, specBase)
|
|
2022
2273
|
|
|
2023
|
-
//
|
|
2024
|
-
|
|
2274
|
+
// 验证关键文件是否在正确的变更目录下(仅当所有步骤确实完成时才校验)
|
|
2275
|
+
if (actualCompleted === actualTotal && actualTotal > 0) {
|
|
2276
|
+
validateFileLocations(cwd, stageName, progress, changeName, specBase)
|
|
2277
|
+
}
|
|
2025
2278
|
|
|
2026
2279
|
// 辅助阶段完成后重置步骤
|
|
2027
2280
|
const stageDef = stageRegistry[stageName]
|
|
@@ -2065,51 +2318,30 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
|
|
|
2065
2318
|
} else {
|
|
2066
2319
|
console.log(`\n下一步由你决定:sillyspec run <stage>(brainstorm/plan/execute/verify/archive 等)`)
|
|
2067
2320
|
}
|
|
2068
|
-
return { stageCompleted: true, currentIdx, nextPendingIdx: -1 }
|
|
2069
|
-
}
|
|
2070
2321
|
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
if (contractResult.warnings.length > 0) {
|
|
2082
|
-
console.warn(`\n⚠️ 阶段 ${stageName} 校验警告:`)
|
|
2083
|
-
for (const w of contractResult.warnings) {
|
|
2084
|
-
console.warn(` - ${w}`)
|
|
2085
|
-
}
|
|
2086
|
-
}
|
|
2087
|
-
|
|
2088
|
-
// quick 阶段完成审计
|
|
2089
|
-
if (stageName === 'quick' && progress.quickGuard) {
|
|
2090
|
-
const review = await auditQuickCompletion(cwd, progress.quickGuard, { isConfirm })
|
|
2091
|
-
progress.quickGuard.review = review
|
|
2092
|
-
progress.quickGuard.completedAt = new Date().toISOString()
|
|
2093
|
-
// 清理 quick-guard.json
|
|
2094
|
-
try {
|
|
2095
|
-
const { unlinkSync } = await import('fs')
|
|
2096
|
-
const guardFile = join(specBase, '.runtime', 'quick-guard.json')
|
|
2097
|
-
unlinkSync(guardFile)
|
|
2098
|
-
} catch {}
|
|
2099
|
-
if (review.status === 'blocked') {
|
|
2100
|
-
console.error(`\n🚫 quick 变更边界审计 — BLOCKED:`)
|
|
2101
|
-
for (const r of review.reasons) {
|
|
2102
|
-
console.error(` - ${r}`)
|
|
2322
|
+
// 阶段完成校验 — 防御性守卫:仅当所有步骤确实标记为 completed 时才跑 validator
|
|
2323
|
+
if (actualCompleted === actualTotal && actualTotal > 0) {
|
|
2324
|
+
const projectName = progress.project || basename(cwd)
|
|
2325
|
+
const contractResult = runValidators(stageName, cwd, changeName, { projectName, specRoot: platformOpts?.specRoot })
|
|
2326
|
+
if (contractResult.errors.length > 0) {
|
|
2327
|
+
console.error(`\n❌ 阶段 ${stageName} 校验失败:`)
|
|
2328
|
+
for (const err of contractResult.errors) {
|
|
2329
|
+
console.error(` - ${err}`)
|
|
2330
|
+
}
|
|
2331
|
+
console.error(`\n 提示:修复缺失产物后重新运行此步骤,或使用 --skip-approval 跳过校验`)
|
|
2103
2332
|
}
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2333
|
+
if (contractResult.warnings.length > 0) {
|
|
2334
|
+
console.warn(`\n⚠️ 阶段 ${stageName} 校验警告:`)
|
|
2335
|
+
for (const w of contractResult.warnings) {
|
|
2336
|
+
console.warn(` - ${w}`)
|
|
2337
|
+
}
|
|
2109
2338
|
}
|
|
2110
|
-
} else {
|
|
2111
|
-
|
|
2339
|
+
} else if (actualCompleted < actualTotal) {
|
|
2340
|
+
// 实际步骤未全部完成,跳过 validator(状态可能不同步)
|
|
2341
|
+
console.log(`\n⚠️ 阶段校验跳过:${actualTotal} 步中仅 ${actualCompleted} 步标记为已完成,可能存在状态不同步。如确认阶段已完成,请运行 --status 确认。`)
|
|
2112
2342
|
}
|
|
2343
|
+
|
|
2344
|
+
return { stageCompleted: true, currentIdx, nextPendingIdx: -1 }
|
|
2113
2345
|
}
|
|
2114
2346
|
|
|
2115
2347
|
progress.lastActive = new Date().toLocaleString('zh-CN',{hour12:false})
|