sillyspec 3.18.0 → 3.18.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/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 +255 -66
- package/src/stage-contract.js +244 -12
- package/src/stages/brainstorm.js +228 -8
- 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}`)
|
|
@@ -1171,7 +1257,7 @@ export async function runCommand(args, cwd, specDir = null) {
|
|
|
1171
1257
|
}
|
|
1172
1258
|
|
|
1173
1259
|
// 默认:输出当前步骤
|
|
1174
|
-
return await runStage(pm, progress, stageName, cwd, effectiveChange, isSkipApproval, platformOpts, { quickFiles, isAllowNew, isForceBaseline })
|
|
1260
|
+
return await runStage(pm, progress, stageName, cwd, effectiveChange, isSkipApproval, platformOpts, { quickFiles, isAllowNew, isForceBaseline, isForceRescan })
|
|
1175
1261
|
}
|
|
1176
1262
|
|
|
1177
1263
|
/**
|
|
@@ -1187,6 +1273,7 @@ function resolveChangeNameAuto(cwd, specDir = null) {
|
|
|
1187
1273
|
}
|
|
1188
1274
|
|
|
1189
1275
|
async function runStage(pm, progress, stageName, cwd, changeName, skipApproval = false, platformOpts = {}, quickOpts = {}) {
|
|
1276
|
+
const specBase = platformOpts.specRoot || join(cwd, '.sillyspec')
|
|
1190
1277
|
// 状态转换校验
|
|
1191
1278
|
const prevStage = progress.currentStage || ''
|
|
1192
1279
|
const transition = checkTransition(prevStage, stageName)
|
|
@@ -1268,6 +1355,28 @@ async function runStage(pm, progress, stageName, cwd, changeName, skipApproval =
|
|
|
1268
1355
|
}
|
|
1269
1356
|
if (scanProfile) platformOpts.scanProfile = scanProfile
|
|
1270
1357
|
|
|
1358
|
+
if (stageName === 'scan') {
|
|
1359
|
+
try {
|
|
1360
|
+
const gitResult = safeGit(cwd, ['rev-parse', 'HEAD'])
|
|
1361
|
+
const scanGuard = {
|
|
1362
|
+
sourceCommit: gitResult.value,
|
|
1363
|
+
sourceCommitError: gitResult.error,
|
|
1364
|
+
startedAt: new Date().toISOString(),
|
|
1365
|
+
forceRescan: quickOpts?.isForceRescan || false,
|
|
1366
|
+
}
|
|
1367
|
+
const guardFile = join(specBase, '.runtime', 'scan-guard.json')
|
|
1368
|
+
mkdirSync(dirname(guardFile), { recursive: true })
|
|
1369
|
+
writeFileSync(guardFile, JSON.stringify(scanGuard, null, 2) + '\n')
|
|
1370
|
+
if (scanGuard.forceRescan) {
|
|
1371
|
+
console.log('🛡️ scan 覆盖保护已记录: --force-rescan 已开启')
|
|
1372
|
+
} else {
|
|
1373
|
+
console.log('🛡️ scan 覆盖保护已记录: existing scan docs require current source_commit/updated_at')
|
|
1374
|
+
}
|
|
1375
|
+
} catch (e) {
|
|
1376
|
+
console.warn(`⚠️ scan 覆盖保护记录失败: ${e.message}`)
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1271
1380
|
if (currentIdx === -1) {
|
|
1272
1381
|
// 已完成 → 自动重置,重新开始
|
|
1273
1382
|
const freshSteps = await getStageSteps(stageName, cwd, progress)
|
|
@@ -1400,9 +1509,8 @@ function validateFileLocations(cwd, stageName, progress, changeName, specBase) {
|
|
|
1400
1509
|
|
|
1401
1510
|
// 每个阶段完成后预期存在的文件
|
|
1402
1511
|
const expectedFiles = {
|
|
1403
|
-
|
|
1512
|
+
brainstorm: ['design.md', 'proposal.md', 'requirements.md', 'tasks.md'],
|
|
1404
1513
|
plan: ['plan.md'],
|
|
1405
|
-
verify: ['verify-result.md'],
|
|
1406
1514
|
archive: ['module-impact.md'],
|
|
1407
1515
|
}
|
|
1408
1516
|
|
|
@@ -1434,7 +1542,7 @@ function validateFileLocations(cwd, stageName, progress, changeName, specBase) {
|
|
|
1434
1542
|
}
|
|
1435
1543
|
}
|
|
1436
1544
|
|
|
1437
|
-
async function archiveChangeDirectory(pm, cwd, progress) {
|
|
1545
|
+
async function archiveChangeDirectory(pm, cwd, progress, specBase) {
|
|
1438
1546
|
const { renameSync } = await import('fs')
|
|
1439
1547
|
const archiveChangeName = progress.currentChange
|
|
1440
1548
|
if (!archiveChangeName) {
|
|
@@ -1486,6 +1594,28 @@ async function waitStep(pm, progress, stageName, cwd, outputText, waitReason, wa
|
|
|
1486
1594
|
process.exit(1)
|
|
1487
1595
|
}
|
|
1488
1596
|
|
|
1597
|
+
// 前置检查:不允许已有 waiting 步骤时再 --wait
|
|
1598
|
+
const existingWaitingIdx = stageData.steps.findIndex(s => s.status === 'waiting')
|
|
1599
|
+
if (existingWaitingIdx !== -1) {
|
|
1600
|
+
const ws = stageData.steps[existingWaitingIdx]
|
|
1601
|
+
console.error(`❌ 已有步骤处于等待状态:Step ${existingWaitingIdx + 1} "${ws.name}"`)
|
|
1602
|
+
console.error(` 请先 --continue 或 --reset 该步骤,再开始新的 --wait`)
|
|
1603
|
+
process.exit(1)
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
// maxWaitRounds 硬上限:达到后拒绝继续 --wait
|
|
1607
|
+
const currentStep = stageData.steps[currentIdx]
|
|
1608
|
+
const defSteps = await getStageSteps(stageName, cwd, progress, platformOpts?.specRoot || null)
|
|
1609
|
+
const stepDef = defSteps?.[currentIdx] || {}
|
|
1610
|
+
const maxWaitRounds = currentStep.maxWaitRounds ?? stepDef.maxWaitRounds
|
|
1611
|
+
const currentWaitRound = currentStep.waitRound || 0
|
|
1612
|
+
if (maxWaitRounds && currentWaitRound >= maxWaitRounds) {
|
|
1613
|
+
console.error(`❌ Step "${currentStep.name}" 已达到最大等待轮次(maxWaitRounds=${maxWaitRounds})`)
|
|
1614
|
+
console.error(` 请基于已有回答完成本步骤:`)
|
|
1615
|
+
console.error(` sillyspec run ${stageName} --done${changeName ? ` --change ${changeName}` : ''} --output "需求理解摘要"`)
|
|
1616
|
+
process.exit(1)
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1489
1619
|
// 非交互模式下拒绝等待
|
|
1490
1620
|
if (nonInteractive) {
|
|
1491
1621
|
console.error(`❌ Human decision required in non-interactive mode.`)
|
|
@@ -1560,38 +1690,82 @@ async function continueStep(pm, progress, stageName, cwd, answer, options = {})
|
|
|
1560
1690
|
process.exit(1)
|
|
1561
1691
|
}
|
|
1562
1692
|
const currentIdx = waitingSteps[0].idx
|
|
1693
|
+
const defSteps = await getStageSteps(stageName, cwd, progress, platformOpts?.specRoot || null)
|
|
1694
|
+
const currentStepDef = defSteps?.[currentIdx] || {}
|
|
1695
|
+
const currentStep = stageData.steps[currentIdx]
|
|
1696
|
+
const isRepeatableWait = currentStepDef.repeatableWait === true || currentStep.repeatableWait === true
|
|
1697
|
+
const requiresWait = currentStepDef.requiresWait === true || currentStep.requiresWait === true
|
|
1698
|
+
const shouldReturnToCurrentStep = isRepeatableWait || requiresWait
|
|
1563
1699
|
|
|
1564
1700
|
const now = new Date().toLocaleString('zh-CN', { hour12: false })
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1701
|
+
const prevOutput = currentStep.output || ''
|
|
1702
|
+
const waitRound = (currentStep.waitRound || 0) + 1
|
|
1703
|
+
currentStep.waitRound = waitRound
|
|
1704
|
+
currentStep.waitAnswer = answer
|
|
1705
|
+
currentStep.waitAnswers = Array.isArray(currentStep.waitAnswers) ? currentStep.waitAnswers : []
|
|
1706
|
+
currentStep.waitAnswers.push({
|
|
1707
|
+
round: waitRound,
|
|
1708
|
+
answer,
|
|
1709
|
+
question: prevOutput || null,
|
|
1710
|
+
answeredAt: now,
|
|
1711
|
+
})
|
|
1712
|
+
currentStep.maxWaitRounds = currentStepDef.maxWaitRounds ?? currentStep.maxWaitRounds
|
|
1568
1713
|
|
|
1569
1714
|
// 合并 waiting 信息到 output
|
|
1570
|
-
const
|
|
1571
|
-
const waitInfo = stageData.steps[currentIdx].waitReason || ''
|
|
1715
|
+
const waitInfo = currentStep.waitReason || ''
|
|
1572
1716
|
if (waitInfo) {
|
|
1573
|
-
|
|
1574
|
-
? `${prevOutput} |
|
|
1575
|
-
:
|
|
1717
|
+
currentStep.output = prevOutput
|
|
1718
|
+
? `${prevOutput} | 用户回答#${waitRound}:${answer}`
|
|
1719
|
+
: `用户回答#${waitRound}:${answer}`
|
|
1576
1720
|
}
|
|
1577
1721
|
|
|
1578
1722
|
// 清除等待状态
|
|
1579
|
-
delete
|
|
1580
|
-
delete
|
|
1581
|
-
delete
|
|
1723
|
+
delete currentStep.waitReason
|
|
1724
|
+
delete currentStep.waitOptions
|
|
1725
|
+
delete currentStep.waitedAt
|
|
1726
|
+
|
|
1727
|
+
if (shouldReturnToCurrentStep) {
|
|
1728
|
+
currentStep.status = 'pending'
|
|
1729
|
+
currentStep.completedAt = null
|
|
1730
|
+
} else {
|
|
1731
|
+
currentStep.status = 'completed'
|
|
1732
|
+
currentStep.completedAt = now
|
|
1733
|
+
}
|
|
1582
1734
|
|
|
1583
1735
|
progress.lastActive = now
|
|
1584
1736
|
await pm._write(cwd, progress, changeName)
|
|
1585
1737
|
triggerSync(cwd, changeName, platformOpts)
|
|
1586
1738
|
|
|
1587
|
-
console.log(`✅ Step ${currentIdx + 1}/${stageData.steps.length} 已继续:${
|
|
1739
|
+
console.log(`✅ Step ${currentIdx + 1}/${stageData.steps.length} 已继续:${currentStep.name}`)
|
|
1588
1740
|
console.log(` 回答:${answer}`)
|
|
1589
1741
|
|
|
1590
1742
|
// Append to user-inputs.md
|
|
1591
1743
|
const inputsPath = join(specBase, '.runtime', 'user-inputs.md')
|
|
1592
|
-
const entry = `\n## ${now} | ${changeName || '?'} | ${stageName}: ${
|
|
1744
|
+
const entry = `\n## ${now} | ${changeName || '?'} | ${stageName}: ${currentStep.name} [CONTINUED]\n- 回答:${answer}\n`
|
|
1593
1745
|
appendFileSync(inputsPath, entry)
|
|
1594
1746
|
|
|
1747
|
+
// shouldReturnToCurrentStep: 回到当前步骤继续执行(repeatable=多轮探索,requiresWait=确认后执行动作)
|
|
1748
|
+
if (shouldReturnToCurrentStep) {
|
|
1749
|
+
console.log(`\n🔁 Step ${currentIdx + 1}/${stageData.steps.length} 已收到用户输入,回到当前步骤继续执行。`)
|
|
1750
|
+
if (isRepeatableWait) {
|
|
1751
|
+
console.log(` 已收集回答轮次:${waitRound}${currentStep.maxWaitRounds ? `/${currentStep.maxWaitRounds}` : ''}`)
|
|
1752
|
+
}
|
|
1753
|
+
if (defSteps && defSteps[currentIdx]) {
|
|
1754
|
+
console.log('')
|
|
1755
|
+
await outputStep(
|
|
1756
|
+
stageName,
|
|
1757
|
+
currentIdx,
|
|
1758
|
+
defSteps,
|
|
1759
|
+
cwd,
|
|
1760
|
+
changeName,
|
|
1761
|
+
progress.project || null,
|
|
1762
|
+
platformOpts,
|
|
1763
|
+
formatWaitHistory(currentStep)
|
|
1764
|
+
)
|
|
1765
|
+
}
|
|
1766
|
+
return { stageCompleted: false, currentIdx, nextPendingIdx: currentIdx }
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1595
1769
|
// 检查阶段是否全部完成
|
|
1596
1770
|
const nextPendingIdx = stageData.steps.findIndex(s => s.status === 'pending')
|
|
1597
1771
|
const nextWaitingIdx = stageData.steps.findIndex(s => s.status === 'waiting')
|
|
@@ -1604,7 +1778,6 @@ async function continueStep(pm, progress, stageName, cwd, answer, options = {})
|
|
|
1604
1778
|
}
|
|
1605
1779
|
|
|
1606
1780
|
// 输出下一步
|
|
1607
|
-
const defSteps = await getStageSteps(stageName, cwd, progress)
|
|
1608
1781
|
if (nextPendingIdx !== -1 && defSteps) {
|
|
1609
1782
|
console.log('')
|
|
1610
1783
|
await outputStep(stageName, nextPendingIdx, defSteps, cwd, changeName, progress.project || null, platformOpts, answer)
|
|
@@ -1649,6 +1822,25 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
|
|
|
1649
1822
|
|
|
1650
1823
|
const steps = stageData.steps
|
|
1651
1824
|
const currentIdx = steps.findIndex(s => s.status === 'pending' || s.status === 'in-progress')
|
|
1825
|
+
if (currentIdx === -1) {
|
|
1826
|
+
console.error('没有待完成的步骤')
|
|
1827
|
+
process.exit(1)
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
// ── requiresWait 硬门控 ──
|
|
1831
|
+
const defStepsForCurrent = await getStageSteps(stageName, cwd, progress, platformOpts?.specRoot || null)
|
|
1832
|
+
const currentStepDef = defStepsForCurrent?.[currentIdx] || {}
|
|
1833
|
+
const currentStep = steps[currentIdx]
|
|
1834
|
+
if (currentStepDef.requiresWait === true && !currentStep.waitAnswer) {
|
|
1835
|
+
console.error(`❌ Step "${currentStep.name}" 必须先等待用户输入,不能直接 --done。`)
|
|
1836
|
+
console.error(` 原因:${currentStepDef.waitReason || '该步骤需要人工确认/回答'}`)
|
|
1837
|
+
if (currentStepDef.waitOptions) {
|
|
1838
|
+
console.error(` 选项:${currentStepDef.waitOptions.join(', ')}`)
|
|
1839
|
+
}
|
|
1840
|
+
console.error(` 请先执行:`)
|
|
1841
|
+
console.error(` sillyspec run ${stageName} --wait --reason "${currentStepDef.waitReason || '等待用户输入'}" --options "${(currentStepDef.waitOptions || ['确认']).join(',')}"${changeName ? ` --change ${changeName}` : ''} --output "你的问题/方案摘要"`)
|
|
1842
|
+
process.exit(1)
|
|
1843
|
+
}
|
|
1652
1844
|
|
|
1653
1845
|
steps[currentIdx].status = 'completed'
|
|
1654
1846
|
steps[currentIdx].completedAt = new Date().toLocaleString('zh-CN',{hour12:false})
|
|
@@ -1679,7 +1871,7 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
|
|
|
1679
1871
|
console.log('⚠️ 请添加 --confirm 确认归档,例如:sillyspec run archive --done --confirm --output "确认归档"')
|
|
1680
1872
|
return { stageCompleted: false, currentIdx, nextPendingIdx: currentIdx }
|
|
1681
1873
|
}
|
|
1682
|
-
await archiveChangeDirectory(pm, cwd, progress)
|
|
1874
|
+
await archiveChangeDirectory(pm, cwd, progress, specBase)
|
|
1683
1875
|
}
|
|
1684
1876
|
|
|
1685
1877
|
// archive "确认归档" 步骤完成后,校验归档完整性
|
|
@@ -1881,6 +2073,25 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
|
|
|
1881
2073
|
console.error(` 请先创建 quicklog 记录再 --done,或使用 --skip-approval 跳过此校验。`)
|
|
1882
2074
|
return { stageCompleted: false, currentIdx, nextPendingIdx: -1 }
|
|
1883
2075
|
}
|
|
2076
|
+
if (progress.quickGuard) {
|
|
2077
|
+
const review = await auditQuickCompletion(cwd, progress.quickGuard, { isConfirm })
|
|
2078
|
+
progress.quickGuard.review = review
|
|
2079
|
+
progress.quickGuard.completedAt = new Date().toISOString()
|
|
2080
|
+
printQuickAuditReview(review)
|
|
2081
|
+
if (review.status === 'blocked') {
|
|
2082
|
+
steps[currentIdx].status = 'pending'
|
|
2083
|
+
steps[currentIdx].completedAt = null
|
|
2084
|
+
if (outputText) steps[currentIdx].output = null
|
|
2085
|
+
process.exit(1)
|
|
2086
|
+
}
|
|
2087
|
+
try {
|
|
2088
|
+
const { unlinkSync } = await import('fs')
|
|
2089
|
+
const guardFile = join(specBase, '.runtime', 'quick-guard.json')
|
|
2090
|
+
unlinkSync(guardFile)
|
|
2091
|
+
} catch {}
|
|
2092
|
+
progress.lastQuickReview = review
|
|
2093
|
+
delete progress.quickGuard
|
|
2094
|
+
}
|
|
1884
2095
|
}
|
|
1885
2096
|
|
|
1886
2097
|
stageData.status = 'completed'
|
|
@@ -1906,9 +2117,14 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
|
|
|
1906
2117
|
const manifestDir = platformOpts.specRoot
|
|
1907
2118
|
mkdirSync(manifestDir, { recursive: true })
|
|
1908
2119
|
let sourceCommit = null
|
|
2120
|
+
let sourceCommitError = null
|
|
1909
2121
|
try {
|
|
1910
|
-
const
|
|
1911
|
-
|
|
2122
|
+
const gitResult = safeGit(cwd, ['rev-parse', 'HEAD'])
|
|
2123
|
+
sourceCommit = gitResult.value
|
|
2124
|
+
sourceCommitError = gitResult.error
|
|
2125
|
+
} catch (e) {
|
|
2126
|
+
sourceCommitError = e.message
|
|
2127
|
+
}
|
|
1912
2128
|
const manifest = {
|
|
1913
2129
|
workspace_id: platformOpts.workspaceId || null,
|
|
1914
2130
|
scan_run_id: platformOpts.scanRunId || null,
|
|
@@ -1916,7 +2132,7 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
|
|
|
1916
2132
|
spec_root: platformOpts.specRoot || null,
|
|
1917
2133
|
runtime_root: platformOpts.runtimeRoot || null,
|
|
1918
2134
|
source_commit: sourceCommit,
|
|
1919
|
-
source_commit_error: sourceCommit === null ? (
|
|
2135
|
+
source_commit_error: sourceCommit === null ? (sourceCommitError || 'unknown') : undefined,
|
|
1920
2136
|
generated_at: new Date().toISOString(),
|
|
1921
2137
|
schema_version: 1,
|
|
1922
2138
|
postcheck_result_path: null,
|
|
@@ -2085,33 +2301,6 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
|
|
|
2085
2301
|
}
|
|
2086
2302
|
}
|
|
2087
2303
|
|
|
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}`)
|
|
2103
|
-
}
|
|
2104
|
-
console.error(`\n 这些文件是 baseline 保护的,不应被修改。`)
|
|
2105
|
-
} else if (review.status === 'warning') {
|
|
2106
|
-
console.warn(`\n⚠️ quick 变更边界审计 — WARNING:`)
|
|
2107
|
-
for (const r of review.reasons) {
|
|
2108
|
-
console.warn(` - ${r}`)
|
|
2109
|
-
}
|
|
2110
|
-
} else {
|
|
2111
|
-
console.log(`\n✅ quick 变更边界审计 — SAFE (变更 ${review.changedFiles.length} 个文件)`)
|
|
2112
|
-
}
|
|
2113
|
-
}
|
|
2114
|
-
|
|
2115
2304
|
progress.lastActive = new Date().toLocaleString('zh-CN',{hour12:false})
|
|
2116
2305
|
await pm._write(cwd, progress, changeName)
|
|
2117
2306
|
triggerSync(cwd, changeName, platformOpts)
|