sillyspec 3.26.10 → 3.26.13

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.
Files changed (101) hide show
  1. package/.claude/skills/sillyspec-archive/SKILL.md +85 -85
  2. package/.claude/skills/sillyspec-brainstorm/SKILL.md +114 -114
  3. package/.claude/skills/sillyspec-commit/SKILL.md +109 -109
  4. package/.claude/skills/sillyspec-continue/SKILL.md +45 -45
  5. package/.claude/skills/sillyspec-doctor/SKILL.md +76 -76
  6. package/.claude/skills/sillyspec-execute/SKILL.md +200 -200
  7. package/.claude/skills/sillyspec-explore/SKILL.md +95 -95
  8. package/.claude/skills/sillyspec-knowledge/SKILL.md +269 -269
  9. package/.claude/skills/sillyspec-plan/SKILL.md +148 -148
  10. package/.claude/skills/sillyspec-quick/SKILL.md +115 -111
  11. package/.claude/skills/sillyspec-resume/SKILL.md +68 -68
  12. package/.claude/skills/sillyspec-scan/SKILL.md +79 -79
  13. package/.claude/skills/sillyspec-state/SKILL.md +64 -64
  14. package/.claude/skills/sillyspec-status/SKILL.md +54 -54
  15. package/.claude/skills/sillyspec-verify/SKILL.md +83 -83
  16. package/.claude/skills/sillyspec-workspace/SKILL.md +169 -169
  17. package/.gitattributes +2 -0
  18. package/.husky/pre-push +20 -20
  19. package/README.md +203 -201
  20. package/SKILL.md +91 -90
  21. package/package.json +39 -39
  22. package/packages/dashboard/dist/assets/index-Bq_Z2hne.js +7446 -7446
  23. package/packages/dashboard/dist/assets/index-O2W5RV4z.css +1 -1
  24. package/packages/dashboard/dist/index.html +16 -16
  25. package/packages/dashboard/package.json +25 -25
  26. package/packages/dashboard/server/executor.js +95 -86
  27. package/packages/dashboard/server/index.js +663 -601
  28. package/packages/dashboard/server/parser.js +526 -526
  29. package/src/change-risk-profile.js +254 -254
  30. package/src/classify-change.js +109 -109
  31. package/src/config-schema.js +313 -310
  32. package/src/contract-matrix.js +388 -379
  33. package/src/db.js +370 -350
  34. package/src/dispatch/backends/local-agent.js +155 -155
  35. package/src/dispatch/backends/sillyhub-mcp.js +245 -245
  36. package/src/dispatch/probe.js +246 -210
  37. package/src/dispatch/strategy.js +121 -121
  38. package/src/docs-check.js +188 -16
  39. package/src/docs-debt.js +6 -2
  40. package/src/docs-gate.js +1 -0
  41. package/src/doctor-diagnostics.js +898 -711
  42. package/src/endpoint-extractor.js +401 -401
  43. package/src/fs-atomic.js +71 -69
  44. package/src/git-helper.js +82 -79
  45. package/src/hooks/claude-pre-tool-use.cjs +125 -125
  46. package/src/hooks/worktree-guard.js +854 -848
  47. package/src/index.js +251 -20
  48. package/src/init.js +624 -623
  49. package/src/knowledge-match.js +130 -130
  50. package/src/local-detect.js +128 -120
  51. package/src/machine-interface.js +543 -543
  52. package/src/migrate.js +127 -127
  53. package/src/modules.js +7 -5
  54. package/src/progress/change-registry.js +297 -260
  55. package/src/progress/consistency-doctor.js +355 -355
  56. package/src/progress/shared.js +36 -36
  57. package/src/progress/stage-machine.js +589 -550
  58. package/src/progress/step-store.js +261 -261
  59. package/src/progress.js +1285 -1127
  60. package/src/quicklog.js +724 -693
  61. package/src/review-tier.js +77 -77
  62. package/src/run/command.js +1408 -1364
  63. package/src/run/complete-handlers.js +1450 -1399
  64. package/src/run/complete.js +1181 -920
  65. package/src/run/gates.js +878 -755
  66. package/src/run/prompt.js +765 -761
  67. package/src/run/quick-audit.js +155 -146
  68. package/src/run/shared.js +1348 -1215
  69. package/src/run/stage.js +527 -479
  70. package/src/scan-postcheck.js +371 -371
  71. package/src/setup.js +414 -416
  72. package/src/sillyhub-mcp/client.js +597 -529
  73. package/src/spec-dir-typo.js +3 -24
  74. package/src/spec-sync.js +358 -319
  75. package/src/stage-contract.js +871 -897
  76. package/src/stage-review.js +549 -549
  77. package/src/stages/archive.js +156 -155
  78. package/src/stages/brainstorm-auto.js +236 -236
  79. package/src/stages/brainstorm.js +597 -597
  80. package/src/stages/cmd-existence.js +145 -145
  81. package/src/stages/doctor.js +386 -371
  82. package/src/stages/execute.js +1014 -1003
  83. package/src/stages/explore.js +34 -34
  84. package/src/stages/index.js +29 -29
  85. package/src/stages/knowledge.js +498 -498
  86. package/src/stages/plan-postcheck.js +1323 -1302
  87. package/src/stages/plan.js +642 -623
  88. package/src/stages/quick.js +153 -151
  89. package/src/stages/scan.js +607 -607
  90. package/src/stages/status.js +64 -64
  91. package/src/stages/verify.js +264 -264
  92. package/src/sync.js +1325 -1135
  93. package/src/task-review.js +1204 -1019
  94. package/src/taskcard.js +178 -0
  95. package/src/verify-postcheck.js +1199 -1186
  96. package/src/workflow.js +600 -600
  97. package/src/worktree-apply.js +1237 -1136
  98. package/src/worktree-deps.js +453 -353
  99. package/src/worktree.js +1527 -1481
  100. package/templates/workflows/archive-impact.yaml +79 -79
  101. package/templates/workflows/scan-docs.yaml +132 -132
package/src/run/gates.js CHANGED
@@ -1,755 +1,878 @@
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, readdirSync, statSync, mkdirSync, writeFileSync } from 'node:fs'
24
- import { triggerSync, resolveChangeDir, resolveRuntimeRoot } from './shared.js'
25
- import { runValidators } from '../stage-contract.js'
26
- import { handleScanStageCompleted, handleExecuteWorktreeCleanup } from './complete-handlers.js'
27
- import { detectConcurrentChanges, formatConcurrentWarning } from './concurrent-detect.js'
28
- import { stageRegistry } from '../stages/index.js'
29
-
30
- /**
31
- * 从 plan.md 提取全部 task id(task-XX)——符号影响面覆盖度校验用。
32
- * 只认 checkbox 行 `- [ ] task-XX:` / `- [x] task-XX:`(与 execute.js parseWavesFromPlan 的
33
- * 收容口径一致:### 标题式 task / 非 task 区 checkbox 不算),light plan 的 `## Tasks` 段同样收。
34
- */
35
- function extractTaskIdsFromPlan(planContent) {
36
- if (!planContent) return []
37
- return [...String(planContent).matchAll(/^[-*]\s*\[[ x]\]\s*(?:.*\b)?task-(\d+)\b/gim)]
38
- .map(m => `task-${m[1].padStart(2, '0')}`)
39
- }
40
-
41
- /**
42
- * 符号影响面报告结构校验(纯函数):execute「加载上下文」步的产出落盘核验。
43
- *
44
- * 治 persuasion-only 失效现场(ql-20260816-005-3d7f):execute 前缀 4 步无任何 gate,agent 可
45
- * <1s 连发 4 次 --done 盖章跳过,「符号影响面扫描」(execute.js 操作 11)被一句「上下文在会话内」
46
- * 带过(输出契约只写「上下文摘要」,操作与输出脱节)。修法 = 报告落盘
47
- * {SPEC_ROOT}/changes/<change>/symbol-impact.md + 本函数校验:plan.md 每个 task-XX 在报告中出现
48
- * (逐 task 结论,「无签名级变更」也要显式写,治整体一句话带过)。语义质量(调用点找没找全)
49
- * 仍归 agent,CLI 只核结构覆盖度——符合债单「persuasion-only → 补最小硬门」原则。
50
- *
51
- * @param {{reportContent: string|null, planContent: string|null}} params
52
- * @returns {{ok: boolean, errors: string[]}}
53
- */
54
- export function validateSymbolImpactReport({ reportContent, planContent }) {
55
- const errors = []
56
- if (!reportContent || !String(reportContent).trim()) {
57
- return { ok: false, errors: ['symbol-impact.md 不存在或内容为空——「加载上下文」步的符号影响面报告必须落盘(逐 task 一行结论,「无签名级变更」也要显式写)'] }
58
- }
59
- const taskIds = extractTaskIdsFromPlan(planContent)
60
- if (taskIds.length === 0) return { ok: true, errors } // plan 无 task 可列(默认兜底形态)→ 无可校验对象
61
- const missing = taskIds.filter(id => !String(reportContent).includes(id))
62
- for (const id of missing) {
63
- errors.push(`${id} 未在 symbol-impact.md 中出现——每个 task 都要有一行结论(含「无签名级变更」的显式声明)`)
64
- }
65
- return { ok: errors.length === 0, errors }
66
- }
67
-
68
- /**
69
- * 符号影响面报告硬门:execute「加载上下文」步 --done 时校验 symbol-impact.md 落盘 + task 覆盖度。
70
- * fail-closed:不通过 → 当前步标 blocked + exit 1(进度不推进)。非目标步骤/阶段直接放行。
71
- */
72
- export async function enforceSymbolImpactGate(stageName, changeName, currentStepName, specBase) {
73
- if (stageName !== 'execute') return
74
- if (!currentStepName || !currentStepName.includes('加载上下文')) return
75
- const planPath = join(specBase, 'changes', changeName || '', 'plan.md')
76
- const reportPath = join(specBase, 'changes', changeName || '', 'symbol-impact.md')
77
- if (!existsSync(planPath)) return // plan.md 缺失场景已有 plan 阶段 gate 把关,此处不重复拦
78
- let planContent = ''
79
- try { planContent = readFileSync(planPath, 'utf8') } catch { return }
80
- let reportContent = null
81
- try { reportContent = readFileSync(reportPath, 'utf8') } catch { /* 缺失由 validate 报 */ }
82
- const result = validateSymbolImpactReport({ reportContent, planContent })
83
- if (result.ok) return
84
- console.error('❌ ── 符号影响面报告校验阻断(本次 --done 未完成,进度未推进)──')
85
- console.error(` 「加载上下文」步的符号影响面检查报告未落盘或不完整:`)
86
- console.error(` 期望路径:${reportPath}`)
87
- for (const e of result.errors) console.error(` • ${e}`)
88
- console.error(' 修复:按「加载上下文」步操作 11 完成符号影响面扫描,逐 task 写结论(无签名级变更也要显式写「无」),落盘后重跑 execute --done。')
89
- process.exit(1)
90
- }
91
-
92
- /**
93
- * 判断当前 execute step 所在 wave 是否全部 task 都声明 no_deps_verify: true(D-006@v2)。
94
- * 仅 wave 执行步骤(名如 "Wave N 执行")可 opt-out;非 wave 步骤恒返回 false(保守过门)。
95
- */
96
- function isCurrentWaveAllNoDepsVerify(stepName, changeDir) {
97
- if (!stepName) return false
98
- const m = String(stepName).match(/^Wave\s+(\d+)/i)
99
- if (!m) return false
100
- const waveN = parseInt(m[1], 10)
101
- if (!changeDir) return false
102
- const planPath = join(changeDir, 'plan.md')
103
- if (!existsSync(planPath)) return false
104
- const plan = readFileSync(planPath, 'utf8')
105
- const waveRe = new RegExp(`^##\\s*Wave\\s+${waveN}\\b[^\\n]*\\n([\\s\\S]*?)(?=\\n##\\s|\\n#\\s|$)`, 'm')
106
- const waveMatch = plan.match(waveRe)
107
- if (!waveMatch) return false
108
- const taskIds = [...waveMatch[1].matchAll(/^[-*]\s*\[[ x]\]\s*(task-\d+)/gim)].map(x => x[1])
109
- if (taskIds.length === 0) return false
110
- for (const id of taskIds) {
111
- const cardPath = join(changeDir, 'tasks', `${id}.md`)
112
- if (!existsSync(cardPath)) return false // 卡片缺失 → 保守不跳
113
- const card = readFileSync(cardPath, 'utf8')
114
- const fm = card.match(/^---\n([\s\S]*?)\n---/)
115
- if (!fm) return false
116
- if (!/no_deps_verify:\s*true/i.test(fm[1])) return false
117
- }
118
- return true
119
- }
120
-
121
- /**
122
- * execute deps 验证硬门(change 2026-06-28-worktree-deps-provision / D-001@v1, D-003@v1, D-006@v2)。
123
- * depsStatus 不达标且非 wave 级 opt-out 时阻断 --done:置 step=blocked + exit(1),与 requiresWait 同范式。
124
- * 放行返回 true;阻断时 process.exit(1) 不返回。
125
- */
126
- export async function enforceDepsGate(stageName, cwd, changeName, step, steps, currentIdx, specBase, platformOpts) {
127
- if (stageName !== 'execute') return true
128
- let meta = null
129
- let wm = null
130
- try {
131
- const { WorktreeManager } = await import('../worktree.js')
132
- wm = new WorktreeManager({ cwd })
133
- meta = wm.getMeta(changeName)
134
- } catch (e) {
135
- // WorktreeManager 构造/读取失败(git 不可用 / worktree 元数据损坏)不阻断 deps gate;
136
- // 下游已有物理目录存在性判定兜底(G2/R3),这里 warn 留下根因线索,避免静默误诊。
137
- console.warn(`⚠️ worktree meta 读取失败(deps gate 将走物理目录判定): ${e.message}`)
138
- }
139
- const depsStatus = meta?.depsStatus
140
- if (['linked', 'installed', 'n/a'].includes(depsStatus)) return true
141
- const changeDir = changeName ? join(specBase, 'changes', changeName) : null
142
- if (isCurrentWaveAllNoDepsVerify(step?.name, changeDir)) return true
143
- if (steps && steps[currentIdx]) steps[currentIdx].status = 'blocked'
144
- // ── 诊断分支(Phase 2,G2/R3 修正:判定基于物理目录而非 !meta)──
145
- // getMeta 对"目录不存在"与"meta 损坏"都返回 null,后者会误判终态 → 用物理目录存在性判定。
146
- let worktreeGone = true
147
- try {
148
- worktreeGone = !!(wm && changeName) && !existsSync(wm.getWorktreePath(changeName))
149
- } catch {}
150
- // ── fail-loud 块(Phase 3,D-005@v1:仅改拒绝侧 stderr)──
151
- console.error('❌ ── deps 门控阻断(本次 --done 未完成,进度未推进)──')
152
- if (worktreeGone) {
153
- console.error(' worktree 不可用(已 cleanup 或目录不存在)。')
154
- console.error(` 修复:sillyspec doctor --align-execute-progress${changeName ? ` --change ${changeName}` : ''} 按 plan.md 对齐进度`)
155
- console.error(` 或: sillyspec worktree create ${changeName || '<change>'} 重建 worktree 继续跑`)
156
- } else {
157
- console.error(` 原因:依赖未就绪(depsStatus=${depsStatus || 'unknown'}),不得在无构建/测试能力时声称完成。`)
158
- console.error(` 修复:sillyspec worktree doctor --fix${changeName ? ` --change ${changeName}` : ''}`)
159
- console.error(' 或在 worktree 内手动安装依赖后重试。')
160
- if (meta?.depsError) console.error(` 上次供给错误:${meta.depsError}`)
161
- }
162
- process.exit(1)
163
- }
164
-
165
- /**
166
- * execute --done 硬门:已勾 [x] task 的 review.json 必须 schema 完整(坑 review-json-field-gap)。
167
- *
168
- * Task Review Gate(validateTaskReviews)只在 execute 整阶段完成时跑(complete.js 阶段完成分支的
169
- * actualCompleted===actualTotal 守卫),单 task --done 不校验 → 子代理勾 checkbox 却漏写/漏字段
170
- * review.json,要到收尾才暴露,用户被迫事后批量补。本门提前到每次 --done:校验 plan 里所有已勾
171
- * task 的 review.json,缺字段/不存在/JSON 坏 → 置 step=blocked + exit(1),与 enforceDepsGate 同范式。
172
- * 未勾 task 不校验(还没做)。平台模式/无 marker/无 plan 时放行(下游 Task Review Gate 兜底)。
173
- */
174
- export async function enforceReviewJsonGate(stageName, cwd, changeName, step, steps, currentIdx, specBase, platformOpts) {
175
- if (stageName !== 'execute' || !changeName) return true
176
- const runtimeRoot = resolveRuntimeRoot(platformOpts, specBase)
177
- const runIdFile = join(runtimeRoot, `current-execute-run-id-${changeName}`)
178
- const planPath = join(specBase, 'changes', changeName, 'plan.md')
179
- if (!existsSync(runIdFile) || !existsSync(planPath)) return true
180
- const { validateCheckedTaskReviews, resolveLatestExecuteRunIdWithTasks, isValidExecuteRunId } = await import('../task-review.js')
181
- let executeRunId = readFileSync(runIdFile, 'utf8').trim()
182
- // marker 是 agent 可写内容:格式校验防注入/穿越,非法视为缺失走漂移兜底重定位
183
- if (executeRunId && !isValidExecuteRunId(executeRunId)) {
184
- console.warn(`⚠️ execute run marker 内容非法(期望 exec-YYYY-MM-DD-HHMMSS,实得 ${JSON.stringify(executeRunId.slice(0, 60))}),改扫真实 run 目录`)
185
- executeRunId = ''
186
- }
187
- const planContent = readFileSync(planPath, 'utf8')
188
- // marker 漂移兜底(gate-atom-a 正确修法):marker 指向的 run 缺 tasks/(generateExecuteRunId 只写
189
- // marker 不建目录,漂移后新 run 不继承旧 review)时,无视 marker 改扫 execute-runs/ 取 mtime 最新
190
- // 且真正含 tasks/ 的 run,用其齐备的 review.json 校验,避免误报「review.json 不存在」。注意不能用
191
- // resolveLatestExecuteRunId——它见 marker 非空即原样返回(不校验目录),恰是本场景要绕开的值。
192
- if (executeRunId && !existsSync(join(runtimeRoot, 'execute-runs', executeRunId, 'tasks'))) {
193
- const relocated = resolveLatestExecuteRunIdWithTasks({ runtimeRoot })
194
- if (relocated && relocated !== executeRunId) {
195
- console.warn(`⚠️ execute run marker 漂移:${executeRunId} 无 tasks/,改用真实含 review 的 run ${relocated}`)
196
- executeRunId = relocated
197
- }
198
- }
199
- const result = validateCheckedTaskReviews({ planContent, runtimeRoot, executeRunId })
200
- if (result.ok) return true
201
- if (steps && steps[currentIdx]) steps[currentIdx].status = 'blocked'
202
- console.error('❌ ── review.json 字段校验阻断(本次 --done 未完成,进度未推进)──')
203
- console.error(' 已勾选 [x] 的 task review.json 不完整(铁律:勾 checkbox 前必须先写完整 review.json):')
204
- for (const f of result.failures) {
205
- const kindLabel = f.kind === 'missing' ? 'review.json 不存在' : (f.kind === 'parseError' ? 'JSON 解析失败' : '字段缺失')
206
- console.error(` • ${f.taskId}(${kindLabel}): ${f.reviewPath}`)
207
- for (const e of f.errors) console.error(` - ${e}`)
208
- }
209
- console.error(' 修复:补全上述 review.json 字段后重跑 execute --done。')
210
- process.exit(1)
211
- }
212
-
213
- /**
214
- * 阶段完成校验失败时回滚状态。
215
- *
216
- * completeStep 在跑 validator 之前就把 stageData.status 写成 'completed',
217
- * 若校验失败不回滚,DB 会与真实产物不一致(hook/doctor/下游阶段全部误判),
218
- * 且所有步骤都是 completed 时 agent 无法重新 --done("没有待完成的步骤")。
219
- * 此处将 stage 回滚为 in-progress,最后一步重置为 pending,供修复产物后重做。
220
- */
221
- function rollbackStageCompletion(stageData, steps, currentIdx) {
222
- // 辅助阶段在 validator 前已被重置为 pending(steps 也换成了新数组),不要覆盖
223
- if (stageData.status === 'completed') {
224
- stageData.status = 'in-progress'
225
- stageData.completedAt = null
226
- }
227
- if (steps[currentIdx] && steps[currentIdx].status === 'completed') {
228
- steps[currentIdx].status = 'pending'
229
- steps[currentIdx].completedAt = null
230
- }
231
- }
232
-
233
- /**
234
- * 阶段完成校验失败后的统一收尾:回滚 stage/step 状态 + 落盘 + sync + 返回「未完成」。
235
- *
236
- * completeStep 的各 gate(runValidators / verify-test 对账 / plan→execute contract /
237
- * Stage Review / Execute Task Review)失败时都走这套动作;原先每个失败分支手写重复
238
- * ~7 次(含 lastActive 落盘 + triggerSync + return 结构),统一进来消坑,避免某分支
239
- * 漏写 triggerSync / 写错 return 结构导致行为分裂。返回 nextPendingIdx=currentIdx,
240
- * 让上层走「完成但不推进」分支,--done 被拒、agent 修复产物后重跑。
241
- */
242
- function rollbackCompletionAndReturn(pm, progress, stageData, steps, currentIdx, cwd, changeName, platformOpts) {
243
- rollbackStageCompletion(stageData, steps, currentIdx)
244
- progress.lastActive = new Date().toLocaleString('zh-CN', { hour12: false })
245
- pm._write(cwd, progress, changeName)
246
- triggerSync(cwd, changeName, platformOpts)
247
- return { stageCompleted: false, currentIdx, nextPendingIdx: currentIdx }
248
- }
249
- /**
250
- * 阶段完成校验 gate 级联(从 completeStep 抽出,行为保持)。仅当所有步骤确实标记为 completed 时
251
- * 由 completeStep 调用。顺序:runValidators → verify-test 对账 → Plan→Execute contract →
252
- * Stage Review Gate → Execute Task Review Gate。任一 gate 失败 → rollbackCompletionAndReturn
253
- * (统一回滚 + 返回 early-return 对象);全部通过 → 返回 null(completeStep 继续收尾)。
254
- *
255
- * @returns {{stageCompleted:false,currentIdx,nextPendingIdx:number}|null}
256
- */
257
- export async function runStageCompletionGates({ stageName, cwd, changeName, platformOpts, specBase, progress, pm, stageData, steps, currentIdx, ctx = null }) {
258
- const projectName = progress.project || basename(cwd)
259
- const contractResult = runValidators(stageName, cwd, changeName, { projectName, specRoot: platformOpts?.specRoot })
260
- if (contractResult.errors.length > 0) {
261
- console.error(`\n❌ 阶段 ${stageName} 校验失败:`)
262
- for (const err of contractResult.errors) {
263
- console.error(` - ${err}`)
264
- }
265
- // 出路提示常放在 warnings(如「写明不改理由即可豁免」),此处即将阻断 return,
266
- // 必须一并打出,否则 agent 只看到症状看不到怎么过。
267
- for (const w of (contractResult.warnings || [])) {
268
- console.error(` · ${w}`)
269
- }
270
- console.error(`\n 提示:修复缺失产物后重新完成此步骤(--skip-approval 只跳过阶段转换/审批检查,不能跳过产物校验)`)
271
- // 产物校验失败必须阻断完成 —— 否则 validator 形同虚设,
272
- // verify 会带着 FAIL/缺 verify-result.md 被 ✅ 标记完成(历史教训)。
273
- // plan/execute 的专项契约校验(下方)在产物齐全后才需要继续跑,故此处先 return。
274
- return await rollbackCompletionAndReturn(pm, progress, stageData, steps, currentIdx, cwd, changeName, platformOpts)
275
- }
276
- if (contractResult.warnings.length > 0) {
277
- console.warn(`\n⚠️ 阶段 ${stageName} 校验警告:`)
278
- for (const w of contractResult.warnings) {
279
- console.warn(` - ${w}`)
280
- }
281
- }
282
-
283
- // verify 产物校验通过 + 结论非 FAIL(否则上面已阻断)。
284
- // 再由 CLI 亲自执行 local.yaml 的测试命令,与 verify-result.md 的自报告对账:
285
- // 自报告 PASS 但实测失败 → 阻断(防止"文案通过"绕过验证)。
286
- if (stageName === 'verify') {
287
- const { runVerifyTestCheck, printVerifyTestCheck } = await import('../verify-postcheck.js')
288
- // 测试实测是同步 execSync,长套件可跑 2~10min 且中途无输出——先预告避免 agent 误判卡死
289
- console.log(`\n⏳ Verify 测试对账:CLI 亲自执行 local.yaml 的 commands.test(同步,耗时可能较长,请等待…)`)
290
- const testCheck = runVerifyTestCheck({ cwd, specBase, changeName, ctx })
291
- printVerifyTestCheck(testCheck)
292
- if (testCheck.status === 'failed') {
293
- console.error('\n❌ verify 阶段被阻断:verify-result.md 自报告通过,但 CLI 实测测试失败。')
294
- console.error(' 请修复失败的测试并更新 verify-result.md 后重新完成此步骤。')
295
- return await rollbackCompletionAndReturn(pm, progress, stageData, steps, currentIdx, cwd, changeName, platformOpts)
296
- }
297
- // 契约 parity 对账:扫前端 API 调用 vs execute 提取的 provider endpoint artifact。
298
- // 接线自 contract-matrix pipeline(verifyApiParity 的 CLI 入口)。
299
- const { runVerifyParityCheck, printVerifyParityCheck } = await import('../verify-postcheck.js')
300
- const parityRuntimeRoot = resolveRuntimeRoot(platformOpts, specBase)
301
- const parityCheck = runVerifyParityCheck({ cwd, specBase, changeName, runtimeRoot: parityRuntimeRoot })
302
- printVerifyParityCheck(parityCheck)
303
- // ── 删除探针(advisory,不阻断):切斯特顿栅栏护栏,用 git 事实对账静默删除代码 ──
304
- const { runVerifyDeletionCheck, printVerifyDeletionCheck } = await import('../verify-postcheck.js')
305
- const deletionCheck = runVerifyDeletionCheck({ cwd, specBase, changeName })
306
- printVerifyDeletionCheck(deletionCheck)
307
- // ── required-evidence 对账(advisory,不阻断):闭合 execute→verify evidence 死链 ──
308
- // execute Task Review Gate 把 cannot_verify 任务的 evidence 落盘 verify-required-evidence.json,
309
- // 本探针查每个 cannot_verify 任务是否在 verify-result.md 体现(CLI 只查提及,满足度 agent 自报告)。
310
- const { runVerifyRequiredEvidenceCheck, printVerifyRequiredEvidenceCheck } = await import('../verify-postcheck.js')
311
- const evidenceCheck = runVerifyRequiredEvidenceCheck({ cwd, specBase, changeName })
312
- printVerifyRequiredEvidenceCheck(evidenceCheck)
313
- // ── module-impact 死信探针(blocking,债单 D-1/D-5):更新结果表 pending/待办行 → 阻断 verify ──
314
- // 与 archive 移动前校验(extractPendingDocSyncRows)同一口径,把死信号从 archive 提前到 verify:
315
- // agent 在 verify 阶段就须完成文档同步并回填 done/skipped,而非拖到归档被拦(修复 perf-remediation
316
- // 类「verify PASS → archive 才发现 pending」的时序漏洞)。
317
- const verifyChangeDir = resolveChangeDir(cwd, progress, platformOpts?.specRoot)
318
- if (verifyChangeDir) {
319
- const impactPath = join(verifyChangeDir, 'module-impact.md')
320
- if (existsSync(impactPath)) {
321
- const { extractPendingDocSyncRows } = await import('./complete-handlers.js')
322
- const pendingRows = extractPendingDocSyncRows(readFileSync(impactPath, 'utf8'))
323
- if (pendingRows.length > 0) {
324
- console.error(`\n❌ verify 阶段被阻断:module-impact.md「更新结果」表存在 ${pendingRows.length} 个未清 pending/待办项(死信)`)
325
- for (const row of pendingRows) console.error(` - ${row}`)
326
- console.error(' 文档同步是 verify 的收尾义务:请完成模块文档同步并回填状态为 done/skipped(说明原因),再重新完成 verify。')
327
- return rollbackCompletionAndReturn(pm, progress, stageData, steps, currentIdx, cwd, changeName, platformOpts)
328
- }
329
- }
330
- }
331
- console.log('\n✅ 验证通过,下一步:sillyspec run archive')
332
- }
333
-
334
- // ── Plan postcheck contract:plan.md 必须满足 execute 契约 ──
335
- if (stageName === 'plan') {
336
- const planFile = resolveChangeDir(cwd, progress, platformOpts?.specRoot)
337
- const planPath = planFile ? join(planFile, 'plan.md') : null
338
- if (planPath && existsSync(planPath)) {
339
- const { validatePlanForExecute } = await import('../stages/execute.js')
340
- const planContent = readFileSync(planPath, 'utf8')
341
- const planValidation = validatePlanForExecute(planContent)
342
- if (!planValidation.ok) {
343
- console.error(`\n❌ Plan → Execute Contract 校验失败:`)
344
- for (const err of planValidation.errors) console.error(` - ${err}`)
345
- console.error(`\n plan.md 不满足 execute 契约,请修复后重新完成此步骤。`)
346
- // 阻断 completed
347
- return await rollbackCompletionAndReturn(pm, progress, stageData, steps, currentIdx, cwd, changeName, platformOpts)
348
- }
349
- if (planValidation.warnings.length > 0) {
350
- console.warn(`\n⚠️ Plan contract 警告(不阻断完成):`)
351
- for (const w of planValidation.warnings) console.warn(` - ${w}`)
352
- }
353
- if (planValidation.ok) {
354
- console.log(`\n✅ Plan → Execute Contract 校验通过(${planValidation.tasks.length} tasks, ${planValidation.waves.length} waves)`)
355
- }
356
- }
357
- }
358
-
359
- // ── Stage Review Gate:brainstorm/plan/execute 独立审查(按 tier 分级)──
360
- // tier=self 放行+审计打印;tier=independent 必须 review.json 且 verdict 非 fail,fail-closed
361
- if (['brainstorm', 'plan', 'execute'].includes(stageName)) {
362
- try {
363
- const { classifyReviewTier } = await import('../review-tier.js')
364
- const { validateStageReview, getLatestStageReviewRunId, printStageReviewResult, generateStageReviewRunId, stageReviewMarkerPath } = await import('../stage-review.js')
365
- const effectiveSpecBase = platformOpts?.specRoot || specBase
366
- const reviewChangeDir = resolveChangeDir(cwd, progress, platformOpts?.specRoot)
367
- const designPath = reviewChangeDir ? join(reviewChangeDir, 'design.md') : null
368
- let planLevel = null
369
- if (reviewChangeDir) {
370
- const planPath = join(reviewChangeDir, 'plan.md')
371
- if (existsSync(planPath)) {
372
- const fmLine = readFileSync(planPath, 'utf8').split('\n').find(l => l.trim().startsWith('plan_level:'))
373
- if (fmLine) planLevel = fmLine.split(':')[1].trim()
374
- }
375
- }
376
- const tier = classifyReviewTier({ planLevel, designPath })
377
- const runtimeRoot = resolveRuntimeRoot(platformOpts, effectiveSpecBase)
378
-
379
- if (tier.tier === 'self') {
380
- console.log('\nℹ️ Stage Review: ' + stageName + ' tier=self(' + tier.reason + '),已降级为当前 agent 自审,不强制独立子代理。')
381
- } else {
382
- let reviewRunId = getLatestStageReviewRunId(runtimeRoot, stageName, changeName)
383
- if (!reviewRunId) {
384
- // marker 缺失(execute 批量完成跳过 prompt 渲染等场景)→ 自生 + 写盘
385
- // 让 gate 读到确定 ID,错误从 execute-null 变 execute-review-<id>(可执行)
386
- reviewRunId = generateStageReviewRunId()
387
- try {
388
- mkdirSync(runtimeRoot, { recursive: true })
389
- writeFileSync(stageReviewMarkerPath(runtimeRoot, stageName, changeName), reviewRunId + '\n')
390
- } catch {}
391
- }
392
- const reviewType = stageName === 'brainstorm' ? 'design'
393
- : stageName === 'plan' ? 'plan'
394
- : 'acceptance'
395
- const searchDirs = [effectiveSpecBase, reviewChangeDir, cwd].filter(Boolean)
396
- const reviewResult = validateStageReview({ stage: stageName, reviewType, runtimeRoot, reviewRunId, searchDirs })
397
- printStageReviewResult(reviewResult, { stage: stageName, reviewRunId, runtimeRoot })
398
- if (!reviewResult.ok) {
399
- return await rollbackCompletionAndReturn(pm, progress, stageData, steps, currentIdx, cwd, changeName, platformOpts)
400
- }
401
- }
402
- } catch (e) {
403
- // fail-closed:Gate 自身异常阻断完成,不静默放行(与 Task Review Gate 一致)
404
- console.error('❌ Stage Review Gate 异常,阻断 ' + stageName + ' 完成: ' + e.message)
405
- return await rollbackCompletionAndReturn(pm, progress, stageData, steps, currentIdx, cwd, changeName, platformOpts)
406
- }
407
- }
408
-
409
- // ── Execute Task Review Gate:所有 task 必须有 review.json 且 verdict 通过 ──
410
- if (stageName === 'execute') {
411
- try {
412
- const { validateTaskReviews, printReviewResult, writeVerifyRequiredEvidence } = await import('../task-review.js')
413
- const effectiveSpecBase = platformOpts?.specRoot || specBase
414
- const planFile = resolveChangeDir(cwd, progress, platformOpts?.specRoot)
415
- const planPath = planFile ? join(planFile, 'plan.md') : null
416
-
417
- if (planPath && existsSync(planPath)) {
418
- const planContent = readFileSync(planPath, 'utf8')
419
- const runtimeRoot = resolveRuntimeRoot(platformOpts, effectiveSpecBase)
420
-
421
- // execute run id:从变更专属标记文件读取(agent 可写内容,格式校验防注入/穿越)
422
- const runIdFile = join(runtimeRoot, `current-execute-run-id-${changeName}`)
423
- let executeRunId = ''
424
- try {
425
- if (existsSync(runIdFile)) {
426
- const c = readFileSync(runIdFile, 'utf8').trim()
427
- const { isValidExecuteRunId } = await import('../task-review.js')
428
- if (c && !isValidExecuteRunId(c)) {
429
- console.warn(`⚠️ execute run marker 内容非法(期望 exec-YYYY-MM-DD-HHMMSS,实得 ${JSON.stringify(c.slice(0, 60))}),视为缺失回退扫描`)
430
- } else {
431
- executeRunId = c
432
- }
433
- }
434
- } catch {}
435
- if (!executeRunId) {
436
- // marker 缺失:先扫描 execute-runs/ 既有目录找回真实 runId(与 getLatestStageReviewRunId
437
- // 目录扫描兜底同语义),避免 marker 丢失而 agent 已用旧 runId 落盘时,直接 generate 新 ID
438
- // 找不到旧 review、误判缺 review.json。仅当确实无既有 run 才 generate 新 ID 并落盘。
439
- const { generateExecuteRunId, resolveLatestExecuteRunId } = await import('../task-review.js')
440
- executeRunId = resolveLatestExecuteRunId({ runtimeRoot, changeName }) || ''
441
- if (!executeRunId) {
442
- executeRunId = generateExecuteRunId()
443
- // 落盘(marker 缺失时 fallback 生成后写盘,保证后续 checkbox/gate 读到同一 ID)
444
- // D-001#1 fallback 写入点:mkdir execute-runs/<runId>/tasks 先于 marker(不变量:
445
- // marker 在则目录在)。不 try/catch——异常直穿外层 catch 走 fail-closed 阻断
446
- //(gate 自身写 run 目录失败不能静默放行完成)。
447
- mkdirSync(join(runtimeRoot, 'execute-runs', executeRunId, 'tasks'), { recursive: true })
448
- writeFileSync(runIdFile, executeRunId + '\n')
449
- }
450
- }
451
-
452
- // git 真实性校验目录:worktree 存在则用 worktree(base/head commit 在其中),否则主仓库。
453
- // 跨仓支持(task-07 / design §6 gates 行 + D-013):
454
- // - 有 ctx(execute 启动入口 task-09 构造的 MultiRepoContext 实例)→ 用
455
- // ctx.resolve('main').gitDir 作为主仓 task 的校验 cwd。ctx 的 main entry 已在
456
- // _buildMainEntry 内统一编码 worktreePath/cwd + in-place-fallback 兜底(与 task-review.js:724
457
- // generateTaskReviewDrafts 的 in-place 逻辑同源),避免两处漂移。Task Review Gate 内部循环
458
- // 再按 review.repo ?? 'main' 从 ctx.resolve(repo).gitDir 切跨仓 task 的 gitDir(task-04 实现)。
459
- // - 无 ctx(ctx=null 缺省,单仓 / 旧调用链)→ 走原逻辑(meta.worktreePath 或 cwd),零回归。
460
- let reviewGitDir = cwd
461
- if (ctx) {
462
- const mainEntry = ctx.resolve('main')
463
- if (mainEntry?.gitDir) reviewGitDir = mainEntry.gitDir
464
- } else {
465
- try {
466
- const { WorktreeManager } = await import('../worktree.js')
467
- const wm = new WorktreeManager({ cwd })
468
- const meta = wm.getMeta(changeName)
469
- if (meta?.worktreePath && meta.mode !== 'in-place-fallback' && existsSync(meta.worktreePath)) {
470
- reviewGitDir = meta.worktreePath
471
- }
472
- } catch {}
473
- }
474
-
475
- const reviewResult = validateTaskReviews({ planContent, runtimeRoot, executeRunId, changeDir: planFile, gitDir: reviewGitDir, ctx })
476
- printReviewResult(reviewResult, { runtimeRoot, executeRunId })
477
-
478
- if (!reviewResult.ok) {
479
- // Task review 校验失败,阻断 execute 完成
480
- // 检查是否存在 checkbox 已勾但 review 不通过的情况
481
- const uncheckedTasks = reviewResult.errors.filter(e => e.includes('缺少 review.json'))
482
- if (uncheckedTasks.length > 0) {
483
- console.error('\n⚠️ 部分任务已在 plan.md 中勾选,但 review.json 不存在。')
484
- console.error(` 请取消勾选这些任务的 checkbox,或补充对应的 review.json(execute run ID: ${executeRunId})。`)
485
- }
486
- return await rollbackCompletionAndReturn(pm, progress, stageData, steps, currentIdx, cwd, changeName, platformOpts)
487
- }
488
-
489
- // cannot_verify 的 requiredEvidence 写入 change 目录,供 verify 阶段消费
490
- if (reviewResult.requiredEvidence.length > 0) {
491
- const evidencePath = writeVerifyRequiredEvidence(join(effectiveSpecBase, 'changes', changeName), reviewResult.requiredEvidence)
492
- if (evidencePath) {
493
- console.log(`📄 verify-required-evidence.json 已写入: ${evidencePath}`)
494
- console.log(' verify 阶段必须满足这些证据要求。')
495
- }
496
- }
497
- }
498
- } catch (e) {
499
- // fail-closed:Gate 自身异常时不能默认放行,否则异常成了绕过评审的通道
500
- console.error(`❌ Task Review Gate 异常,阻断 execute 完成: ${e.message}`)
501
- console.error(' 请检查 review.json / plan.md 是否可读,修复后重新完成此步骤。')
502
- return await rollbackCompletionAndReturn(pm, progress, stageData, steps, currentIdx, cwd, changeName, platformOpts)
503
- }
504
- }
505
- return null
506
- }
507
-
508
- /**
509
- * 读取 design.md frontmatter 的 scale 字段(brainstorm 末步写入 'small'|'large')。
510
- * completeStep 的下一步提示据此分叉:small→quick,large/读不到→plan(fail-safe 走重流程)。
511
- * 只解析首个 YAML frontmatter 块,避免误读正文里的 "scale:"。
512
- * 从 complete.js 迁入(completeStageGates 共享收尾管线 + completeStep brainstorm 提示消费)。
513
- */
514
- export function readDesignScale(specBase, changeName) {
515
- if (!changeName) return null
516
- const designPath = join(specBase, 'changes', changeName, 'design.md')
517
- if (!existsSync(designPath)) return null
518
- const text = readFileSync(designPath, 'utf8')
519
- const fm = text.match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*\r?\n/)
520
- if (!fm) return null
521
- const m = fm[1].match(/^scale:[ \t]*['"]?(\w+)/m)
522
- return m ? m[1] : null
523
- }
524
-
525
- /**
526
- * 校验变更目录下近 10 分钟新增的 .md/.yaml/.yml 文件含 author/created_at 元数据(advisory 打印)。
527
- * 从 complete.js 迁入(completeStageGates 共享收尾管线消费)。
528
- */
529
- export function validateMetadata(cwd, stageName, specBase) {
530
- const changesDir = join(specBase, 'changes')
531
- if (!existsSync(changesDir)) return
532
-
533
- const cutoff = Date.now() - 10 * 60 * 1000
534
- const missing = []
535
-
536
- function walk(dir) {
537
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
538
- const full = join(dir, entry.name)
539
- try {
540
- if (entry.isDirectory()) { walk(full); continue }
541
- if (!/\.(md|yaml|yml)$/.test(entry.name)) continue
542
- const mtime = statSync(full).mtimeMs
543
- if (mtime < cutoff) continue
544
- const content = readFileSync(full, 'utf-8')
545
- if (!content.includes('author:') && !content.includes('author:')) missing.push(full)
546
- if (!content.includes('created_at:') && !content.includes('created_at:')) missing.push(full)
547
- } catch (e) { /* skip unreadable files */ }
548
- }
549
- }
550
-
551
- walk(changesDir)
552
- const unique = [...new Set(missing)]
553
- if (unique.length > 0) {
554
- console.log(`\n⚠️ 以下文件缺少 author 或 created_at 元数据:`)
555
- unique.forEach(f => console.log(` - ${f.replace(cwd + '/', '')}`))
556
- console.log('请在文件头部添加 author(git 用户名)和 created_at(精确到秒)')
557
- }
558
- }
559
-
560
- /**
561
- * 验证关键文件是否存在于正确的变更目录下(advisory 打印,防 AI 写错路径)。
562
- * 从 complete.js 迁入(completeStageGates 共享收尾管线消费)。
563
- */
564
- export function validateFileLocations(cwd, stageName, progress, changeName, specBase) {
565
- const effectiveChange = changeName || progress.currentChange
566
- if (!effectiveChange) return
567
-
568
- const changeDir = join(specBase, 'changes', effectiveChange)
569
- if (!existsSync(changeDir)) return
570
-
571
- // 每个阶段完成后预期存在的文件
572
- // brainstorm:scale=small(小变更)只必产 design.md;large/未标 scale → 四件套全。
573
- // 与 validateBrainstormOutputs 的 BRAINSTORM_RULES condition(scale≠small)同源,避免对合法 small 变更
574
- // 误报"⬜ proposal/requirements/tasks 未找到"(本检查仅 advisory 打印不 gate,但误导输出仍要消除)。
575
- const brainstormExpected = readDesignScale(specBase, effectiveChange) === 'small'
576
- ? ['design.md']
577
- : ['design.md', 'proposal.md', 'requirements.md', 'tasks.md']
578
- const expectedFiles = {
579
- brainstorm: brainstormExpected,
580
- plan: ['plan.md'],
581
- archive: ['module-impact.md'],
582
- }
583
-
584
- const expected = expectedFiles[stageName]
585
- if (!expected) return
586
-
587
- const missing = []
588
- for (const file of expected) {
589
- if (!existsSync(join(changeDir, file))) {
590
- missing.push(file)
591
- }
592
- }
593
-
594
- if (missing.length > 0) {
595
- console.log(`\n⚠️ 文件位置验证:以下文件未在变更目录中找到`)
596
- console.log(` 变更目录:${changeDir.replace(cwd + '/', '')}/`)
597
- for (const f of missing) {
598
- // 检查是否写到了错误的位置
599
- const wrongPath = join(specBase, 'changes', 'change', effectiveChange, f)
600
- if (existsSync(wrongPath)) {
601
- console.log(` ❌ ${f} — 不存在,但发现了错误路径:${wrongPath.replace(cwd + '/', '')}`)
602
- console.log(` 提示:应该写入 ${changeDir.replace(cwd + '/', '')}/${f}`)
603
- } else {
604
- console.log(` ⬜ ${f} — 未找到(该阶段可能未产出此文件)`)
605
- }
606
- }
607
- } else {
608
- console.log(`\n✅ 文件位置验证:所有 ${expected.length} 个预期文件均在变更目录中`)
609
- }
610
- }
611
-
612
- /**
613
- * 读本变更 design.md §6「文件变更清单」表格,提取声明交付的文件路径(concurrent 预检 ownFiles 源,D-002)。
614
- *
615
- * 用途:execute --done 并发预检在 in-place-fallback 模式下,需把本变更交付文件从 foreignFiles 排除
616
- * (否则自己产出的脏文件会被当他者)。worktree 模式下交付文件不在主仓 git status,ownFiles 用空数组即可。
617
- *
618
- * 解析:定位 `## 6.` 段(到下一个 `## N.` 前),逐行扫表格行第 2 列的反引号文件路径,跳过表头/分隔行。
619
- * 设计容错(design §9 / R-02):design.md 缺失 / 无 §6 / 解析空 → 返回 [],foreignFiles 退化为保守噪音,
620
- * otherActiveChanges 仍可靠。
621
- *
622
- * @param {string} specBase 规范根(.sillyspec/ 所在)
623
- * @param {string} changeName 当前变更名
624
- * @returns {string[]} 本变更声明交付的文件路径(git 路径,正斜杠)
625
- */
626
- function readDesignOwnFiles(specBase, changeName) {
627
- if (!specBase || !changeName) return []
628
- let content
629
- try {
630
- content = readFileSync(join(specBase, 'changes', changeName, 'design.md'), 'utf8')
631
- } catch {
632
- return []
633
- }
634
- const files = []
635
- let inSection6 = false
636
- for (const line of content.split('\n')) {
637
- const heading = line.match(/^##\s+(\d+)\./)
638
- if (heading) {
639
- inSection6 = heading[1] === '6'
640
- continue
641
- }
642
- if (!inSection6 || !line.startsWith('|')) continue
643
- // 跳过表头分隔行(|---|---|)
644
- if (/^\|[\s:|-]+\|?$/.test(line.trim())) continue
645
- // 表格第 2 列反引号文件路径:`| 新增 | \`src/.../x.js\` | 说明 |`
646
- const m = line.match(/^\|[^|]+\|\s*`([^`]+)`/)
647
- if (m) files.push(m[1].trim())
648
- }
649
- return files
650
- }
651
-
652
- /**
653
- * 阶段完成收尾共享管线(从 completeStep 抽出,消除 noAI 末步 / continueStep 完成分支绕过 gate 的
654
- * S1/S2/S3 三处不对称)。调用方已自行标记 stageData.status='completed' 并 pm._write 落盘后调用本函数。
655
- *
656
- * 序列:handleScanStageCompleted → validateMetadata → validateFileLocations[completed‖skipped 守卫] →
657
- * auxiliary 重置 → runStageCompletionGates[同守卫] → handleExecuteWorktreeCleanup。
658
- *
659
- * 关键陷阱(design §5.4):auxiliary 重置把 stageData.steps 换成 freshSteps(全 pending);下方
660
- * runStageCompletionGates 守卫与 rollbackStageCompletion 必须用入参 steps(pre-reset 原数组),
661
- * 不得重读 stageData.steps(否则计数恒 0 → gate 永跳过)。本函数全程用入参 steps,未重读。
662
- *
663
- * @returns {Promise<{stageCompleted:false,currentIdx,nextPendingIdx:number}|null>}
664
- * null = 全部通过,调用方继续自管收尾(如下一步提示);
665
- * 非 null = gate/handler 失败已 rollback,调用方直接 return。
666
- */
667
- export async function completeStageGates({ stageName, cwd, changeName, platformOpts, specBase, progress, pm, stageData, steps, currentIdx, outputText, ctx = null }) {
668
- // 整体 try/catch(task-04 / review-2026-08-09 #2):收尾段(execute 预检 + scan handler + validate* +
669
- // auxiliary 重置 + runStageCompletionGates)任一段抛非结构化异常 → rollbackCompletionAndReturn
670
- // (回滚 in-progress + 落盘 + 返回未完成对象),不冒顶 exit 1。正常的 _scanResult/_gateEarlyReturn
671
- // early-return 是 return 非 throw,不被 catch 拦;下方 handleExecuteWorktreeCleanup 在 try 外(副作用独立,失败不 rollback stage 状态)。
672
- try {
673
- // ── execute --done 并发他者改动预检(FR-06/FR-07,非阻断 advisory)──
674
- // 仅 execute 触发,不影响 scan/plan/verify/archive 等 stage 的 completeStageGates。
675
- // 纯副作用:console.warn 后照常推进,不改 stageData / 不阻断级联(FR-07)。
676
- // 整个钩子 try/catch 兜底——任何异常吞掉,绝不影响 gate 级联(FR-07 不阻断铁律)。
677
- if (stageName === 'execute') {
678
- try {
679
- // ownFiles 源(D-002 + B-002,钉死):动态 import WorktreeManager 取 meta.mode(复用上方
680
- // Task Review Gate 的 ../worktree.js 动态 import 先例)。WorktreeManager 无 appliedFiles
681
- // 字段(B-002 已证),不用模糊的「worktree applied 文件」。
682
- // - worktree 模式(meta.mode 非 in-place-fallback):主仓 git status 看不见本变更交付文件
683
- // → ownFiles=[](无害,dogfood 当前实际模式)
684
- // - in-place-fallback 模式:交付文件就在主仓 dirty → ownFiles 读 design.md §6 文件清单排除
685
- let ownFiles = []
686
- // linkedChanges:execute 通常无(quick 管线概念,存 quick guard 文件);stageData 取不到则 []。
687
- const linkedChanges = Array.isArray(stageData?.linkedChanges) ? stageData.linkedChanges : []
688
- try {
689
- const { WorktreeManager } = await import('../worktree.js')
690
- const wm = new WorktreeManager({ cwd })
691
- const meta = wm.getMeta(changeName)
692
- if (meta?.mode === 'in-place-fallback') {
693
- ownFiles = readDesignOwnFiles(specBase, changeName)
694
- }
695
- } catch {
696
- // getMeta 抛(meta.json 缺 / worktree 损坏)→ ownFiles=[] 兜底,不崩(FR-07)。
697
- ownFiles = []
698
- }
699
- const detected = detectConcurrentChanges(cwd, { changeName, linkedChanges, ownFiles })
700
- const warn = formatConcurrentWarning(detected)
701
- if (warn) console.warn(warn)
702
- } catch {
703
- // FR-07 不阻断:钩子任何意外异常静默吞掉,gate 级联照常推进。
704
- }
705
- }
706
-
707
- // scan 平台 manifest + post-check(S1 平台受害者:noAI scanPostcheck 末步 / continueStep 现也走这里)
708
- const _scanResult = await handleScanStageCompleted({ stageName, currentIdx, cwd, progress, pm, stageData, changeName, outputText, platformOpts })
709
- if (_scanResult) return _scanResult
710
-
711
- // 守卫计数:completed‖skipped === total(修 S3);用入参 steps(pre-reset 原数组,design §5.4)
712
- const settledCount = steps.filter(s => s.status === 'completed' || s.status === 'skipped').length
713
- const total = steps.length
714
-
715
- validateMetadata(cwd, stageName, specBase)
716
-
717
- // 验证关键文件位置(仅当所有步骤已结案 completed‖skipped 时才校验)
718
- if (settledCount === total && total > 0) {
719
- validateFileLocations(cwd, stageName, progress, changeName, specBase)
720
- }
721
-
722
- // 辅助阶段完成后重置步骤(scan 等 auxiliary 阶段重置回 pending 可重跑)
723
- const stageDef = stageRegistry[stageName]
724
- if (stageDef?.auxiliary) {
725
- const freshSteps = (stageDef.steps || []).map(s => ({
726
- name: s.name,
727
- status: 'pending',
728
- output: null,
729
- completedAt: null
730
- }))
731
- stageData.steps = freshSteps
732
- stageData.status = 'pending'
733
- stageData.completedAt = null
734
- if (progress.currentStage === stageName) progress.currentStage = ''
735
- pm._write(cwd, progress, changeName)
736
- }
737
-
738
- // 阶段完成校验 gate 级联(runValidators → verify-test → Plan→Execute → Stage Review → Task Review)
739
- if (settledCount === total && total > 0) {
740
- const _gateEarlyReturn = await runStageCompletionGates({ stageName, cwd, changeName, platformOpts, specBase, progress, pm, stageData, steps, currentIdx, ctx })
741
- if (_gateEarlyReturn) return _gateEarlyReturn
742
- } else if (settledCount < total) {
743
- console.log(`\n⚠️ 阶段校验跳过:${total} 步中仅 ${settledCount} 步标记为已结案(completed‖skipped),可能存在状态不同步。如确认阶段已完成,请运行 --status 确认。`)
744
- }
745
- } catch (e) {
746
- console.error(`\n❌ ${stageName} 阶段完成收尾异常(已 rollback 为 in-progress,请修复后重新 --done):${e?.message || e}`)
747
- return await rollbackCompletionAndReturn(pm, progress, stageData, steps, currentIdx, cwd, changeName, platformOpts)
748
- }
749
-
750
- // execute worktree cleanup(completeStep / continueStep 完成分支统一调用,避免双清理)
751
- await handleExecuteWorktreeCleanup({ stageName, changeName, cwd })
752
-
753
- return null
754
- }
755
-
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, readdirSync, statSync, mkdirSync, writeFileSync } from 'node:fs'
24
+ import { triggerSync, resolveChangeDir, resolveRuntimeRoot } from './shared.js'
25
+ import { runValidators } from '../stage-contract.js'
26
+ import { handleScanStageCompleted, handleExecuteWorktreeCleanup } from './complete-handlers.js'
27
+ import { detectConcurrentChanges, formatConcurrentWarning } from './concurrent-detect.js'
28
+ import { stageRegistry } from '../stages/index.js'
29
+ import { normalizeTaskId } from '../taskcard.js'
30
+
31
+ /**
32
+ * 从任务注册表(tasks.md)提取全部 task id(task-XX)——符号影响面覆盖度校验用。
33
+ * 只认 checkbox 行 `- [ ] task-XX:` / `- [x] task-XX:`(与 execute.js parseTaskRegistry 同口径)。
34
+ * 2026-08-20-task-truth-unify:源从 plan.md 迁 tasks.md(任务唯一真相);ql-xxx 行不收。
35
+ */
36
+ function extractTaskIdsFromRegistry(tasksContent) {
37
+ if (!tasksContent) return []
38
+ return [...String(tasksContent).matchAll(/^[-*]\s*\[[ xX]\]\s*task-(\d+)\b/gim)]
39
+ .map(m => `task-${m[1].padStart(2, '0')}`)
40
+ }
41
+
42
+ /**
43
+ * 符号影响面报告结构校验(纯函数):execute「加载上下文」步的产出落盘核验。
44
+ *
45
+ * 治 persuasion-only 失效现场(ql-20260816-005-3d7f):execute 前缀 4 步无任何 gate,agent 可
46
+ * <1s 连发 4 次 --done 盖章跳过,「符号影响面扫描」(execute.js 操作 11)被一句「上下文在会话内」
47
+ * 带过(输出契约只写「上下文摘要」,操作与输出脱节)。修法 = 报告落盘
48
+ * {SPEC_ROOT}/changes/<change>/symbol-impact.md + 本函数校验:plan.md 每个 task-XX 在报告中出现
49
+ * (逐 task 结论,「无签名级变更」也要显式写,治整体一句话带过)。语义质量(调用点找没找全)
50
+ * 仍归 agent,CLI 只核结构覆盖度——符合债单「persuasion-only → 补最小硬门」原则。
51
+ *
52
+ * @param {{reportContent: string|null, planContent: string|null}} params
53
+ * @returns {{ok: boolean, errors: string[]}}
54
+ */
55
+ export function validateSymbolImpactReport({ reportContent, planContent }) {
56
+ const errors = []
57
+ if (!reportContent || !String(reportContent).trim()) {
58
+ return { ok: false, errors: ['symbol-impact.md 不存在或内容为空——「加载上下文」步的符号影响面报告必须落盘(逐 task 一行结论,「无签名级变更」也要显式写)'] }
59
+ }
60
+ // 2026-08-20-task-truth-unify:task 清单源迁 tasks.md(参数名 planContent 保持兼容既有调用,
61
+ // 语义=任务注册表内容;调用方 enforceSymbolImpactGate 已改读 tasks.md)
62
+ const taskIds = extractTaskIdsFromRegistry(planContent)
63
+ if (taskIds.length === 0) return { ok: true, errors } // 注册表无 task 可列(默认兜底形态)→ 无可校验对象
64
+ const missing = taskIds.filter(id => !String(reportContent).includes(id))
65
+ for (const id of missing) {
66
+ errors.push(`${id} 未在 symbol-impact.md 中出现——每个 task 都要有一行结论(含「无签名级变更」的显式声明)`)
67
+ }
68
+ return { ok: errors.length === 0, errors }
69
+ }
70
+
71
+ /**
72
+ * 符号影响面报告硬门:execute「加载上下文」步 --done 时校验 symbol-impact.md 落盘 + task 覆盖度。
73
+ * fail-closed:不通过 → 当前步标 blocked + exit 1(进度不推进)。非目标步骤/阶段直接放行。
74
+ */
75
+ export async function enforceSymbolImpactGate(stageName, changeName, currentStepName, specBase) {
76
+ if (stageName !== 'execute') return
77
+ if (!currentStepName || !currentStepName.includes('加载上下文')) return
78
+ const tasksPath = join(specBase, 'changes', changeName || '', 'tasks.md')
79
+ const reportPath = join(specBase, 'changes', changeName || '', 'symbol-impact.md')
80
+ if (!existsSync(tasksPath)) return // 注册表缺失场景已有 plan 阶段 gate 把关,此处不重复拦
81
+ let tasksContent = ''
82
+ try { tasksContent = readFileSync(tasksPath, 'utf8') } catch { return }
83
+ let reportContent = null
84
+ try { reportContent = readFileSync(reportPath, 'utf8') } catch { /* 缺失由 validate 报 */ }
85
+ const result = validateSymbolImpactReport({ reportContent, planContent: tasksContent })
86
+ if (result.ok) return
87
+ console.error('❌ ── 符号影响面报告校验阻断(本次 --done 未完成,进度未推进)──')
88
+ console.error(` 「加载上下文」步的符号影响面检查报告未落盘或不完整:`)
89
+ console.error(` 期望路径:${reportPath}`)
90
+ for (const e of result.errors) console.error(` • ${e}`)
91
+ console.error(' 修复:按「加载上下文」步操作 11 完成符号影响面扫描,逐 task 写结论(无签名级变更也要显式写「无」),落盘后重跑 execute --done。')
92
+ process.exit(1)
93
+ }
94
+
95
+ /**
96
+ * 判断当前 execute step 所在 wave 是否全部 task 都声明 no_deps_verify: true(D-006@v2)。
97
+ * 仅 wave 执行步骤(名如 "Wave N 执行")可 opt-out;非 wave 步骤恒返回 false(保守过门)。
98
+ */
99
+ function isCurrentWaveAllNoDepsVerify(stepName, changeDir) {
100
+ if (!stepName) return false
101
+ const m = String(stepName).match(/^Wave\s+(\d+)/i)
102
+ if (!m) return false
103
+ const waveN = parseInt(m[1], 10)
104
+ if (!changeDir) return false
105
+ const planPath = join(changeDir, 'plan.md')
106
+ if (!existsSync(planPath)) return false
107
+ const plan = readFileSync(planPath, 'utf8')
108
+ const waveRe = new RegExp(`^##\\s*Wave\\s+${waveN}\\b[^\\n]*\\n([\\s\\S]*?)(?=\\n##\\s|\\n#\\s|$)`, 'm')
109
+ const waveMatch = plan.match(waveRe)
110
+ if (!waveMatch) return false
111
+ // 2026-08-20-task-truth-unify:Wave 段任务为纯 ID 引用行("- task-XX"),不再有 checkbox
112
+ const taskIds = [...waveMatch[1].matchAll(/^[-*]\s+(task-\d+)\s*$/gim)].map(x => x[1])
113
+ if (taskIds.length === 0) return false
114
+ for (const id of taskIds) {
115
+ // 体检 BUG-19:plan 常态写补零 task-01,但 AI 写出 task-3 时卡片实际是 task-03.md——
116
+ // 未归一则 existsSync 恒 false,wave 级 no_deps_verify 豁免静默失效(保守方向)
117
+ const cardPath = join(changeDir, 'tasks', `${normalizeTaskId(id)}.md`)
118
+ if (!existsSync(cardPath)) return false // 卡片缺失 → 保守不跳
119
+ const card = readFileSync(cardPath, 'utf8')
120
+ const fm = card.match(/^---\n([\s\S]*?)\n---/)
121
+ if (!fm) return false
122
+ if (!/no_deps_verify:\s*true/i.test(fm[1])) return false
123
+ }
124
+ return true
125
+ }
126
+
127
+ /**
128
+ * doctor --align-execute-progress --confirm 的前置 review 门(坑 doctor-align-bypass-review-gate,
129
+ * 2026-08-20 实证):alignExecuteToPlan 直写 completed「绕过 completeStep 推导」,此前 execute 的
130
+ * Stage Review Gate 与 Task Review Gate 被整体跳过——而 worktree 清理后的恢复场景恰是最需要
131
+ * review 审计的时刻(实证靠 15 份 task review + verify 全程补足覆盖才没出事)。
132
+ *
133
+ * 本函数供 CLI 层(index.js doctor align 分支)在 confirm 落盘前调用,跑与正常完成路径同源的
134
+ * 只读校验(不自动生成 marker/runId——恢复读场景缺失就是缺失,如实报告):
135
+ * 1. Stage Review:tier=independent 时须存在有效 execute stage review.json(verdict 非 fail);
136
+ * tier=self 放行(与正常 gate 分级一致)。
137
+ * 2. Task Review:所有 task 的 review.json 齐备且 verdict 非 fail(runId 解析同 Task Review Gate:
138
+ * marker → 含 tasks/ 目录扫描;不 generate)。
139
+ *
140
+ * @param {{ cwd: string, changeName: string, specBase: string, platformOpts?: object }} opts
141
+ * @returns {Promise<boolean>} true=被门阻断(CLI 应放弃 align 并 exit 1);false=校验通过可继续
142
+ */
143
+ export async function enforceAlignExecuteReviewGate({ cwd, changeName, specBase, platformOpts = {} }) {
144
+ const effectiveSpecBase = platformOpts?.specRoot || specBase
145
+ const runtimeRoot = resolveRuntimeRoot(platformOpts, effectiveSpecBase)
146
+ const reviewChangeDir = resolveChangeDir(cwd, { currentChange: changeName }, platformOpts?.specRoot || null)
147
+ const blocked = (msgs) => {
148
+ console.error('\n❌ doctor --align-execute-progress 前置 review 校验未过——execute 完成审计不能绕过:')
149
+ for (const m of msgs) console.error(' - ' + m)
150
+ console.error(' 解法:补齐上述 review 后重跑 align(stage review:sillyspec register-stage-review --change ' + changeName + ' --stage execute;task review:按报错路径补 review.json)')
151
+ return true
152
+ }
153
+
154
+ // 1. Stage Review(tier 分级同正常 gate)
155
+ try {
156
+ const { classifyReviewTier } = await import('../review-tier.js')
157
+ const { validateStageReview, getLatestStageReviewRunId, printStageReviewResult } = await import('../stage-review.js')
158
+ const designPath = reviewChangeDir ? join(reviewChangeDir, 'design.md') : null
159
+ let planLevel = null
160
+ if (reviewChangeDir) {
161
+ const planPath = join(reviewChangeDir, 'plan.md')
162
+ if (existsSync(planPath)) {
163
+ const fmLine = readFileSync(planPath, 'utf8').split('\n').find(l => l.trim().startsWith('plan_level:'))
164
+ if (fmLine) planLevel = fmLine.split(':')[1].trim()
165
+ }
166
+ }
167
+ const tier = classifyReviewTier({ planLevel, designPath })
168
+ if (tier.tier === 'self') {
169
+ console.log('ℹ️ Stage Review: execute tier=self(' + tier.reason + '),align 前置门放行(不强制独立审查)')
170
+ } else {
171
+ // 只读解析(不 generate/写 marker):恢复场景下缺失即缺失
172
+ const reviewRunId = getLatestStageReviewRunId(runtimeRoot, 'execute', changeName)
173
+ if (!reviewRunId) {
174
+ return blocked(['execute stage review runId 无法定位(marker 缺失且 stage-reviews/ 无含 review 的 run 目录)'])
175
+ }
176
+ const searchDirs = [effectiveSpecBase, reviewChangeDir, cwd].filter(Boolean)
177
+ const reviewResult = validateStageReview({ stage: 'execute', reviewType: 'acceptance', runtimeRoot, reviewRunId, searchDirs })
178
+ printStageReviewResult(reviewResult, { stage: 'execute', reviewRunId, runtimeRoot })
179
+ if (!reviewResult.ok) return true // printStageReviewResult 已给明细,不再重复列
180
+ }
181
+ } catch (e) {
182
+ // fail-closed:门自身异常阻断 align(与正常 gate 一致),不静默放行
183
+ console.error('❌ align 前置 Stage Review 校验异常,阻断对齐: ' + e.message)
184
+ return true
185
+ }
186
+
187
+ // 2. Task Review(runId 只读解析:marker → 含 tasks/ 扫描;不 generate)
188
+ try {
189
+ const { validateTaskReviews, printReviewResult, resolveLatestExecuteRunIdWithTasks, isValidExecuteRunId } = await import('../task-review.js')
190
+ const planPath = reviewChangeDir ? join(reviewChangeDir, 'plan.md') : null
191
+ if (!planPath || !existsSync(planPath)) {
192
+ return blocked(['plan.md 不存在,Task Review 无从校验(align 的 checkbox 判定依赖同目录,疑似路径解析异常)'])
193
+ }
194
+ const planContent = readFileSync(planPath, 'utf8')
195
+ const runIdFile = join(runtimeRoot, `current-execute-run-id-${changeName}`)
196
+ let executeRunId = ''
197
+ try {
198
+ if (existsSync(runIdFile)) {
199
+ const c = readFileSync(runIdFile, 'utf8').trim()
200
+ if (c && isValidExecuteRunId(c)) executeRunId = c
201
+ }
202
+ } catch {}
203
+ if (!executeRunId) {
204
+ executeRunId = resolveLatestExecuteRunIdWithTasks({ runtimeRoot, changeName }) || ''
205
+ }
206
+ if (!executeRunId) {
207
+ return blocked(['execute runId 无法定位(marker 缺失且 execute-runs/ 无含 review 的 run 目录)——15/N 份 task review 所在 run 不可寻,无法对账'])
208
+ }
209
+ const reviewResult = validateTaskReviews({ planContent, runtimeRoot, executeRunId, changeDir: reviewChangeDir, gitDir: cwd })
210
+ printReviewResult(reviewResult, { runtimeRoot, executeRunId })
211
+ if (!reviewResult.ok) return true // 报错明细已在 printReviewResult 输出
212
+ console.log('✅ align 前置 review 校验通过(stage review + task review 均齐备),继续对齐')
213
+ } catch (e) {
214
+ console.error('❌ align 前置 Task Review 校验异常,阻断对齐: ' + e.message)
215
+ return true
216
+ }
217
+ return false
218
+ }
219
+
220
+ /**
221
+ * execute deps 验证硬门(change 2026-06-28-worktree-deps-provision / D-001@v1, D-003@v1, D-006@v2)。
222
+ * depsStatus 不达标且非 wave 级 opt-out 时阻断 --done:置 step=blocked + exit(1),与 requiresWait 同范式。
223
+ * 放行返回 true;阻断时 process.exit(1) 不返回。
224
+ */
225
+ export async function enforceDepsGate(stageName, cwd, changeName, step, steps, currentIdx, specBase, platformOpts) {
226
+ if (stageName !== 'execute') return true
227
+ let meta = null
228
+ let wm = null
229
+ try {
230
+ const { WorktreeManager } = await import('../worktree.js')
231
+ wm = new WorktreeManager({ cwd })
232
+ meta = wm.getMeta(changeName)
233
+ } catch (e) {
234
+ // WorktreeManager 构造/读取失败(git 不可用 / worktree 元数据损坏)不阻断 deps gate;
235
+ // 下游已有物理目录存在性判定兜底(G2/R3),这里 warn 留下根因线索,避免静默误诊。
236
+ console.warn(`⚠️ worktree meta 读取失败(deps gate 将走物理目录判定): ${e.message}`)
237
+ }
238
+ const depsStatus = meta?.depsStatus
239
+ if (['linked', 'installed', 'n/a'].includes(depsStatus)) return true
240
+ const changeDir = changeName ? join(specBase, 'changes', changeName) : null
241
+ if (isCurrentWaveAllNoDepsVerify(step?.name, changeDir)) return true
242
+ if (steps && steps[currentIdx]) steps[currentIdx].status = 'blocked'
243
+ // ── 诊断分支(Phase 2,G2/R3 修正:判定基于物理目录而非 !meta)──
244
+ // getMeta 对"目录不存在"与"meta 损坏"都返回 null,后者会误判终态 → 用物理目录存在性判定。
245
+ let worktreeGone = true
246
+ try {
247
+ worktreeGone = !!(wm && changeName) && !existsSync(wm.getWorktreePath(changeName))
248
+ } catch {}
249
+ // ── fail-loud 块(Phase 3,D-005@v1:仅改拒绝侧 stderr)──
250
+ console.error('❌ ── deps 门控阻断(本次 --done 未完成,进度未推进)──')
251
+ if (worktreeGone) {
252
+ console.error(' worktree 不可用(已 cleanup 或目录不存在)。')
253
+ console.error(` 修复:sillyspec doctor --align-execute-progress${changeName ? ` --change ${changeName}` : ''} 按 plan.md 对齐进度`)
254
+ console.error(` 或: sillyspec worktree create ${changeName || '<change>'} 重建 worktree 继续跑`)
255
+ } else {
256
+ console.error(` 原因:依赖未就绪(depsStatus=${depsStatus || 'unknown'}),不得在无构建/测试能力时声称完成。`)
257
+ console.error(` 修复:sillyspec worktree doctor --fix${changeName ? ` --change ${changeName}` : ''}`)
258
+ console.error(' 或在 worktree 内手动安装依赖后重试。')
259
+ if (meta?.depsError) console.error(` 上次供给错误:${meta.depsError}`)
260
+ }
261
+ process.exit(1)
262
+ }
263
+
264
+ /**
265
+ * execute --done 硬门:已勾 [x] task 的 review.json 必须 schema 完整(坑 review-json-field-gap)。
266
+ *
267
+ * Task Review Gate(validateTaskReviews)只在 execute 整阶段完成时跑(complete.js 阶段完成分支的
268
+ * actualCompleted===actualTotal 守卫),单 task --done 不校验 → 子代理勾 checkbox 却漏写/漏字段
269
+ * review.json,要到收尾才暴露,用户被迫事后批量补。本门提前到每次 --done:校验 plan 里所有已勾
270
+ * task 的 review.json,缺字段/不存在/JSON 坏 → 置 step=blocked + exit(1),与 enforceDepsGate 同范式。
271
+ * 未勾 task 不校验(还没做)。平台模式/无 marker/无 plan 时放行(下游 Task Review Gate 兜底)。
272
+ */
273
+ export async function enforceReviewJsonGate(stageName, cwd, changeName, step, steps, currentIdx, specBase, platformOpts) {
274
+ if (stageName !== 'execute' || !changeName) return true
275
+ const runtimeRoot = resolveRuntimeRoot(platformOpts, specBase)
276
+ const runIdFile = join(runtimeRoot, `current-execute-run-id-${changeName}`)
277
+ const planPath = join(specBase, 'changes', changeName, 'plan.md')
278
+ if (!existsSync(runIdFile) || !existsSync(planPath)) return true
279
+ const { validateCheckedTaskReviews, resolveLatestExecuteRunIdWithTasks, isValidExecuteRunId } = await import('../task-review.js')
280
+ // existsSync 与 read 之间 marker 可能被并发 cleanup/归档删除(多 agent 场景):读失败按
281
+ //「marker 缺失」处理,走漂移兜底重定位,不让 ENOENT 冒成顶层 stack(与 :426-435 口径对齐)
282
+ let executeRunId = ''
283
+ try {
284
+ executeRunId = readFileSync(runIdFile, 'utf8').trim()
285
+ } catch {
286
+ console.warn('⚠️ execute run marker 读取失败(可能被并发清理),改扫真实 run 目录')
287
+ executeRunId = resolveLatestExecuteRunIdWithTasks({ runtimeRoot, changeName }) || ''
288
+ }
289
+ // marker 是 agent 可写内容:格式校验防注入/穿越,非法视为缺失走漂移兜底重定位
290
+ if (executeRunId && !isValidExecuteRunId(executeRunId)) {
291
+ console.warn(`⚠️ execute run marker 内容非法(期望 exec-YYYY-MM-DD-HHMMSS,实得 ${JSON.stringify(executeRunId.slice(0, 60))}),改扫真实 run 目录`)
292
+ executeRunId = ''
293
+ }
294
+ // 2026-08-20-task-truth-unify:已勾 task 清单源迁 tasks.md(勾选唯一落点);tasks.md 缺失
295
+ // 回退 planPath 内容(旧变更兼容读侧)。planPath 存在性已在上方把关。
296
+ const tasksRegistryPath = join(specBase, 'changes', changeName, 'tasks.md')
297
+ const planContent = existsSync(tasksRegistryPath)
298
+ ? readFileSync(tasksRegistryPath, 'utf8')
299
+ : readFileSync(planPath, 'utf8')
300
+ // marker 读失败且重定位无果(无任何含 tasks/ 的 run):无 run 可校验,放行(下游 Task Review Gate 兜底)
301
+ if (!executeRunId) return true
302
+ // marker 漂移兜底(gate-atom-a 正确修法):marker 指向的 run 缺 tasks/(generateExecuteRunId 只写
303
+ // marker 不建目录,漂移后新 run 不继承旧 review)时,无视 marker 改扫 execute-runs/ 取 mtime 最新
304
+ // 且真正含 tasks/ 的 run,用其齐备的 review.json 校验,避免误报「review.json 不存在」。注意不能用
305
+ // resolveLatestExecuteRunId——它见 marker 非空即原样返回(不校验目录),恰是本场景要绕开的值。
306
+ if (executeRunId && !existsSync(join(runtimeRoot, 'execute-runs', executeRunId, 'tasks'))) {
307
+ const relocated = resolveLatestExecuteRunIdWithTasks({ runtimeRoot, changeName })
308
+ if (relocated && relocated !== executeRunId) {
309
+ console.warn(`⚠️ execute run marker 漂移:${executeRunId} 无 tasks/,改用真实含 review 的 run ${relocated}`)
310
+ executeRunId = relocated
311
+ }
312
+ }
313
+ const result = validateCheckedTaskReviews({ planContent, runtimeRoot, executeRunId })
314
+ if (result.ok) return true
315
+ if (steps && steps[currentIdx]) steps[currentIdx].status = 'blocked'
316
+ console.error('❌ ── review.json 字段校验阻断(本次 --done 未完成,进度未推进)──')
317
+ console.error(' 已勾选 [x] 的 task review.json 不完整(铁律:勾 checkbox 前必须先写完整 review.json):')
318
+ for (const f of result.failures) {
319
+ const kindLabel = f.kind === 'missing' ? 'review.json 不存在' : (f.kind === 'parseError' ? 'JSON 解析失败' : '字段缺失')
320
+ console.error(` • ${f.taskId}(${kindLabel}): ${f.reviewPath}`)
321
+ for (const e of f.errors) console.error(` - ${e}`)
322
+ }
323
+ console.error(' 修复:补全上述 review.json 字段后重跑 execute --done。')
324
+ process.exit(1)
325
+ }
326
+
327
+ /**
328
+ * 阶段完成校验失败时回滚状态。
329
+ *
330
+ * completeStep 在跑 validator 之前就把 stageData.status 写成 'completed',
331
+ * 若校验失败不回滚,DB 会与真实产物不一致(hook/doctor/下游阶段全部误判),
332
+ * 且所有步骤都是 completed 时 agent 无法重新 --done("没有待完成的步骤")。
333
+ * 此处将 stage 回滚为 in-progress,最后一步重置为 pending,供修复产物后重做。
334
+ */
335
+ function rollbackStageCompletion(stageData, steps, currentIdx) {
336
+ // 辅助阶段在 validator 前已被重置为 pending(steps 也换成了新数组),不要覆盖
337
+ if (stageData.status === 'completed') {
338
+ stageData.status = 'in-progress'
339
+ stageData.completedAt = null
340
+ }
341
+ if (steps[currentIdx] && steps[currentIdx].status === 'completed') {
342
+ steps[currentIdx].status = 'pending'
343
+ steps[currentIdx].completedAt = null
344
+ }
345
+ }
346
+
347
+ /**
348
+ * 阶段完成校验失败后的统一收尾:回滚 stage/step 状态 + 落盘 + sync + 返回「未完成」。
349
+ *
350
+ * completeStep 的各 gate(runValidators / verify-test 对账 / plan→execute contract /
351
+ * Stage Review / Execute Task Review)失败时都走这套动作;原先每个失败分支手写重复
352
+ * ~7 次(含 lastActive 落盘 + triggerSync + return 结构),统一进来消坑,避免某分支
353
+ * 漏写 triggerSync / 写错 return 结构导致行为分裂。返回 nextPendingIdx=currentIdx,
354
+ * 让上层走「完成但不推进」分支,--done 被拒、agent 修复产物后重跑。
355
+ */
356
+ function rollbackCompletionAndReturn(pm, progress, stageData, steps, currentIdx, cwd, changeName, platformOpts) {
357
+ rollbackStageCompletion(stageData, steps, currentIdx)
358
+ progress.lastActive = new Date().toLocaleString('zh-CN', { hour12: false })
359
+ pm._write(cwd, progress, changeName)
360
+ triggerSync(cwd, changeName, platformOpts)
361
+ return { stageCompleted: false, currentIdx, nextPendingIdx: currentIdx }
362
+ }
363
+ /**
364
+ * 阶段完成校验 gate 级联(从 completeStep 抽出,行为保持)。仅当所有步骤确实标记为 completed 时
365
+ * 由 completeStep 调用。顺序:runValidators → verify-test 对账 → Plan→Execute contract →
366
+ * Stage Review Gate → Execute Task Review Gate。任一 gate 失败 → rollbackCompletionAndReturn
367
+ * (统一回滚 + 返回 early-return 对象);全部通过 → 返回 null(completeStep 继续收尾)。
368
+ *
369
+ * @returns {{stageCompleted:false,currentIdx,nextPendingIdx:number}|null}
370
+ */
371
+ export async function runStageCompletionGates({ stageName, cwd, changeName, platformOpts, specBase, progress, pm, stageData, steps, currentIdx, ctx = null }) {
372
+ const projectName = progress.project || basename(cwd)
373
+ const contractResult = runValidators(stageName, cwd, changeName, { projectName, specRoot: platformOpts?.specRoot })
374
+ if (contractResult.errors.length > 0) {
375
+ console.error(`\n❌ 阶段 ${stageName} 校验失败:`)
376
+ for (const err of contractResult.errors) {
377
+ console.error(` - ${err}`)
378
+ }
379
+ // 出路提示常放在 warnings(如「写明不改理由即可豁免」),此处即将阻断 return,
380
+ // 必须一并打出,否则 agent 只看到症状看不到怎么过。
381
+ for (const w of (contractResult.warnings || [])) {
382
+ console.error(` · ${w}`)
383
+ }
384
+ console.error(`\n 提示:修复缺失产物后重新完成此步骤(--skip-approval 只跳过阶段转换/审批检查,不能跳过产物校验)`)
385
+ // 产物校验失败必须阻断完成 —— 否则 validator 形同虚设,
386
+ // verify 会带着 FAIL/缺 verify-result.md 被 ✅ 标记完成(历史教训)。
387
+ // plan/execute 的专项契约校验(下方)在产物齐全后才需要继续跑,故此处先 return。
388
+ return await rollbackCompletionAndReturn(pm, progress, stageData, steps, currentIdx, cwd, changeName, platformOpts)
389
+ }
390
+ if (contractResult.warnings.length > 0) {
391
+ console.warn(`\n⚠️ 阶段 ${stageName} 校验警告:`)
392
+ for (const w of contractResult.warnings) {
393
+ console.warn(` - ${w}`)
394
+ }
395
+ }
396
+
397
+ // verify 产物校验通过 + 结论非 FAIL(否则上面已阻断)。
398
+ // 再由 CLI 亲自执行 local.yaml 的测试命令,与 verify-result.md 的自报告对账:
399
+ // 自报告 PASS 但实测失败 → 阻断(防止"文案通过"绕过验证)。
400
+ if (stageName === 'verify') {
401
+ const { runVerifyTestCheck, printVerifyTestCheck } = await import('../verify-postcheck.js')
402
+ // 测试实测是同步 execSync,长套件可跑 2~10min 且中途无输出——先预告避免 agent 误判卡死
403
+ console.log(`\n⏳ Verify 测试对账:CLI 亲自执行 local.yaml 的 commands.test(同步,耗时可能较长,请等待…)`)
404
+ const testCheck = runVerifyTestCheck({ cwd, specBase, changeName, ctx })
405
+ printVerifyTestCheck(testCheck)
406
+ if (testCheck.status === 'failed') {
407
+ console.error('\n❌ verify 阶段被阻断:verify-result.md 自报告通过,但 CLI 实测测试失败。')
408
+ console.error(' 请修复失败的测试并更新 verify-result.md 后重新完成此步骤。')
409
+ return await rollbackCompletionAndReturn(pm, progress, stageData, steps, currentIdx, cwd, changeName, platformOpts)
410
+ }
411
+ // 契约 parity 对账:扫前端 API 调用 vs execute 提取的 provider endpoint artifact。
412
+ // 接线自 contract-matrix pipeline(verifyApiParity 的 CLI 入口)。
413
+ const { runVerifyParityCheck, printVerifyParityCheck } = await import('../verify-postcheck.js')
414
+ const parityRuntimeRoot = resolveRuntimeRoot(platformOpts, specBase)
415
+ const parityCheck = runVerifyParityCheck({ cwd, specBase, changeName, runtimeRoot: parityRuntimeRoot })
416
+ printVerifyParityCheck(parityCheck)
417
+ // ── 删除探针(advisory,不阻断):切斯特顿栅栏护栏,用 git 事实对账静默删除代码 ──
418
+ const { runVerifyDeletionCheck, printVerifyDeletionCheck } = await import('../verify-postcheck.js')
419
+ const deletionCheck = runVerifyDeletionCheck({ cwd, specBase, changeName })
420
+ printVerifyDeletionCheck(deletionCheck)
421
+ // ── required-evidence 对账(advisory,不阻断):闭合 execute→verify evidence 死链 ──
422
+ // execute Task Review Gate 把 cannot_verify 任务的 evidence 落盘 verify-required-evidence.json,
423
+ // 本探针查每个 cannot_verify 任务是否在 verify-result.md 体现(CLI 只查提及,满足度 agent 自报告)。
424
+ const { runVerifyRequiredEvidenceCheck, printVerifyRequiredEvidenceCheck } = await import('../verify-postcheck.js')
425
+ const evidenceCheck = runVerifyRequiredEvidenceCheck({ cwd, specBase, changeName })
426
+ printVerifyRequiredEvidenceCheck(evidenceCheck)
427
+ // ── module-impact 死信探针(blocking,债单 D-1/D-5):更新结果表 pending/待办行 → 阻断 verify ──
428
+ // 与 archive 移动前校验(extractPendingDocSyncRows)同一口径,把死信号从 archive 提前到 verify:
429
+ // agent 在 verify 阶段就须完成文档同步并回填 done/skipped,而非拖到归档被拦(修复 perf-remediation
430
+ // 类「verify PASS → archive 才发现 pending」的时序漏洞)。
431
+ const verifyChangeDir = resolveChangeDir(cwd, progress, platformOpts?.specRoot)
432
+ if (verifyChangeDir) {
433
+ const impactPath = join(verifyChangeDir, 'module-impact.md')
434
+ if (existsSync(impactPath)) {
435
+ const { extractPendingDocSyncRows } = await import('./complete-handlers.js')
436
+ const pendingRows = extractPendingDocSyncRows(readFileSync(impactPath, 'utf8'))
437
+ if (pendingRows.length > 0) {
438
+ console.error(`\n❌ verify 阶段被阻断:module-impact.md「更新结果」表存在 ${pendingRows.length} 个未清 pending/待办项(死信)`)
439
+ for (const row of pendingRows) console.error(` - ${row}`)
440
+ console.error(' 文档同步是 verify 的收尾义务:请完成模块文档同步并回填状态为 done/skipped(说明原因),再重新完成 verify。')
441
+ return rollbackCompletionAndReturn(pm, progress, stageData, steps, currentIdx, cwd, changeName, platformOpts)
442
+ }
443
+ }
444
+ }
445
+ console.log('\n✅ 验证通过,下一步:sillyspec run archive')
446
+ }
447
+
448
+ // ── Plan postcheck contract:tasks.md(注册表)× plan.md(Wave 引用)须满足 execute 契约 ──
449
+ if (stageName === 'plan') {
450
+ const planFile = resolveChangeDir(cwd, progress, platformOpts?.specRoot)
451
+ const planPath = planFile ? join(planFile, 'plan.md') : null
452
+ if (planPath && existsSync(planPath)) {
453
+ const { validatePlanForExecute } = await import('../stages/execute.js')
454
+ // CRLF 归一:Windows 编辑器/python 写文件可产生 CRLF,归一后交校验(与 plan-postcheck.js 同口径)
455
+ const planContent = readFileSync(planPath, 'utf8').replace(/\r\n/g, '\n')
456
+ const tasksPath = join(planFile, 'tasks.md')
457
+ const tasksContent = existsSync(tasksPath) ? readFileSync(tasksPath, 'utf8').replace(/\r\n/g, '\n') : ''
458
+ const planValidation = validatePlanForExecute(tasksContent, planContent)
459
+ if (!planValidation.ok) {
460
+ console.error(`\n❌ Plan → Execute Contract 校验失败:`)
461
+ for (const err of planValidation.errors) console.error(` - ${err}`)
462
+ console.error(`\n tasks.md/plan.md 不满足 execute 契约,请修复后重新完成此步骤。`)
463
+ // 阻断 completed
464
+ return await rollbackCompletionAndReturn(pm, progress, stageData, steps, currentIdx, cwd, changeName, platformOpts)
465
+ }
466
+ if (planValidation.warnings.length > 0) {
467
+ console.warn(`\n⚠️ Plan contract 警告(不阻断完成):`)
468
+ for (const w of planValidation.warnings) console.warn(` - ${w}`)
469
+ }
470
+ if (planValidation.ok) {
471
+ console.log(`\n✅ Plan → Execute Contract 校验通过(${planValidation.tasks.length} tasks, ${planValidation.waves.length} waves)`)
472
+ }
473
+ }
474
+ }
475
+
476
+ // ── Stage Review Gate:brainstorm/plan/execute 独立审查(按 tier 分级)──
477
+ // tier=self 放行+审计打印;tier=independent 必须 review.json 且 verdict 非 fail,fail-closed
478
+ if (['brainstorm', 'plan', 'execute'].includes(stageName)) {
479
+ try {
480
+ const { classifyReviewTier } = await import('../review-tier.js')
481
+ const { validateStageReview, getLatestStageReviewRunId, printStageReviewResult, generateStageReviewRunId, stageReviewMarkerPath } = await import('../stage-review.js')
482
+ const effectiveSpecBase = platformOpts?.specRoot || specBase
483
+ const reviewChangeDir = resolveChangeDir(cwd, progress, platformOpts?.specRoot)
484
+ const designPath = reviewChangeDir ? join(reviewChangeDir, 'design.md') : null
485
+ let planLevel = null
486
+ if (reviewChangeDir) {
487
+ const planPath = join(reviewChangeDir, 'plan.md')
488
+ if (existsSync(planPath)) {
489
+ const fmLine = readFileSync(planPath, 'utf8').split('\n').find(l => l.trim().startsWith('plan_level:'))
490
+ if (fmLine) planLevel = fmLine.split(':')[1].trim()
491
+ }
492
+ }
493
+ const tier = classifyReviewTier({ planLevel, designPath })
494
+ const runtimeRoot = resolveRuntimeRoot(platformOpts, effectiveSpecBase)
495
+
496
+ if (tier.tier === 'self') {
497
+ console.log('\nℹ️ Stage Review: ' + stageName + ' tier=self(' + tier.reason + '),已降级为当前 agent 自审,不强制独立子代理。')
498
+ } else {
499
+ let reviewRunId = getLatestStageReviewRunId(runtimeRoot, stageName, changeName)
500
+ if (!reviewRunId) {
501
+ // marker 缺失(execute 批量完成跳过 prompt 渲染等场景)→ 自生 + 写盘
502
+ // 让 gate 读到确定 ID,错误从 execute-null 变 execute-review-<id>(可执行)
503
+ reviewRunId = generateStageReviewRunId()
504
+ try {
505
+ mkdirSync(runtimeRoot, { recursive: true })
506
+ writeFileSync(stageReviewMarkerPath(runtimeRoot, stageName, changeName), reviewRunId + '\n')
507
+ } catch {}
508
+ }
509
+ const reviewType = stageName === 'brainstorm' ? 'design'
510
+ : stageName === 'plan' ? 'plan'
511
+ : 'acceptance'
512
+ const searchDirs = [effectiveSpecBase, reviewChangeDir, cwd].filter(Boolean)
513
+ const reviewResult = validateStageReview({ stage: stageName, reviewType, runtimeRoot, reviewRunId, searchDirs })
514
+ printStageReviewResult(reviewResult, { stage: stageName, reviewRunId, runtimeRoot })
515
+ if (!reviewResult.ok) {
516
+ return await rollbackCompletionAndReturn(pm, progress, stageData, steps, currentIdx, cwd, changeName, platformOpts)
517
+ }
518
+ }
519
+ } catch (e) {
520
+ // fail-closed:Gate 自身异常阻断完成,不静默放行(与 Task Review Gate 一致)
521
+ console.error('❌ Stage Review Gate 异常,阻断 ' + stageName + ' 完成: ' + e.message)
522
+ return await rollbackCompletionAndReturn(pm, progress, stageData, steps, currentIdx, cwd, changeName, platformOpts)
523
+ }
524
+ }
525
+
526
+ // ── Execute Task Review Gate:所有 task 必须有 review.json 且 verdict 通过 ──
527
+ if (stageName === 'execute') {
528
+ try {
529
+ const { validateTaskReviews, printReviewResult, writeVerifyRequiredEvidence } = await import('../task-review.js')
530
+ const effectiveSpecBase = platformOpts?.specRoot || specBase
531
+ const planFile = resolveChangeDir(cwd, progress, platformOpts?.specRoot)
532
+ const planPath = planFile ? join(planFile, 'plan.md') : null
533
+
534
+ if (planPath && existsSync(planPath)) {
535
+ // 2026-08-20-task-truth-unify:任务清单源迁 tasks.md(validateTaskReviews 内按
536
+ // checkbox 行解析,tasks.md 行格式兼容原正则);缺失回退 plan.md(旧变更兼容读侧)
537
+ const tasksRegistryPath = join(planFile, 'tasks.md')
538
+ const planContent = existsSync(tasksRegistryPath)
539
+ ? readFileSync(tasksRegistryPath, 'utf8')
540
+ : readFileSync(planPath, 'utf8')
541
+ const runtimeRoot = resolveRuntimeRoot(platformOpts, effectiveSpecBase)
542
+
543
+ // execute run id:从变更专属标记文件读取(agent 可写内容,格式校验防注入/穿越)
544
+ const runIdFile = join(runtimeRoot, `current-execute-run-id-${changeName}`)
545
+ let executeRunId = ''
546
+ try {
547
+ if (existsSync(runIdFile)) {
548
+ const c = readFileSync(runIdFile, 'utf8').trim()
549
+ const { isValidExecuteRunId } = await import('../task-review.js')
550
+ if (c && !isValidExecuteRunId(c)) {
551
+ console.warn(`⚠️ execute run marker 内容非法(期望 exec-YYYY-MM-DD-HHMMSS,实得 ${JSON.stringify(c.slice(0, 60))}),视为缺失回退扫描`)
552
+ } else {
553
+ executeRunId = c
554
+ }
555
+ }
556
+ } catch {}
557
+ if (!executeRunId) {
558
+ // marker 缺失:先扫描 execute-runs/ 既有目录找回真实 runId(与 getLatestStageReviewRunId
559
+ // 目录扫描兜底同语义),避免 marker 丢失而 agent 已用旧 runId 落盘时,直接 generate 新 ID
560
+ // 找不到旧 review、误判缺 review.json。仅当确实无既有 run 才 generate 新 ID 并落盘。
561
+ const { generateExecuteRunId, resolveLatestExecuteRunId, stampExecuteRunChange } = await import('../task-review.js')
562
+ executeRunId = resolveLatestExecuteRunId({ runtimeRoot, changeName }) || ''
563
+ if (!executeRunId) {
564
+ executeRunId = generateExecuteRunId()
565
+ // 落盘(marker 缺失时 fallback 生成后写盘,保证后续 checkbox/gate 读到同一 ID)
566
+ // D-001#1 fallback 写入点:mkdir execute-runs/<runId>/tasks 先于 marker(不变量:
567
+ // marker 在则目录在)。不 try/catch——异常直穿外层 catch 走 fail-closed 阻断
568
+ //(gate 自身写 run 目录失败不能静默放行完成)。
569
+ mkdirSync(join(runtimeRoot, 'execute-runs', executeRunId, 'tasks'), { recursive: true })
570
+ writeFileSync(runIdFile, executeRunId + '\n')
571
+ stampExecuteRunChange(runtimeRoot, executeRunId, changeName)
572
+ }
573
+ }
574
+
575
+ // git 真实性校验目录:worktree 存在则用 worktree(base/head commit 在其中),否则主仓库。
576
+ // 跨仓支持(task-07 / design §6 gates 行 + D-013):
577
+ // - 有 ctx(execute 启动入口 task-09 构造的 MultiRepoContext 实例)→ 用
578
+ // ctx.resolve('main').gitDir 作为主仓 task 的校验 cwd。ctx 的 main entry 已在
579
+ // _buildMainEntry 内统一编码 worktreePath/cwd + in-place-fallback 兜底(与 task-review.js:724
580
+ // generateTaskReviewDrafts 的 in-place 逻辑同源),避免两处漂移。Task Review Gate 内部循环
581
+ // 再按 review.repo ?? 'main' 从 ctx.resolve(repo).gitDir 切跨仓 task 的 gitDir(task-04 实现)。
582
+ // - 无 ctx(ctx=null 缺省,单仓 / 旧调用链)→ 走原逻辑(meta.worktreePath 或 cwd),零回归。
583
+ let reviewGitDir = cwd
584
+ if (ctx) {
585
+ const mainEntry = ctx.resolve('main')
586
+ if (mainEntry?.gitDir) reviewGitDir = mainEntry.gitDir
587
+ } else {
588
+ try {
589
+ const { WorktreeManager } = await import('../worktree.js')
590
+ const wm = new WorktreeManager({ cwd })
591
+ const meta = wm.getMeta(changeName)
592
+ if (meta?.worktreePath && meta.mode !== 'in-place-fallback' && existsSync(meta.worktreePath)) {
593
+ reviewGitDir = meta.worktreePath
594
+ }
595
+ } catch {}
596
+ }
597
+
598
+ const reviewResult = validateTaskReviews({ planContent, runtimeRoot, executeRunId, changeDir: planFile, gitDir: reviewGitDir, ctx })
599
+ printReviewResult(reviewResult, { runtimeRoot, executeRunId })
600
+
601
+ if (!reviewResult.ok) {
602
+ // Task review 校验失败,阻断 execute 完成
603
+ // 检查是否存在 checkbox 已勾但 review 不通过的情况
604
+ const uncheckedTasks = reviewResult.errors.filter(e => e.includes('缺少 review.json'))
605
+ if (uncheckedTasks.length > 0) {
606
+ console.error('\n⚠️ 部分任务已在 tasks.md 中勾选,但 review.json 不存在。')
607
+ console.error(` 请取消勾选这些任务的 checkbox,或补充对应的 review.json(execute run ID: ${executeRunId})。`)
608
+ }
609
+ return await rollbackCompletionAndReturn(pm, progress, stageData, steps, currentIdx, cwd, changeName, platformOpts)
610
+ }
611
+
612
+ // cannot_verify 的 requiredEvidence 写入 change 目录,供 verify 阶段消费
613
+ if (reviewResult.requiredEvidence.length > 0) {
614
+ const evidencePath = writeVerifyRequiredEvidence(join(effectiveSpecBase, 'changes', changeName), reviewResult.requiredEvidence)
615
+ if (evidencePath) {
616
+ console.log(`📄 verify-required-evidence.json 已写入: ${evidencePath}`)
617
+ console.log(' verify 阶段必须满足这些证据要求。')
618
+ }
619
+ }
620
+ }
621
+ } catch (e) {
622
+ // fail-closed:Gate 自身异常时不能默认放行,否则异常成了绕过评审的通道
623
+ console.error(`❌ Task Review Gate 异常,阻断 execute 完成: ${e.message}`)
624
+ console.error(' 请检查 review.json / plan.md 是否可读,修复后重新完成此步骤。')
625
+ return await rollbackCompletionAndReturn(pm, progress, stageData, steps, currentIdx, cwd, changeName, platformOpts)
626
+ }
627
+ }
628
+ return null
629
+ }
630
+
631
+ /**
632
+ * 读取 design.md frontmatter 的 scale 字段(brainstorm 末步写入 'small'|'large')。
633
+ * completeStep 的下一步提示据此分叉:small→quick,large/读不到→plan(fail-safe 走重流程)。
634
+ * 只解析首个 YAML frontmatter 块,避免误读正文里的 "scale:"。
635
+ * 从 complete.js 迁入(completeStageGates 共享收尾管线 + completeStep brainstorm 提示消费)。
636
+ */
637
+ export function readDesignScale(specBase, changeName) {
638
+ if (!changeName) return null
639
+ const designPath = join(specBase, 'changes', changeName, 'design.md')
640
+ if (!existsSync(designPath)) return null
641
+ const text = readFileSync(designPath, 'utf8')
642
+ const fm = text.match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*\r?\n/)
643
+ if (!fm) return null
644
+ const m = fm[1].match(/^scale:[ \t]*['"]?(\w+)/m)
645
+ return m ? m[1] : null
646
+ }
647
+
648
+ /**
649
+ * 校验变更目录下近 10 分钟新增的 .md/.yaml/.yml 文件含 author/created_at 元数据(advisory 打印)。
650
+ * 从 complete.js 迁入(completeStageGates 共享收尾管线消费)。
651
+ */
652
+ export function validateMetadata(cwd, stageName, specBase) {
653
+ const changesDir = join(specBase, 'changes')
654
+ if (!existsSync(changesDir)) return
655
+
656
+ const cutoff = Date.now() - 10 * 60 * 1000
657
+ const missing = []
658
+
659
+ function walk(dir) {
660
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
661
+ const full = join(dir, entry.name)
662
+ try {
663
+ if (entry.isDirectory()) { walk(full); continue }
664
+ if (!/\.(md|yaml|yml)$/.test(entry.name)) continue
665
+ const mtime = statSync(full).mtimeMs
666
+ if (mtime < cutoff) continue
667
+ const content = readFileSync(full, 'utf-8')
668
+ if (!content.includes('author:') && !content.includes('author:')) missing.push(full)
669
+ if (!content.includes('created_at:') && !content.includes('created_at:')) missing.push(full)
670
+ } catch (e) { /* skip unreadable files */ }
671
+ }
672
+ }
673
+
674
+ walk(changesDir)
675
+ const unique = [...new Set(missing)]
676
+ if (unique.length > 0) {
677
+ console.log(`\n⚠️ 以下文件缺少 author 或 created_at 元数据:`)
678
+ unique.forEach(f => console.log(` - ${f.replace(cwd + '/', '')}`))
679
+ console.log('请在文件头部添加 author(git 用户名)和 created_at(精确到秒)')
680
+ }
681
+ }
682
+
683
+ /**
684
+ * 验证关键文件是否存在于正确的变更目录下(advisory 打印,防 AI 写错路径)。
685
+ * 从 complete.js 迁入(completeStageGates 共享收尾管线消费)。
686
+ */
687
+ export function validateFileLocations(cwd, stageName, progress, changeName, specBase) {
688
+ const effectiveChange = changeName || progress.currentChange
689
+ if (!effectiveChange) return
690
+
691
+ const changeDir = join(specBase, 'changes', effectiveChange)
692
+ if (!existsSync(changeDir)) return
693
+
694
+ // 每个阶段完成后预期存在的文件
695
+ // brainstorm:scale=small(小变更)只必产 design.md;large/未标 scale → 四件套全。
696
+ // 与 validateBrainstormOutputs 的 BRAINSTORM_RULES condition(scale≠small)同源,避免对合法 small 变更
697
+ // 误报"⬜ proposal/requirements/tasks 未找到"(本检查仅 advisory 打印不 gate,但误导输出仍要消除)。
698
+ const brainstormExpected = readDesignScale(specBase, effectiveChange) === 'small'
699
+ ? ['design.md']
700
+ : ['design.md', 'proposal.md', 'requirements.md', 'tasks.md']
701
+ const expectedFiles = {
702
+ brainstorm: brainstormExpected,
703
+ plan: ['plan.md'],
704
+ archive: ['module-impact.md'],
705
+ }
706
+
707
+ const expected = expectedFiles[stageName]
708
+ if (!expected) return
709
+
710
+ const missing = []
711
+ for (const file of expected) {
712
+ if (!existsSync(join(changeDir, file))) {
713
+ missing.push(file)
714
+ }
715
+ }
716
+
717
+ if (missing.length > 0) {
718
+ console.log(`\n⚠️ 文件位置验证:以下文件未在变更目录中找到`)
719
+ console.log(` 变更目录:${changeDir.replace(cwd + '/', '')}/`)
720
+ for (const f of missing) {
721
+ // 检查是否写到了错误的位置
722
+ const wrongPath = join(specBase, 'changes', 'change', effectiveChange, f)
723
+ if (existsSync(wrongPath)) {
724
+ console.log(` ❌ ${f} — 不存在,但发现了错误路径:${wrongPath.replace(cwd + '/', '')}`)
725
+ console.log(` 提示:应该写入 ${changeDir.replace(cwd + '/', '')}/${f}`)
726
+ } else {
727
+ console.log(` ⬜ ${f} — 未找到(该阶段可能未产出此文件)`)
728
+ }
729
+ }
730
+ } else {
731
+ console.log(`\n✅ 文件位置验证:所有 ${expected.length} 个预期文件均在变更目录中`)
732
+ }
733
+ }
734
+
735
+ /**
736
+ * 读本变更 design.md §6「文件变更清单」表格,提取声明交付的文件路径(concurrent 预检 ownFiles 源,D-002)。
737
+ *
738
+ * 用途:execute --done 并发预检在 in-place-fallback 模式下,需把本变更交付文件从 foreignFiles 排除
739
+ * (否则自己产出的脏文件会被当他者)。worktree 模式下交付文件不在主仓 git status,ownFiles 用空数组即可。
740
+ *
741
+ * 解析:定位 `## 6.` 段(到下一个 `## N.` 前),逐行扫表格行第 2 列的反引号文件路径,跳过表头/分隔行。
742
+ * 设计容错(design §9 / R-02):design.md 缺失 / 无 §6 / 解析空 → 返回 [],foreignFiles 退化为保守噪音,
743
+ * otherActiveChanges 仍可靠。
744
+ *
745
+ * @param {string} specBase 规范根(.sillyspec/ 所在)
746
+ * @param {string} changeName 当前变更名
747
+ * @returns {string[]} 本变更声明交付的文件路径(git 路径,正斜杠)
748
+ */
749
+ function readDesignOwnFiles(specBase, changeName) {
750
+ if (!specBase || !changeName) return []
751
+ let content
752
+ try {
753
+ content = readFileSync(join(specBase, 'changes', changeName, 'design.md'), 'utf8')
754
+ } catch {
755
+ return []
756
+ }
757
+ const files = []
758
+ let inSection6 = false
759
+ for (const line of content.split('\n')) {
760
+ const heading = line.match(/^##\s+(\d+)\./)
761
+ if (heading) {
762
+ inSection6 = heading[1] === '6'
763
+ continue
764
+ }
765
+ if (!inSection6 || !line.startsWith('|')) continue
766
+ // 跳过表头分隔行(|---|---|)
767
+ if (/^\|[\s:|-]+\|?$/.test(line.trim())) continue
768
+ // 表格第 2 列反引号文件路径:`| 新增 | \`src/.../x.js\` | 说明 |`
769
+ const m = line.match(/^\|[^|]+\|\s*`([^`]+)`/)
770
+ if (m) files.push(m[1].trim())
771
+ }
772
+ return files
773
+ }
774
+
775
+ /**
776
+ * 阶段完成收尾共享管线(从 completeStep 抽出,消除 noAI 末步 / continueStep 完成分支绕过 gate 的
777
+ * S1/S2/S3 三处不对称)。调用方已自行标记 stageData.status='completed' 并 pm._write 落盘后调用本函数。
778
+ *
779
+ * 序列:handleScanStageCompleted → validateMetadata → validateFileLocations[completed‖skipped 守卫] →
780
+ * auxiliary 重置 → runStageCompletionGates[同守卫] → handleExecuteWorktreeCleanup。
781
+ *
782
+ * 关键陷阱(design §5.4):auxiliary 重置把 stageData.steps 换成 freshSteps(全 pending);下方
783
+ * runStageCompletionGates 守卫与 rollbackStageCompletion 必须用入参 steps(pre-reset 原数组),
784
+ * 不得重读 stageData.steps(否则计数恒 0 → gate 永跳过)。本函数全程用入参 steps,未重读。
785
+ *
786
+ * @returns {Promise<{stageCompleted:false,currentIdx,nextPendingIdx:number}|null>}
787
+ * null = 全部通过,调用方继续自管收尾(如下一步提示);
788
+ * 非 null = gate/handler 失败已 rollback,调用方直接 return。
789
+ */
790
+ export async function completeStageGates({ stageName, cwd, changeName, platformOpts, specBase, progress, pm, stageData, steps, currentIdx, outputText, ctx = null }) {
791
+ // 整体 try/catch(task-04 / review-2026-08-09 #2):收尾段(execute 预检 + scan handler + validate* +
792
+ // auxiliary 重置 + runStageCompletionGates)任一段抛非结构化异常 → rollbackCompletionAndReturn
793
+ // (回滚 in-progress + 落盘 + 返回未完成对象),不冒顶 exit 1。正常的 _scanResult/_gateEarlyReturn
794
+ // early-return 是 return 非 throw,不被 catch 拦;下方 handleExecuteWorktreeCleanup 在 try 外(副作用独立,失败不 rollback stage 状态)。
795
+ try {
796
+ // ── execute --done 并发他者改动预检(FR-06/FR-07,非阻断 advisory)──
797
+ // 仅 execute 触发,不影响 scan/plan/verify/archive 等 stage 的 completeStageGates。
798
+ // 纯副作用:console.warn 后照常推进,不改 stageData / 不阻断级联(FR-07)。
799
+ // 整个钩子 try/catch 兜底——任何异常吞掉,绝不影响 gate 级联(FR-07 不阻断铁律)。
800
+ if (stageName === 'execute') {
801
+ try {
802
+ // ownFiles 源(D-002 + B-002,钉死):动态 import WorktreeManager 取 meta.mode(复用上方
803
+ // Task Review Gate 的 ../worktree.js 动态 import 先例)。WorktreeManager 无 appliedFiles
804
+ // 字段(B-002 已证),不用模糊的「worktree applied 文件」。
805
+ // - worktree 模式(meta.mode 非 in-place-fallback):主仓 git status 看不见本变更交付文件
806
+ // → ownFiles=[](无害,dogfood 当前实际模式)
807
+ // - in-place-fallback 模式:交付文件就在主仓 dirty → ownFiles 读 design.md §6 文件清单排除
808
+ let ownFiles = []
809
+ // linkedChanges:execute 通常无(quick 管线概念,存 quick guard 文件);stageData 取不到则 []。
810
+ const linkedChanges = Array.isArray(stageData?.linkedChanges) ? stageData.linkedChanges : []
811
+ try {
812
+ const { WorktreeManager } = await import('../worktree.js')
813
+ const wm = new WorktreeManager({ cwd })
814
+ const meta = wm.getMeta(changeName)
815
+ if (meta?.mode === 'in-place-fallback') {
816
+ ownFiles = readDesignOwnFiles(specBase, changeName)
817
+ }
818
+ } catch {
819
+ // getMeta 抛(meta.json 缺 / worktree 损坏)→ ownFiles=[] 兜底,不崩(FR-07)。
820
+ ownFiles = []
821
+ }
822
+ const detected = detectConcurrentChanges(cwd, { changeName, linkedChanges, ownFiles })
823
+ const warn = formatConcurrentWarning(detected)
824
+ if (warn) console.warn(warn)
825
+ } catch {
826
+ // FR-07 不阻断:钩子任何意外异常静默吞掉,gate 级联照常推进。
827
+ }
828
+ }
829
+
830
+ // scan 平台 manifest + post-check(S1 平台受害者:noAI scanPostcheck 末步 / continueStep 现也走这里)
831
+ const _scanResult = await handleScanStageCompleted({ stageName, currentIdx, cwd, progress, pm, stageData, changeName, outputText, platformOpts })
832
+ if (_scanResult) return _scanResult
833
+
834
+ // 守卫计数:completed‖skipped === total(修 S3);用入参 steps(pre-reset 原数组,design §5.4)
835
+ const settledCount = steps.filter(s => s.status === 'completed' || s.status === 'skipped').length
836
+ const total = steps.length
837
+
838
+ validateMetadata(cwd, stageName, specBase)
839
+
840
+ // 验证关键文件位置(仅当所有步骤已结案 completed‖skipped 时才校验)
841
+ if (settledCount === total && total > 0) {
842
+ validateFileLocations(cwd, stageName, progress, changeName, specBase)
843
+ }
844
+
845
+ // 辅助阶段完成后重置步骤(scan 等 auxiliary 阶段重置回 pending 可重跑)
846
+ const stageDef = stageRegistry[stageName]
847
+ if (stageDef?.auxiliary) {
848
+ const freshSteps = (stageDef.steps || []).map(s => ({
849
+ name: s.name,
850
+ status: 'pending',
851
+ output: null,
852
+ completedAt: null
853
+ }))
854
+ stageData.steps = freshSteps
855
+ stageData.status = 'pending'
856
+ stageData.completedAt = null
857
+ if (progress.currentStage === stageName) progress.currentStage = ''
858
+ pm._write(cwd, progress, changeName)
859
+ }
860
+
861
+ // 阶段完成校验 gate 级联(runValidators → verify-test → Plan→Execute → Stage Review → Task Review)
862
+ if (settledCount === total && total > 0) {
863
+ const _gateEarlyReturn = await runStageCompletionGates({ stageName, cwd, changeName, platformOpts, specBase, progress, pm, stageData, steps, currentIdx, ctx })
864
+ if (_gateEarlyReturn) return _gateEarlyReturn
865
+ } else if (settledCount < total) {
866
+ console.log(`\n⚠️ 阶段校验跳过:${total} 步中仅 ${settledCount} 步标记为已结案(completed‖skipped),可能存在状态不同步。如确认阶段已完成,请运行 --status 确认。`)
867
+ }
868
+ } catch (e) {
869
+ console.error(`\n❌ ${stageName} 阶段完成收尾异常(已 rollback 为 in-progress,请修复后重新 --done):${e?.message || e}`)
870
+ return await rollbackCompletionAndReturn(pm, progress, stageData, steps, currentIdx, cwd, changeName, platformOpts)
871
+ }
872
+
873
+ // execute worktree cleanup(completeStep / continueStep 完成分支统一调用,避免双清理)
874
+ await handleExecuteWorktreeCleanup({ stageName, changeName, cwd })
875
+
876
+ return null
877
+ }
878
+