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