sillyspec 3.25.0 → 3.25.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.
@@ -0,0 +1,321 @@
1
+ /**
2
+ * run/gates.js(W6 Step4 从 run.js 抽出)。
3
+ *
4
+ * 阶段完成校验 gate 级联 + execute deps 硬门 + 完成回滚(自洽叶子模块,仅被 completeStep 调用):
5
+ * - runStageCompletionGates:runValidators → verify-test 对账 → Plan→Execute contract →
6
+ * Stage Review Gate → Execute Task Review Gate,任一失败走 rollbackCompletionAndReturn
7
+ * - enforceDepsGate:execute depsStatus 不达标且非 wave 级 opt-out 时阻断 --done(exit 1)
8
+ * - rollbackCompletionAndReturn / rollbackStageCompletion:统一回滚 stage/step 状态 + 落盘 + sync
9
+ * - isCurrentWaveAllNoDepsVerify:enforceDepsGate 的私有 helper(wave 级 no_deps_verify opt-out 判定)
10
+ *
11
+ * 安全锚:run.js 始终 barrel。enforceDepsGate + runStageCompletionGates 由 run.js import 回来;
12
+ * 无 test 直接 import(与 parsePorcelainPath/applyRootPlaceholders 不同),无需 barrel re-export。
13
+ * completeStep(Step7 搬)将把这行 import 一起带走。
14
+ *
15
+ * 路径修正(相对 src/run/):
16
+ * - triggerSync/resolveChangeDir 从 './shared.js';runValidators 从 '../stage-contract.js'
17
+ * - 动态 import ./worktree.js / ./verify-postcheck.js / ./stages/execute.js / ./review-tier.js /
18
+ * ./stage-review.js / ./task-review.js → '../X.js'(src/ 下,退一层;真环依赖保留动态)
19
+ *
20
+ * 不搬:ensureDepsFreshness(execute 入口 deps 自检,调用方 runStage 非 completeStep,归属未来 execute-handler)
21
+ */
22
+ import { basename, join } from 'node:path'
23
+ import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs'
24
+ import { triggerSync, resolveChangeDir } from './shared.js'
25
+ import { runValidators } from '../stage-contract.js'
26
+
27
+ /**
28
+ * 判断当前 execute step 所在 wave 是否全部 task 都声明 no_deps_verify: true(D-006@v2)。
29
+ * 仅 wave 执行步骤(名如 "Wave N 执行")可 opt-out;非 wave 步骤恒返回 false(保守过门)。
30
+ */
31
+ function isCurrentWaveAllNoDepsVerify(stepName, changeDir) {
32
+ if (!stepName) return false
33
+ const m = String(stepName).match(/^Wave\s+(\d+)/i)
34
+ if (!m) return false
35
+ const waveN = parseInt(m[1], 10)
36
+ if (!changeDir) return false
37
+ const planPath = join(changeDir, 'plan.md')
38
+ if (!existsSync(planPath)) return false
39
+ const plan = readFileSync(planPath, 'utf8')
40
+ const waveRe = new RegExp(`^##\\s*Wave\\s+${waveN}\\b[^\\n]*\\n([\\s\\S]*?)(?=\\n##\\s|\\n#\\s|$)`, 'm')
41
+ const waveMatch = plan.match(waveRe)
42
+ if (!waveMatch) return false
43
+ const taskIds = [...waveMatch[1].matchAll(/^[-*]\s*\[[ x]\]\s*(task-\d+)/gim)].map(x => x[1])
44
+ if (taskIds.length === 0) return false
45
+ for (const id of taskIds) {
46
+ const cardPath = join(changeDir, 'tasks', `${id}.md`)
47
+ if (!existsSync(cardPath)) return false // 卡片缺失 → 保守不跳
48
+ const card = readFileSync(cardPath, 'utf8')
49
+ const fm = card.match(/^---\n([\s\S]*?)\n---/)
50
+ if (!fm) return false
51
+ if (!/no_deps_verify:\s*true/i.test(fm[1])) return false
52
+ }
53
+ return true
54
+ }
55
+
56
+ /**
57
+ * execute deps 验证硬门(change 2026-06-28-worktree-deps-provision / D-001@v1, D-003@v1, D-006@v2)。
58
+ * depsStatus 不达标且非 wave 级 opt-out 时阻断 --done:置 step=blocked + exit(1),与 requiresWait 同范式。
59
+ * 放行返回 true;阻断时 process.exit(1) 不返回。
60
+ */
61
+ export async function enforceDepsGate(stageName, cwd, changeName, step, steps, currentIdx, specBase, platformOpts) {
62
+ if (stageName !== 'execute') return true
63
+ let meta = null
64
+ let wm = null
65
+ try {
66
+ const { WorktreeManager } = await import('../worktree.js')
67
+ wm = new WorktreeManager({ cwd })
68
+ meta = wm.getMeta(changeName)
69
+ } catch (e) {
70
+ // WorktreeManager 构造/读取失败(git 不可用 / worktree 元数据损坏)不阻断 deps gate;
71
+ // 下游已有物理目录存在性判定兜底(G2/R3),这里 warn 留下根因线索,避免静默误诊。
72
+ console.warn(`⚠️ worktree meta 读取失败(deps gate 将走物理目录判定): ${e.message}`)
73
+ }
74
+ const depsStatus = meta?.depsStatus
75
+ if (['linked', 'installed', 'n/a'].includes(depsStatus)) return true
76
+ const changeDir = changeName ? join(specBase, 'changes', changeName) : null
77
+ if (isCurrentWaveAllNoDepsVerify(step?.name, changeDir)) return true
78
+ if (steps && steps[currentIdx]) steps[currentIdx].status = 'blocked'
79
+ // ── 诊断分支(Phase 2,G2/R3 修正:判定基于物理目录而非 !meta)──
80
+ // getMeta 对"目录不存在"与"meta 损坏"都返回 null,后者会误判终态 → 用物理目录存在性判定。
81
+ let worktreeGone = true
82
+ try {
83
+ worktreeGone = !!(wm && changeName) && !existsSync(wm.getWorktreePath(changeName))
84
+ } catch {}
85
+ // ── fail-loud 块(Phase 3,D-005@v1:仅改拒绝侧 stderr)──
86
+ console.error('❌ ── deps 门控阻断(本次 --done 未完成,进度未推进)──')
87
+ if (worktreeGone) {
88
+ console.error(' worktree 不可用(已 cleanup 或目录不存在)。')
89
+ console.error(` 修复:sillyspec doctor --align-execute-progress${changeName ? ` --change ${changeName}` : ''} 按 plan.md 对齐进度`)
90
+ console.error(` 或: sillyspec worktree create ${changeName || '<change>'} 重建 worktree 继续跑`)
91
+ } else {
92
+ console.error(` 原因:依赖未就绪(depsStatus=${depsStatus || 'unknown'}),不得在无构建/测试能力时声称完成。`)
93
+ console.error(` 修复:sillyspec worktree doctor --fix${changeName ? ` --change ${changeName}` : ''}`)
94
+ console.error(' 或在 worktree 内手动安装依赖后重试。')
95
+ if (meta?.depsError) console.error(` 上次供给错误:${meta.depsError}`)
96
+ }
97
+ process.exit(1)
98
+ }
99
+ /**
100
+ * 阶段完成校验失败时回滚状态。
101
+ *
102
+ * completeStep 在跑 validator 之前就把 stageData.status 写成 'completed',
103
+ * 若校验失败不回滚,DB 会与真实产物不一致(hook/doctor/下游阶段全部误判),
104
+ * 且所有步骤都是 completed 时 agent 无法重新 --done("没有待完成的步骤")。
105
+ * 此处将 stage 回滚为 in-progress,最后一步重置为 pending,供修复产物后重做。
106
+ */
107
+ function rollbackStageCompletion(stageData, steps, currentIdx) {
108
+ // 辅助阶段在 validator 前已被重置为 pending(steps 也换成了新数组),不要覆盖
109
+ if (stageData.status === 'completed') {
110
+ stageData.status = 'in-progress'
111
+ stageData.completedAt = null
112
+ }
113
+ if (steps[currentIdx] && steps[currentIdx].status === 'completed') {
114
+ steps[currentIdx].status = 'pending'
115
+ steps[currentIdx].completedAt = null
116
+ }
117
+ }
118
+
119
+ /**
120
+ * 阶段完成校验失败后的统一收尾:回滚 stage/step 状态 + 落盘 + sync + 返回「未完成」。
121
+ *
122
+ * completeStep 的各 gate(runValidators / verify-test 对账 / plan→execute contract /
123
+ * Stage Review / Execute Task Review)失败时都走这套动作;原先每个失败分支手写重复
124
+ * ~7 次(含 lastActive 落盘 + triggerSync + return 结构),统一进来消坑,避免某分支
125
+ * 漏写 triggerSync / 写错 return 结构导致行为分裂。返回 nextPendingIdx=currentIdx,
126
+ * 让上层走「完成但不推进」分支,--done 被拒、agent 修复产物后重跑。
127
+ */
128
+ async function rollbackCompletionAndReturn(pm, progress, stageData, steps, currentIdx, cwd, changeName, platformOpts) {
129
+ rollbackStageCompletion(stageData, steps, currentIdx)
130
+ progress.lastActive = new Date().toLocaleString('zh-CN', { hour12: false })
131
+ await pm._write(cwd, progress, changeName)
132
+ triggerSync(cwd, changeName, platformOpts)
133
+ return { stageCompleted: false, currentIdx, nextPendingIdx: currentIdx }
134
+ }
135
+ /**
136
+ * 阶段完成校验 gate 级联(从 completeStep 抽出,行为保持)。仅当所有步骤确实标记为 completed 时
137
+ * 由 completeStep 调用。顺序:runValidators → verify-test 对账 → Plan→Execute contract →
138
+ * Stage Review Gate → Execute Task Review Gate。任一 gate 失败 → rollbackCompletionAndReturn
139
+ * (统一回滚 + 返回 early-return 对象);全部通过 → 返回 null(completeStep 继续收尾)。
140
+ *
141
+ * @returns {{stageCompleted:false,currentIdx,nextPendingIdx:number}|null}
142
+ */
143
+ export async function runStageCompletionGates({ stageName, cwd, changeName, platformOpts, specBase, progress, pm, stageData, steps, currentIdx }) {
144
+ const projectName = progress.project || basename(cwd)
145
+ const contractResult = runValidators(stageName, cwd, changeName, { projectName, specRoot: platformOpts?.specRoot })
146
+ if (contractResult.errors.length > 0) {
147
+ console.error(`\n❌ 阶段 ${stageName} 校验失败:`)
148
+ for (const err of contractResult.errors) {
149
+ console.error(` - ${err}`)
150
+ }
151
+ console.error(`\n 提示:修复缺失产物后重新完成此步骤(--skip-approval 只跳过阶段转换/审批检查,不能跳过产物校验)`)
152
+ // 产物校验失败必须阻断完成 —— 否则 validator 形同虚设,
153
+ // verify 会带着 FAIL/缺 verify-result.md 被 ✅ 标记完成(历史教训)。
154
+ // plan/execute 的专项契约校验(下方)在产物齐全后才需要继续跑,故此处先 return。
155
+ return await rollbackCompletionAndReturn(pm, progress, stageData, steps, currentIdx, cwd, changeName, platformOpts)
156
+ }
157
+ if (contractResult.warnings.length > 0) {
158
+ console.warn(`\n⚠️ 阶段 ${stageName} 校验警告:`)
159
+ for (const w of contractResult.warnings) {
160
+ console.warn(` - ${w}`)
161
+ }
162
+ }
163
+
164
+ // verify 产物校验通过 + 结论非 FAIL(否则上面已阻断)。
165
+ // 再由 CLI 亲自执行 local.yaml 的测试命令,与 verify-result.md 的自报告对账:
166
+ // 自报告 PASS 但实测失败 → 阻断(防止"文案通过"绕过验证)。
167
+ if (stageName === 'verify') {
168
+ const { runVerifyTestCheck, printVerifyTestCheck } = await import('../verify-postcheck.js')
169
+ const testCheck = runVerifyTestCheck({ cwd, specBase, changeName })
170
+ printVerifyTestCheck(testCheck)
171
+ if (testCheck.status === 'failed') {
172
+ console.error('\n❌ verify 阶段被阻断:verify-result.md 自报告通过,但 CLI 实测测试失败。')
173
+ console.error(' 请修复失败的测试并更新 verify-result.md 后重新完成此步骤。')
174
+ return await rollbackCompletionAndReturn(pm, progress, stageData, steps, currentIdx, cwd, changeName, platformOpts)
175
+ }
176
+ // 契约 parity 对账:扫前端 API 调用 vs execute 提取的 provider endpoint artifact。
177
+ // 接线自 contract-matrix pipeline(verifyApiParity 的 CLI 入口)。
178
+ const { runVerifyParityCheck, printVerifyParityCheck } = await import('../verify-postcheck.js')
179
+ const parityCheck = runVerifyParityCheck({ cwd, specBase, changeName, runtimeRoot: platformOpts?.runtimeRoot })
180
+ printVerifyParityCheck(parityCheck)
181
+ console.log('\n✅ 验证通过,下一步:sillyspec run archive')
182
+ }
183
+
184
+ // ── Plan postcheck contract:plan.md 必须满足 execute 契约 ──
185
+ if (stageName === 'plan') {
186
+ const planFile = resolveChangeDir(cwd, progress, platformOpts?.specRoot)
187
+ const planPath = planFile ? join(planFile, 'plan.md') : null
188
+ if (planPath && existsSync(planPath)) {
189
+ const { validatePlanForExecute } = await import('../stages/execute.js')
190
+ const planContent = readFileSync(planPath, 'utf8')
191
+ const planValidation = validatePlanForExecute(planContent)
192
+ if (!planValidation.ok) {
193
+ console.error(`\n❌ Plan → Execute Contract 校验失败:`)
194
+ for (const err of planValidation.errors) console.error(` - ${err}`)
195
+ console.error(`\n plan.md 不满足 execute 契约,请修复后重新完成此步骤。`)
196
+ // 阻断 completed
197
+ return await rollbackCompletionAndReturn(pm, progress, stageData, steps, currentIdx, cwd, changeName, platformOpts)
198
+ }
199
+ if (planValidation.warnings.length > 0) {
200
+ console.warn(`\n⚠️ Plan contract 警告(不阻断完成):`)
201
+ for (const w of planValidation.warnings) console.warn(` - ${w}`)
202
+ }
203
+ if (planValidation.ok) {
204
+ console.log(`\n✅ Plan → Execute Contract 校验通过(${planValidation.tasks.length} tasks, ${planValidation.waves.length} waves)`)
205
+ }
206
+ }
207
+ }
208
+
209
+ // ── Stage Review Gate:brainstorm/plan/propose/execute 独立审查(按 tier 分级)──
210
+ // tier=self 放行+审计打印;tier=independent 必须 review.json 且 verdict 非 fail,fail-closed
211
+ if (['brainstorm', 'plan', 'propose', 'execute'].includes(stageName)) {
212
+ try {
213
+ const { classifyReviewTier } = await import('../review-tier.js')
214
+ const { validateStageReview, getLatestStageReviewRunId, printStageReviewResult } = await import('../stage-review.js')
215
+ const effectiveSpecBase = platformOpts?.specRoot || specBase
216
+ const reviewChangeDir = resolveChangeDir(cwd, progress, platformOpts?.specRoot)
217
+ const designPath = reviewChangeDir ? join(reviewChangeDir, 'design.md') : null
218
+ let planLevel = null
219
+ if (reviewChangeDir) {
220
+ const planPath = join(reviewChangeDir, 'plan.md')
221
+ if (existsSync(planPath)) {
222
+ const fmLine = readFileSync(planPath, 'utf8').split('\n').find(l => l.trim().startsWith('plan_level:'))
223
+ if (fmLine) planLevel = fmLine.split(':')[1].trim()
224
+ }
225
+ }
226
+ const tier = classifyReviewTier({ planLevel, designPath })
227
+ const runtimeRoot = platformOpts?.runtimeRoot || join(effectiveSpecBase, '.runtime')
228
+
229
+ if (tier.tier === 'self') {
230
+ console.log('\nℹ️ Stage Review: ' + stageName + ' tier=self(' + tier.reason + '),已降级为当前 agent 自审,不强制独立子代理。')
231
+ } else {
232
+ const reviewRunId = getLatestStageReviewRunId(runtimeRoot, stageName)
233
+ const reviewType = stageName === 'brainstorm' ? 'design'
234
+ : stageName === 'plan' ? 'plan'
235
+ : stageName === 'propose' ? 'proposal'
236
+ : 'acceptance'
237
+ const searchDirs = [effectiveSpecBase, reviewChangeDir, cwd].filter(Boolean)
238
+ const reviewResult = validateStageReview({ stage: stageName, reviewType, runtimeRoot, reviewRunId, searchDirs })
239
+ printStageReviewResult(reviewResult, { stage: stageName })
240
+ if (!reviewResult.ok) {
241
+ return await rollbackCompletionAndReturn(pm, progress, stageData, steps, currentIdx, cwd, changeName, platformOpts)
242
+ }
243
+ }
244
+ } catch (e) {
245
+ // fail-closed:Gate 自身异常阻断完成,不静默放行(与 Task Review Gate 一致)
246
+ console.error('❌ Stage Review Gate 异常,阻断 ' + stageName + ' 完成: ' + e.message)
247
+ return await rollbackCompletionAndReturn(pm, progress, stageData, steps, currentIdx, cwd, changeName, platformOpts)
248
+ }
249
+ }
250
+
251
+ // ── Execute Task Review Gate:所有 task 必须有 review.json 且 verdict 通过 ──
252
+ if (stageName === 'execute') {
253
+ try {
254
+ const { validateTaskReviews, printReviewResult, writeVerifyRequiredEvidence } = await import('../task-review.js')
255
+ const effectiveSpecBase = platformOpts?.specRoot || specBase
256
+ const planFile = resolveChangeDir(cwd, progress, platformOpts?.specRoot)
257
+ const planPath = planFile ? join(planFile, 'plan.md') : null
258
+
259
+ if (planPath && existsSync(planPath)) {
260
+ const planContent = readFileSync(planPath, 'utf8')
261
+ const runtimeRoot = platformOpts?.runtimeRoot || join(effectiveSpecBase, '.runtime')
262
+
263
+ // execute run id:从变更专属标记文件读取
264
+ const runIdFile = join(runtimeRoot, `current-execute-run-id-${changeName}`)
265
+ let executeRunId = ''
266
+ try {
267
+ if (existsSync(runIdFile)) {
268
+ executeRunId = readFileSync(runIdFile, 'utf8').trim()
269
+ }
270
+ } catch {}
271
+ if (!executeRunId) {
272
+ const { generateExecuteRunId } = await import('../task-review.js')
273
+ executeRunId = generateExecuteRunId()
274
+ // 落盘(marker 缺失时 fallback 生成后写盘,保证后续 checkbox/gate 读到同一 ID)
275
+ try { mkdirSync(runtimeRoot, { recursive: true }); writeFileSync(runIdFile, executeRunId + '\n') } catch {}
276
+ }
277
+
278
+ // git 真实性校验目录:worktree 存在则用 worktree(base/head commit 在其中),否则主仓库
279
+ let reviewGitDir = cwd
280
+ try {
281
+ const { WorktreeManager } = await import('../worktree.js')
282
+ const wm = new WorktreeManager({ cwd })
283
+ const meta = wm.getMeta(changeName)
284
+ if (meta?.worktreePath && meta.mode !== 'in-place-fallback' && existsSync(meta.worktreePath)) {
285
+ reviewGitDir = meta.worktreePath
286
+ }
287
+ } catch {}
288
+
289
+ const reviewResult = validateTaskReviews({ planContent, runtimeRoot, executeRunId, changeDir: planFile, gitDir: reviewGitDir })
290
+ printReviewResult(reviewResult, { runtimeRoot, executeRunId })
291
+
292
+ if (!reviewResult.ok) {
293
+ // Task review 校验失败,阻断 execute 完成
294
+ // 检查是否存在 checkbox 已勾但 review 不通过的情况
295
+ const uncheckedTasks = reviewResult.errors.filter(e => e.includes('缺少 review.json'))
296
+ if (uncheckedTasks.length > 0) {
297
+ console.error('\n⚠️ 部分任务已在 plan.md 中勾选,但 review.json 不存在。')
298
+ console.error(` 请取消勾选这些任务的 checkbox,或补充对应的 review.json(execute run ID: ${executeRunId})。`)
299
+ }
300
+ return await rollbackCompletionAndReturn(pm, progress, stageData, steps, currentIdx, cwd, changeName, platformOpts)
301
+ }
302
+
303
+ // cannot_verify 的 requiredEvidence 写入 change 目录,供 verify 阶段消费
304
+ if (reviewResult.requiredEvidence.length > 0) {
305
+ const evidencePath = writeVerifyRequiredEvidence(join(effectiveSpecBase, 'changes', changeName), reviewResult.requiredEvidence)
306
+ if (evidencePath) {
307
+ console.log(`📄 verify-required-evidence.json 已写入: ${evidencePath}`)
308
+ console.log(' verify 阶段必须满足这些证据要求。')
309
+ }
310
+ }
311
+ }
312
+ } catch (e) {
313
+ // fail-closed:Gate 自身异常时不能默认放行,否则异常成了绕过评审的通道
314
+ console.error(`❌ Task Review Gate 异常,阻断 execute 完成: ${e.message}`)
315
+ console.error(' 请检查 review.json / plan.md 是否可读,修复后重新完成此步骤。')
316
+ return await rollbackCompletionAndReturn(pm, progress, stageData, steps, currentIdx, cwd, changeName, platformOpts)
317
+ }
318
+ }
319
+ return null
320
+ }
321
+