sillyspec 3.25.9 → 3.26.0

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 (76) hide show
  1. package/.claude/skills/sillyspec-auto/SKILL.md +8 -13
  2. package/.claude/skills/sillyspec-brainstorm/SKILL.md +8 -6
  3. package/.claude/skills/sillyspec-continue/SKILL.md +3 -3
  4. package/.claude/skills/sillyspec-doctor/SKILL.md +1 -1
  5. package/.claude/skills/sillyspec-execute/SKILL.md +10 -4
  6. package/.claude/skills/sillyspec-plan/SKILL.md +1 -1
  7. package/.claude/skills/sillyspec-quick/SKILL.md +3 -1
  8. package/.claude/skills/sillyspec-verify/SKILL.md +1 -1
  9. package/.sillyspec-platform-cleaned +1 -0
  10. package/.sillyspec-platform.json +7 -0
  11. package/README.md +9 -0
  12. package/package.json +2 -2
  13. package/src/change-list.js +11 -8
  14. package/src/change-risk-profile.js +22 -0
  15. package/src/db.js +170 -161
  16. package/src/dispatch/backends/local-agent.js +152 -0
  17. package/src/dispatch/backends/sillyhub-mcp.js +242 -0
  18. package/src/dispatch/probe.js +210 -0
  19. package/src/dispatch/strategy.js +121 -0
  20. package/src/doctor-diagnostics.js +19 -25
  21. package/src/fs-atomic.js +69 -69
  22. package/src/git-helper.js +66 -0
  23. package/src/hooks/worktree-guard.js +64 -55
  24. package/src/index.js +326 -36
  25. package/src/init.js +7 -14
  26. package/src/local-detect.js +120 -110
  27. package/src/machine-interface.js +54 -19
  28. package/src/migrate.js +14 -4
  29. package/src/modules.js +16 -5
  30. package/src/progress/change-registry.js +85 -70
  31. package/src/progress/consistency-doctor.js +71 -14
  32. package/src/progress/shared.js +8 -5
  33. package/src/progress/stage-machine.js +67 -65
  34. package/src/progress/step-store.js +72 -67
  35. package/src/progress.js +448 -236
  36. package/src/quicklog.js +59 -12
  37. package/src/run/command.js +78 -39
  38. package/src/run/complete-handlers.js +1028 -889
  39. package/src/run/complete.js +132 -212
  40. package/src/run/concurrent-detect.js +132 -0
  41. package/src/run/gates.js +302 -19
  42. package/src/run/prompt.js +22 -68
  43. package/src/run/quick-audit.js +10 -9
  44. package/src/run/shared.js +722 -600
  45. package/src/run/stage.js +43 -24
  46. package/src/scan-postcheck.js +9 -31
  47. package/src/sillyhub-mcp/client.js +394 -0
  48. package/src/sillyhub-mcp/config.js +62 -0
  49. package/src/spec-dir-typo.js +71 -0
  50. package/src/stage-contract-engine.js +22 -1
  51. package/src/stage-contract-spec.js +11 -4
  52. package/src/stage-contract.js +26 -3
  53. package/src/stage-review.js +118 -10
  54. package/src/stages/archive.js +4 -6
  55. package/src/stages/brainstorm-auto.js +26 -20
  56. package/src/stages/brainstorm.js +29 -5
  57. package/src/stages/cmd-existence.js +145 -0
  58. package/src/stages/doctor.js +10 -7
  59. package/src/stages/execute.js +64 -22
  60. package/src/stages/plan-postcheck.js +152 -0
  61. package/src/stages/plan.js +16 -31
  62. package/src/stages/quick.js +25 -9
  63. package/src/stages/scan.js +53 -8
  64. package/src/stages/status.js +0 -1
  65. package/src/stages/verify.js +14 -9
  66. package/src/sync.js +433 -16
  67. package/src/task-review.js +59 -1
  68. package/src/verify-postcheck.js +938 -862
  69. package/src/version.js +19 -0
  70. package/src/worktree-apply.js +218 -93
  71. package/src/worktree-deps.js +89 -5
  72. package/src/worktree.js +213 -111
  73. package/templates/prompts/taskcard-rules.md +27 -0
  74. package/templates/prompts/verify-probes.md +3 -3
  75. package/.claude/skills/sillyspec-propose/SKILL.md +0 -87
  76. package/src/stages/propose.js +0 -181
@@ -1,889 +1,1028 @@
1
- /**
2
- * run/complete-handlers.js(W6 Step5 从 run.js 抽出)。
3
- *
4
- * completeStep 的子 handler + archive 收尾(自洽叶子模块,仅被 completeStep 调用):
5
- * - handleArchiveConfirmStep:archive「确认归档」步骤 --confirm 门控 + 推荐文档校验
6
- * - handlePlanGeneratePlanStep:plan「generate_plan」完成后动态插入 coordinator + postcheck 步骤
7
- * - handleScanProjectListStep:scan step 2 完成后按项目展开 perProject 步骤(用 sanitizeProjectName/validateParsedProjects)
8
- * - archiveChangeDirectory:归档移动变更目录(6 处 process.exit(1) + worktree 清理;handleArchiveConfirmStep 内部调用)
9
- * - sanitizeProjectName / validateParsedProjects:项目名清洗 + 列表校验纯函数(handleScanProjectListStep 专用)
10
- *
11
- * 安全锚:run.js 始终 barrel。3 handler 由 run.js import 回来;sanitizeProjectName + validateParsedProjects
12
- * 被 test 直接 import(run-sanitize-project-name / run-scan-project-parse),run.js barrel re-export 契约保留。
13
- * 4 目标 handler + archive 无 test 直接 import,无需 re-export。completeStep(Step7 搬)将把 import 行带走。
14
- *
15
- * 路径修正(相对 src/run/):
16
- * - resolveChangeDir 从 './shared.js';renameSyncRetry 从 '../fs-atomic.js';stageRegistry 从 '../stages/index.js'
17
- * - 动态 import './stages/plan.js' / './worktree.js' → '../'(src/ 下,退一层;真环依赖保留动态)
18
- * - 删除 archiveChangeDirectory 内死代码 `const { renameSync } = await import('fs')`(renameSync 解构未用,实际走 renameSyncRetry)
19
- *
20
- * archiveChangeDirectory 的 6 处 process.exit 全 exit(1):5 个顶层 guard 直接终止;L1325 在 catch 内主动 exit
21
- * (非被外层吞);process.exit 不可被 try 捕获 → 搬迁行为完全等价。
22
- */
23
- import { basename, join, resolve, relative, isAbsolute } from 'node:path'
24
- import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync, unlinkSync, rmSync } from 'node:fs'
25
- import { renameSyncRetry, writeAtomicSync } from '../fs-atomic.js'
26
- import { resolveChangeDir, safeGit, auditQuickCompletion, triggerSync, isQuickMetadata } from './shared.js'
27
- import { stageRegistry } from '../stages/index.js'
28
- import { SCAN_STATUS, POINTER_STATUS } from '../constants.js'
29
- import { printQuickAuditReview } from './quick-audit.js'
30
- import { validateQuickResult, allocateQuicklogEntry, findQuicklogEntry, completeQuicklogEntry } from '../quicklog.js'
31
- import { getRule } from '../stage-contract-spec.js'
32
- import { archiveDestDirName } from '../stage-contract.js'
33
-
34
- /**
35
- * 清洗项目名:只保留 ASCII 字母/数字/横线/下划线/点,过滤中文和特殊字符。
36
- * - 必须含至少一个字母(拒绝纯数字 "0"/"7"/"07",避免 scan-projects.json 脏数据)
37
- * - 长度必须 ≥ 2(拒绝单字符 "a"/"0")
38
- * @param {string} name - 原始项目名候选
39
- * @returns {string | null} 合法项目名或 null(拒绝)
40
- */
41
- export function sanitizeProjectName(name) {
42
- if (!name) return null
43
- const clean = String(name).replace(/[^a-zA-Z0-9_\-.]/g, '').trim()
44
- if (!clean) return null
45
- if (!/[a-zA-Z]/.test(clean)) return null // 纯数字/符号拒绝("0"/"7"/"07")
46
- if (clean.length < 2) return null // 单字符拒绝("a"/"0")
47
- return clean
48
- }
49
- /**
50
- * 校验从 step 2 解析出的项目列表。
51
- * 不通过则不落盘 projects/*.yaml、不展开 perProject 步骤。
52
- *
53
- * @param {Array<{id: string, path?: string}>} projects - 项目列表(含可选 path)
54
- * @param {string} sourceRoot - 源码根目录,用于 path 安全校验
55
- */
56
- export function validateParsedProjects(projects, sourceRoot) {
57
- const errors = []
58
- if (!projects || projects.length === 0) {
59
- return { ok: false, errors: ['项目列表为空'] }
60
- }
61
- if (projects.length > 10) {
62
- return { ok: false, errors: [`项目数量 ${projects.length} 超过上限 10,疑似误解析`] }
63
- }
64
- const safeRoot = resolve(sourceRoot)
65
- const seen = new Set()
66
- for (const proj of projects) {
67
- const id = proj.id || proj
68
- if (seen.has(id)) {
69
- errors.push(`重复项目名: ${id}`)
70
- }
71
- seen.add(id)
72
- // slug 合法性:只允许 a-z 0-9 _ - .,长度 2-64
73
- if (!/^[a-zA-Z][\w\-.]{1,63}$/.test(id)) {
74
- errors.push(`项目名 "${id}" 不合法(需 slug 格式:字母开头,只含 a-zA-Z0-9_-., 长度 2-64)`)
75
- }
76
- // path 安全校验(如果提供了 path)
77
- if (proj.path) {
78
- if (proj.path.includes('..')) {
79
- errors.push(`项目 "${id}" 的 path 包含 .. ,拒绝越界`)
80
- } else {
81
- const absPath = resolve(safeRoot, proj.path)
82
- const rel = relative(safeRoot, absPath)
83
- if (rel.startsWith('..') || isAbsolute(rel)) {
84
- errors.push(`项目 "${id}" 的 path "${proj.path}" 解析后超出 source_root`)
85
- }
86
- if (!existsSync(absPath)) {
87
- errors.push(`项目 "${id}" 的 path "${proj.path}" 不存在`)
88
- }
89
- }
90
- }
91
- }
92
- if (errors.length > 0) return { ok: false, errors }
93
- return { ok: true, errors: [] }
94
- }
95
- async function archiveChangeDirectory(pm, cwd, progress, specBase) {
96
- const archiveChangeName = progress.currentChange
97
- if (!archiveChangeName) {
98
- console.error('❌ 归档失败:未找到当前变更名(currentChange)')
99
- process.exit(1)
100
- }
101
- const changesDir = join(specBase, 'changes')
102
- const archiveDir = join(changesDir, 'archive')
103
- const srcDir = join(changesDir, archiveChangeName)
104
- const date = new Date().toISOString().slice(0, 10)
105
- const destName = archiveDestDirName(date, archiveChangeName)
106
- const destDir = join(archiveDir, destName)
107
-
108
- if (!existsSync(srcDir)) {
109
- console.error(`❌ 归档失败:源目录不存在 ${srcDir}`)
110
- process.exit(1)
111
- }
112
- // 移动前硬校验:变更包必须含 plan.md,否则不该归档。
113
- // 在移动前阻断(而非移动后),目录尚未动,用户可直接修复后重试。
114
- if (!existsSync(join(srcDir, 'plan.md'))) {
115
- console.error(`❌ 归档失败:变更目录缺少 plan.md(${srcDir})`)
116
- console.error(` plan.md 是归档的必需产物。请先补全 plan 阶段产出再归档。`)
117
- process.exit(1)
118
- }
119
- if (existsSync(destDir)) {
120
- console.error(`❌ 归档失败:目标目录已存在 ${destDir}`)
121
- process.exit(1)
122
- }
123
- mkdirSync(archiveDir, { recursive: true })
124
- try {
125
- renameSyncRetry(srcDir, destDir)
126
- } catch (e) {
127
- console.error(`❌ 归档失败:移动变更目录时出错(${e.message})`)
128
- console.error(` 常见原因:变更目录内文件被 IDE / 杀毒 / 索引占用,已重试 5 次仍失败。请关闭相关程序后重试。`)
129
- process.exit(1)
130
- }
131
-
132
- if (!existsSync(destDir) || existsSync(srcDir)) {
133
- console.error('❌ 归档校验失败:移动操作异常')
134
- process.exit(1)
135
- }
136
-
137
- await pm.unregisterChange(cwd, archiveChangeName)
138
-
139
- // 归档时清理可能残留的 worktree(execute 自动清理未走到 / 有未 apply 变更被遗弃)。
140
- // 安全策略:有未 apply 变更时保留 worktree 并警告,避免误删用户未应用的代码。
141
- try {
142
- const { WorktreeManager } = await import('../worktree.js')
143
- const wm = new WorktreeManager({ cwd })
144
- const meta = wm.getMeta(archiveChangeName)
145
- if (meta) {
146
- const check = meta.mode !== 'in-place-fallback' ? wm.hasUnappliedChanges(archiveChangeName) : { hasChanges: false }
147
- if (check.hasChanges) {
148
- console.warn(`⚠️ 归档时 worktree 仍有 ${check.changedFiles.length} 个未 apply 变更,保留 worktree`)
149
- console.warn(` 确认不需要后手动清理: sillyspec worktree cleanup ${archiveChangeName} --force`)
150
- } else {
151
- const cleanResult = wm.cleanup(archiveChangeName)
152
- if (cleanResult.residual?.length > 0) {
153
- console.warn(`⚠️ 归档 worktree 清理残留: ${cleanResult.residual.join('; ')}`)
154
- console.warn(` 手动处理: sillyspec worktree cleanup ${archiveChangeName} --force`)
155
- }
156
- }
157
- }
158
- } catch (e) {
159
- console.warn(`⚠️ 归档 worktree 清理失败(不阻断归档): ${e.message}`)
160
- }
161
-
162
- console.log(`📦 已归档:${archiveChangeName} → archive/${destName}/`)
163
- return destDir
164
- }
165
- /**
166
- * archive 阶段「确认归档」步骤的收尾处理器(从 completeStep 抽出,行为保持)。
167
- *
168
- * 两件事:
169
- * 1. --confirm 门控:缺 --confirm → 回退 step 状态、提示、返回 early-return 对象(completeStep 透传)
170
- * 2. --confirm 通过 → archiveChangeDirectory 移动变更目录 + 推荐文档(design.md / module-impact.md)校验
171
- * (contracts.archive.validators 为空,两个 validator 生效窗口互斥;plan.md 已在移动前硬校验阻断)
172
- *
173
- * @returns {{stageCompleted:false,currentIdx,nextPendingIdx:number}|null}
174
- */
175
- export async function handleArchiveConfirmStep({ stageName, steps, currentIdx, confirm, outputText, pm, cwd, progress, changeName, specBase }) {
176
- if (stageName !== 'archive' || steps[currentIdx]?.name !== '确认归档') return null
177
- if (!confirm) {
178
- steps[currentIdx].status = 'pending'
179
- steps[currentIdx].completedAt = null
180
- if (outputText) steps[currentIdx].output = null
181
- await pm._write(cwd, progress, changeName)
182
- console.log('⚠️ 请添加 --confirm 确认归档,例如:sillyspec run archive --done --confirm --output "确认归档"')
183
- return { stageCompleted: false, currentIdx, nextPendingIdx: currentIdx }
184
- }
185
- const archivedDir = await archiveChangeDirectory(pm, cwd, progress, specBase)
186
- if (archivedDir && existsSync(archivedDir)) {
187
- const recommendedDocs = ['design.md', 'module-impact.md']
188
- const missingRecommended = recommendedDocs.filter(d => !existsSync(join(archivedDir, d)))
189
- if (missingRecommended.length > 0) {
190
- console.warn(`\n⚠️ 归档校验警告:归档目录缺少推荐文档`)
191
- for (const d of missingRecommended) console.warn(` - ${d}(${archivedDir})`)
192
- } else {
193
- console.log(`\n✅ 归档校验通过:核心文档齐全`)
194
- }
195
- }
196
- return null
197
- }
198
- /**
199
- * plan 阶段「generate_plan」步骤完成后,动态插入任务蓝图(coordinator)+ postcheck 步骤
200
- * (从 completeStep 抽出,行为保持)。使用稳定 id 匹配,不依赖中文标题。
201
- *
202
- * plan.md 已含任务时,buildPlanSteps 返回 [fixedPrefix(classify/generate_plan/review_plan),
203
- * coordinator, postcheck];本函数把 generate_plan 之后的 coordinator+postcheck 插到当前步后。
204
- */
205
- export async function handlePlanGeneratePlanStep({ stageName, steps, currentIdx, defStepsForCurrent, cwd, progress }) {
206
- if (stageName !== 'plan') return
207
- const currentStepDef = defStepsForCurrent?.[currentIdx]
208
- const currentStepEntry = steps[currentIdx]
209
- const stepId = currentStepDef?.id || currentStepEntry?.id || currentStepEntry?._stepId
210
- if (stepId !== 'generate_plan') return
211
- const changeDir = resolveChangeDir(cwd, progress)
212
- if (!changeDir) return
213
- const planFile = join(changeDir, 'plan.md')
214
- if (!existsSync(planFile)) return
215
- const planContent = readFileSync(planFile, 'utf8')
216
- const { buildPlanSteps, fixedPrefix, fixedSuffix } = await import('../stages/plan.js')
217
- const fullSteps = buildPlanSteps(changeDir, planContent)
218
- const prefixLen = fixedPrefix.length
219
- const suffixLen = fixedSuffix.length
220
- // 新结构:[...fixedPrefix, coordinatorStep?, postcheckStep?];fixedSuffix 为空,coordinator+postcheck 都在 prefix 之后
221
- const coordinatorSteps = fullSteps.slice(prefixLen, suffixLen > 0 ? -suffixLen : undefined)
222
- if (coordinatorSteps.length === 0) return
223
- for (let i = 0; i < coordinatorSteps.length; i++) {
224
- const stepDef = coordinatorSteps[i]
225
- const stepEntry = {
226
- id: stepDef.id,
227
- name: stepDef.name,
228
- status: 'pending',
229
- prompt: stepDef.prompt || '',
230
- outputHint: stepDef.outputHint,
231
- optional: stepDef.optional
232
- }
233
- // 传递 noAI / _cliAction 属性
234
- if (stepDef.noAI) stepEntry.noAI = true
235
- if (stepDef._cliAction) stepEntry._cliAction = stepDef._cliAction
236
- steps.splice(currentIdx + 1 + i, 0, stepEntry)
237
- }
238
- console.log(` 📝 已动态插入 ${coordinatorSteps.length} 个步骤(${coordinatorSteps.map(s => s.name).join(', ')})`)
239
- }
240
- /**
241
- * scan 阶段 step 2「构建扫描项目列表」完成后,按项目展开 perProject 步骤(从 completeStep 抽出,行为保持)。
242
- *
243
- * 只接受结构化输出(scan_projects YAML block 或 BEGIN_PROJECT_LIST 标记块),校验通过后
244
- * 自动注册 projects/<id>.yaml + 写 scan-projects.json + 把 perProject 步骤按项目展开。
245
- * 不展开 completeStep 提前 return(失败只记 validationError,由 completeStep 继续推进下一步)。
246
- */
247
- export async function handleScanProjectListStep({ stageName, steps, currentIdx, outputText, stageData, specBase, cwd, platformOpts }) {
248
- if (stageName !== 'scan' || steps[currentIdx]?.name !== '构建扫描项目列表') return
249
- // 解析项目列表:只接受结构化输出(YAML block 或 BEGIN_PROJECT_LIST 标记)
250
- // 不再从自由文本猜测项目名——自由文本列表的误解析会导致垃圾项目落盘
251
- let parsedProjects = [] // Array<{id, path?}>
252
- let parsedFromStructuredOutput = false
253
- if (outputText) {
254
- // 格式 A: YAML block — 匹配 scan_projects: 下所有 - id: xxx 条目(含多行属性)
255
- const yamlBlock = outputText.match(/scan_projects:\s*\n([\s\S]+?)(?=$|\n[^\s])/)
256
- if (yamlBlock) {
257
- const entries = [...yamlBlock[1].matchAll(/-\s+id:\s*(\S+)(?:[\s\S]*?)(?=\n\s+-\s+id:|$)/g)]
258
- for (const m of entries) {
259
- const id = sanitizeProjectName(m[1])
260
- if (!id) continue
261
- // 提取可选 path 字段
262
- const pathMatch = m[0].match(/path:\s*(\S+)/)
263
- const entry = pathMatch ? { id, path: pathMatch[1].trim() } : { id }
264
- parsedProjects.push(entry)
265
- }
266
- parsedFromStructuredOutput = parsedProjects.length > 0
267
- }
268
- // 格式 B: BEGIN_PROJECT_LIST ... END_PROJECT_LIST 标记块
269
- if (!parsedFromStructuredOutput) {
270
- const blockMatch = outputText.match(/BEGIN_PROJECT_LIST\s*\n([\s\S]*?)\n*END_PROJECT_LIST/)
271
- if (blockMatch) {
272
- const raw = [...blockMatch[1].matchAll(/^-\s+(\S+)/gm)].map(m => m[1])
273
- parsedProjects = raw.map(s => sanitizeProjectName(s)).filter(Boolean).map(id => ({ id }))
274
- parsedFromStructuredOutput = parsedProjects.length > 0
275
- }
276
- }
277
- }
278
-
279
- const projectNames = parsedProjects.map(p => p.id)
280
-
281
- if (parsedFromStructuredOutput) {
282
- stageData.scanMeta = stageData.scanMeta || {}
283
- stageData.scanMeta.projectListParsed = true
284
- } else {
285
- // 结构化输出未解析到 → 回退读取已有 projects/*.yaml
286
- // 读取时也校验:path 不存在的 yaml 视为垃圾,直接跳过
287
- console.warn('⚠️ step 2 未输出结构化项目列表,回退扫描已注册项目')
288
- stageData.scanMeta = stageData.scanMeta || {}
289
- stageData.scanMeta.projectListParsed = false
290
- const projectsDir = join(specBase, 'projects')
291
- if (existsSync(projectsDir)) {
292
- const yamlFiles = readdirSync(projectsDir).filter(f => f.endsWith('.yaml'))
293
- const fallbackProjects = []
294
- const fallbackSkipped = []
295
- for (const yf of yamlFiles) {
296
- const pName = yf.replace(/\.yaml$/, '')
297
- const yamlContent = readFileSync(join(projectsDir, yf), 'utf8')
298
- const pathMatch = yamlContent.match(/^path:\s*(.+)/m)
299
- const pPath = pathMatch ? pathMatch[1].trim() : pName
300
- // 校验 path 是否存在且在 source_root 内
301
- const absPath = resolve(cwd, pPath)
302
- if (pPath.includes('..') || (!absPath.startsWith(resolve(cwd)) && absPath !== resolve(cwd))) {
303
- fallbackSkipped.push(`${pName} (path 越界: ${pPath})`)
304
- continue
305
- }
306
- if (!existsSync(absPath)) {
307
- fallbackSkipped.push(`${pName} (path 不存在: ${pPath})`)
308
- continue
309
- }
310
- fallbackProjects.push({ id: pName, path: pPath })
311
- }
312
- if (fallbackSkipped.length > 0) {
313
- console.warn(`⚠️ 跳过 ${fallbackSkipped.length} 个垃圾/过期项目配置:${fallbackSkipped.join(', ')}`)
314
- console.warn(' 建议清理 projects/ 下的无效 yaml 文件')
315
- }
316
- parsedProjects = fallbackProjects
317
- projectNames.length = 0
318
- projectNames.push(...fallbackProjects.map(p => p.id))
319
- }
320
- if (parsedProjects.length === 0) {
321
- // 无结构化输出 + 无合法已有项目 → step 2 失败
322
- console.error('❌ step 2 未输出结构化项目列表,且 projects/ 下无合法项目配置')
323
- console.error(' 请在 --output 中输出 scan_projects YAML block 或 BEGIN_PROJECT_LIST 标记块')
324
- steps[currentIdx].validationError = '未输出结构化项目列表且无合法 fallback'
325
- // 不展开 perProject 步骤,直接跳到下一步
326
- }
327
- }
328
-
329
- // 校验解析出的项目列表(原子守卫:不通过就不落盘)
330
- const validation = validateParsedProjects(parsedProjects, cwd)
331
- if (!validation.ok) {
332
- console.error(`❌ 项目列表校验失败: ${validation.errors.join('; ')}`)
333
- console.error(' step 2 完成,但不展开 perProject 步骤。请检查 --output 中的项目列表。')
334
- steps[currentIdx].validationError = validation.errors.join('; ')
335
- }
336
-
337
- // 自动注册 + 保存 runtime + 展开 perProject 步骤(仅在校验通过时)
338
- const projectsDir = join(specBase, 'projects')
339
- if (validation.ok) {
340
- for (const proj of parsedProjects) {
341
- const pName = proj.id
342
- const projYaml = join(projectsDir, `${pName}.yaml`)
343
- if (!existsSync(projYaml)) {
344
- mkdirSync(projectsDir, { recursive: true })
345
- const candidates = [
346
- join(cwd, pName),
347
- join(cwd, 'backend', pName),
348
- join(cwd, 'packages', pName),
349
- join(cwd, 'apps', pName),
350
- join(cwd, 'services', pName),
351
- ]
352
- const detected = candidates.find(c => existsSync(c))
353
- const regPath = detected || join(cwd, pName)
354
- writeFileSync(projYaml, `name: ${pName}\npath: ${regPath}\nstatus: active\n`)
355
- console.log(` 📝 自动注册子项目: ${pName} → ${regPath}`)
356
- }
357
- }
358
-
359
- // 保存 runtime 状态
360
- const scanStatePath = join(specBase, '.runtime', 'scan-projects.json')
361
- mkdirSync(join(specBase, '.runtime'), { recursive: true })
362
- let scanState = { projects: projectNames, expanded: false }
363
- if (existsSync(scanStatePath)) {
364
- try { scanState = JSON.parse(readFileSync(scanStatePath, 'utf8')) } catch {}
365
- }
366
-
367
- // 收集当前步骤之后所有 perProject 步骤
368
- const stageDef = stageRegistry[stageName]
369
- const allSteps = stageDef?.steps || []
370
- const perProjectSteps = allSteps.filter(s => s.perProject)
371
-
372
- // 防重复展开
373
- const alreadyExpanded = scanState.expanded || steps.some(s => s.name?.match(/\[.+\]\s*$/))
374
- if (!alreadyExpanded && perProjectSteps.length > 0) {
375
- // 找到当前步骤(step 2)在动态 steps 中的位置
376
- const insertBase = currentIdx + 1
377
- let insertPos = insertBase
378
- for (const pName of projectNames) {
379
- // 读取项目配置获取 projectRoot
380
- const projYaml = join(specBase, 'projects', `${pName}.yaml`)
381
- let projectRoot = '.'
382
- if (existsSync(projYaml)) {
383
- const yamlContent = readFileSync(projYaml, 'utf8')
384
- const pathMatch = yamlContent.match(/^path:\s*(.+)/m)
385
- if (pathMatch) projectRoot = pathMatch[1].trim()
386
- }
387
- const docOutputDir = platformOpts.specRoot ? `${specBase}/docs/${pName}` : `.sillyspec/docs/${pName}`
388
- const contextPrefix = `\n---\n## 当前项目\n- **项目名**: ${pName}\n- **项目路径**: ${projectRoot}\n- **文档输出**: ${docOutputDir}\n\n⚠️ 本步骤只处理上面这个项目,不要处理其他项目。\n---\n\n`
389
-
390
- for (const ppStep of perProjectSteps) {
391
- steps.splice(insertPos, 0, {
392
- name: `${ppStep.name} [${pName}]`,
393
- project: pName,
394
- status: 'pending',
395
- prompt: contextPrefix + ppStep.prompt,
396
- outputHint: ppStep.outputHint,
397
- optional: ppStep.optional
398
- })
399
- insertPos++
400
- }
401
- }
402
- // 移除原始的 perProject 步骤(未展开的版本)
403
- for (let i = steps.length - 1; i >= 0; i--) {
404
- if (steps[i].perProject && !steps[i].name?.includes('[')) {
405
- steps.splice(i, 1)
406
- }
407
- }
408
- console.log(` 📝 已按项目展开 ${perProjectSteps.length} 个步骤 × ${projectNames.length} 个项目 = ${perProjectSteps.length * projectNames.length} 个项目步骤`)
409
- console.log(` 📁 扫描项目:${projectNames.join(', ')}`)
410
- // 标记已展开,防止 resume 重复插入
411
- scanState.expanded = true
412
- writeFileSync(scanStatePath, JSON.stringify(scanState))
413
- } // end !alreadyExpanded
414
- } // end validation.ok
415
- }
416
- /**
417
- * Workflow post_check(W6 Step6 从 completeStep 内联块抽出):
418
- * - scan「深度扫描」完成 → 跑 scan-docs workflow postcheck,失败阻断推进(early-return)
419
- * - archive「extract-module-impact」完成 → 跑 archive-impact workflow postcheck(impact-analyzer 结果)
420
- * 返回 early-return 对象({stageCompleted:false,...})由 completeStep 透传;null = 放行。
421
- *
422
- * ctx 字段:stageName/steps/currentIdx/cwd/specBase/progress/platformOpts/changeName(completeStep 局部)。
423
- * 辅助函数直接 import:basename/join/existsSync/readdirSync(顶部静态);loadWorkflow/runPostCheck/
424
- * formatCheckReport/saveWorkflowRun 动态 import ../workflow.js(真环依赖保留动态)。
425
- *
426
- *搬迁清理:删除死代码 `typeof change !== 'undefined'`(completeStep 作用域无 change 变量,恒 null)。
427
- */
428
- export async function handleWorkflowPostCheck({ stageName, steps, currentIdx, cwd, specBase, progress, platformOpts, changeName }) {
429
- // Workflow post_check:scan 深度扫描完成后自动检查产物
430
- if (stageName === 'scan' && steps[currentIdx]?.name?.includes('深度扫描')) {
431
- try {
432
- const { loadWorkflow, runPostCheck, formatCheckReport, saveWorkflowRun } = await import('../workflow.js')
433
- const wf = loadWorkflow(cwd, 'scan-docs')
434
- if (wf) {
435
- // 确定当前项目(优先级链):
436
- // progress.project (dbProjectName,平台模式真实项目名,与 outputStep 占位符渲染对齐)
437
- // > change?.project (变更对象的项目字段,平台模式 change 创建时传入)
438
- // > steps[idx].project (perProject 展开标记,兼容旧模式)
439
- // > steps[idx].name 正则提取 [xxx] 后缀
440
- // > null(回退检查所有项目)
441
- // task-05 修复:日志显示项目名变 frontend 是 perProject 误展开 bug,
442
- // 用 progress.project(与 outputStep 占位符渲染路径一致)修正 myaaa/frontend 分裂。
443
- const currentProjectName = progress.project
444
- || steps[currentIdx].project
445
- || (steps[currentIdx].name.match(/\[([^\]]+)\]\s*$/) || [])[1]
446
- || null
447
-
448
- // 确定要检查的项目列表
449
- let projectsToCheck = []
450
- if (currentProjectName) {
451
- // 按项目展开模式:只检查当前项目
452
- projectsToCheck = [currentProjectName]
453
- } else {
454
- // 兼容旧模式(未展开):检查所有项目
455
- const projectsDir = join(specBase, 'projects')
456
- const projectFiles = existsSync(projectsDir)
457
- ? readdirSync(projectsDir).filter(f => f.endsWith('.yaml'))
458
- : []
459
- projectsToCheck = projectFiles.map(f => f.replace(/\.yaml$/, ''))
460
- }
461
-
462
- let anyFailed = false
463
- for (const pName of projectsToCheck) {
464
- const result = runPostCheck(wf, cwd, pName, {}, specBase)
465
- const report = formatCheckReport(result)
466
- console.log(report)
467
- if (result.status === 'fail') {
468
- anyFailed = true
469
- // retry_prompts 由 _checkWorkflow 自动生成
470
- for (const rp of (result.retry_prompts || [])) {
471
- console.log(`\n🔄 重试提示(项目 ${pName}):\n`)
472
- console.log(rp.prompt)
473
- }
474
- }
475
- const saved = saveWorkflowRun(result, {
476
- cwd,
477
- source: 'run.js',
478
- stage: 'scan',
479
- step: steps[currentIdx]?.name,
480
- ...(platformOpts.runtimeRoot ? { runtimeRoot: platformOpts.runtimeRoot } : {}),
481
- ...(platformOpts.scanRunId ? { scanRunId: platformOpts.scanRunId } : {})
482
- })
483
- if (saved) console.log(`📁 结果已归档:${saved}`)
484
- }
485
- if (anyFailed) {
486
- console.log(`\n⚠️ 存在检查失败项,请按上面的重试提示修复后再继续。`)
487
- // task-07: 阻断推进(与 task-06 平台模式 scan-postcheck 失败分支 return 结构对齐)
488
- // scan 深度扫描产物校验未通过时,不允许 clean success / 进入下一 step,
489
- // 让上层走"完成但不推进"分支,--done 被拒。
490
- return { stageCompleted: false, currentIdx, nextPendingIdx: currentIdx }
491
- }
492
- }
493
- } catch (e) {
494
- console.warn(`⚠️ workflow 检查跳过:${e.message}`)
495
- }
496
- }
497
-
498
- // Workflow post_check:archive extract-module-impact 完成后检查产物
499
- if (stageName === 'archive' && steps[currentIdx]?.name?.includes('extract-module-impact')) {
500
- try {
501
- const { loadWorkflow, runPostCheck, formatCheckReport, saveWorkflowRun } = await import('../workflow.js')
502
- const wf = loadWorkflow(cwd, 'archive-impact')
503
- if (wf && changeName) {
504
- const raw = JSON.stringify(wf)
505
- const resolved = JSON.parse(raw.replace(/<change-name>/g, changeName))
506
- const result = runPostCheck(resolved, cwd, progress.project || basename(cwd), {}, specBase)
507
- // 只报告 impact-analyzer 的结果(doc-syncer 是后续步骤)
508
- const impactResult = (result.roles || []).find(r => r.id === 'impact-analyzer')
509
- if (impactResult) {
510
- const icon = impactResult.status === 'pass' ? '✅' : '❌'
511
- console.log(`${icon} module-impact.md 检查${impactResult.status === 'pass' ? '通过' : '失败'}`)
512
- for (const f of (result.failures || []).filter(f => f.role_id === 'impact-analyzer')) {
513
- console.log(` └─ ${f}`)
514
- }
515
- }
516
- const saved = saveWorkflowRun(result, {
517
- cwd,
518
- source: 'run.js',
519
- stage: 'archive',
520
- step: steps[currentIdx]?.name,
521
- ...(platformOpts.runtimeRoot ? { runtimeRoot: platformOpts.runtimeRoot } : {}),
522
- ...(platformOpts.scanRunId ? { scanRunId: platformOpts.scanRunId } : {})
523
- })
524
- if (saved) console.log(`📁 结果已归档:${saved}`)
525
- }
526
- } catch (e) {
527
- console.warn(`⚠️ workflow 检查跳过:${e.message}`)
528
- }
529
- }
530
- return null
531
- }
532
- /**
533
- * quick 阶段完成收尾(W6 Step6b 从 completeStep 内联块抽出):
534
- * 强校验 QUICKLOG 条目 + 审计(auditQuickCompletion)+ 结果摘要结构校验 + 翻状态/勾 tasks.md
535
- * (CLI 接管 QUICKLOG 分配/写入/收尾)。blocked → process.exit(1)(无 early-return,调用点纯 await)。
536
- *
537
- * ctx:stageName/steps/currentIdx/cwd/progress/changeName/specBase/outputText/confirm/
538
- * isForceBaseline/isAllowNew/platformOpts。辅助函数直接 import(safeGit/auditQuickCompletion ← shared,
539
- * printQuickAuditReview ← quick-audit,4 个 quicklog fns ← quicklog,unlinkSync/rmSync ← fs 静态)。
540
- */
541
- export async function handleQuickStageCompletion({ stageName, steps, currentIdx, cwd, progress, changeName, specBase, outputText, confirm, isForceBaseline, isAllowNew, platformOpts }) {
542
- // quick 收尾:强校验 QUICKLOG 条目 + 翻状态 + 勾 tasks.md(CLI 接管)
543
- if (stageName === 'quick') {
544
- // §4.6 从 session guard.json 读 guard(不依赖 progress.quickGuard)。
545
- // D-003@v1:progress._write 不持久化顶层 quickGuard,跨进程 --done 时读出的 progress 无 quickGuard,
546
- // 若仍用 if (progress.quickGuard) 驱动收尾会整体跳过,导致 .runtime/quick-sessions/<sessionId>/ 残留僵尸。
547
- // 改为从文件读 guard:优先 session 目录 guard.json,回退旧单文件 quick-guard.json(task-03 前兼容)。
548
- // sessionId == changeName == quick-<uuid8>(completeStep 作用域内 changeName 已解构自 options)。
549
- const runtimeBase = platformOpts.runtimeRoot || join(specBase, '.runtime')
550
- const sessionGuardFile = join(runtimeBase, 'quick-sessions', changeName, 'guard.json')
551
- const legacyGuardFile = join(specBase, '.runtime', 'quick-guard.json')
552
- let guard = null
553
- try {
554
- guard = existsSync(sessionGuardFile)
555
- ? JSON.parse(readFileSync(sessionGuardFile, 'utf8'))
556
- : (existsSync(legacyGuardFile) ? JSON.parse(readFileSync(legacyGuardFile, 'utf8')) : null)
557
- } catch {}
558
-
559
- // 强校验 / 收尾:本会话必须有一条真实 QUICKLOG 条目(治「报 SAFE 但漏写」bug)。
560
- // guard 缺失(brownfield:新代码前启动的会话)不阻断——兜底补写一条记录,保住「完成必有记录」不变量。
561
- const gitUser = safeGit(cwd, ['config', 'user.name']).value || 'unknown'
562
- let qlId = guard?.quicklogId || null
563
- const linkedChanges = Array.isArray(guard?.linkedChanges) ? guard.linkedChanges : []
564
- // 审计结果(仅 guard 存在时填充)。提到 if(guard) 外声明,供下方回填 QUICKLOG 文件行复用
565
- // review.changedFiles;brownfield 无 guard 时保持 null → 文件行不回填(降级,不报错)。
566
- let review = null
567
-
568
- // 审计:仅在有 guard 时跑(brownfield 无 guard 跳过,兼容 D-003 brownfield 行为)。
569
- if (guard) {
570
- // --done 的 --force-baseline/--allow-new 并入 guard(与 step1 持久化值取或)。
571
- // 修复 ql-20260713-002-7628:旧代码解析了这两个 flag 但只传 {isConfirm} 给审计,
572
- // 致 --done --force-baseline 静默无效、用户被误导「重跑 --confirm」也无法解锁。
573
- const mergedGuard = {
574
- ...guard,
575
- forceBaseline: guard.forceBaseline || isForceBaseline,
576
- allowNew: guard.allowNew || isAllowNew,
577
- }
578
- review = await auditQuickCompletion(cwd, mergedGuard, { isConfirm: confirm })
579
- printQuickAuditReview(review)
580
- if (review.status === 'blocked') {
581
- steps[currentIdx].status = 'pending'
582
- steps[currentIdx].completedAt = null
583
- if (outputText) steps[currentIdx].output = null
584
- process.exit(1)
585
- }
586
- progress.lastQuickReview = review
587
- }
588
-
589
- // 结果摘要结构校验(最后一步、isDone 且带了 --output 时):--output 是 QUICKLOG「结果:」
590
- // 归档的唯一来源,要求按 需求/根因/方案/结果 模板给全(见 stages/quick.js step3 prompt)。
591
- // 确定性校验:只查必填字段是否齐全,不判内容质量。缺字段 → 本次不完成(回滚 step 状态 +
592
- // exit 1),保留「进行中」条目,agent 补全 --output 后重跑 --done 即可,不丢进度。
593
- // 仅 completeQuicklogEntry 会实际持久化时才校验;前两个 step 的 --done output 不入 QUICKLOG,不校验。
594
- if (outputText) {
595
- const resultCheck = validateQuickResult(outputText)
596
- if (!resultCheck.ok) {
597
- console.error('\n' + getRule('quick.result-labels').failMessage.replaceAll('${missing}', resultCheck.missing.join('、')))
598
- console.error(` --output 是 QUICKLOG「结果:」归档的唯一来源,四个标签必须放在 --output 里(不是 --input)。`)
599
- console.error(` 补全后重跑 --done(不丢进度),直接照抄此模板:`)
600
- console.error(` sillyspec run quick --done --change <changeName> --output "需求:用户/任务要什么`)
601
- console.error(` 根因:为什么这样改(纯新增/样式则写「无,纯新增/纯样式」)`)
602
- console.error(` 方案:怎么改的`)
603
- console.error(` 结果:验证情况(测试数 / lint / typecheck / 部署状态)"`)
604
- steps[currentIdx].status = 'pending'
605
- steps[currentIdx].completedAt = null
606
- if (outputText) steps[currentIdx].output = null
607
- process.exit(1)
608
- }
609
- }
610
-
611
- if (!qlId) {
612
- // 无 ql-ID(guard 缺失或 brownfield 无 quicklogId):补分配后立即完成,不阻断。
613
- try {
614
- const alloc = await allocateQuicklogEntry(specBase, gitUser, {
615
- description: guard?.taskDescription || '(补分配)',
616
- linkedChanges,
617
- allowedFiles: Array.isArray(guard?.allowedFiles) ? guard.allowedFiles : [],
618
- })
619
- qlId = alloc.qlId
620
- console.log(`📝 QUICKLOG 兜底补写: ${qlId}(guard 缺失/brownfield 会话)`)
621
- } catch (e) {
622
- console.error(`\n❌ QUICKLOG 补分配失败: ${e.message}`)
623
- steps[currentIdx].status = 'pending'
624
- steps[currentIdx].completedAt = null
625
- if (outputText) steps[currentIdx].output = null
626
- process.exit(1)
627
- }
628
- }
629
- if (!findQuicklogEntry(specBase, gitUser, qlId)) {
630
- console.error(`\n❌ quick 阶段完成校验失败:QUICKLOG 条目 ${qlId} 不存在。`)
631
- console.error(` 会话期间记录被删除或从未写入。请检查 .sillyspec/quicklog/ 后重跑 --done。`)
632
- steps[currentIdx].status = 'pending'
633
- steps[currentIdx].completedAt = null
634
- if (outputText) steps[currentIdx].output = null
635
- process.exit(1)
636
- }
637
- // 翻状态进行中→已完成 + 追加结果 + 勾选关联 tasks.md
638
- // resultText 不再截断:结构化结果块(需求/根因/方案/结果)完整落盘,多行写成字段化块。
639
- try {
640
- // 回填实际改动文件:review.changedFiles 含 quick 自身元数据(quicklog/.runtime/modules 等),
641
- // 用 isQuickMetadata 过滤掉,只留真实业务文件。brownfield 无 review → 空数组,文件行不动。
642
- const realFiles = Array.isArray(review?.changedFiles)
643
- ? review.changedFiles.filter(f => !isQuickMetadata(f, linkedChanges))
644
- : []
645
- await completeQuicklogEntry(specBase, gitUser, qlId, {
646
- resultText: outputText || '',
647
- linkedChanges,
648
- changedFiles: realFiles,
649
- })
650
- console.log(`📝 QUICKLOG 条目 ${qlId} 已标记完成`)
651
- } catch (e) {
652
- console.warn(`⚠️ QUICKLOG 完成态写入失败: ${e.message}`)
653
- }
654
-
655
- // 清理 session 目录(rmSync/unlinkSync 容忍不存在)。
656
- try {
657
- if (changeName) {
658
- const sessionDir = join(runtimeBase, 'quick-sessions', changeName)
659
- rmSync(sessionDir, { recursive: true, force: true })
660
- }
661
- if (existsSync(legacyGuardFile)) unlinkSync(legacyGuardFile)
662
- } catch {}
663
- }
664
- return null
665
- }
666
- /**
667
- * execute「Wave N 执行」步骤完成后扫 worktree 提取 provider endpoint artifact(W6 Step6c 从
668
- * completeStep 内联块抽出)。供 verify 阶段 parity 对账 + consumer task 上游契约注入。
669
- * 接线自 contract-matrix pipeline。step 级(每个 Wave 执行步骤后跑),无 early-return(try/catch warn)。
670
- *
671
- * ctx:stageName/steps/currentIdx/changeName/specBase/cwd。extractArtifactsForChange ← 动态 ../contract-matrix.js,
672
- * WorktreeManager ← 动态 ../worktree.js(真环依赖保留动态)。
673
- */
674
- export async function handleExecuteWaveArtifact({ stageName, steps, currentIdx, changeName, specBase, cwd }) {
675
- if (stageName === 'execute' && /^Wave \d+ 执行$/.test(steps[currentIdx]?.name || '')) {
676
- try {
677
- const { extractArtifactsForChange } = await import('../contract-matrix.js')
678
- let worktreePath = null
679
- try {
680
- const { WorktreeManager } = await import('../worktree.js')
681
- const meta = new WorktreeManager({ cwd }).getMeta(changeName)
682
- if (meta?.worktreePath && existsSync(meta.worktreePath)) worktreePath = meta.worktreePath
683
- } catch {}
684
- const msg = extractArtifactsForChange({ changeDir: join(specBase, 'changes', changeName), specBase, changeName, worktreePath })
685
- if (msg) console.log(msg)
686
- } catch (e) { console.warn(`⚠️ 契约 artifact 提取跳过: ${e?.message || e}`) }
687
- }
688
- return null
689
- }
690
-
691
- /**
692
- * execute 阶段完成时条件性清理 worktree(W6 Step6c 从 completeStep 完成路径内联块抽出)。
693
- * 不依赖 AI agent 的完成确认步骤:有未 apply 变更 → 保留 worktree;否则 cleanup(含 in-place 安全清理)。
694
- * stage 级(execute 阶段全部完成时跑),无 early-return(try/catch warn)。
695
- *
696
- * ctx:stageName/changeName/cwd。WorktreeManager ← 动态 ../worktree.js。
697
- */
698
- export async function handleExecuteWorktreeCleanup({ stageName, changeName, cwd }) {
699
- if (stageName === 'execute' && changeName) {
700
- try {
701
- const { WorktreeManager } = await import('../worktree.js');
702
- const wm = new WorktreeManager({ cwd });
703
- const meta = wm.getMeta(changeName);
704
- if (!meta) {
705
- console.log('🔗 Worktree: n/a (no meta)');
706
- } else if (meta.mode === 'native-worktree') {
707
- console.log('🔗 Worktree: kept (外部隔离环境)');
708
- } else {
709
- // in-place 模式不再短路:cleanup 现在能安全处理 in-place(只清 meta,不碰主工作区)
710
- const check = wm.hasUnappliedChanges(changeName);
711
- if (check.hasChanges) {
712
- console.log(`🔗 Worktree: pending apply (${check.changedFiles.length} 个未应用变更)`);
713
- console.log(` 下一步: sillyspec worktree apply ${changeName}`);
714
- } else {
715
- const cleanResult = wm.cleanup(changeName);
716
- console.log(`🔗 Worktree: ${cleanResult.result}`);
717
- if (cleanResult.residual?.length > 0) {
718
- console.warn(` ⚠️ 清理残留: ${cleanResult.residual.join('; ')}`);
719
- console.warn(` 手动处理: sillyspec worktree cleanup ${changeName} --force`);
720
- } else if (cleanResult.details?.length > 0) {
721
- for (const d of cleanResult.details) {
722
- if (d.startsWith('⚠️')) console.log(` ${d}`);
723
- }
724
- }
725
- }
726
- }
727
- } catch (e) {
728
- console.warn(`🔗 Worktree: check failed — ${e.message}`);
729
- }
730
- }
731
- return null
732
- }
733
- /**
734
- * scan 阶段完成后处理(W6 Step6d 从 completeStep 完成路径内联块抽出):
735
- * - 平台模式(specRoot/runtimeRoot):写 manifest.json + 跑 scan-postcheck + 结构化结果 +
736
- * 更新平台指针(SCAN_COMPLETED)+ failed_post_check 阻断(exit 1 / early-return)
737
- * - 非平台模式:轻量 postcheck + 结构化结果写 .runtime/
738
- * 返回 early-return 对象(platform failed_post_check 非 exit 路径)由 completeStep 透传;null = 放行。
739
- *
740
- * ctx:stageName/currentIdx/cwd/progress/pm/stageData/changeName/outputText/platformOpts。
741
- * safeGit/triggerSync ← shared;writeAtomicSync ← fs-atomic;SCAN_STATUS/POINTER_STATUS ← constants;
742
- * mkdirSync/writeFileSync/readFileSync/unlinkSync/join ← 顶部静态;runScanPostCheck 等 ← 动态 ../scan-postcheck.js。
743
- *
744
- * 搬迁清理:删 4 个冗余动态 builtin import(fs/path/child_process,execSync 死代码)+ _readFileSync 别名改回 readFileSync。
745
- */
746
- export async function handleScanStageCompleted({ stageName, currentIdx, cwd, progress, pm, stageData, changeName, outputText, platformOpts }) {
747
- // 平台模式:scan 完成后生成 manifest.json + post-check
748
- if (stageName === 'scan' && (platformOpts.specRoot || platformOpts.runtimeRoot)) {
749
- try {
750
- stageData.scanMeta = stageData.scanMeta || {}; stageData.scanMeta.manifestWritten = false; // 默认失败
751
- const manifestDir = platformOpts.specRoot
752
- mkdirSync(manifestDir, { recursive: true })
753
- let sourceCommit = null
754
- let sourceCommitError = null
755
- try {
756
- const gitResult = safeGit(cwd, ['rev-parse', 'HEAD'])
757
- sourceCommit = gitResult.value
758
- sourceCommitError = gitResult.error
759
- } catch (e) {
760
- sourceCommitError = e.message
761
- }
762
- const manifest = {
763
- workspace_id: platformOpts.workspaceId || null,
764
- scan_run_id: platformOpts.scanRunId || null,
765
- source_root: cwd,
766
- spec_root: platformOpts.specRoot || null,
767
- runtime_root: platformOpts.runtimeRoot || null,
768
- source_commit: sourceCommit,
769
- source_commit_error: sourceCommit === null ? (sourceCommitError || 'unknown') : undefined,
770
- generated_at: new Date().toISOString(),
771
- schema_version: 1,
772
- scan_profile: stageData.scanProfile
773
- ? { mode: stageData.scanProfile.mode, reason: stageData.scanProfile.reason }
774
- : null,
775
- postcheck_result_path: null,
776
- workflow_runs_dir: platformOpts.runtimeRoot
777
- ? join(platformOpts.runtimeRoot, 'scan-runs', platformOpts.scanRunId || 'unknown', 'workflow-runs')
778
- : null,
779
- platform_pointer_path: join(cwd, '.sillyspec-platform.json'),
780
- platform_pointer_status: POINTER_STATUS.ACTIVE,
781
- }
782
- const manifestPath = join(manifestDir, 'manifest.json')
783
- writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n')
784
- console.log(`📄 manifest.json 已写入: ${manifestPath}`)
785
- stageData.scanMeta = stageData.scanMeta || {}; stageData.scanMeta.manifestWritten = true;
786
- if (!sourceCommit) {
787
- console.log(`⚠️ source_commit 无法获取(可能非 git 目录),已设为 null`)
788
- }
789
- // 清理平台参数临时文件
790
- const platformOptsFile = join(manifestDir, '.runtime', 'platform-scan.json')
791
- try { unlinkSync(platformOptsFile) } catch {}
792
-
793
- // CLI 层 post-check(替代旧的简单检查)
794
- const { runScanPostCheck, printScanPostCheckResult, formatStructuredResult, writeStructuredResult } = await import('../scan-postcheck.js')
795
- const postResult = runScanPostCheck({
796
- cwd,
797
- specDir: platformOpts.specRoot,
798
- outputText,
799
- scanMeta: {
800
- projectListParsed: stageData.scanMeta?.projectListParsed ?? null,
801
- manifestWritten: stageData.scanMeta?.manifestWritten ?? null,
802
- },
803
- scanProfile: stageData.scanProfile || null,
804
- })
805
- printScanPostCheckResult(postResult)
806
-
807
- // 生成结构化 JSON 并写入 runtime(供 SillyHub 消费)
808
- const structured = formatStructuredResult(postResult, {
809
- workspace_id: platformOpts.workspaceId,
810
- scan_run_id: platformOpts.scanRunId,
811
- source_root: cwd,
812
- spec_root: platformOpts.specRoot,
813
- runtime_root: platformOpts.runtimeRoot,
814
- })
815
- const postcheckJsonPath = writeStructuredResult(structured, platformOpts.specRoot, {
816
- runtimeRoot: platformOpts.runtimeRoot,
817
- scanRunId: platformOpts.scanRunId,
818
- })
819
- if (postcheckJsonPath) {
820
- console.log(`📄 postcheck-result.json 已写入: ${postcheckJsonPath}`)
821
- manifest.postcheck_result_path = postcheckJsonPath
822
- }
823
-
824
- // 将 post-check 结果写入 manifest
825
- manifest.scan_post_check = {
826
- status: postResult.status,
827
- checks: postResult.checks,
828
- }
829
- // 更新 manifest
830
- writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n')
831
- console.log(`📄 manifest.json 已更新(含 post-check 结果)`)
832
-
833
- // 更新平台指针状态为 scan_completed
834
- const pointerPath = join(cwd, '.sillyspec-platform.json')
835
- try {
836
- const pointer = JSON.parse(readFileSync(pointerPath, 'utf8'))
837
- pointer.status = POINTER_STATUS.SCAN_COMPLETED
838
- pointer.completedAt = new Date().toISOString()
839
- pointer.scanStatus = postResult.status
840
- writeAtomicSync(pointerPath, JSON.stringify(pointer, null, 2) + '\n')
841
- } catch (e) {
842
- // 不阻断 scan 主流程,但暴露失败——pointer 写失败会让平台看不到 scan_completed,
843
- // 与项目 fail-loud 原则一致:宁可可见地 warn,也不静默吞错。
844
- console.warn(`⚠️ 更新平台指针状态失败(scan_completed 可能未落盘): ${e.message}`)
845
- }
846
-
847
- // failed_post_check 时强制阻止 clean success
848
- if (postResult.status === 'failed_post_check') {
849
- stageData.status = SCAN_STATUS.FAILED_POST_CHECK
850
- stageData.completedAt = new Date().toLocaleString('zh-CN',{hour12:false})
851
- await pm._write(cwd, progress, changeName)
852
- triggerSync(cwd, changeName, platformOpts)
853
- console.error(`\n❌ scan post-check 失败,状态设为 failed_post_check。不允许 clean success。`)
854
- console.error(` 请检查上方错误信息并修复后重新 scan。`)
855
- // 平台模式:exit(1) 让 daemon/SillyHub 感知非 0 退出码(manifest.json 已落盘,不会被撤销)
856
- if (platformOpts.specRoot || platformOpts.runtimeRoot) {
857
- console.error(' 平台模式:CLI 将以 exit code 1 退出,通知 SillyHub scan 失败。')
858
- process.exit(1)
859
- }
860
- // 接口与 plan contract (run.js:2551 附近 plan 失败分支) 对齐:
861
- // 返回 { stageCompleted:false, currentIdx, nextPendingIdx: currentIdx }
862
- // 让上层 runStage 走"完成但不推进"分支,--done 被拒
863
- return { stageCompleted: false, currentIdx, nextPendingIdx: currentIdx }
864
- } else if (postResult.status === 'completed_with_warnings') {
865
- // 警告不阻止完成,但记录
866
- stageData.status = 'completed'
867
- stageData.completedAt = new Date().toLocaleString('zh-CN',{hour12:false})
868
- await pm._write(cwd, progress, changeName)
869
- }
870
- } catch (e) {
871
- console.warn(`⚠️ manifest.json 写入失败: ${e.message}`)
872
- }
873
- }
874
-
875
- // 非 platform 模式 scan 也做轻量 post-check + 结构化输出
876
- if (stageName === 'scan' && !platformOpts.specRoot && !platformOpts.runtimeRoot) {
877
- const { runScanPostCheck, printScanPostCheckResult, formatStructuredResult, writeStructuredResult } = await import('../scan-postcheck.js')
878
- const postResult = runScanPostCheck({ cwd, specDir: null, outputText, scanProfile: stageData.scanProfile || null })
879
- printScanPostCheckResult(postResult)
880
- // 结构化结果写入 .sillyspec/.runtime/
881
- const structured = formatStructuredResult(postResult, { source_root: cwd })
882
- const postcheckJsonPath = writeStructuredResult(structured, join(cwd, '.sillyspec'))
883
- if (postcheckJsonPath) {
884
- console.log(`📄 postcheck-result.json 已写入: ${postcheckJsonPath}`)
885
- }
886
- }
887
- return null
888
- }
889
-
1
+ /**
2
+ * run/complete-handlers.js(W6 Step5 从 run.js 抽出)。
3
+ *
4
+ * completeStep 的子 handler + archive 收尾(自洽叶子模块,仅被 completeStep 调用):
5
+ * - handleArchiveConfirmStep:archive「确认归档」步骤 --confirm 门控 + 推荐文档校验
6
+ * - handlePlanGeneratePlanStep:plan「generate_plan」完成后动态插入 coordinator + postcheck 步骤
7
+ * - handleScanProjectListStep:scan step 2 完成后按项目展开 perProject 步骤(用 sanitizeProjectName/validateParsedProjects)
8
+ * - archiveChangeDirectory:归档移动变更目录(6 处 process.exit(1) + worktree 清理;handleArchiveConfirmStep 内部调用)
9
+ * srcDir 缺失时走 findAlreadyArchivedDir 幂等自愈(issue archive-stage-physical-tracking-desync);
10
+ * archiveChangeDirectory + findAlreadyArchivedDir 已 export 供 test 直接 import
11
+ * - sanitizeProjectName / validateParsedProjects:项目名清洗 + 列表校验纯函数(handleScanProjectListStep 专用)
12
+ *
13
+ * 安全锚:run.js 始终 barrel。3 handler 由 run.js import 回来;sanitizeProjectName + validateParsedProjects
14
+ * 被 test 直接 import(run-sanitize-project-name / run-scan-project-parse),run.js barrel re-export 契约保留。
15
+ * 4 目标 handler 无 test 直接 import;archiveChangeDirectory + findAlreadyArchivedDir 供自愈 test 直接 import,无需 barrel re-export。completeStep(Step7 搬)将把 import 行带走。
16
+ *
17
+ * 路径修正(相对 src/run/):
18
+ * - resolveChangeDir 从 './shared.js';renameSyncRetry 从 '../fs-atomic.js';stageRegistry 从 '../stages/index.js'
19
+ * - 动态 import './stages/plan.js' / './worktree.js' → '../'(src/ 下,退一层;真环依赖保留动态)
20
+ * - 删除 archiveChangeDirectory 内死代码 `const { renameSync } = await import('fs')`(renameSync 解构未用,实际走 renameSyncRetry)
21
+ *
22
+ * archiveChangeDirectory 的 6 处 process.exit 全 exit(1):5 个顶层 guard 直接终止;L1325 在 catch 内主动 exit
23
+ * (非被外层吞);process.exit 不可被 try 捕获 → 搬迁行为完全等价。
24
+ */
25
+ import { basename, join, resolve, relative, isAbsolute } from 'node:path'
26
+ import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync, unlinkSync, rmSync } from 'node:fs'
27
+ import { renameSyncRetry, writeAtomicSync } from '../fs-atomic.js'
28
+ import { resolveChangeDir, resolveQuickSessionsDir, safeGit, auditQuickCompletion, triggerSync, isQuickMetadata, resolveRuntimeRoot } from './shared.js'
29
+ import { detectConcurrentChanges, formatConcurrentWarning } from './concurrent-detect.js'
30
+ import { stageRegistry } from '../stages/index.js'
31
+ import { SCAN_STATUS, POINTER_STATUS } from '../constants.js'
32
+ import { printQuickAuditReview } from './quick-audit.js'
33
+ import { validateQuickResult, allocateQuicklogEntry, findQuicklogEntry, completeQuicklogEntry } from '../quicklog.js'
34
+ import { getRule } from '../stage-contract-spec.js'
35
+ import { archiveDestDirName } from '../stage-contract.js'
36
+
37
+ /**
38
+ * 清洗项目名:只保留 ASCII 字母/数字/横线/下划线/点,过滤中文和特殊字符。
39
+ * - 必须含至少一个字母(拒绝纯数字 "0"/"7"/"07",避免 scan-projects.json 脏数据)
40
+ * - 长度必须 ≥ 2(拒绝单字符 "a"/"0")
41
+ * @param {string} name - 原始项目名候选
42
+ * @returns {string | null} 合法项目名或 null(拒绝)
43
+ */
44
+ export function sanitizeProjectName(name) {
45
+ if (!name) return null
46
+ const clean = String(name).replace(/[^a-zA-Z0-9_\-.]/g, '').trim()
47
+ if (!clean) return null
48
+ if (!/[a-zA-Z]/.test(clean)) return null // 纯数字/符号拒绝("0"/"7"/"07")
49
+ if (clean.length < 2) return null // 单字符拒绝("a"/"0")
50
+ return clean
51
+ }
52
+ /**
53
+ * 校验从 step 2 解析出的项目列表。
54
+ * 不通过则不落盘 projects/*.yaml、不展开 perProject 步骤。
55
+ *
56
+ * @param {Array<{id: string, path?: string}>} projects - 项目列表(含可选 path)
57
+ * @param {string} sourceRoot - 源码根目录,用于 path 安全校验
58
+ */
59
+ export function validateParsedProjects(projects, sourceRoot) {
60
+ const errors = []
61
+ if (!projects || projects.length === 0) {
62
+ return { ok: false, errors: ['项目列表为空'] }
63
+ }
64
+ if (projects.length > 10) {
65
+ return { ok: false, errors: [`项目数量 ${projects.length} 超过上限 10,疑似误解析`] }
66
+ }
67
+ const safeRoot = resolve(sourceRoot)
68
+ const seen = new Set()
69
+ for (const proj of projects) {
70
+ const id = proj.id || proj
71
+ if (seen.has(id)) {
72
+ errors.push(`重复项目名: ${id}`)
73
+ }
74
+ seen.add(id)
75
+ // slug 合法性:只允许 a-z 0-9 _ - .,长度 2-64
76
+ if (!/^[a-zA-Z][\w\-.]{1,63}$/.test(id)) {
77
+ errors.push(`项目名 "${id}" 不合法(需 slug 格式:字母开头,只含 a-zA-Z0-9_-., 长度 2-64)`)
78
+ }
79
+ // path 安全校验(如果提供了 path)
80
+ if (proj.path) {
81
+ if (proj.path.includes('..')) {
82
+ errors.push(`项目 "${id}" 的 path 包含 .. ,拒绝越界`)
83
+ } else {
84
+ const absPath = resolve(safeRoot, proj.path)
85
+ const rel = relative(safeRoot, absPath)
86
+ if (rel.startsWith('..') || isAbsolute(rel)) {
87
+ errors.push(`项目 "${id}" 的 path "${proj.path}" 解析后超出 source_root`)
88
+ }
89
+ if (!existsSync(absPath)) {
90
+ errors.push(`项目 "${id}" 的 path "${proj.path}" 不存在`)
91
+ }
92
+ }
93
+ }
94
+ }
95
+ if (errors.length > 0) return { ok: false, errors }
96
+ return { ok: true, errors: [] }
97
+ }
98
+ /**
99
+ * 在 changes/archive/ 下查找变更 <changeName> 是否已被归档(archive 脱钩自愈用)。
100
+ *
101
+ * 归档目录名 = archiveDestDirName(date, changeName) = `<归档日期>-<剥前导日期的描述>`;
102
+ * 但手动 / 部分流程归档可能保留原 changeName(含前导日期)或用别的日期前缀。
103
+ * 按「描述部分」(剥前导 YYYY-MM-DD-)匹配,并要求目录含 plan.md(归档必备产物,
104
+ * 防止同名巧合误判)。issue: archive-stage-physical-tracking-desync。
105
+ *
106
+ * @param {string} archiveDir - changes/archive 绝对路径
107
+ * @param {string} changeName - 变更名(currentChange)
108
+ * @returns {string|null} 命中的归档目录绝对路径,无则 null
109
+ */
110
+ export function findAlreadyArchivedDir(archiveDir, changeName) {
111
+ if (!existsSync(archiveDir)) return null
112
+ let entries
113
+ try {
114
+ entries = readdirSync(archiveDir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name)
115
+ } catch {
116
+ return null
117
+ }
118
+ const descOf = (n) => String(n).replace(/^\d{4}-\d{2}-\d{2}-/, '')
119
+ const targetDesc = descOf(changeName)
120
+ if (!targetDesc) return null
121
+ // 1. 精确原名命中(手动 mv 保留原 changeName)
122
+ const exact = entries.find((e) => e === changeName && existsSync(join(archiveDir, e, 'plan.md')))
123
+ if (exact) return join(archiveDir, exact)
124
+ // 2. 描述部分命中(archiveDestDirName 产出的 <date>-<desc>,或手动剥前导日期的目录)
125
+ const descMatch = entries.find((e) => descOf(e) === targetDesc && existsSync(join(archiveDir, e, 'plan.md')))
126
+ if (descMatch) return join(archiveDir, descMatch)
127
+ return null
128
+ }
129
+
130
+ /**
131
+ * 归档时清理可能残留的 worktree(execute 自动清理未走到 / 有未 apply 变更被遗弃)。
132
+ * 安全策略:有未 apply 变更时保留 worktree 并警告,避免误删用户未应用的代码。
133
+ * 同时清理该变更的 execute / stage-review runId marker(只写不删的累积物,
134
+ * 见 stage.js execute 启动固定 executeRunId / stage-review.js stageReviewMarkerPath)。
135
+ * 从 archiveChangeDirectory 抽出,供正常归档 + 自愈归档复用。
136
+ */
137
+ async function archiveWorktreeCleanup(cwd, archiveChangeName, specBase, platformOpts = {}) {
138
+ // ── 清理 runId marker:execute(current-execute-run-id-<change>)与 stage-review
139
+ // (current-stage-review-run-id-<stage>-<change>)。marker 只服务 execute→verify→archive
140
+ // 期间,归档后无读者;不删则 .runtime 随变更数无限累积。runtimeRoot 解析同写入侧
141
+ // (resolveRuntimeRoot 锚主仓),避免平台模式清理错位置。
142
+ try {
143
+ const runtimeRoot = resolveRuntimeRoot(platformOpts, specBase)
144
+ if (existsSync(runtimeRoot)) {
145
+ const markers = readdirSync(runtimeRoot).filter((f) =>
146
+ f === `current-execute-run-id-${archiveChangeName}` ||
147
+ (f.startsWith('current-stage-review-run-id-') && f.endsWith(`-${archiveChangeName}`)),
148
+ )
149
+ for (const m of markers) {
150
+ try { unlinkSync(join(runtimeRoot, m)) } catch {}
151
+ }
152
+ if (markers.length > 0) {
153
+ console.log(`🧹 归档清理 ${markers.length} 个 runId marker(execute/stage-review)`)
154
+ }
155
+ }
156
+ } catch (e) {
157
+ console.warn(`⚠️ 归档 runId marker 清理失败(不阻断归档): ${e.message}`)
158
+ }
159
+ try {
160
+ const { WorktreeManager } = await import('../worktree.js')
161
+ const wm = new WorktreeManager({ cwd })
162
+ const meta = wm.getMeta(archiveChangeName)
163
+ if (!meta) return
164
+ const check = meta.mode !== 'in-place-fallback' ? wm.hasUnappliedChanges(archiveChangeName) : { hasChanges: false }
165
+ if (check.hasChanges) {
166
+ console.warn(`⚠️ 归档时 worktree 仍有 ${check.changedFiles.length} 个未 apply 变更,保留 worktree`)
167
+ console.warn(` 确认不需要后手动清理: sillyspec worktree cleanup ${archiveChangeName} --force`)
168
+ return
169
+ }
170
+ const cleanResult = wm.cleanup(archiveChangeName)
171
+ if (cleanResult.residual?.length > 0) {
172
+ console.warn(`⚠️ 归档 worktree 清理残留: ${cleanResult.residual.join('; ')}`)
173
+ console.warn(` 手动处理: sillyspec worktree cleanup ${archiveChangeName} --force`)
174
+ }
175
+ } catch (e) {
176
+ console.warn(`⚠️ 归档 worktree 清理失败(不阻断归档): ${e.message}`)
177
+ }
178
+ }
179
+
180
+ export async function archiveChangeDirectory(pm, cwd, progress, specBase, platformOpts = {}) {
181
+ const archiveChangeName = progress.currentChange
182
+ if (!archiveChangeName) {
183
+ console.error('❌ 归档失败:未找到当前变更名(currentChange)')
184
+ process.exit(1)
185
+ }
186
+ const changesDir = join(specBase, 'changes')
187
+ const archiveDir = join(changesDir, 'archive')
188
+ const srcDir = join(changesDir, archiveChangeName)
189
+ const date = new Date().toISOString().slice(0, 10)
190
+ const destName = archiveDestDirName(date, archiveChangeName)
191
+ const destDir = join(archiveDir, destName)
192
+
193
+ if (!existsSync(srcDir)) {
194
+ // 幂等自愈(issue archive-stage-physical-tracking-desync):源目录已不存在,但变更可能已被
195
+ // 手动 / 部分流程移到 changes/archive/ 并 commit,而 --done --confirm 从未正式跑完 → 进度 DB
196
+ // 卡 archive 阶段、active 列表仍列此 change。检测到已归档则回填进度(unregisterChange)并
197
+ // 成功返回,让收尾流程把 archive 阶段标完成,而非 exit(1) 死路(source 已移走无法重跑 move)。
198
+ const alreadyArchivedDir = findAlreadyArchivedDir(archiveDir, archiveChangeName)
199
+ if (alreadyArchivedDir) {
200
+ console.log(`ℹ️ 源目录不存在但变更已在 archive/(${basename(alreadyArchivedDir)}),判定已归档,自愈进度 DB`)
201
+ pm.unregisterChange(cwd, archiveChangeName)
202
+ await archiveWorktreeCleanup(cwd, archiveChangeName, specBase, platformOpts)
203
+ console.log(`📦 已自愈归档:${archiveChangeName} → archive/${basename(alreadyArchivedDir)}/`)
204
+ return alreadyArchivedDir
205
+ }
206
+ console.error(`❌ 归档失败:源目录不存在 ${srcDir}`)
207
+ console.error(` 且 changes/archive/ 下未找到该变更的归档目录。若已手动归档请核对目录名;否则先补全变更产物。`)
208
+ process.exit(1)
209
+ }
210
+ // 移动前硬校验:变更包必须含 plan.md,否则不该归档。
211
+ // 在移动前阻断(而非移动后),目录尚未动,用户可直接修复后重试。
212
+ if (!existsSync(join(srcDir, 'plan.md'))) {
213
+ console.error(`❌ 归档失败:变更目录缺少 plan.md(${srcDir})`)
214
+ console.error(` plan.md 是归档的必需产物。请先补全 plan 阶段产出再归档。`)
215
+ process.exit(1)
216
+ }
217
+ if (existsSync(destDir)) {
218
+ console.error(`❌ 归档失败:目标目录已存在 ${destDir}`)
219
+ process.exit(1)
220
+ }
221
+ mkdirSync(archiveDir, { recursive: true })
222
+ try {
223
+ renameSyncRetry(srcDir, destDir)
224
+ } catch (e) {
225
+ console.error(`❌ 归档失败:移动变更目录时出错(${e.message})`)
226
+ console.error(` 常见原因:变更目录内文件被 IDE / 杀毒 / 索引占用,已重试 5 次仍失败。请关闭相关程序后重试。`)
227
+ process.exit(1)
228
+ }
229
+
230
+ if (!existsSync(destDir) || existsSync(srcDir)) {
231
+ console.error('❌ 归档校验失败:移动操作异常')
232
+ process.exit(1)
233
+ }
234
+
235
+ pm.unregisterChange(cwd, archiveChangeName)
236
+
237
+ // CLI 下沉 git add(坑4,FR-04):确定性暂存归档目录 + 模块文档,不靠 step5 prompt 驱动。
238
+ // step5 prompt 的 git add 保留作幂等兜底;POSIX 路径跨平台(git 接受正斜杠)。
239
+ // safeGit 内部已 try-catch(返回 {value,error} 不抛),外层 try 兜底防御;失败不阻断归档
240
+ // (目录已移动 + change 已注销),由 step5 prompt git add + agent git status 核对兜底。
241
+ try {
242
+ safeGit(cwd, ['add', '--', '.sillyspec/changes/archive/'])
243
+ safeGit(cwd, ['add', '--', '.sillyspec/docs/'])
244
+ } catch {}
245
+
246
+ // 归档时清理可能残留的 worktree(自愈路径也复用,见上方 srcDir 缺失分支)。
247
+ await archiveWorktreeCleanup(cwd, archiveChangeName, specBase, platformOpts)
248
+
249
+ console.log(`📦 已归档:${archiveChangeName} → archive/${destName}/`)
250
+ return destDir
251
+ }
252
+ /**
253
+ * archive 阶段「确认归档」步骤的收尾处理器(从 completeStep 抽出,行为保持)。
254
+ *
255
+ * 两件事:
256
+ * 1. --confirm 门控:缺 --confirm → 回退 step 状态、提示、返回 early-return 对象(completeStep 透传)
257
+ * 2. --confirm 通过 → archiveChangeDirectory 移动变更目录 + 推荐文档(design.md / module-impact.md)校验
258
+ * (contracts.archive.validators 为空,两个 validator 生效窗口互斥;plan.md 已在移动前硬校验阻断)
259
+ *
260
+ * @returns {{stageCompleted:false,currentIdx,nextPendingIdx:number}|null}
261
+ */
262
+ export async function handleArchiveConfirmStep({ stageName, steps, currentIdx, confirm, outputText, pm, cwd, progress, changeName, specBase, platformOpts = {} }) {
263
+ if (stageName !== 'archive' || steps[currentIdx]?.name !== '确认归档') return null
264
+ if (!confirm) {
265
+ steps[currentIdx].status = 'pending'
266
+ steps[currentIdx].completedAt = null
267
+ if (outputText) steps[currentIdx].output = null
268
+ pm._write(cwd, progress, changeName)
269
+ console.log('⚠️ 请添加 --confirm 确认归档,例如:sillyspec run archive --done --confirm --output "确认归档"')
270
+ return { stageCompleted: false, currentIdx, nextPendingIdx: currentIdx }
271
+ }
272
+ const archivedDir = await archiveChangeDirectory(pm, cwd, progress, specBase, platformOpts)
273
+ if (archivedDir && existsSync(archivedDir)) {
274
+ const recommendedDocs = ['design.md', 'module-impact.md']
275
+ const missingRecommended = recommendedDocs.filter(d => !existsSync(join(archivedDir, d)))
276
+ if (missingRecommended.length > 0) {
277
+ console.warn(`\n⚠️ 归档校验警告:归档目录缺少推荐文档`)
278
+ for (const d of missingRecommended) console.warn(` - ${d}(${archivedDir})`)
279
+ } else {
280
+ console.log(`\n✅ 归档校验通过:核心文档齐全`)
281
+ }
282
+ }
283
+ return null
284
+ }
285
+ /**
286
+ * plan 阶段「generate_plan」步骤完成后,动态插入任务蓝图(coordinator)+ postcheck 步骤
287
+ * (从 completeStep 抽出,行为保持)。使用稳定 id 匹配,不依赖中文标题。
288
+ *
289
+ * plan.md 已含任务时,buildPlanSteps 返回 [fixedPrefix(classify/generate_plan/review_plan),
290
+ * coordinator, postcheck];本函数把 generate_plan 之后的 coordinator+postcheck 插到当前步后。
291
+ */
292
+ export async function handlePlanGeneratePlanStep({ stageName, steps, currentIdx, defStepsForCurrent, cwd, progress }) {
293
+ if (stageName !== 'plan') return
294
+ const currentStepDef = defStepsForCurrent?.[currentIdx]
295
+ const currentStepEntry = steps[currentIdx]
296
+ const stepId = currentStepDef?.id || currentStepEntry?.id || currentStepEntry?._stepId
297
+ if (stepId !== 'generate_plan') return
298
+ const changeDir = resolveChangeDir(cwd, progress)
299
+ if (!changeDir) return
300
+ const planFile = join(changeDir, 'plan.md')
301
+ if (!existsSync(planFile)) return
302
+ const planContent = readFileSync(planFile, 'utf8')
303
+ const { buildPlanSteps, fixedPrefix, fixedSuffix } = await import('../stages/plan.js')
304
+ const fullSteps = buildPlanSteps(changeDir, planContent)
305
+ const prefixLen = fixedPrefix.length
306
+ const suffixLen = fixedSuffix.length
307
+ // 新结构:[...fixedPrefix, coordinatorStep?, postcheckStep?];fixedSuffix 为空,coordinator+postcheck 都在 prefix 之后
308
+ const coordinatorSteps = fullSteps.slice(prefixLen, suffixLen > 0 ? -suffixLen : undefined)
309
+ if (coordinatorSteps.length === 0) return
310
+ for (let i = 0; i < coordinatorSteps.length; i++) {
311
+ const stepDef = coordinatorSteps[i]
312
+ const stepEntry = {
313
+ id: stepDef.id,
314
+ name: stepDef.name,
315
+ status: 'pending',
316
+ prompt: stepDef.prompt || '',
317
+ outputHint: stepDef.outputHint,
318
+ optional: stepDef.optional
319
+ }
320
+ // 传递 noAI / _cliAction 属性
321
+ if (stepDef.noAI) stepEntry.noAI = true
322
+ if (stepDef._cliAction) stepEntry._cliAction = stepDef._cliAction
323
+ steps.splice(currentIdx + 1 + i, 0, stepEntry)
324
+ }
325
+ console.log(` 📝 已动态插入 ${coordinatorSteps.length} 个步骤(${coordinatorSteps.map(s => s.name).join(', ')})`)
326
+ }
327
+ /**
328
+ * scan 阶段 step 2「构建扫描项目列表」完成后,按项目展开 perProject 步骤(从 completeStep 抽出,行为保持)。
329
+ *
330
+ * 只接受结构化输出(scan_projects YAML block 或 BEGIN_PROJECT_LIST 标记块),校验通过后
331
+ * 自动注册 projects/<id>.yaml + 写 scan-projects.json + 把 perProject 步骤按项目展开。
332
+ * 不展开 completeStep 提前 return(失败只记 validationError,由 completeStep 继续推进下一步)。
333
+ */
334
+ export async function handleScanProjectListStep({ stageName, steps, currentIdx, outputText, stageData, specBase, cwd, platformOpts }) {
335
+ if (stageName !== 'scan' || steps[currentIdx]?.name !== '构建扫描项目列表') return
336
+ // 解析项目列表:只接受结构化输出(YAML block 或 BEGIN_PROJECT_LIST 标记)
337
+ // 不再从自由文本猜测项目名——自由文本列表的误解析会导致垃圾项目落盘
338
+ let parsedProjects = [] // Array<{id, path?}>
339
+ let parsedFromStructuredOutput = false
340
+ if (outputText) {
341
+ // 格式 A: YAML block — 匹配 scan_projects: 下所有 - id: xxx 条目(含多行属性)
342
+ const yamlBlock = outputText.match(/scan_projects:\s*\n([\s\S]+?)(?=$|\n[^\s])/)
343
+ if (yamlBlock) {
344
+ const entries = [...yamlBlock[1].matchAll(/-\s+id:\s*(\S+)(?:[\s\S]*?)(?=\n\s+-\s+id:|$)/g)]
345
+ for (const m of entries) {
346
+ const id = sanitizeProjectName(m[1])
347
+ if (!id) continue
348
+ // 提取可选 path 字段
349
+ const pathMatch = m[0].match(/path:\s*(\S+)/)
350
+ const entry = pathMatch ? { id, path: pathMatch[1].trim() } : { id }
351
+ parsedProjects.push(entry)
352
+ }
353
+ parsedFromStructuredOutput = parsedProjects.length > 0
354
+ }
355
+ // 格式 B: BEGIN_PROJECT_LIST ... END_PROJECT_LIST 标记块
356
+ if (!parsedFromStructuredOutput) {
357
+ const blockMatch = outputText.match(/BEGIN_PROJECT_LIST\s*\n([\s\S]*?)\n*END_PROJECT_LIST/)
358
+ if (blockMatch) {
359
+ const raw = [...blockMatch[1].matchAll(/^-\s+(\S+)/gm)].map(m => m[1])
360
+ parsedProjects = raw.map(s => sanitizeProjectName(s)).filter(Boolean).map(id => ({ id }))
361
+ parsedFromStructuredOutput = parsedProjects.length > 0
362
+ }
363
+ }
364
+ }
365
+
366
+ const projectNames = parsedProjects.map(p => p.id)
367
+
368
+ if (parsedFromStructuredOutput) {
369
+ stageData.scanMeta = stageData.scanMeta || {}
370
+ stageData.scanMeta.projectListParsed = true
371
+ } else {
372
+ // 结构化输出未解析到 → 回退读取已有 projects/*.yaml
373
+ // 读取时也校验:path 不存在的 yaml 视为垃圾,直接跳过
374
+ console.warn('⚠️ step 2 未输出结构化项目列表,回退扫描已注册项目')
375
+ stageData.scanMeta = stageData.scanMeta || {}
376
+ stageData.scanMeta.projectListParsed = false
377
+ const projectsDir = join(specBase, 'projects')
378
+ if (existsSync(projectsDir)) {
379
+ const yamlFiles = readdirSync(projectsDir).filter(f => f.endsWith('.yaml'))
380
+ const fallbackProjects = []
381
+ const fallbackSkipped = []
382
+ for (const yf of yamlFiles) {
383
+ const pName = yf.replace(/\.yaml$/, '')
384
+ const yamlContent = readFileSync(join(projectsDir, yf), 'utf8')
385
+ const pathMatch = yamlContent.match(/^path:\s*(.+)/m)
386
+ const pPath = pathMatch ? pathMatch[1].trim() : pName
387
+ // 校验 path 是否存在且在 source_root 内
388
+ const absPath = resolve(cwd, pPath)
389
+ if (pPath.includes('..') || (!absPath.startsWith(resolve(cwd)) && absPath !== resolve(cwd))) {
390
+ fallbackSkipped.push(`${pName} (path 越界: ${pPath})`)
391
+ continue
392
+ }
393
+ if (!existsSync(absPath)) {
394
+ fallbackSkipped.push(`${pName} (path 不存在: ${pPath})`)
395
+ continue
396
+ }
397
+ fallbackProjects.push({ id: pName, path: pPath })
398
+ }
399
+ if (fallbackSkipped.length > 0) {
400
+ console.warn(`⚠️ 跳过 ${fallbackSkipped.length} 个垃圾/过期项目配置:${fallbackSkipped.join(', ')}`)
401
+ console.warn(' 建议清理 projects/ 下的无效 yaml 文件')
402
+ }
403
+ parsedProjects = fallbackProjects
404
+ projectNames.length = 0
405
+ projectNames.push(...fallbackProjects.map(p => p.id))
406
+ }
407
+ if (parsedProjects.length === 0) {
408
+ // 无结构化输出 + 无合法已有项目 → step 2 失败
409
+ console.error('❌ step 2 未输出结构化项目列表,且 projects/ 下无合法项目配置')
410
+ console.error(' 请在 --output 中输出 scan_projects YAML block 或 BEGIN_PROJECT_LIST 标记块')
411
+ steps[currentIdx].validationError = '未输出结构化项目列表且无合法 fallback'
412
+ // 不展开 perProject 步骤,直接跳到下一步
413
+ }
414
+ }
415
+
416
+ // 校验解析出的项目列表(原子守卫:不通过就不落盘)
417
+ const validation = validateParsedProjects(parsedProjects, cwd)
418
+ if (!validation.ok) {
419
+ console.error(`❌ 项目列表校验失败: ${validation.errors.join('; ')}`)
420
+ console.error(' step 2 完成,但不展开 perProject 步骤。请检查 --output 中的项目列表。')
421
+ steps[currentIdx].validationError = validation.errors.join('; ')
422
+ }
423
+
424
+ // 自动注册 + 保存 runtime + 展开 perProject 步骤(仅在校验通过时)
425
+ const projectsDir = join(specBase, 'projects')
426
+ if (validation.ok) {
427
+ for (const proj of parsedProjects) {
428
+ const pName = proj.id
429
+ const projYaml = join(projectsDir, `${pName}.yaml`)
430
+ if (!existsSync(projYaml)) {
431
+ mkdirSync(projectsDir, { recursive: true })
432
+ const candidates = [
433
+ join(cwd, pName),
434
+ join(cwd, 'backend', pName),
435
+ join(cwd, 'packages', pName),
436
+ join(cwd, 'apps', pName),
437
+ join(cwd, 'services', pName),
438
+ ]
439
+ const detected = candidates.find(c => existsSync(c))
440
+ const regPath = detected || join(cwd, pName)
441
+ writeFileSync(projYaml, `name: ${pName}\npath: ${regPath}\nstatus: active\n`)
442
+ console.log(` 📝 自动注册子项目: ${pName} → ${regPath}`)
443
+ }
444
+ }
445
+
446
+ // 保存 runtime 状态
447
+ const scanStatePath = join(specBase, '.runtime', 'scan-projects.json')
448
+ mkdirSync(join(specBase, '.runtime'), { recursive: true })
449
+ let scanState = { projects: projectNames, expanded: false }
450
+ if (existsSync(scanStatePath)) {
451
+ try { scanState = JSON.parse(readFileSync(scanStatePath, 'utf8')) } catch {}
452
+ }
453
+
454
+ // 收集当前步骤之后所有 perProject 步骤
455
+ const stageDef = stageRegistry[stageName]
456
+ const allSteps = stageDef?.steps || []
457
+ const perProjectSteps = allSteps.filter(s => s.perProject)
458
+
459
+ // 防重复展开
460
+ const alreadyExpanded = scanState.expanded || steps.some(s => s.name?.match(/\[.+\]\s*$/))
461
+ if (!alreadyExpanded && perProjectSteps.length > 0) {
462
+ // 找到当前步骤(step 2)在动态 steps 中的位置
463
+ const insertBase = currentIdx + 1
464
+ let insertPos = insertBase
465
+ for (const pName of projectNames) {
466
+ // 读取项目配置获取 projectRoot
467
+ const projYaml = join(specBase, 'projects', `${pName}.yaml`)
468
+ let projectRoot = '.'
469
+ if (existsSync(projYaml)) {
470
+ const yamlContent = readFileSync(projYaml, 'utf8')
471
+ const pathMatch = yamlContent.match(/^path:\s*(.+)/m)
472
+ if (pathMatch) projectRoot = pathMatch[1].trim()
473
+ }
474
+ const docOutputDir = platformOpts.specRoot ? `${specBase}/docs/${pName}` : `.sillyspec/docs/${pName}`
475
+ const contextPrefix = `\n---\n## 当前项目\n- **项目名**: ${pName}\n- **项目路径**: ${projectRoot}\n- **文档输出**: ${docOutputDir}\n\n⚠️ 本步骤只处理上面这个项目,不要处理其他项目。\n---\n\n`
476
+
477
+ for (const ppStep of perProjectSteps) {
478
+ steps.splice(insertPos, 0, {
479
+ name: `${ppStep.name} [${pName}]`,
480
+ project: pName,
481
+ status: 'pending',
482
+ prompt: contextPrefix + ppStep.prompt,
483
+ outputHint: ppStep.outputHint,
484
+ optional: ppStep.optional
485
+ })
486
+ insertPos++
487
+ }
488
+ }
489
+ // 移除原始的 perProject 步骤(未展开的版本)
490
+ for (let i = steps.length - 1; i >= 0; i--) {
491
+ if (steps[i].perProject && !steps[i].name?.includes('[')) {
492
+ steps.splice(i, 1)
493
+ }
494
+ }
495
+ console.log(` 📝 已按项目展开 ${perProjectSteps.length} 个步骤 × ${projectNames.length} 个项目 = ${perProjectSteps.length * projectNames.length} 个项目步骤`)
496
+ console.log(` 📁 扫描项目:${projectNames.join(', ')}`)
497
+ // 标记已展开,防止 resume 重复插入
498
+ scanState.expanded = true
499
+ writeFileSync(scanStatePath, JSON.stringify(scanState))
500
+ } // end !alreadyExpanded
501
+ } // end validation.ok
502
+ }
503
+ /**
504
+ * Workflow post_check(W6 Step6 从 completeStep 内联块抽出):
505
+ * - scan「深度扫描」完成 → 跑 scan-docs workflow postcheck,失败阻断推进(early-return)
506
+ * - archive「extract-module-impact」完成 → 跑 archive-impact workflow postcheck(impact-analyzer 结果)
507
+ * 返回 early-return 对象({stageCompleted:false,...})由 completeStep 透传;null = 放行。
508
+ *
509
+ * ctx 字段:stageName/steps/currentIdx/cwd/specBase/progress/platformOpts/changeName(completeStep 局部)。
510
+ * 辅助函数直接 import:basename/join/existsSync/readdirSync(顶部静态);loadWorkflow/runPostCheck/
511
+ * formatCheckReport/saveWorkflowRun 动态 import ../workflow.js(真环依赖保留动态)。
512
+ *
513
+ *搬迁清理:删除死代码 `typeof change !== 'undefined'`(completeStep 作用域无 change 变量,恒 null)。
514
+ */
515
+ export async function handleWorkflowPostCheck({ stageName, steps, currentIdx, cwd, specBase, progress, platformOpts, changeName }) {
516
+ // Workflow post_check:scan 深度扫描完成后自动检查产物
517
+ if (stageName === 'scan' && steps[currentIdx]?.name?.includes('深度扫描')) {
518
+ try {
519
+ const { loadWorkflow, runPostCheck, formatCheckReport, saveWorkflowRun } = await import('../workflow.js')
520
+ const wf = loadWorkflow(cwd, 'scan-docs')
521
+ if (wf) {
522
+ // 确定当前项目(优先级链):
523
+ // progress.project (dbProjectName,平台模式真实项目名,与 outputStep 占位符渲染对齐)
524
+ // > change?.project (变更对象的项目字段,平台模式 change 创建时传入)
525
+ // > steps[idx].project (perProject 展开标记,兼容旧模式)
526
+ // > steps[idx].name 正则提取 [xxx] 后缀
527
+ // > null(回退检查所有项目)
528
+ // task-05 修复:日志显示项目名变 frontend 是 perProject 误展开 bug,
529
+ // 用 progress.project(与 outputStep 占位符渲染路径一致)修正 myaaa/frontend 分裂。
530
+ const currentProjectName = progress.project
531
+ || steps[currentIdx].project
532
+ || (steps[currentIdx].name.match(/\[([^\]]+)\]\s*$/) || [])[1]
533
+ || null
534
+
535
+ // 确定要检查的项目列表
536
+ let projectsToCheck = []
537
+ if (currentProjectName) {
538
+ // 按项目展开模式:只检查当前项目
539
+ projectsToCheck = [currentProjectName]
540
+ } else {
541
+ // 兼容旧模式(未展开):检查所有项目
542
+ const projectsDir = join(specBase, 'projects')
543
+ const projectFiles = existsSync(projectsDir)
544
+ ? readdirSync(projectsDir).filter(f => f.endsWith('.yaml'))
545
+ : []
546
+ projectsToCheck = projectFiles.map(f => f.replace(/\.yaml$/, ''))
547
+ }
548
+
549
+ let anyFailed = false
550
+ for (const pName of projectsToCheck) {
551
+ const result = runPostCheck(wf, cwd, pName, {}, specBase)
552
+ const report = formatCheckReport(result)
553
+ console.log(report)
554
+ if (result.status === 'fail') {
555
+ anyFailed = true
556
+ // retry_prompts 由 _checkWorkflow 自动生成
557
+ for (const rp of (result.retry_prompts || [])) {
558
+ console.log(`\n🔄 重试提示(项目 ${pName}):\n`)
559
+ console.log(rp.prompt)
560
+ }
561
+ }
562
+ const saved = saveWorkflowRun(result, {
563
+ cwd,
564
+ source: 'run.js',
565
+ stage: 'scan',
566
+ step: steps[currentIdx]?.name,
567
+ ...(platformOpts.runtimeRoot ? { runtimeRoot: platformOpts.runtimeRoot } : {}),
568
+ ...(platformOpts.scanRunId ? { scanRunId: platformOpts.scanRunId } : {})
569
+ })
570
+ if (saved) console.log(`📁 结果已归档:${saved}`)
571
+ }
572
+ if (anyFailed) {
573
+ console.log(`\n⚠️ 存在检查失败项,请按上面的重试提示修复后再继续。`)
574
+ // task-07: 阻断推进(与 task-06 平台模式 scan-postcheck 失败分支 return 结构对齐)
575
+ // scan 深度扫描产物校验未通过时,不允许 clean success / 进入下一 step,
576
+ // 让上层走"完成但不推进"分支,--done 被拒。
577
+ return { stageCompleted: false, currentIdx, nextPendingIdx: currentIdx }
578
+ }
579
+ }
580
+ } catch (e) {
581
+ console.warn(`⚠️ workflow 检查跳过:${e.message}`)
582
+ }
583
+ }
584
+
585
+ // Workflow post_check:archive extract-module-impact 完成后检查产物
586
+ if (stageName === 'archive' && steps[currentIdx]?.name?.includes('extract-module-impact')) {
587
+ try {
588
+ const { loadWorkflow, runPostCheck, formatCheckReport, saveWorkflowRun } = await import('../workflow.js')
589
+ const wf = loadWorkflow(cwd, 'archive-impact')
590
+ if (wf && changeName) {
591
+ const raw = JSON.stringify(wf)
592
+ const resolved = JSON.parse(raw.replace(/<change-name>/g, changeName))
593
+ const result = runPostCheck(resolved, cwd, progress.project || basename(cwd), {}, specBase)
594
+ // 只报告 impact-analyzer 的结果(doc-syncer 是后续步骤)
595
+ const impactResult = (result.roles || []).find(r => r.id === 'impact-analyzer')
596
+ if (impactResult) {
597
+ const icon = impactResult.status === 'pass' ? '✅' : '❌'
598
+ console.log(`${icon} module-impact.md 检查${impactResult.status === 'pass' ? '通过' : '失败'}`)
599
+ for (const f of (result.failures || []).filter(f => f.role_id === 'impact-analyzer')) {
600
+ console.log(` └─ ${f}`)
601
+ }
602
+ }
603
+ const saved = saveWorkflowRun(result, {
604
+ cwd,
605
+ source: 'run.js',
606
+ stage: 'archive',
607
+ step: steps[currentIdx]?.name,
608
+ ...(platformOpts.runtimeRoot ? { runtimeRoot: platformOpts.runtimeRoot } : {}),
609
+ ...(platformOpts.scanRunId ? { scanRunId: platformOpts.scanRunId } : {})
610
+ })
611
+ if (saved) console.log(`📁 结果已归档:${saved}`)
612
+ }
613
+ } catch (e) {
614
+ console.warn(`⚠️ workflow 检查跳过:${e.message}`)
615
+ }
616
+ }
617
+ return null
618
+ }
619
+ /**
620
+ * quick 阶段完成收尾(W6 Step6b 从 completeStep 内联块抽出):
621
+ * 强校验 QUICKLOG 条目 + 审计(auditQuickCompletion)+ 结果摘要结构校验 + 翻状态/勾 tasks.md
622
+ * (CLI 接管 QUICKLOG 分配/写入/收尾)。blocked → process.exit(1)(无 early-return,调用点纯 await)。
623
+ *
624
+ * ctx:stageName/steps/currentIdx/cwd/progress/changeName/specBase/outputText/confirm/
625
+ * isForceBaseline/isAllowNew/platformOpts。辅助函数直接 import(safeGit/auditQuickCompletion ← shared,
626
+ * printQuickAuditReview ← quick-audit,4 个 quicklog fns ← quicklog,unlinkSync/rmSync ← fs 静态)。
627
+ */
628
+ export async function handleQuickStageCompletion({ stageName, steps, currentIdx, cwd, progress, changeName, specBase, outputText, confirm, isForceBaseline, isAllowNew, platformOpts, pm }) {
629
+ // quick 收尾:强校验 QUICKLOG 条目 + 翻状态 + 勾 tasks.md(CLI 接管)
630
+ if (stageName === 'quick') {
631
+ // §4.6 从 session guard.json 读 guard(不依赖 progress.quickGuard)。
632
+ // D-003@v1:progress._write 不持久化顶层 quickGuard,跨进程 --done 时读出的 progress 无 quickGuard,
633
+ // 若仍用 if (progress.quickGuard) 驱动收尾会整体跳过,导致 .runtime/quick-sessions/<sessionId>/ 残留僵尸。
634
+ // 改为从文件读 guard:优先 session 目录 guard.json,回退旧单文件 quick-guard.json(task-03 前兼容)。
635
+ // sessionId == changeName == quick-<uuid8>(completeStep 作用域内 changeName 已解构自 options)。
636
+ // session 目录经 resolveQuickSessionsDir 单一解析(multi-agent-review Q4):与 stage.js 写入路径对齐,
637
+ // 平台模式 runtimeRoot 与 specBase/.runtime 不同时不再读不到 guard。
638
+ const sessionGuardFile = join(resolveQuickSessionsDir(platformOpts, specBase), changeName, 'guard.json')
639
+ const legacyGuardFile = join(specBase, '.runtime', 'quick-guard.json')
640
+ let guard = null
641
+ try {
642
+ guard = existsSync(sessionGuardFile)
643
+ ? JSON.parse(readFileSync(sessionGuardFile, 'utf8'))
644
+ : (existsSync(legacyGuardFile) ? JSON.parse(readFileSync(legacyGuardFile, 'utf8')) : null)
645
+ } catch {}
646
+
647
+ // 强校验 / 收尾:本会话必须有一条真实 QUICKLOG 条目(治「报 SAFE 但漏写」bug)。
648
+ // guard 缺失(brownfield:新代码前启动的会话)不阻断——兜底补写一条记录,保住「完成必有记录」不变量。
649
+ const gitUser = safeGit(cwd, ['config', 'user.name']).value || 'unknown'
650
+ let qlId = guard?.quicklogId || null
651
+ const linkedChanges = Array.isArray(guard?.linkedChanges) ? guard.linkedChanges : []
652
+ // 审计结果(仅 guard 存在时填充)。提到 if(guard) 外声明,供下方回填 QUICKLOG 文件行复用
653
+ // review.changedFiles;brownfield 无 guard 时保持 null → 文件行不回填(降级,不报错)。
654
+ let review = null
655
+
656
+ // 审计:仅在有 guard 时跑(brownfield 无 guard 跳过,兼容 D-003 brownfield 行为)。
657
+ // task-02:mergedGuard 提升到 if 外声明,供下方并发预检钩子复用与 auditQuickCompletion
658
+ // 同源的 guard 字段(baselineFiles/linkedChanges)。brownfield 无 guard 时保持 null,
659
+ // 钩子 ownFiles/linkedChanges 走 ?? [] 兜底(D-003 不抛 TypeError)。
660
+ let mergedGuard = null
661
+ if (guard) {
662
+ // --done 的 --force-baseline/--allow-new 并入 guard(与 step1 持久化值取或)。
663
+ // 修复 ql-20260713-002-7628:旧代码解析了这两个 flag 但只传 {isConfirm} 给审计,
664
+ // 致 --done --force-baseline 静默无效、用户被误导「重跑 --confirm」也无法解锁。
665
+ mergedGuard = {
666
+ ...guard,
667
+ forceBaseline: guard.forceBaseline || isForceBaseline,
668
+ allowNew: guard.allowNew || isAllowNew,
669
+ }
670
+ review = await auditQuickCompletion(cwd, mergedGuard, { isConfirm: confirm })
671
+ printQuickAuditReview(review)
672
+ if (review.status === 'blocked') {
673
+ steps[currentIdx].status = 'pending'
674
+ steps[currentIdx].completedAt = null
675
+ if (outputText) steps[currentIdx].output = null
676
+ process.exit(1)
677
+ }
678
+ progress.lastQuickReview = review
679
+ }
680
+
681
+ // task-02 并发预检(FR-05/FR-07,纯副作用 advisory):auditQuickCompletion 返回后、推进前
682
+ // 扫工作树,识别他者未提交改动 / 他者脏变更目录,有则 console.warn。不改 status/gate、
683
+ // 不 exit、不 return early(FR-07 铁律)。ownFiles 必含 baselineFiles(D-001:脏工作树 quick
684
+ // 完成时本会话 baseline 不被他者误报);review/mergedGuard 均可能为 null(brownfield 无 guard,
685
+ // D-003),用 ?. + ?? [] 兜底防 spread undefined 抛 TypeError(B-005)。整钩子 try/catch 隔离
686
+ // ——detect 本就 fail-open,保守再包一层,任何异常只吞不 bubble,主完成流程不受影响。
687
+ try {
688
+ const ownFiles = [
689
+ ...(review?.changedFiles ?? []),
690
+ ...(mergedGuard?.baselineFiles ?? []),
691
+ ]
692
+ const detected = detectConcurrentChanges(cwd, {
693
+ changeName,
694
+ linkedChanges: mergedGuard?.linkedChanges ?? [],
695
+ ownFiles,
696
+ })
697
+ const warn = formatConcurrentWarning(detected)
698
+ if (warn) console.warn(warn)
699
+ } catch (e) {
700
+ // fail-open:并发预检异常绝不阻断主完成流程(FR-07)。
701
+ }
702
+
703
+ // 结果摘要结构校验(最后一步、isDone 且带了 --output 时):--output 是 QUICKLOG「结果:」
704
+ // 归档的唯一来源,要求按 需求/根因/方案/结果 模板给全(见 stages/quick.js step3 prompt)。
705
+ // 确定性校验:只查必填字段是否齐全,不判内容质量。缺字段 → 本次不完成(回滚 step 状态 +
706
+ // exit 1),保留「进行中」条目,agent 补全 --output 后重跑 --done 即可,不丢进度。
707
+ // 仅 completeQuicklogEntry 会实际持久化时才校验;前两个 step 的 --done output 不入 QUICKLOG,不校验。
708
+ // Q6:quick 末步 --done 必须带 --output(四字段结果是 QUICKLOG「结果:」唯一来源)。缺则回退 pending +
709
+ // exit(1),不静默落空结果条目(原 if(outputText) 守卫致 outputText 为空时跳过校验,completeQuicklogEntry
710
+ // 又用 outputText||'' 兜底 → 结果块为空却翻「已完成」)。handleQuickStageCompletion 仅在阶段完成(=末步)时触发。
711
+ const isLastStep = currentIdx === steps.length - 1
712
+ if (isLastStep && !outputText) {
713
+ console.error('\n❌ quick 最后一步 --done 必须带 --output(四字段结果模板)。')
714
+ console.error(' --output 是 QUICKLOG「结果:」归档的唯一来源。补全后重跑 --done(不丢进度),照抄模板:')
715
+ console.error(' sillyspec run quick --done --change <changeName> --output "需求:… 根因:… 方案:… 结果:…"')
716
+ steps[currentIdx].status = 'pending'
717
+ steps[currentIdx].completedAt = null
718
+ process.exit(1)
719
+ }
720
+ if (outputText) {
721
+ const resultCheck = validateQuickResult(outputText)
722
+ if (!resultCheck.ok) {
723
+ console.error('\n' + getRule('quick.result-labels').failMessage.replaceAll('${missing}', resultCheck.missing.join('、')))
724
+ console.error(` --output 是 QUICKLOG「结果:」归档的唯一来源,四个标签必须放在 --output 里(不是 --input)。`)
725
+ console.error(` 补全后重跑 --done(不丢进度),直接照抄此模板:`)
726
+ console.error(` sillyspec run quick --done --change <changeName> --output "需求:用户/任务要什么`)
727
+ console.error(` 根因:为什么这样改(纯新增/样式则写「无,纯新增/纯样式」)`)
728
+ console.error(` 方案:怎么改的`)
729
+ console.error(` 结果:验证情况(测试数 / lint / typecheck / 部署状态)"`)
730
+ steps[currentIdx].status = 'pending'
731
+ steps[currentIdx].completedAt = null
732
+ if (outputText) steps[currentIdx].output = null
733
+ process.exit(1)
734
+ }
735
+ }
736
+
737
+ if (!qlId) {
738
+ // 无 ql-ID(guard 缺失或 brownfield 无 quicklogId):补分配后立即完成,不阻断。
739
+ try {
740
+ const alloc = await allocateQuicklogEntry(specBase, gitUser, {
741
+ description: guard?.taskDescription || '(补分配)',
742
+ linkedChanges,
743
+ allowedFiles: Array.isArray(guard?.allowedFiles) ? guard.allowedFiles : [],
744
+ })
745
+ qlId = alloc.qlId
746
+ console.log(`📝 QUICKLOG 兜底补写: ${qlId}(guard 缺失/brownfield 会话)`)
747
+ } catch (e) {
748
+ console.error(`\n❌ QUICKLOG 补分配失败: ${e.message}`)
749
+ steps[currentIdx].status = 'pending'
750
+ steps[currentIdx].completedAt = null
751
+ if (outputText) steps[currentIdx].output = null
752
+ process.exit(1)
753
+ }
754
+ }
755
+ if (!findQuicklogEntry(specBase, gitUser, qlId)) {
756
+ console.error(`\n❌ quick 阶段完成校验失败:QUICKLOG 条目 ${qlId} 不存在。`)
757
+ console.error(` 会话期间记录被删除或从未写入。请检查 .sillyspec/quicklog/ 后重跑 --done。`)
758
+ steps[currentIdx].status = 'pending'
759
+ steps[currentIdx].completedAt = null
760
+ if (outputText) steps[currentIdx].output = null
761
+ process.exit(1)
762
+ }
763
+ // 翻状态进行中→已完成 + 追加结果 + 勾选关联 tasks.md
764
+ // resultText 不再截断:结构化结果块(需求/根因/方案/结果)完整落盘,多行写成字段化块。
765
+ try {
766
+ // 回填实际改动文件:review.changedFiles 含 quick 自身元数据(quicklog/.runtime/modules 等),
767
+ // 用 isQuickMetadata 过滤掉,只留真实业务文件。brownfield 无 review → 空数组,文件行不动。
768
+ const realFiles = Array.isArray(review?.changedFiles)
769
+ ? review.changedFiles.filter(f => !isQuickMetadata(f, linkedChanges))
770
+ : []
771
+ await completeQuicklogEntry(specBase, gitUser, qlId, {
772
+ resultText: outputText || '',
773
+ linkedChanges,
774
+ changedFiles: realFiles,
775
+ })
776
+ console.log(`📝 QUICKLOG 条目 ${qlId} 已标记完成`)
777
+ } catch (e) {
778
+ console.warn(`⚠️ QUICKLOG 完成态写入失败: ${e.message}`)
779
+ }
780
+
781
+ // 清理 session 目录(rmSync/unlinkSync 容忍不存在)。路径与写入对齐(Q4 resolveQuickSessionsDir)。
782
+ try {
783
+ if (changeName) {
784
+ const sessionDir = join(resolveQuickSessionsDir(platformOpts, specBase), changeName)
785
+ rmSync(sessionDir, { recursive: true, force: true })
786
+ }
787
+ if (existsSync(legacyGuardFile)) unlinkSync(legacyGuardFile)
788
+ } catch {}
789
+
790
+ // 注销 quick 会话注册的 changes 行(quick-<uuid8>),避免 active 行随每次 quick 单调累积污染
791
+ // listChanges / doctor / resolveQuickLinkedChanges(multi-agent-review Q1)。quick 是收尾型会话,
792
+ // 不走 archive,旧代码从不调 unregisterChange → DB 里 active 的 quick-<hex> 行只增不减。
793
+ // 仅对 quick-<8hex> sessionId 注销——非 sessionId 形态的变更名是旧兼容路径/真实关联变更,
794
+ // 误注销会把用户真实变更标 archived(与 command.js:569 sessionId 守卫同形正则)。
795
+ if (changeName && /^quick-[0-9a-f]{8}$/.test(changeName)) {
796
+ try {
797
+ pm.unregisterChange(cwd, changeName)
798
+ } catch (e) {
799
+ console.warn(`⚠️ 注销 quick 会话 changes 行失败(不阻断完成): ${e.message}`)
800
+ }
801
+ }
802
+ }
803
+ return null
804
+ }
805
+ /**
806
+ * execute「Wave N 执行」步骤完成后扫 worktree 提取 provider endpoint artifact(W6 Step6c 从
807
+ * completeStep 内联块抽出)。供 verify 阶段 parity 对账 + consumer task 上游契约注入。
808
+ * 接线自 contract-matrix pipeline。step 级(每个 Wave 执行步骤后跑),无 early-return(try/catch warn)。
809
+ *
810
+ * ctx:stageName/steps/currentIdx/changeName/specBase/cwd。extractArtifactsForChange ← 动态 ../contract-matrix.js,
811
+ * WorktreeManager ← 动态 ../worktree.js(真环依赖保留动态)。
812
+ */
813
+ export async function handleExecuteWaveArtifact({ stageName, steps, currentIdx, changeName, specBase, cwd }) {
814
+ if (stageName === 'execute' && /^Wave \d+ 执行$/.test(steps[currentIdx]?.name || '')) {
815
+ try {
816
+ const { extractArtifactsForChange } = await import('../contract-matrix.js')
817
+ let worktreePath = null
818
+ try {
819
+ const { WorktreeManager } = await import('../worktree.js')
820
+ const meta = new WorktreeManager({ cwd }).getMeta(changeName)
821
+ if (meta?.worktreePath && existsSync(meta.worktreePath)) worktreePath = meta.worktreePath
822
+ } catch {}
823
+ const msg = extractArtifactsForChange({ changeDir: join(specBase, 'changes', changeName), specBase, changeName, worktreePath })
824
+ if (msg) console.log(msg)
825
+ } catch (e) { console.warn(`⚠️ 契约 artifact 提取跳过: ${e?.message || e}`) }
826
+ }
827
+ return null
828
+ }
829
+
830
+ /**
831
+ * execute 阶段完成时条件性清理 worktree(W6 Step6c 从 completeStep 完成路径内联块抽出)。
832
+ * 不依赖 AI agent 的完成确认步骤:有未 apply 变更 → 保留 worktree;否则 cleanup(含 in-place 安全清理)。
833
+ * stage 级(execute 阶段全部完成时跑),无 early-return(try/catch warn)。
834
+ *
835
+ * ctx:stageName/changeName/cwd。WorktreeManager ← 动态 ../worktree.js。
836
+ */
837
+ export async function handleExecuteWorktreeCleanup({ stageName, changeName, cwd }) {
838
+ if (stageName === 'execute' && changeName) {
839
+ try {
840
+ const { WorktreeManager } = await import('../worktree.js');
841
+ const wm = new WorktreeManager({ cwd });
842
+ const meta = wm.getMeta(changeName);
843
+ if (!meta) {
844
+ console.log('🔗 Worktree: n/a (no meta)');
845
+ } else if (meta.mode === 'native-worktree') {
846
+ console.log('🔗 Worktree: kept (外部隔离环境)');
847
+ } else {
848
+ // in-place 模式不再短路:cleanup 现在能安全处理 in-place(只清 meta,不碰主工作区)
849
+ const check = wm.hasUnappliedChanges(changeName);
850
+ if (check.hasChanges) {
851
+ console.log(`🔗 Worktree: pending apply (${check.changedFiles.length} 个未应用变更)`);
852
+ console.log(` 下一步: sillyspec worktree apply ${changeName}`);
853
+ } else {
854
+ const cleanResult = wm.cleanup(changeName);
855
+ console.log(`🔗 Worktree: ${cleanResult.result}`);
856
+ if (cleanResult.residual?.length > 0) {
857
+ console.warn(` ⚠️ 清理残留: ${cleanResult.residual.join('; ')}`);
858
+ console.warn(` 手动处理: sillyspec worktree cleanup ${changeName} --force`);
859
+ } else if (cleanResult.details?.length > 0) {
860
+ for (const d of cleanResult.details) {
861
+ if (d.startsWith('⚠️')) console.log(` ${d}`);
862
+ }
863
+ }
864
+ }
865
+ }
866
+ } catch (e) {
867
+ console.warn(`🔗 Worktree: check failed — ${e.message}`);
868
+ }
869
+ }
870
+ return null
871
+ }
872
+ /**
873
+ * scan 阶段完成后处理(W6 Step6d 从 completeStep 完成路径内联块抽出):
874
+ * - 平台模式(specRoot/runtimeRoot):写 manifest.json + 跑 scan-postcheck + 结构化结果 +
875
+ * 更新平台指针(SCAN_COMPLETED)+ failed_post_check 阻断(exit 1 / early-return)
876
+ * - 非平台模式:轻量 postcheck + 结构化结果写 .runtime/
877
+ * 返回 early-return 对象(platform failed_post_check 非 exit 路径)由 completeStep 透传;null = 放行。
878
+ *
879
+ * ctx:stageName/currentIdx/cwd/progress/pm/stageData/changeName/outputText/platformOpts。
880
+ * safeGit/triggerSync ← shared;writeAtomicSync ← fs-atomic;SCAN_STATUS/POINTER_STATUS ← constants;
881
+ * mkdirSync/writeFileSync/readFileSync/unlinkSync/join ← 顶部静态;runScanPostCheck 等 ← 动态 ../scan-postcheck.js。
882
+ *
883
+ * 搬迁清理:删 4 个冗余动态 builtin import(fs/path/child_process,execSync 死代码)+ _readFileSync 别名改回 readFileSync。
884
+ */
885
+ export async function handleScanStageCompleted({ stageName, currentIdx, cwd, progress, pm, stageData, changeName, outputText, platformOpts }) {
886
+ // 平台模式:scan 完成后生成 manifest.json + post-check
887
+ if (stageName === 'scan' && (platformOpts.specRoot || platformOpts.runtimeRoot)) {
888
+ try {
889
+ stageData.scanMeta = stageData.scanMeta || {}; stageData.scanMeta.manifestWritten = false; // 默认失败
890
+ const manifestDir = platformOpts.specRoot
891
+ mkdirSync(manifestDir, { recursive: true })
892
+ let sourceCommit = null
893
+ let sourceCommitError = null
894
+ try {
895
+ const gitResult = safeGit(cwd, ['rev-parse', 'HEAD'])
896
+ sourceCommit = gitResult.value
897
+ sourceCommitError = gitResult.error
898
+ } catch (e) {
899
+ sourceCommitError = e.message
900
+ }
901
+ const manifest = {
902
+ workspace_id: platformOpts.workspaceId || null,
903
+ scan_run_id: platformOpts.scanRunId || null,
904
+ source_root: cwd,
905
+ spec_root: platformOpts.specRoot || null,
906
+ runtime_root: platformOpts.runtimeRoot || null,
907
+ source_commit: sourceCommit,
908
+ source_commit_error: sourceCommit === null ? (sourceCommitError || 'unknown') : undefined,
909
+ generated_at: new Date().toISOString(),
910
+ schema_version: 1,
911
+ scan_profile: stageData.scanProfile
912
+ ? { mode: stageData.scanProfile.mode, reason: stageData.scanProfile.reason }
913
+ : null,
914
+ postcheck_result_path: null,
915
+ workflow_runs_dir: platformOpts.runtimeRoot
916
+ ? join(platformOpts.runtimeRoot, 'scan-runs', platformOpts.scanRunId || 'unknown', 'workflow-runs')
917
+ : null,
918
+ platform_pointer_path: join(cwd, '.sillyspec-platform.json'),
919
+ platform_pointer_status: POINTER_STATUS.ACTIVE,
920
+ }
921
+ const manifestPath = join(manifestDir, 'manifest.json')
922
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n')
923
+ console.log(`📄 manifest.json 已写入: ${manifestPath}`)
924
+ stageData.scanMeta = stageData.scanMeta || {}; stageData.scanMeta.manifestWritten = true;
925
+ if (!sourceCommit) {
926
+ console.log(`⚠️ source_commit 无法获取(可能非 git 目录),已设为 null`)
927
+ }
928
+ // 清理平台参数临时文件
929
+ const platformOptsFile = join(manifestDir, '.runtime', 'platform-scan.json')
930
+ try { unlinkSync(platformOptsFile) } catch {}
931
+
932
+ // CLI 层 post-check(替代旧的简单检查)
933
+ const { runScanPostCheck, printScanPostCheckResult, formatStructuredResult, writeStructuredResult } = await import('../scan-postcheck.js')
934
+ const postResult = runScanPostCheck({
935
+ cwd,
936
+ specDir: platformOpts.specRoot,
937
+ outputText,
938
+ scanMeta: {
939
+ projectListParsed: stageData.scanMeta?.projectListParsed ?? null,
940
+ manifestWritten: stageData.scanMeta?.manifestWritten ?? null,
941
+ },
942
+ scanProfile: stageData.scanProfile || null,
943
+ })
944
+ printScanPostCheckResult(postResult)
945
+
946
+ // 生成结构化 JSON 并写入 runtime(供 SillyHub 消费)
947
+ const structured = formatStructuredResult(postResult, {
948
+ workspace_id: platformOpts.workspaceId,
949
+ scan_run_id: platformOpts.scanRunId,
950
+ source_root: cwd,
951
+ spec_root: platformOpts.specRoot,
952
+ runtime_root: platformOpts.runtimeRoot,
953
+ })
954
+ const postcheckJsonPath = writeStructuredResult(structured, platformOpts.specRoot, {
955
+ runtimeRoot: platformOpts.runtimeRoot,
956
+ scanRunId: platformOpts.scanRunId,
957
+ })
958
+ if (postcheckJsonPath) {
959
+ console.log(`📄 postcheck-result.json 已写入: ${postcheckJsonPath}`)
960
+ manifest.postcheck_result_path = postcheckJsonPath
961
+ }
962
+
963
+ // 将 post-check 结果写入 manifest
964
+ manifest.scan_post_check = {
965
+ status: postResult.status,
966
+ checks: postResult.checks,
967
+ }
968
+ // 更新 manifest
969
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n')
970
+ console.log(`📄 manifest.json 已更新(含 post-check 结果)`)
971
+
972
+ // 更新平台指针状态为 scan_completed
973
+ const pointerPath = join(cwd, '.sillyspec-platform.json')
974
+ try {
975
+ const pointer = JSON.parse(readFileSync(pointerPath, 'utf8'))
976
+ pointer.status = POINTER_STATUS.SCAN_COMPLETED
977
+ pointer.completedAt = new Date().toISOString()
978
+ pointer.scanStatus = postResult.status
979
+ writeAtomicSync(pointerPath, JSON.stringify(pointer, null, 2) + '\n')
980
+ } catch (e) {
981
+ // 不阻断 scan 主流程,但暴露失败——pointer 写失败会让平台看不到 scan_completed,
982
+ // 与项目 fail-loud 原则一致:宁可可见地 warn,也不静默吞错。
983
+ console.warn(`⚠️ 更新平台指针状态失败(scan_completed 可能未落盘): ${e.message}`)
984
+ }
985
+
986
+ // failed_post_check 时强制阻止 clean success
987
+ if (postResult.status === 'failed_post_check') {
988
+ stageData.status = SCAN_STATUS.FAILED_POST_CHECK
989
+ stageData.completedAt = new Date().toLocaleString('zh-CN',{hour12:false})
990
+ pm._write(cwd, progress, changeName)
991
+ triggerSync(cwd, changeName, platformOpts)
992
+ console.error(`\n❌ scan post-check 失败,状态设为 failed_post_check。不允许 clean success。`)
993
+ console.error(` 请检查上方错误信息并修复后重新 scan。`)
994
+ // 平台模式:exit(1) 让 daemon/SillyHub 感知非 0 退出码(manifest.json 已落盘,不会被撤销)
995
+ if (platformOpts.specRoot || platformOpts.runtimeRoot) {
996
+ console.error(' 平台模式:CLI 将以 exit code 1 退出,通知 SillyHub scan 失败。')
997
+ process.exit(1)
998
+ }
999
+ // 接口与 plan contract (run.js:2551 附近 plan 失败分支) 对齐:
1000
+ // 返回 { stageCompleted:false, currentIdx, nextPendingIdx: currentIdx }
1001
+ // 让上层 runStage 走"完成但不推进"分支,--done 被拒
1002
+ return { stageCompleted: false, currentIdx, nextPendingIdx: currentIdx }
1003
+ } else if (postResult.status === 'completed_with_warnings') {
1004
+ // 警告不阻止完成,但记录
1005
+ stageData.status = 'completed'
1006
+ stageData.completedAt = new Date().toLocaleString('zh-CN',{hour12:false})
1007
+ pm._write(cwd, progress, changeName)
1008
+ }
1009
+ } catch (e) {
1010
+ console.warn(`⚠️ manifest.json 写入失败: ${e.message}`)
1011
+ }
1012
+ }
1013
+
1014
+ // 非 platform 模式 scan 也做轻量 post-check + 结构化输出
1015
+ if (stageName === 'scan' && !platformOpts.specRoot && !platformOpts.runtimeRoot) {
1016
+ const { runScanPostCheck, printScanPostCheckResult, formatStructuredResult, writeStructuredResult } = await import('../scan-postcheck.js')
1017
+ const postResult = runScanPostCheck({ cwd, specDir: null, outputText, scanProfile: stageData.scanProfile || null })
1018
+ printScanPostCheckResult(postResult)
1019
+ // 结构化结果写入 .sillyspec/.runtime/
1020
+ const structured = formatStructuredResult(postResult, { source_root: cwd })
1021
+ const postcheckJsonPath = writeStructuredResult(structured, join(cwd, '.sillyspec'))
1022
+ if (postcheckJsonPath) {
1023
+ console.log(`📄 postcheck-result.json 已写入: ${postcheckJsonPath}`)
1024
+ }
1025
+ }
1026
+ return null
1027
+ }
1028
+