sillyspec 3.25.0 → 3.25.2
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/.claude/skills/sillyspec-doctor/SKILL.md +9 -0
- package/.playwright-mcp/console-2026-07-27T15-45-50-621Z.log +1 -0
- package/.playwright-mcp/page-2026-07-27T15-45-51-459Z.yml +144 -0
- package/.playwright-mcp/prompt-final.png +0 -0
- package/.playwright-mcp/prompt-view.png +0 -0
- package/CLAUDE.md +18 -0
- package/package.json +1 -1
- package/src/change-risk-profile.js +60 -12
- package/src/check-primitives.js +65 -0
- package/src/db.js +28 -8
- package/src/hooks/worktree-guard.js +9 -2
- package/src/index.js +140 -11
- package/src/machine-interface.js +13 -7
- package/src/progress/change-registry.js +217 -0
- package/src/progress/consistency-doctor.js +259 -0
- package/src/progress/shared.js +33 -0
- package/src/progress/stage-machine.js +535 -0
- package/src/progress/step-store.js +256 -0
- package/src/progress.js +75 -1191
- package/src/quicklog.js +3 -1
- package/src/run/command.js +1088 -0
- package/src/run/complete-handlers.js +874 -0
- package/src/run/complete.js +725 -0
- package/src/run/gates.js +326 -0
- package/src/run/prompt.js +619 -0
- package/src/run/quick-audit.js +107 -0
- package/src/run/scan-profile.js +238 -0
- package/src/run/shared.js +418 -0
- package/src/run/stage.js +414 -0
- package/src/run.js +23 -4379
- package/src/stage-contract-engine.js +150 -0
- package/src/stage-contract-spec.js +517 -0
- package/src/stage-contract.js +87 -150
- package/src/stage-review.js +63 -0
- package/src/stages/brainstorm.js +3 -1
- package/src/stages/execute.js +5 -2
- package/src/stages/plan-postcheck.js +38 -27
- package/src/stages/plan.js +12 -49
- package/src/stages/propose.js +2 -1
- package/src/stages/scan.js +3 -1
- package/src/workflow.js +9 -17
- package/src/worktree-apply.js +5 -2
- package/src/worktree-deps.js +114 -32
- package/src/worktree.js +23 -11
- package/src/brainstorm-postcheck.js +0 -158
package/src/run.js
CHANGED
|
@@ -1,4379 +1,23 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* sillyspec run
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
import
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const tplPath = join(__dirname, '..', 'templates', 'prompts', `${name}.md`)
|
|
25
|
-
if (!existsSync(tplPath)) {
|
|
26
|
-
console.warn(`⚠️ prompt include 模板缺失: ${name} (期望: ${tplPath})`)
|
|
27
|
-
return match
|
|
28
|
-
}
|
|
29
|
-
return readFileSync(tplPath, 'utf8')
|
|
30
|
-
})
|
|
31
|
-
}
|
|
32
|
-
import { ProgressManager } from './progress.js'
|
|
33
|
-
import { SCAN_STATUS, POINTER_STATUS, isPointerCorrupted } from './constants.js'
|
|
34
|
-
import { allocateQuicklogEntry, completeQuicklogEntry, findQuicklogEntry, validateQuickResult } from './quicklog.js'
|
|
35
|
-
import { checkbox } from '@inquirer/prompts'
|
|
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
|
-
/**
|
|
54
|
-
* 校验从 step 2 解析出的项目列表。
|
|
55
|
-
* 不通过则不落盘 projects/*.yaml、不展开 perProject 步骤。
|
|
56
|
-
*
|
|
57
|
-
* @param {Array<{id: string, path?: string}>} projects - 项目列表(含可选 path)
|
|
58
|
-
* @param {string} sourceRoot - 源码根目录,用于 path 安全校验
|
|
59
|
-
*/
|
|
60
|
-
export function validateParsedProjects(projects, sourceRoot) {
|
|
61
|
-
const errors = []
|
|
62
|
-
if (!projects || projects.length === 0) {
|
|
63
|
-
return { ok: false, errors: ['项目列表为空'] }
|
|
64
|
-
}
|
|
65
|
-
if (projects.length > 10) {
|
|
66
|
-
return { ok: false, errors: [`项目数量 ${projects.length} 超过上限 10,疑似误解析`] }
|
|
67
|
-
}
|
|
68
|
-
const safeRoot = resolve(sourceRoot)
|
|
69
|
-
const seen = new Set()
|
|
70
|
-
for (const proj of projects) {
|
|
71
|
-
const id = proj.id || proj
|
|
72
|
-
if (seen.has(id)) {
|
|
73
|
-
errors.push(`重复项目名: ${id}`)
|
|
74
|
-
}
|
|
75
|
-
seen.add(id)
|
|
76
|
-
// slug 合法性:只允许 a-z 0-9 _ - .,长度 2-64
|
|
77
|
-
if (!/^[a-zA-Z][\w\-.]{1,63}$/.test(id)) {
|
|
78
|
-
errors.push(`项目名 "${id}" 不合法(需 slug 格式:字母开头,只含 a-zA-Z0-9_-., 长度 2-64)`)
|
|
79
|
-
}
|
|
80
|
-
// path 安全校验(如果提供了 path)
|
|
81
|
-
if (proj.path) {
|
|
82
|
-
if (proj.path.includes('..')) {
|
|
83
|
-
errors.push(`项目 "${id}" 的 path 包含 .. ,拒绝越界`)
|
|
84
|
-
} else {
|
|
85
|
-
const absPath = resolve(safeRoot, proj.path)
|
|
86
|
-
const rel = relative(safeRoot, absPath)
|
|
87
|
-
if (rel.startsWith('..') || isAbsolute(rel)) {
|
|
88
|
-
errors.push(`项目 "${id}" 的 path "${proj.path}" 解析后超出 source_root`)
|
|
89
|
-
}
|
|
90
|
-
if (!existsSync(absPath)) {
|
|
91
|
-
errors.push(`项目 "${id}" 的 path "${proj.path}" 不存在`)
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
if (errors.length > 0) return { ok: false, errors }
|
|
97
|
-
return { ok: true, errors: [] }
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* 在容器/Docker 环境下,git 可能因目录所有权不匹配报 dubious ownership。
|
|
102
|
-
* 使用 -c safe.directory= 临时参数,不污染全局 git config。
|
|
103
|
-
* @param {string} cwd - 仓库根目录
|
|
104
|
-
* @param {string[]} args - git 子命令及参数,如 ['rev-parse', 'HEAD']
|
|
105
|
-
* @returns {{ value: string, error: string|null }}
|
|
106
|
-
*/
|
|
107
|
-
function safeGit(cwd, args) {
|
|
108
|
-
const { execSync } = require('child_process')
|
|
109
|
-
const fullArgs = ['-c', `safe.directory=${cwd}`, '-C', cwd, ...args]
|
|
110
|
-
try {
|
|
111
|
-
const value = execSync(['git', ...fullArgs].join(' '), { encoding: 'utf8', timeout: 5000 }).trim()
|
|
112
|
-
return { value, error: null }
|
|
113
|
-
} catch (e) {
|
|
114
|
-
return { value: null, error: e.message.split('\n')[0] }
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// ── Wait State Constants ──
|
|
119
|
-
// 正则匹配:只识别独立一行的标记,避免误伤文档正文引用
|
|
120
|
-
const WAIT_MARKER_RE = /^\s*\[(WAIT_FOR_USER|NEEDS_CONFIRM|NEEDS_DECISION)\]\s*$/m
|
|
121
|
-
const WAIT_MARKERS = ['[WAIT_FOR_USER]', '[NEEDS_CONFIRM]', '[NEEDS_DECISION]']
|
|
122
|
-
|
|
123
|
-
/**
|
|
124
|
-
* 格式化 waitOptions 为人类可读字符串
|
|
125
|
-
*/
|
|
126
|
-
function formatWaitOptions(raw) {
|
|
127
|
-
if (!raw) return null
|
|
128
|
-
try {
|
|
129
|
-
const parsed = JSON.parse(raw)
|
|
130
|
-
if (Array.isArray(parsed)) return parsed.join(', ')
|
|
131
|
-
return raw
|
|
132
|
-
} catch {
|
|
133
|
-
return raw
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* 格式化 repeatableWait 步骤的历史用户回答,注入到重新输出的 step prompt 前。
|
|
139
|
-
* @param {object} step - progress 中的 step 对象(含 waitAnswers 数组)
|
|
140
|
-
* @returns {string|null} 格式化的历史文本,或 null(无历史)
|
|
141
|
-
*/
|
|
142
|
-
function formatWaitHistory(step) {
|
|
143
|
-
const answers = Array.isArray(step.waitAnswers) ? step.waitAnswers : []
|
|
144
|
-
if (answers.length === 0) return null
|
|
145
|
-
let text = `本步骤历史用户回答(共 ${answers.length} 轮):\n`
|
|
146
|
-
for (const item of answers) {
|
|
147
|
-
text += `\n${item.round}. ${item.answer}`
|
|
148
|
-
if (item.question) {
|
|
149
|
-
text += `\n 对应问题/摘要:${item.question}`
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
const maxRounds = step.maxWaitRounds || null
|
|
153
|
-
if (maxRounds && answers.length >= maxRounds) {
|
|
154
|
-
text += `\n\n已达到 maxWaitRounds=${maxRounds}。请基于以上回答总结需求;除非仍有阻塞问题,否则完成本步骤并进入方案讨论。`
|
|
155
|
-
} else {
|
|
156
|
-
text += `\n\n请判断信息是否足够:如果足够,完成本步骤;如果仍缺关键约束,再提出一个问题并 --wait。`
|
|
157
|
-
}
|
|
158
|
-
return text
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/**
|
|
162
|
-
* 解析规范目录路径
|
|
163
|
-
* 向上查找含 .sillyspec 的祖先目录,类似 git 找 .git 的逻辑。
|
|
164
|
-
* @param {string} cwd - 项目根目录(或子目录)
|
|
165
|
-
* @param {object} [opts]
|
|
166
|
-
* @param {string} [opts.specDir] - 用户指定的 specDir(通过 --spec-dir 或 --spec-root)
|
|
167
|
-
* @returns {string} 规范目录的绝对路径
|
|
168
|
-
*/
|
|
169
|
-
function resolveSpecDir(cwd, opts = {}) {
|
|
170
|
-
if (opts.specDir) return resolve(opts.specDir)
|
|
171
|
-
let dir = resolve(cwd)
|
|
172
|
-
while (true) {
|
|
173
|
-
const candidate = join(dir, '.sillyspec')
|
|
174
|
-
if (existsSync(candidate)) return candidate
|
|
175
|
-
const parent = dirname(dir)
|
|
176
|
-
if (parent === dir) break
|
|
177
|
-
dir = parent
|
|
178
|
-
}
|
|
179
|
-
return join(resolve(cwd), '.sillyspec')
|
|
180
|
-
}
|
|
181
|
-
import { stageRegistry, auxiliaryStages } from './stages/index.js'
|
|
182
|
-
import { checkTransition, runValidators, validateChangeExists } from './stage-contract.js'
|
|
183
|
-
import { buildExecuteSteps } from './stages/execute.js'
|
|
184
|
-
import { buildPlanSteps } from './stages/plan.js'
|
|
185
|
-
import { formatExecuteSummary } from './worktree-apply.js'
|
|
186
|
-
import { classifyChange } from './classify-change.js'
|
|
187
|
-
import { detectRiskProfile } from './change-risk-profile.js'
|
|
188
|
-
import { definition as brainstormAutoDef } from './stages/brainstorm-auto.js'
|
|
189
|
-
|
|
190
|
-
/**
|
|
191
|
-
* 从 _module-map.yaml 读取模块上下文索引
|
|
192
|
-
* 用于 brainstorm/plan/execute 阶段按任务命中模块精准注入上下文
|
|
193
|
-
*
|
|
194
|
-
* @param {string} specBase - 规范目录(.sillyspec 或 specRoot)
|
|
195
|
-
* @param {string} projectName - 项目名
|
|
196
|
-
* @returns {object|null} 解析后的模块索引,null 表示无索引
|
|
197
|
-
*/
|
|
198
|
-
function loadModuleContextIndex(specBase, projectName) {
|
|
199
|
-
try {
|
|
200
|
-
const { existsSync, readFileSync } = require('fs')
|
|
201
|
-
const { join } = require('path')
|
|
202
|
-
const mapPath = join(specBase, 'docs', projectName, 'modules', '_module-map.yaml')
|
|
203
|
-
if (!existsSync(mapPath)) return null
|
|
204
|
-
const content = readFileSync(mapPath, 'utf8')
|
|
205
|
-
return parseModuleMapSimple(content)
|
|
206
|
-
} catch {
|
|
207
|
-
return null
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
/**
|
|
212
|
-
* 根据 AI 输出的任务描述,匹配相关模块并生成上下文注入文本
|
|
213
|
-
* 匹配策略:模块 id / role / doc 路径中的关键词
|
|
214
|
-
*
|
|
215
|
-
* @param {string} taskDescription - 任务描述(来自 plan.md / step prompt / outputText)
|
|
216
|
-
* @param {object} moduleIndex - loadModuleContextIndex 返回值
|
|
217
|
-
* @param {string} specBase - 规范目录
|
|
218
|
-
* @param {string} projectName - 项目名
|
|
219
|
-
* @returns {string} 上下文注入文本,空字符串表示无匹配模块
|
|
220
|
-
*/
|
|
221
|
-
function buildModuleContextInjection(taskDescription, moduleIndex, specBase, projectName) {
|
|
222
|
-
if (!moduleIndex || !taskDescription) return ''
|
|
223
|
-
const { existsSync } = require('fs')
|
|
224
|
-
const { join } = require('path')
|
|
225
|
-
|
|
226
|
-
const taskLower = taskDescription.toLowerCase()
|
|
227
|
-
const matched = []
|
|
228
|
-
|
|
229
|
-
for (const [moduleId, data] of Object.entries(moduleIndex)) {
|
|
230
|
-
let score = 0
|
|
231
|
-
let matchReasons = []
|
|
232
|
-
// 模块 id 匹配
|
|
233
|
-
if (taskLower.includes(moduleId.toLowerCase())) { score += 3; matchReasons.push(`id:${moduleId}`) }
|
|
234
|
-
// role 描述匹配
|
|
235
|
-
if (data.role && taskLower.includes(data.role.toLowerCase())) { score += 2; matchReasons.push('role') }
|
|
236
|
-
// core_files 路径匹配
|
|
237
|
-
const coreFiles = data.paths || data.core_files || []
|
|
238
|
-
for (const p of coreFiles) {
|
|
239
|
-
if (taskLower.includes(p.toLowerCase())) { score += 1; matchReasons.push(`file:${p}`); break }
|
|
240
|
-
}
|
|
241
|
-
if (score > 0) matched.push({ moduleId, data, score, matchReasons })
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
if (matched.length === 0) return ''
|
|
245
|
-
|
|
246
|
-
matched.sort((a, b) => b.score - a.score)
|
|
247
|
-
|
|
248
|
-
let injection = '\n### 📦 模块上下文(按相关性排序,来自 Module Context Index)\n\n'
|
|
249
|
-
injection += `> 以下模块上下文由 scan 阶段生成的 _module-map.yaml 自动匹配。\n`
|
|
250
|
-
injection += `> Matched modules: ${matched.map(m => m.moduleId).join(', ')}\n`
|
|
251
|
-
injection += `> Reasons: ${matched.map(m => m.matchReasons.join(', ')).join('; ')}\n\n`
|
|
252
|
-
|
|
253
|
-
for (const { moduleId, data } of matched) {
|
|
254
|
-
injection += `#### ${moduleId}\n`
|
|
255
|
-
if (data.role) injection += `- **职责**: ${String(data.role).slice(0, 100)}\n`
|
|
256
|
-
const riskLevel = data.risk_level || 'medium'
|
|
257
|
-
injection += `- **风险等级**: ${riskLevel}\n`
|
|
258
|
-
const coreFiles = data.paths || data.core_files || []
|
|
259
|
-
if (coreFiles.length > 0) injection += `- **核心文件**: ${coreFiles.join(', ')}\n`
|
|
260
|
-
if (data.doc) {
|
|
261
|
-
const docPath = join(specBase, 'docs', projectName, data.doc)
|
|
262
|
-
const exists = existsSync(docPath)
|
|
263
|
-
injection += `- **模块文档**: ${data.doc}${exists ? ' ✅' : ' ⚠️ 不存在'}\n`
|
|
264
|
-
}
|
|
265
|
-
const deps = data.depends_on || []
|
|
266
|
-
if (deps.length > 0) injection += `- **依赖**: ${deps.join(', ')}\n`
|
|
267
|
-
const usedBy = data.used_by || []
|
|
268
|
-
if (usedBy.length > 0) injection += `- **被引用**: ${usedBy.join(', ')}\n`
|
|
269
|
-
injection += '\n'
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
return injection
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
// 复用 modules.js 的简单 YAML 解析(避免循环依赖)
|
|
276
|
-
function parseModuleMapSimple(content) {
|
|
277
|
-
const modules = {}
|
|
278
|
-
let currentModule = null
|
|
279
|
-
let currentKey = null
|
|
280
|
-
let currentArray = null
|
|
281
|
-
|
|
282
|
-
for (const line of content.split('\n')) {
|
|
283
|
-
const moduleMatch = line.match(/^ ([a-zA-Z0-9_-]+):$/)
|
|
284
|
-
if (moduleMatch) {
|
|
285
|
-
if (currentArray && currentModule && currentKey) {
|
|
286
|
-
modules[currentModule][currentKey] = currentArray
|
|
287
|
-
}
|
|
288
|
-
currentModule = moduleMatch[1]
|
|
289
|
-
modules[currentModule] = {}
|
|
290
|
-
currentKey = null
|
|
291
|
-
currentArray = null
|
|
292
|
-
continue
|
|
293
|
-
}
|
|
294
|
-
if (!currentModule) continue
|
|
295
|
-
|
|
296
|
-
const arrayFieldMatch = line.match(/^ (depends_on|used_by|paths|tags|aliases|entrypoints|main_symbols|review_reasons|core_files|test_files|related_docs|verify_commands):$/)
|
|
297
|
-
if (arrayFieldMatch) {
|
|
298
|
-
if (currentArray && currentKey) modules[currentModule][currentKey] = currentArray
|
|
299
|
-
currentKey = arrayFieldMatch[1]
|
|
300
|
-
currentArray = []
|
|
301
|
-
continue
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
const inlineArrayMatch = line.match(/^ (depends_on|used_by|paths|tags|aliases|entrypoints|main_symbols|review_reasons|core_files|test_files|related_docs|verify_commands): \[(.*)\]$/)
|
|
305
|
-
if (inlineArrayMatch) {
|
|
306
|
-
if (currentArray && currentKey) modules[currentModule][currentKey] = currentArray
|
|
307
|
-
const vals = inlineArrayMatch[2].split(',').map(v => v.trim()).filter(Boolean)
|
|
308
|
-
modules[currentModule][inlineArrayMatch[1]] = vals
|
|
309
|
-
currentKey = null
|
|
310
|
-
currentArray = null
|
|
311
|
-
continue
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
const scalarMatch = line.match(/^ (status|doc|needs_review|role|risk_level): (.+)$/)
|
|
315
|
-
if (scalarMatch) {
|
|
316
|
-
if (currentArray && currentKey) { modules[currentModule][currentKey] = currentArray; currentArray = null; currentKey = null }
|
|
317
|
-
modules[currentModule][scalarMatch[1]] = scalarMatch[2]
|
|
318
|
-
continue
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
const itemMatch = line.match(/^ - (.+)$/)
|
|
322
|
-
if (itemMatch && currentArray !== null) {
|
|
323
|
-
currentArray.push(itemMatch[1].trim())
|
|
324
|
-
continue
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
if (currentArray && currentModule && currentKey) {
|
|
328
|
-
modules[currentModule][currentKey] = currentArray
|
|
329
|
-
}
|
|
330
|
-
return modules
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
/**
|
|
334
|
-
* 同步触发辅助函数:_write 后 best-effort 同步到平台
|
|
335
|
-
*/
|
|
336
|
-
/**
|
|
337
|
-
* 解析单行 `git status --porcelain` 输出为文件路径。
|
|
338
|
-
* porcelain 格式:`XY<SP>path`(XY = 2 字符状态码)。
|
|
339
|
-
* - 路径含空格/特殊字符时 git 以 C 风格加引号(如 `?? "front end.txt"`),需去引号 + 反转义。
|
|
340
|
-
* - 重命名显示为 `R old -> new`,取新路径(当前存在的文件)。
|
|
341
|
-
* - 反斜杠归一化为正斜杠(Windows 路径)。
|
|
342
|
-
* ⚠️ 调用方不要对【整段多行】输出先 .trim() 再 split——会削掉首行前导空格(首行 status 的空格),
|
|
343
|
-
* 使 slice(3) 误吃首文件路径首字符(实测 ` M frontend/...` → "rontend/...",见 guard.json baseline)。
|
|
344
|
-
* 应直接 split('\n').filter(Boolean),每行单独解析。
|
|
345
|
-
*/
|
|
346
|
-
export function parsePorcelainPath(line) {
|
|
347
|
-
if (!line) return ''
|
|
348
|
-
let path = line.slice(3).trim()
|
|
349
|
-
if (path.length >= 2 && path.startsWith('"') && path.endsWith('"')) {
|
|
350
|
-
path = path.slice(1, -1).replace(/\\(.)/g, (_, c) => c)
|
|
351
|
-
}
|
|
352
|
-
const arrow = path.indexOf(' -> ')
|
|
353
|
-
if (arrow !== -1) path = path.slice(arrow + 4)
|
|
354
|
-
return path.replace(/\\/g, '/')
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
/**
|
|
358
|
-
* quick 完成审计:对比 baseline 与实际变更
|
|
359
|
-
*/
|
|
360
|
-
export async function auditQuickCompletion(cwd, guard, options = {}) {
|
|
361
|
-
const { baselineFiles, allowedFiles = [], allowNew = false, forceBaseline = false } = guard
|
|
362
|
-
const { isConfirm } = options
|
|
363
|
-
const result = { status: 'safe', reasons: [], changedFiles: [], newFiles: [], deletedFiles: [], baselineHit: [] }
|
|
364
|
-
|
|
365
|
-
try {
|
|
366
|
-
const { execSync } = await import('child_process')
|
|
367
|
-
const gitStatus = execSync('git status --porcelain', { cwd, encoding: 'utf8', timeout: 10000 })
|
|
368
|
-
// 不对整段 .trim():会削首行前导空格致首文件路径丢首字符(见 parsePorcelainPath 注释)。
|
|
369
|
-
const currentEntries = gitStatus.split('\n').filter(Boolean)
|
|
370
|
-
|
|
371
|
-
const normalizeGitPath = (p) => p.replace(/\\/g, '/')
|
|
372
|
-
// step1 启动时记录的全量脏文件 = 预存改动(非本次 quick 产生)。审计必须排除它们,
|
|
373
|
-
// 否则脏工作区下预存文件持续留在 git status → 命中 baselineFiles → 误判「覆盖 baseline」
|
|
374
|
-
// → 永远 blocked(--force-baseline 也救不回来,因为 status 判定看 baselineHit 数组)。
|
|
375
|
-
// 前缀匹配:baseline 录入时未跟踪目录会被 git 折叠成 `dir/`(带尾斜杠)token,审计时若该
|
|
376
|
-
// 目录下文件被跟踪则展开成文件级 `dir/file`——精确匹配对不上,故尾斜杠 token 按目录前缀放行。
|
|
377
|
-
const normBaseline = (baselineFiles || []).map(f => normalizeGitPath(f))
|
|
378
|
-
const baselineExact = new Set(normBaseline.filter(f => !f.endsWith('/')))
|
|
379
|
-
const baselineDirs = normBaseline.filter(f => f.endsWith('/'))
|
|
380
|
-
const isBaselineFile = (p) => {
|
|
381
|
-
const f = normalizeGitPath(p)
|
|
382
|
-
return baselineExact.has(f) || baselineDirs.some(d => f.startsWith(d))
|
|
383
|
-
}
|
|
384
|
-
// quick 自己没有 .sillyspec/changes/ 目录——该路径下任何文件要么是关联变更(reverse-sync),
|
|
385
|
-
// 要么是并发其他会话的变更工作。多会话并行时别人的 changes/ 不应被本 quick 审计拦截
|
|
386
|
-
// (确定性校验无法区分「并发工作」与「本 quick 偷建变更」,后者这类意图软判定留给 sillyhub)。
|
|
387
|
-
// 故:非关联变更目录整体放行;关联变更的文件仍走正常审计(reverse-sync 可见,需 --force-baseline)。
|
|
388
|
-
const linkedChangeNames = new Set((Array.isArray(guard.linkedChanges) ? guard.linkedChanges : []).map(c => normalizeGitPath(c)))
|
|
389
|
-
const isQuickMetadata = (p) => {
|
|
390
|
-
const file = normalizeGitPath(p)
|
|
391
|
-
if (file.startsWith('.sillyspec/quicklog/')
|
|
392
|
-
|| file.startsWith('.sillyspec/.runtime/')
|
|
393
|
-
|| file === '.sillyspec/knowledge/uncategorized.md'
|
|
394
|
-
|| (/^\.sillyspec\/docs\/[^/]+\/modules\/[^/]+\.md$/.test(file))
|
|
395
|
-
|| (/^\.sillyspec\/docs\/[^/]+\/modules\/_module-map\.yaml$/.test(file))) return true
|
|
396
|
-
// .sillyspec/changes/ 下:bare 折叠 token(git 把全新未跟踪 changes/ 折叠成 `changes/`)
|
|
397
|
-
// 或具体但【非关联】的变更 → 都属并发其他会话工作,放行;关联变更文件仍走审计。
|
|
398
|
-
if (file.startsWith('.sillyspec/changes/')) {
|
|
399
|
-
const m = file.match(/^\.sillyspec\/changes\/([^/]+)(\/|$)/)
|
|
400
|
-
if (!m || !linkedChangeNames.has(m[1])) return true
|
|
401
|
-
}
|
|
402
|
-
return false
|
|
403
|
-
}
|
|
404
|
-
const DANGEROUS_PATTERNS = [
|
|
405
|
-
'package.json',
|
|
406
|
-
'package-lock.json',
|
|
407
|
-
'yarn.lock',
|
|
408
|
-
'pnpm-lock.yaml',
|
|
409
|
-
'.eslintrc',
|
|
410
|
-
'tsconfig.json',
|
|
411
|
-
'src/db.js',
|
|
412
|
-
'src/progress.js',
|
|
413
|
-
'src/run.js',
|
|
414
|
-
'src/stage-contract.js',
|
|
415
|
-
'src/worktree.js',
|
|
416
|
-
'src/worktree-apply.js',
|
|
417
|
-
'src/hooks/',
|
|
418
|
-
]
|
|
419
|
-
|
|
420
|
-
for (const entry of currentEntries) {
|
|
421
|
-
const status = entry.slice(0, 2).trim()
|
|
422
|
-
const file = parsePorcelainPath(entry) // 已去引号/处理 rename/归一化,修正首行丢首字符
|
|
423
|
-
if (!file) continue
|
|
424
|
-
|
|
425
|
-
// 预存脏文件:step1 baseline 已记录,非本次 quick 产生,跳过审计(含折叠目录前缀匹配)
|
|
426
|
-
if (isBaselineFile(file)) continue
|
|
427
|
-
|
|
428
|
-
result.changedFiles.push(file)
|
|
429
|
-
if (status === 'D' || status === ' D') result.deletedFiles.push(file)
|
|
430
|
-
if (status === '??') result.newFiles.push(file)
|
|
431
|
-
|
|
432
|
-
// 检查是否命中 baseline protected files
|
|
433
|
-
if (baselineFiles.includes(file)) {
|
|
434
|
-
result.baselineHit.push(file)
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
// 检查危险文件(除非 force-baseline)
|
|
438
|
-
if (file.startsWith('.sillyspec/') && !isQuickMetadata(file) && !forceBaseline) {
|
|
439
|
-
result.reasons.push(`危险文件变更: ${file}`)
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
if (DANGEROUS_PATTERNS.some(p => file === p || file.startsWith(p)) && !forceBaseline) {
|
|
443
|
-
result.reasons.push(`危险文件变更: ${file}`)
|
|
444
|
-
}
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
// 检查 deleted files
|
|
448
|
-
for (const f of result.deletedFiles) {
|
|
449
|
-
result.reasons.push(`删除文件: ${f}`)
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
// 检查 baseline hit(除非 force-baseline)
|
|
453
|
-
if (!forceBaseline) {
|
|
454
|
-
for (const f of result.baselineHit) {
|
|
455
|
-
result.reasons.push(`覆盖 baseline 文件: ${f}`)
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
// 检查 new files(除非 allow-new)
|
|
460
|
-
if (!allowNew) {
|
|
461
|
-
for (const f of result.newFiles) {
|
|
462
|
-
if (!isQuickMetadata(f)) {
|
|
463
|
-
result.reasons.push(`新增文件(需 --allow-new): ${f}`)
|
|
464
|
-
}
|
|
465
|
-
}
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
// 检查 allowedFiles 范围
|
|
469
|
-
if (allowedFiles.length > 0) {
|
|
470
|
-
for (const f of result.changedFiles) {
|
|
471
|
-
if (!allowedFiles.includes(f) && !isQuickMetadata(f)) {
|
|
472
|
-
result.reasons.push(`超出 allowedFiles: ${f}`)
|
|
473
|
-
}
|
|
474
|
-
}
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
// 判定结果(force-baseline 降级 baselineHit → 非 blocked;allow-new 降级新增文件 → 非 warning。
|
|
478
|
-
// reasons 文案本就受这两个 flag 控制,但原判定直接看数组长度,致 flag 对 status 失效。)
|
|
479
|
-
if ((!forceBaseline && result.baselineHit.length > 0) || result.deletedFiles.length > 0 || result.reasons.some(r => r.startsWith('危险') || r.startsWith('删除'))) {
|
|
480
|
-
result.status = 'blocked'
|
|
481
|
-
} else if ((!allowNew && result.newFiles.length > 0) || (allowedFiles.length > 0 && result.reasons.some(r => r.startsWith('超出')))) {
|
|
482
|
-
result.status = 'warning'
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
// quicklog 存在性检查
|
|
486
|
-
try {
|
|
487
|
-
const quicklogDir = join(cwd, '.sillyspec', 'quicklog')
|
|
488
|
-
if (existsSync(quicklogDir)) {
|
|
489
|
-
const qlFiles = readdirSync(quicklogDir).filter(f => f.endsWith('.md'))
|
|
490
|
-
if (qlFiles.length === 0) {
|
|
491
|
-
result.reasons.push('quicklog 目录为空(无任务记录)')
|
|
492
|
-
if (result.status === 'safe') result.status = 'warning'
|
|
493
|
-
}
|
|
494
|
-
} else {
|
|
495
|
-
result.reasons.push('quicklog 目录不存在(agent 未创建任务记录)')
|
|
496
|
-
if (result.status === 'safe') result.status = 'warning'
|
|
497
|
-
}
|
|
498
|
-
} catch {}
|
|
499
|
-
|
|
500
|
-
// --confirm 模式:展示 diff 并等待确认
|
|
501
|
-
if (isConfirm && (result.status === 'warning' || result.status === 'blocked')) {
|
|
502
|
-
console.log(`\n📋 quick 变更概览:`)
|
|
503
|
-
console.log(` 新增: ${result.newFiles.length}, 修改: ${result.changedFiles.length - result.newFiles.length - result.deletedFiles.length}, 删除: ${result.deletedFiles.length}`)
|
|
504
|
-
if (result.changedFiles.length > 0) {
|
|
505
|
-
console.log(`\n 变更文件:`)
|
|
506
|
-
for (const f of result.changedFiles) {
|
|
507
|
-
const isBaseline = baselineFiles.includes(f)
|
|
508
|
-
const isDangerous = DANGEROUS_PATTERNS.some(p => f.includes(p))
|
|
509
|
-
const marker = isBaseline ? '🔴' : isDangerous ? '⚠️' : ' '
|
|
510
|
-
console.log(` ${marker} ${f}`)
|
|
511
|
-
}
|
|
512
|
-
}
|
|
513
|
-
console.log(`\n 状态: ${result.status.toUpperCase()}`)
|
|
514
|
-
if (result.reasons.length > 0) {
|
|
515
|
-
console.log(` 原因:`)
|
|
516
|
-
for (const r of result.reasons) {
|
|
517
|
-
console.log(` - ${r}`)
|
|
518
|
-
}
|
|
519
|
-
}
|
|
520
|
-
console.log(`\n 如确认接受这些变更,重新运行 --done 时带上对应 flag 即可解锁:`)
|
|
521
|
-
console.log(` sillyspec run quick --done --force-baseline --allow-new --change <id> --output "..."`)
|
|
522
|
-
console.log(` (--force-baseline 覆盖受保护/危险文件如 src/run.js;--allow-new 允许新增文件)`)
|
|
523
|
-
console.log(` 或在首个 sillyspec run quick 启动(step 1)时就声明这些 flag,持久化进 guard。`)
|
|
524
|
-
}
|
|
525
|
-
} catch (e) {
|
|
526
|
-
result.reasons.push(`审计失败: ${e.message}`)
|
|
527
|
-
result.status = 'warning'
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
return result
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
function printQuickAuditReview(review) {
|
|
534
|
-
if (review.status === 'blocked') {
|
|
535
|
-
console.error(`\n🚫 quick 变更边界审计 — BLOCKED:`)
|
|
536
|
-
for (const r of review.reasons) {
|
|
537
|
-
console.error(` - ${r}`)
|
|
538
|
-
}
|
|
539
|
-
console.error(`\n quick 已停止:请恢复/拆分这些变更,或重新运行 quick 并显式声明范围。`)
|
|
540
|
-
} else if (review.status === 'warning') {
|
|
541
|
-
console.warn(`\n⚠️ quick 变更边界审计 — WARNING:`)
|
|
542
|
-
for (const r of review.reasons) {
|
|
543
|
-
console.warn(` - ${r}`)
|
|
544
|
-
}
|
|
545
|
-
} else {
|
|
546
|
-
console.log(`\n✅ quick 变更边界审计 — SAFE (变更 ${review.changedFiles.length} 个文件)`)
|
|
547
|
-
}
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
async function triggerSync(cwd, changeName, platformOpts = {}) {
|
|
551
|
-
// 平台模式(SillyHub)走自己的回传链路,不走 CLI 内置 sync
|
|
552
|
-
if (platformOpts?.specRoot || platformOpts?.runtimeRoot) return
|
|
553
|
-
try {
|
|
554
|
-
if (changeName && !existsSync(join(cwd, '.sillyspec', 'changes', changeName))) return
|
|
555
|
-
const syncMod = await import('./sync.js')
|
|
556
|
-
await syncMod.sync(changeName, cwd)
|
|
557
|
-
} catch (e) {
|
|
558
|
-
// sync.js 不存在或同步失败,静默跳过
|
|
559
|
-
console.warn('⚠️ 同步失败:', e.message)
|
|
560
|
-
}
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
/**
|
|
564
|
-
* 审批检查辅助函数:execute 阶段启动前检查
|
|
565
|
-
* @returns {{ status: string, reason?: string } | null}
|
|
566
|
-
*/
|
|
567
|
-
async function checkApproval(cwd, changeName, platformOpts = {}) {
|
|
568
|
-
// 平台模式不需要 CLI 内置审批检查
|
|
569
|
-
if (platformOpts?.specRoot || platformOpts?.runtimeRoot) return null
|
|
570
|
-
try {
|
|
571
|
-
const syncMod = await import('./sync.js')
|
|
572
|
-
return await syncMod.checkApproval(changeName, cwd)
|
|
573
|
-
} catch (e) {
|
|
574
|
-
return null
|
|
575
|
-
}
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
/**
|
|
579
|
-
* 统一查找变更目录(与 progress.js 的变更检测逻辑一致)
|
|
580
|
-
*/
|
|
581
|
-
function resolveChangeDir(cwd, progress, specDir = null) {
|
|
582
|
-
const changesDir = join(specDir || resolveSpecDir(cwd), 'changes')
|
|
583
|
-
if (!existsSync(changesDir)) return null
|
|
584
|
-
|
|
585
|
-
// 1. 优先用 currentChange
|
|
586
|
-
if (progress.currentChange) {
|
|
587
|
-
const target = join(changesDir, progress.currentChange)
|
|
588
|
-
if (existsSync(target)) return target
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
// 2. fallback:唯一非 archive 目录
|
|
592
|
-
const entries = readdirSync(changesDir, { withFileTypes: true })
|
|
593
|
-
.filter(e => e.isDirectory() && e.name !== 'archive')
|
|
594
|
-
if (entries.length === 1) return join(changesDir, entries[0].name)
|
|
595
|
-
|
|
596
|
-
return null
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
/**
|
|
600
|
-
* 自动探测并设置 currentChange(唯一变更目录时)
|
|
601
|
-
* @returns {boolean} 是否设置了 currentChange
|
|
602
|
-
*/
|
|
603
|
-
function autoDetectChange(progress, cwd, specDir = null) {
|
|
604
|
-
if (progress.currentChange) return false
|
|
605
|
-
const changesDir = join(specDir || resolveSpecDir(cwd), 'changes')
|
|
606
|
-
if (!existsSync(changesDir)) return false
|
|
607
|
-
const entries = readdirSync(changesDir, { withFileTypes: true })
|
|
608
|
-
.filter(e => e.isDirectory() && e.name !== 'archive')
|
|
609
|
-
if (entries.length === 1) {
|
|
610
|
-
progress.currentChange = entries[0].name
|
|
611
|
-
return true
|
|
612
|
-
}
|
|
613
|
-
return false
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
/**
|
|
617
|
-
* 从 progress 或变更目录推导变更名
|
|
618
|
-
*/
|
|
619
|
-
function resolveChangeName(cwd, progress, specDir = null) {
|
|
620
|
-
if (progress.currentChange) return progress.currentChange
|
|
621
|
-
const changesDir = join(specDir || resolveSpecDir(cwd), 'changes')
|
|
622
|
-
if (!existsSync(changesDir)) return null
|
|
623
|
-
const entries = readdirSync(changesDir, { withFileTypes: true })
|
|
624
|
-
.filter(e => e.isDirectory() && e.name !== 'archive')
|
|
625
|
-
if (entries.length === 1) return entries[0].name
|
|
626
|
-
return null
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
/**
|
|
630
|
-
* 获取阶段的步骤定义(execute 需要动态构建)
|
|
631
|
-
*/
|
|
632
|
-
async function getStageSteps(stageName, cwd, progress, specDir = null) {
|
|
633
|
-
if (stageName === 'execute') {
|
|
634
|
-
const changeDir = resolveChangeDir(cwd, progress, specDir)
|
|
635
|
-
let planFile = null
|
|
636
|
-
let worktreePath = null
|
|
637
|
-
if (changeDir) {
|
|
638
|
-
const p = join(changeDir, 'plan.md')
|
|
639
|
-
if (existsSync(p)) planFile = p
|
|
640
|
-
// 自动检测 worktree 路径,注入 Wave prompt 的 workdir 指令
|
|
641
|
-
// 修复:之前未传 worktreePath 给 buildExecuteSteps,导致 Wave prompt 缺失工作目录指令,
|
|
642
|
-
// 子代理可能把文件写到主工作区而非 worktree 内,破坏隔离。
|
|
643
|
-
try {
|
|
644
|
-
const changeName = basename(changeDir)
|
|
645
|
-
const { WorktreeManager } = await import('./worktree.js')
|
|
646
|
-
const wm = new WorktreeManager({ cwd })
|
|
647
|
-
const meta = wm.getMeta(changeName)
|
|
648
|
-
if (meta?.worktreePath && existsSync(meta.worktreePath)) {
|
|
649
|
-
worktreePath = meta.worktreePath
|
|
650
|
-
}
|
|
651
|
-
} catch {
|
|
652
|
-
// 无 worktree meta 不阻断——可能是首次启动或 in-place 模式
|
|
653
|
-
}
|
|
654
|
-
}
|
|
655
|
-
return buildExecuteSteps(planFile, { worktreePath })
|
|
656
|
-
}
|
|
657
|
-
if (stageName === 'plan') {
|
|
658
|
-
const changeDir = resolveChangeDir(cwd, progress, specDir)
|
|
659
|
-
return buildPlanSteps(changeDir)
|
|
660
|
-
}
|
|
661
|
-
const def = stageRegistry[stageName]
|
|
662
|
-
return def ? def.steps : null
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
/**
|
|
666
|
-
* 确保阶段的 steps 已初始化到 progress
|
|
667
|
-
*/
|
|
668
|
-
export async function ensureStageSteps(progress, stageName, cwd, specDir = null) {
|
|
669
|
-
if (!progress.stages) progress.stages = {}
|
|
670
|
-
|
|
671
|
-
const steps = await getStageSteps(stageName, cwd, progress, specDir)
|
|
672
|
-
if (!steps) return false
|
|
673
|
-
|
|
674
|
-
if (!progress.stages[stageName] || !progress.stages[stageName].steps || progress.stages[stageName].steps.length === 0) {
|
|
675
|
-
progress.stages[stageName] = {
|
|
676
|
-
status: 'in-progress',
|
|
677
|
-
startedAt: new Date().toLocaleString('zh-CN',{hour12:false}),
|
|
678
|
-
completedAt: null,
|
|
679
|
-
steps: steps.map(s => ({ name: s.name, status: 'pending' }))
|
|
680
|
-
}
|
|
681
|
-
return true // 需要写入
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
// 检查步骤数量是否匹配(execute 动态步骤可能变化)
|
|
685
|
-
if (progress.stages[stageName].steps.length !== steps.length) {
|
|
686
|
-
const oldSteps = progress.stages[stageName].steps
|
|
687
|
-
progress.stages[stageName].steps = steps.map(s => {
|
|
688
|
-
const old = oldSteps.find(step => step.name === s.name)
|
|
689
|
-
if (old) return old // 精确名命中 → 保留旧状态
|
|
690
|
-
// migratedFrom:新步骤是合并/重命名来的(如 brainstorm 13→8 折叠)。
|
|
691
|
-
// 吸收的旧步骤全部 completed → 新步骤标 completed,避免 completed 丢失导致 currentIdx 回跳。
|
|
692
|
-
if (Array.isArray(s.migratedFrom) && s.migratedFrom.length > 0) {
|
|
693
|
-
const migrated = oldSteps.filter(step => s.migratedFrom.includes(step.name))
|
|
694
|
-
if (migrated.length > 0 && migrated.every(step => step.status === 'completed')) {
|
|
695
|
-
return { name: s.name, status: 'completed', migratedFrom: true }
|
|
696
|
-
}
|
|
697
|
-
}
|
|
698
|
-
return { name: s.name, status: 'pending' }
|
|
699
|
-
})
|
|
700
|
-
return true
|
|
701
|
-
}
|
|
702
|
-
|
|
703
|
-
return false
|
|
704
|
-
}
|
|
705
|
-
|
|
706
|
-
/**
|
|
707
|
-
* 输出当前步骤的 prompt
|
|
708
|
-
*/
|
|
709
|
-
async function outputStep(stageName, stepIndex, steps, cwd, changeName, dbProjectName, platformOpts = {}, prevStepAnswer = null) {
|
|
710
|
-
const step = steps[stepIndex]
|
|
711
|
-
const total = steps.length
|
|
712
|
-
// ── 越界防御 ──
|
|
713
|
-
// steps/defSteps 来自 getStageSteps(如 buildPlanSteps),其长度可能与 progress.steps
|
|
714
|
-
// 不一致(例如平台模式下 changeDir 解析失败导致 buildPlanSteps(null) 只返回 2 步 fixedPrefix)。
|
|
715
|
-
// 此时 stepIndex 可能越界,访问 step.name 会 TypeError 崩溃。降级处理而非崩溃。
|
|
716
|
-
if (!step) {
|
|
717
|
-
console.error(`⚠️ 无法输出步骤 ${stepIndex + 1}/${total}:步骤定义缺失`)
|
|
718
|
-
console.error(` stage=${stageName} stepIndex=${stepIndex} defSteps.length=${steps.length}`)
|
|
719
|
-
console.error(` 可能原因:平台模式下 getStageSteps 未传 specRoot,或 plan/execute 步骤动态生成后 defSteps 未同步。`)
|
|
720
|
-
console.error(` 请检查 --change 指定的变更目录是否存在、specRoot 是否正确,然后重试。`)
|
|
721
|
-
return false
|
|
722
|
-
}
|
|
723
|
-
const projectName = dbProjectName || basename(cwd)
|
|
724
|
-
|
|
725
|
-
// ── Revision context injection ──
|
|
726
|
-
const revisionCtx = platformOpts?._revision
|
|
727
|
-
if (revisionCtx) {
|
|
728
|
-
console.log(`### 🔄 Revision Context`)
|
|
729
|
-
console.log(`本阶段处于修订模式(revision ${revisionCtx.revision}),不是首次执行。`)
|
|
730
|
-
console.log(`- 修订起始步骤:${revisionCtx.fromStep}`)
|
|
731
|
-
console.log(`- 当前步骤之前已完成的步骤仍然有效,不需要重做。`)
|
|
732
|
-
console.log(`- 当前步骤及之后的步骤需要重新生成或调整已有产物。`)
|
|
733
|
-
console.log(`- 已有产物文件(design.md、plan.md 等)被保留,审视并更新它们,而不是从零创建。`)
|
|
734
|
-
console.log(`- 不要绕过 CLI 进度追踪。\n`)
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
const personas = {
|
|
738
|
-
brainstorm: `### 🎯 你的角色:资深架构师
|
|
739
|
-
你是一位有 15 年经验的系统架构师。先理解业务本质,再设计技术方案。决策附理由,方案列 trade-off。不确定就说不确定,不猜。`,
|
|
740
|
-
plan: `### 📋 你的角色:技术项目经理
|
|
741
|
-
你是一位经验丰富的技术项目经理。任务拆解粒度均匀,依赖关系明确。每个任务有完成标准,Wave 间有依赖说明。条理清晰,不做模糊描述。`,
|
|
742
|
-
execute: `### 💻 你的角色:高级工程师
|
|
743
|
-
你是一位严谨的高级工程师。先读规范再写代码,严格遵循 CONVENTIONS.md 和 plan.md。**你不是设计师,是执行者——按 plan 搬砖,禁止发散思维。** 发现 plan 不合理就停下来反馈,不要自己改方案。代码有清晰职责划分,边界处理完善。少说多做,遇到规范冲突优先问。`,
|
|
744
|
-
verify: `### 🔍 你的角色:QA 专家
|
|
745
|
-
你是一位吹毛求疵的 QA 专家。假设所有代码都有 bug,用最坏情况测试。关注边界、异常、并发。有问题直说,用证据说话,不写"看起来没问题"。`,
|
|
746
|
-
quick: `### 💻 你的角色:全栈老兵
|
|
747
|
-
你是一位实战经验丰富的全栈工程师。不纠结架构和流程,理解需求就直接干。不确定的地方先问清楚再动手,先读后写,改完就收。问题排查思路开阔,前端报错不一定是前端问题——可能是后端数据、浏览器兼容、甚至设备硬件。解决方案实用接地气,用户描述有误敢于直接指出。`,
|
|
748
|
-
explore: `### 🧭 你的角色:技术探索伙伴
|
|
749
|
-
你帮助用户澄清问题、调查代码库、比较方案和暴露风险。探索阶段不写实现代码,不安装依赖,不把讨论强行推进成开发。`
|
|
750
|
-
}
|
|
751
|
-
|
|
752
|
-
console.log(`---`)
|
|
753
|
-
console.log(`stage: ${stageName}`)
|
|
754
|
-
console.log(`step: ${stepIndex + 1}/${total}`)
|
|
755
|
-
console.log(`stepName: ${step.name}`)
|
|
756
|
-
console.log(`project: ${projectName}`)
|
|
757
|
-
if (changeName) {
|
|
758
|
-
console.log(`change: ${changeName}`)
|
|
759
|
-
const isPlatform = platformOpts?.specRoot || platformOpts?.runtimeRoot
|
|
760
|
-
const changeDirBase = isPlatform ? platformOpts.specRoot : '.sillyspec'
|
|
761
|
-
const changeDir = join(changeDirBase, 'changes', changeName)
|
|
762
|
-
console.log(`changeDir: ${changeDir}`)
|
|
763
|
-
}
|
|
764
|
-
console.log(`---\n`)
|
|
765
|
-
// persona 只在 stage 首步注入(step0)——角色设定一次即可,后续 step 重复注入纯属 token 浪费
|
|
766
|
-
if (personas[stageName] && stepIndex === 0) {
|
|
767
|
-
console.log(personas[stageName])
|
|
768
|
-
console.log('')
|
|
769
|
-
}
|
|
770
|
-
// 注入全局护栏(如 _globalGuardrails)
|
|
771
|
-
const stageDef = stageRegistry[stageName]
|
|
772
|
-
const guardrails = stageDef && stageDef._globalGuardrails ? stageDef._globalGuardrails : ''
|
|
773
|
-
|
|
774
|
-
console.log(`## Step ${stepIndex + 1}/${total}: ${step.name}\n`)
|
|
775
|
-
if (guardrails) {
|
|
776
|
-
console.log(guardrails.trim())
|
|
777
|
-
console.log('')
|
|
778
|
-
}
|
|
779
|
-
// 先解析 {{include: name}}(把外部模板片段拉进 prompt),再做下方占位符替换,
|
|
780
|
-
// 保证模板内容里的 {SPEC_ROOT}/<change-name> 等也能被替换
|
|
781
|
-
let promptText = resolvePromptIncludes(step.prompt)
|
|
782
|
-
// 替换 prompt 中的占位符
|
|
783
|
-
if (projectName && promptText.includes('<project>')) {
|
|
784
|
-
promptText = promptText.replace(/<project>/g, projectName)
|
|
785
|
-
}
|
|
786
|
-
// 替换 <git-user> 占位符
|
|
787
|
-
if (promptText.includes('<git-user>')) {
|
|
788
|
-
const { execSync } = await import('child_process')
|
|
789
|
-
try {
|
|
790
|
-
const gitUser = safeGit(cwd, ['config', 'user.name']).value || 'unknown'
|
|
791
|
-
promptText = promptText.replace(/<git-user>/g, gitUser)
|
|
792
|
-
} catch {
|
|
793
|
-
promptText = promptText.replace(/<git-user>/g, 'unknown')
|
|
794
|
-
}
|
|
795
|
-
}
|
|
796
|
-
// 替换时间戳占位符
|
|
797
|
-
const now = new Date()
|
|
798
|
-
const nowDatetime = now.getFullYear() + '-' + String(now.getMonth()+1).padStart(2,'0') + '-' + String(now.getDate()).padStart(2,'0') + ' ' + String(now.getHours()).padStart(2,'0') + ':' + String(now.getMinutes()).padStart(2,'0') + ':' + String(now.getSeconds()).padStart(2,'0')
|
|
799
|
-
const nowTimestamp = now.getFullYear() + String(now.getMonth()+1).padStart(2,'0') + String(now.getDate()).padStart(2,'0') + '-' + String(now.getHours()).padStart(2,'0') + String(now.getMinutes()).padStart(2,'0') + String(now.getSeconds()).padStart(2,'0')
|
|
800
|
-
const nowDate = now.getFullYear() + '-' + String(now.getMonth()+1).padStart(2,'0') + '-' + String(now.getDate()).padStart(2,'0')
|
|
801
|
-
promptText = promptText.replace(/<now-datetime>/g, nowDatetime)
|
|
802
|
-
promptText = promptText.replace(/<now-timestamp>/g, nowTimestamp)
|
|
803
|
-
promptText = promptText.replace(/<now-date>/g, nowDate)
|
|
804
|
-
// 替换 <change-name> 占位符
|
|
805
|
-
if (changeName && promptText.includes('<change-name>')) {
|
|
806
|
-
promptText = promptText.replace(/<change-name>/g, changeName)
|
|
807
|
-
}
|
|
808
|
-
// 替换 <quick-session-id> 占位符(quick 阶段专用:sessionId == changeName == quick-<uuid8>,
|
|
809
|
-
// 见 runStage 参数解析 quickSessionId 生成。告知 agent 本会话 id + --done 需带 --change)
|
|
810
|
-
if (changeName && promptText.includes('<quick-session-id>')) {
|
|
811
|
-
promptText = promptText.replace(/<quick-session-id>/g, changeName)
|
|
812
|
-
}
|
|
813
|
-
// 替换 <quicklog-id> 占位符(quick 阶段:从 session guard.json 读 CLI 分配的 ql-ID,
|
|
814
|
-
// 供 agent 在模块文档变更索引等处引用)
|
|
815
|
-
if (promptText.includes('<quicklog-id>')) {
|
|
816
|
-
const specBaseQl = platformOpts?.specRoot || join(cwd, '.sillyspec')
|
|
817
|
-
let qlIdVal = ''
|
|
818
|
-
try {
|
|
819
|
-
const sessionGuardFile = join(specBaseQl, '.runtime', 'quick-sessions', changeName, 'guard.json')
|
|
820
|
-
if (existsSync(sessionGuardFile)) {
|
|
821
|
-
qlIdVal = JSON.parse(readFileSync(sessionGuardFile, 'utf8')).quicklogId || ''
|
|
822
|
-
}
|
|
823
|
-
} catch {}
|
|
824
|
-
promptText = promptText.replace(/<quicklog-id>/g, qlIdVal || '(未分配)')
|
|
825
|
-
}
|
|
826
|
-
// 替换 <linked-changes> 占位符(quick 阶段:从 .runtime/quick-sessions/<sessionId>/guard.json 读关联变更)
|
|
827
|
-
if (promptText.includes('<linked-changes>')) {
|
|
828
|
-
const specBaseLc = platformOpts?.specRoot || join(cwd, '.sillyspec')
|
|
829
|
-
let linkedChanges = []
|
|
830
|
-
try {
|
|
831
|
-
// D-002:guard 按 session 存。changeName == quick-<uuid8>(见 runStage 参数解析)。回退读旧单文件(兼容 task-03 前)
|
|
832
|
-
const sessionGuardFile = join(specBaseLc, '.runtime', 'quick-sessions', changeName, 'guard.json')
|
|
833
|
-
const legacyGuardFile = join(specBaseLc, '.runtime', 'quick-guard.json')
|
|
834
|
-
const guard = existsSync(sessionGuardFile)
|
|
835
|
-
? JSON.parse(readFileSync(sessionGuardFile, 'utf8'))
|
|
836
|
-
: (existsSync(legacyGuardFile) ? JSON.parse(readFileSync(legacyGuardFile, 'utf8')) : null)
|
|
837
|
-
if (guard) linkedChanges = Array.isArray(guard.linkedChanges) ? guard.linkedChanges : []
|
|
838
|
-
} catch {}
|
|
839
|
-
const display = linkedChanges.length > 0 ? linkedChanges.join(', ') : '(无)'
|
|
840
|
-
promptText = promptText.replace(/<linked-changes>/g, display)
|
|
841
|
-
}
|
|
842
|
-
// 平台模式:注入路径覆盖指令
|
|
843
|
-
if (platformOpts?.specRoot || platformOpts?.runtimeRoot) {
|
|
844
|
-
const projectName = dbProjectName || basename(cwd)
|
|
845
|
-
// platformOpts.specRoot 现在指向 specDir 本身(可能是 cwd/.sillyspec 或外部路径)
|
|
846
|
-
const specSillyspec = platformOpts.specRoot || join(cwd, '.sillyspec')
|
|
847
|
-
const docsRoot = join(specSillyspec, 'docs', projectName)
|
|
848
|
-
const projectsRoot = join(specSillyspec, 'projects')
|
|
849
|
-
const changesRoot = join(specSillyspec, 'changes')
|
|
850
|
-
const workflowsRoot = join(specSillyspec, 'workflows')
|
|
851
|
-
const knowledgeRoot = join(specSillyspec, 'knowledge')
|
|
852
|
-
|
|
853
|
-
promptText = promptText.replace(/\{DOCS_ROOT\}/g, docsRoot)
|
|
854
|
-
promptText = promptText.replace(/\{PROJECTS_ROOT\}/g, projectsRoot)
|
|
855
|
-
promptText = promptText.replace(/\{WORKFLOWS_ROOT\}/g, workflowsRoot)
|
|
856
|
-
promptText = promptText.replace(/\{KNOWLEDGE_ROOT\}/g, knowledgeRoot)
|
|
857
|
-
promptText = promptText.replace(/\{SPEC_ROOT\}/g, specSillyspec)
|
|
858
|
-
|
|
859
|
-
const platformDirectives = []
|
|
860
|
-
platformDirectives.push(
|
|
861
|
-
`## ⚠️ 平台模式 — 写入路径约束(必须严格遵守)\n` +
|
|
862
|
-
`\n` +
|
|
863
|
-
`规范目录(specDir): \`${specSillyspec}\`\n` +
|
|
864
|
-
`- 文档根目录: \`${docsRoot}/\`\n` +
|
|
865
|
-
`- 项目注册表: \`${projectsRoot}/\`\n` +
|
|
866
|
-
`- 变更目录: \`${changesRoot}/\`\n` +
|
|
867
|
-
`- 工作流目录: \`${workflowsRoot}/\`\n` +
|
|
868
|
-
`- 术语目录: \`${knowledgeRoot}/\`\n` +
|
|
869
|
-
`\n` +
|
|
870
|
-
`### ⛔ 写入规则\n` +
|
|
871
|
-
`1. **所有文档、配置、产物只能写入上述路径**。严禁写入源码目录或相对路径 \`.sillyspec/\`。\n` +
|
|
872
|
-
`2. **不允许**从 cwd 推导文档路径,必须使用上面列出的绝对路径。\n` +
|
|
873
|
-
`3. **源码扫描范围**必须排除:.sillyspec/、.claude/、.git/、node_modules/、dist/、build/、__pycache__/\n` +
|
|
874
|
-
`4. **local.yaml 校验**:commands 中引用的命令必须在 package.json 的 scripts 中存在,不存在的标记为 unavailable,不能写 "配置良好"\n` +
|
|
875
|
-
`\n` +
|
|
876
|
-
`### ⛔ Write 工具规则\n` +
|
|
877
|
-
`1. 如果 Write 返回 \"File has not been read yet\",正确动作是:先 Read 目标文件 → 再 Write 覆盖。\n` +
|
|
878
|
-
`2. **不允许**用 cat >、tee、heredoc 等 Bash 方式绕过 Write 工具。\n` +
|
|
879
|
-
`3. 如果 Write 和 Read 均失败,记录失败并停止当前 step。\n` +
|
|
880
|
-
`\n` +
|
|
881
|
-
`### 📍 Workflow YAML 占位符映射(task-05)\n` +
|
|
882
|
-
`读取 \`{WORKFLOWS_ROOT}/scan-docs.yaml\` 时,yaml 内的占位符按以下映射替换为绝对路径:\n` +
|
|
883
|
-
`- \`{SPEC_ROOT}\` → \`${specSillyspec}\`(规范目录根)\n` +
|
|
884
|
-
`- \`<project>\` → 当前项目名(见下方 step 提示,等于 \`${projectName}\`)\n` +
|
|
885
|
-
`- 例:\`{SPEC_ROOT}/docs/<project>/scan/ARCHITECTURE.md\` → \`${docsRoot}/scan/ARCHITECTURE.md\`\n` +
|
|
886
|
-
`\n` +
|
|
887
|
-
`创建目录: \`mkdir -p ${docsRoot}/{scan,modules,flows} ${projectsRoot} ${changesRoot}\`\n`
|
|
888
|
-
)
|
|
889
|
-
if (platformOpts.runtimeRoot) {
|
|
890
|
-
const scanRunId = platformOpts.scanRunId || 'scan-' + new Date().toISOString().slice(0, 19).replace(/[T:]/g, '-')
|
|
891
|
-
platformDirectives.push(
|
|
892
|
-
`运行时产物写入: \`${platformOpts.runtimeRoot}/scan-runs/${scanRunId}/\`\n`
|
|
893
|
-
)
|
|
894
|
-
}
|
|
895
|
-
if (platformOpts.workspaceId) {
|
|
896
|
-
platformDirectives.push(`workspace_id: ${platformOpts.workspaceId}`)
|
|
897
|
-
}
|
|
898
|
-
// 平台 directives 前置条件:
|
|
899
|
-
// - step0(对齐 persona 仅 step0 策略):路径列表/Write 规则/占位符映射一次建立即可;
|
|
900
|
-
// - scan 阶段所有 step:scan 的 profileDirectives 本就每步注入(见下方 L917),平台路径
|
|
901
|
-
// 约束也需每步可见——否则 quick profile 的 step0 是 noAI preflight(自动执行),run scan
|
|
902
|
-
// 输出的首个可见 step 是 step1,会漏掉 specDir 路径,导致平台模式写入约束丢失。
|
|
903
|
-
// 后续 step 靠 changeDir header + footer 平台规则(L1077+)维持,platformDirectives
|
|
904
|
-
// 每步重复约 500 tokens 仅在 scan(步数有限)可接受。
|
|
905
|
-
if (stepIndex === 0 || stageName === 'scan') {
|
|
906
|
-
promptText = platformDirectives.join('\n') + '\n\n' + promptText
|
|
907
|
-
}
|
|
908
|
-
} else {
|
|
909
|
-
// 常规模式(无平台 specRoot):占位符替换为 cwd/.sillyspec 下对应路径
|
|
910
|
-
// 让用 {SPEC_ROOT}/{DOCS_ROOT} 等占位符的 prompt(如 quick/scan)在常规模式也写到正确位置
|
|
911
|
-
const projectName = dbProjectName || basename(cwd)
|
|
912
|
-
const specSillyspec = join(cwd, '.sillyspec')
|
|
913
|
-
promptText = promptText.replace(/\{SPEC_ROOT\}/g, specSillyspec)
|
|
914
|
-
promptText = promptText.replace(/\{DOCS_ROOT\}/g, join(specSillyspec, 'docs', projectName))
|
|
915
|
-
promptText = promptText.replace(/\{PROJECTS_ROOT\}/g, join(specSillyspec, 'projects'))
|
|
916
|
-
promptText = promptText.replace(/\{WORKFLOWS_ROOT\}/g, join(specSillyspec, 'workflows'))
|
|
917
|
-
promptText = promptText.replace(/\{KNOWLEDGE_ROOT\}/g, join(specSillyspec, 'knowledge'))
|
|
918
|
-
}
|
|
919
|
-
|
|
920
|
-
// 注入 scanProfile 硬约束指令
|
|
921
|
-
if (stageName === 'scan' && platformOpts?.scanProfile) {
|
|
922
|
-
const sp = platformOpts.scanProfile
|
|
923
|
-
const profileDirectives = []
|
|
924
|
-
profileDirectives.push(`## 📊 Scan Profile: ${sp.mode} (${sp.reason})`)
|
|
925
|
-
if (sp.maxAgentCalls === 0) {
|
|
926
|
-
profileDirectives.push(`**⛔ 严禁使用子代理(Agent/Task 工具)。** 必须在本 turn 内完成所有工作。`)
|
|
927
|
-
} else if (sp.maxAgentCalls > 0) {
|
|
928
|
-
profileDirectives.push(`**子代理上限:${sp.maxAgentCalls} 个。** 不要超出。`)
|
|
929
|
-
}
|
|
930
|
-
if (sp.maxDocs < 99) {
|
|
931
|
-
profileDirectives.push(`**文档上限:${sp.maxDocs} 份。** 只生成核心文档,不要额外生成 flows/glossary/module-card。`)
|
|
932
|
-
}
|
|
933
|
-
profileDirectives.push(`--output 只需要列出文件名,不要写长篇总结。`)
|
|
934
|
-
promptText = profileDirectives.join('\n') + '\n\n' + promptText
|
|
935
|
-
|
|
936
|
-
// scanProfile 分支也要替换占位符(非 platform 模式也会走到这里)
|
|
937
|
-
const _pName = dbProjectName || basename(cwd)
|
|
938
|
-
const _specSS = platformOpts?.specRoot || join(cwd, '.sillyspec')
|
|
939
|
-
const _docsRoot = join(_specSS, 'docs', _pName)
|
|
940
|
-
promptText = promptText.replace(/\{DOCS_ROOT\}/g, _docsRoot)
|
|
941
|
-
promptText = promptText.replace(/\{PROJECTS_ROOT\}/g, join(_specSS, 'projects'))
|
|
942
|
-
promptText = promptText.replace(/\{WORKFLOWS_ROOT\}/g, join(_specSS, 'workflows'))
|
|
943
|
-
promptText = promptText.replace(/\{KNOWLEDGE_ROOT\}/g, join(_specSS, 'knowledge'))
|
|
944
|
-
promptText = promptText.replace(/\{SPEC_ROOT\}/g, _specSS)
|
|
945
|
-
} else {
|
|
946
|
-
// 非 platform 模式也要替换占位符
|
|
947
|
-
const projectName = dbProjectName || basename(cwd)
|
|
948
|
-
const specSillyspec = join(cwd, '.sillyspec')
|
|
949
|
-
const docsRoot = join(specSillyspec, 'docs', projectName)
|
|
950
|
-
const projectsRoot = join(specSillyspec, 'projects')
|
|
951
|
-
const workflowsRoot = join(specSillyspec, 'workflows')
|
|
952
|
-
const knowledgeRoot = join(specSillyspec, 'knowledge')
|
|
953
|
-
promptText = promptText.replace(/\{DOCS_ROOT\}/g, docsRoot)
|
|
954
|
-
promptText = promptText.replace(/\{PROJECTS_ROOT\}/g, projectsRoot)
|
|
955
|
-
promptText = promptText.replace(/\{WORKFLOWS_ROOT\}/g, workflowsRoot)
|
|
956
|
-
promptText = promptText.replace(/\{KNOWLEDGE_ROOT\}/g, knowledgeRoot)
|
|
957
|
-
promptText = promptText.replace(/\{SPEC_ROOT\}/g, specSillyspec)
|
|
958
|
-
}
|
|
959
|
-
|
|
960
|
-
// Knowledge hit report: execute 阶段注入匹配结果
|
|
961
|
-
if (stageName === 'execute' && promptText.includes('{KNOWLEDGE_HIT_REPORT}')) {
|
|
962
|
-
try {
|
|
963
|
-
const { matchKnowledge } = await import('./knowledge-match.js')
|
|
964
|
-
const effectiveSpecBase = platformOpts?.specRoot || join(cwd, '.sillyspec')
|
|
965
|
-
const knowledgeDir = join(effectiveSpecBase, 'knowledge')
|
|
966
|
-
// taskContext: changeName + plan.md task names for better matching
|
|
967
|
-
let taskContext = changeName || ''
|
|
968
|
-
if (changeName) {
|
|
969
|
-
const planPath = join(effectiveSpecBase, 'changes', changeName, 'plan.md')
|
|
970
|
-
try {
|
|
971
|
-
const planContent = readFileSync(planPath, 'utf8')
|
|
972
|
-
// match both "- [ ] task-01: title" and "## task-01: title" formats
|
|
973
|
-
const taskLines = [...planContent.matchAll(/(?:^\- \[[ x]\] |^## )task-\d+[^:]*:?\s*(.+)/gm)]
|
|
974
|
-
if (taskLines.length > 0) {
|
|
975
|
-
taskContext += ' ' + taskLines.map(t => t[1]).join(' ')
|
|
976
|
-
}
|
|
977
|
-
} catch {}
|
|
978
|
-
}
|
|
979
|
-
const knowledgeResult = matchKnowledge(knowledgeDir, taskContext)
|
|
980
|
-
promptText = promptText.replace(/\{KNOWLEDGE_HIT_REPORT\}/g, knowledgeResult.report)
|
|
981
|
-
// 写入 runtime JSON
|
|
982
|
-
const runtimeDir = join(effectiveSpecBase, '.runtime')
|
|
983
|
-
mkdirSync(runtimeDir, { recursive: true })
|
|
984
|
-
writeFileSync(join(runtimeDir, 'knowledge-hit-report.json'), JSON.stringify(knowledgeResult.json, null, 2) + '\n')
|
|
985
|
-
} catch (e) {
|
|
986
|
-
promptText = promptText.replace(/\{KNOWLEDGE_HIT_REPORT\}/g, 'Status: no matches (error: ' + e.message + ')')
|
|
987
|
-
}
|
|
988
|
-
}
|
|
989
|
-
|
|
990
|
-
// Execute: 注入 currentExecuteRunId(从变更专属标记文件读取)
|
|
991
|
-
if (stageName === 'execute' && promptText.includes('{EXECUTE_RUN_ID}')) {
|
|
992
|
-
let runId = ''
|
|
993
|
-
const execSpecBase = platformOpts?.specRoot || join(cwd, '.sillyspec')
|
|
994
|
-
const runtimeRoot = platformOpts?.runtimeRoot || join(execSpecBase, '.runtime')
|
|
995
|
-
const runIdFile = join(runtimeRoot, `current-execute-run-id-${changeName}`)
|
|
996
|
-
try {
|
|
997
|
-
if (existsSync(runIdFile)) {
|
|
998
|
-
runId = readFileSync(runIdFile, 'utf8').trim()
|
|
999
|
-
}
|
|
1000
|
-
} catch {}
|
|
1001
|
-
if (!runId) {
|
|
1002
|
-
const { generateExecuteRunId } = await import('./task-review.js')
|
|
1003
|
-
runId = generateExecuteRunId()
|
|
1004
|
-
// 落盘(与启动站点一致),保证 agent 收到的 ID == gate/checkbox 读取的 ID
|
|
1005
|
-
try { mkdirSync(runtimeRoot, { recursive: true }); writeFileSync(runIdFile, runId + '\n') } catch {}
|
|
1006
|
-
}
|
|
1007
|
-
promptText = promptText.replace(/\{EXECUTE_RUN_ID\}/g, runId)
|
|
1008
|
-
}
|
|
1009
|
-
|
|
1010
|
-
// Stage Review Tier:brainstorm/plan/propose/execute 阶段注入审查分级占位符
|
|
1011
|
-
// (scanProfile 只在 scan 生效、change-risk-profile 只管 apply/verify,都不约束这些阶段的审查方式,
|
|
1012
|
-
// 故按 plan_level / 变更文件数分级:self 当前 agent 自审,independent 强制独立子代理 + review.json)
|
|
1013
|
-
if (['brainstorm', 'plan', 'propose', 'execute'].includes(stageName) && promptText.includes('{REVIEW_TIER}')) {
|
|
1014
|
-
try {
|
|
1015
|
-
const { classifyReviewTier } = await import('./review-tier.js')
|
|
1016
|
-
const { generateStageReviewRunId } = await import('./stage-review.js')
|
|
1017
|
-
const tierSpecBase = platformOpts?.specRoot || join(cwd, '.sillyspec')
|
|
1018
|
-
const tierChangeDir = changeName ? join(tierSpecBase, 'changes', changeName) : null
|
|
1019
|
-
const designPath = tierChangeDir ? join(tierChangeDir, 'design.md') : null
|
|
1020
|
-
let planLevel = null
|
|
1021
|
-
if (tierChangeDir) {
|
|
1022
|
-
const planPath = join(tierChangeDir, 'plan.md')
|
|
1023
|
-
if (existsSync(planPath)) {
|
|
1024
|
-
const fmLine = readFileSync(planPath, 'utf8').split('\n').find(l => l.trim().startsWith('plan_level:'))
|
|
1025
|
-
if (fmLine) planLevel = fmLine.split(':')[1].trim()
|
|
1026
|
-
}
|
|
1027
|
-
}
|
|
1028
|
-
const tier = classifyReviewTier({ planLevel, designPath })
|
|
1029
|
-
const reviewRunId = generateStageReviewRunId()
|
|
1030
|
-
promptText = promptText
|
|
1031
|
-
.split('{REVIEW_TIER}').join(tier.tier)
|
|
1032
|
-
.split('{REVIEW_TIER_REASON}').join(tier.reason)
|
|
1033
|
-
.split('{STAGE_REVIEW_RUN_ID}').join(reviewRunId)
|
|
1034
|
-
} catch (e) {
|
|
1035
|
-
// 降级 self,避免 prompt 残留占位符
|
|
1036
|
-
promptText = promptText
|
|
1037
|
-
.split('{REVIEW_TIER}').join('self')
|
|
1038
|
-
.split('{REVIEW_TIER_REASON}').join('分级异常降级 self: ' + e.message)
|
|
1039
|
-
.split('{STAGE_REVIEW_RUN_ID}').join('review-unknown')
|
|
1040
|
-
}
|
|
1041
|
-
}
|
|
1042
|
-
|
|
1043
|
-
// 注入模块上下文(brainstorm/plan/execute 阶段,基于 Module Context Index)
|
|
1044
|
-
if (['brainstorm', 'plan', 'execute'].includes(stageName) && projectName) {
|
|
1045
|
-
const effectiveSpecBase = platformOpts?.specRoot || join(cwd, '.sillyspec')
|
|
1046
|
-
const moduleIndex = loadModuleContextIndex(effectiveSpecBase, projectName)
|
|
1047
|
-
if (moduleIndex && Object.keys(moduleIndex).length > 0) {
|
|
1048
|
-
// 尝试从 step prompt / changeName 匹配模块
|
|
1049
|
-
const taskDesc = step.prompt || changeName || ''
|
|
1050
|
-
const injection = buildModuleContextInjection(taskDesc, moduleIndex, effectiveSpecBase, projectName)
|
|
1051
|
-
if (injection) {
|
|
1052
|
-
promptText = injection + '\n' + promptText
|
|
1053
|
-
}
|
|
1054
|
-
}
|
|
1055
|
-
}
|
|
1056
|
-
|
|
1057
|
-
// 平台模式 prompt 自检:确保没有裸相对输出路径
|
|
1058
|
-
// 只匹配正向写入指令中的裸路径,避免误杀「禁止写入 .sillyspec/」等安全说明
|
|
1059
|
-
if ((platformOpts?.specRoot || platformOpts?.runtimeRoot) && stageName === 'scan') {
|
|
1060
|
-
const writeCtx = /(?<!不要|禁止|严禁)(?:save[\s.]+to|write|create|mkdir|git add|写入|保存到|写入到)[^a-zA-Z]*\.sillyspec\/[a-z]/i
|
|
1061
|
-
if (writeCtx.test(promptText)) {
|
|
1062
|
-
console.error(`❌ [sillyspec] BUG: 平台模式 scan prompt 包含写入指令指向裸相对路径 .sillyspec/`)
|
|
1063
|
-
console.error(` 这会导致 agent 写入源码目录而非 spec-root,属于源码污染 bug。`)
|
|
1064
|
-
console.error(` 请将路径改为对应的 {DOCS_ROOT}/{PROJECTS_ROOT}/{WORKFLOWS_ROOT}/{KNOWLEDGE_ROOT}/{SPEC_ROOT} 占位符。`)
|
|
1065
|
-
process.exit(1)
|
|
1066
|
-
}
|
|
1067
|
-
}
|
|
1068
|
-
|
|
1069
|
-
if (prevStepAnswer) {
|
|
1070
|
-
console.log(`\n### 📩 上一步用户回答`)
|
|
1071
|
-
console.log(prevStepAnswer)
|
|
1072
|
-
}
|
|
1073
|
-
|
|
1074
|
-
console.log(promptText)
|
|
1075
|
-
console.log(`\n### ⚠️ 铁律`)
|
|
1076
|
-
console.log('- 文档优先:代码产出必须先有对应的设计/规范文档支撑。')
|
|
1077
|
-
console.log('- 只做本步骤描述的操作,不得自行扩展或跳过')
|
|
1078
|
-
console.log('- 不要回头修改已完成的步骤')
|
|
1079
|
-
console.log('- 不要编造不存在的 CLI 子命令')
|
|
1080
|
-
console.log('- 完成后立即执行 --done 命令,不得跳过')
|
|
1081
|
-
console.log('- 不要用 mv/rename 重命名变更目录,必须用 `sillyspec change-rename <旧名> <新名>`')
|
|
1082
|
-
console.log('- 文档类型文件(.md/.yaml/.json 等)头部必须包含 author(git 用户名)和 created_at(精确到秒)')
|
|
1083
|
-
console.log('- 执行构建/测试前必须先读 local.yaml,优先使用其中配置的命令、路径和环境变量;未配置时才使用默认值')
|
|
1084
|
-
// 平台模式额外铁律
|
|
1085
|
-
if (platformOpts?.specRoot || platformOpts?.runtimeRoot) {
|
|
1086
|
-
const specSillyspec = platformOpts.specRoot || join(cwd, '.sillyspec')
|
|
1087
|
-
console.log(`- **平台模式:所有文件只能写入 \`${specSillyspec}/\` 下的对应子目录,严禁写入源码目录。**`)
|
|
1088
|
-
console.log('- **平台模式:Write 工具失败时,不允许用 cat > / tee / heredoc 等方式绕过。先 Read 再 Write,仍失败则记录并停止。**')
|
|
1089
|
-
console.log('- **平台模式:local.yaml 中的 commands 必须在 package.json scripts 中真实存在,不存在的标记 unavailable。**')
|
|
1090
|
-
}
|
|
1091
|
-
// 路径安全规则:防止 AI 拼错变更目录
|
|
1092
|
-
if (changeName) {
|
|
1093
|
-
const isPlatform = platformOpts?.specRoot || platformOpts?.runtimeRoot
|
|
1094
|
-
const changeDirBase = isPlatform ? platformOpts.specRoot : '.sillyspec'
|
|
1095
|
-
const changeDir = join(changeDirBase, 'changes', changeName)
|
|
1096
|
-
console.log(`- **文件路径规则:所有变更文件必须写入 \`${changeDir}/\` 目录下。不要自己拼接路径,直接使用 changeDir 值。示例:\`${changeDir}/proposal.md\`**`)
|
|
1097
|
-
}
|
|
1098
|
-
const changeFlag = changeName ? ` --change ${changeName}` : ''
|
|
1099
|
-
// 检测当前 step prompt 是否包含 WAIT 指令(即可能需要等待用户)
|
|
1100
|
-
const stepPrompt = promptText || ''
|
|
1101
|
-
const requiresWait = step.requiresWait === true
|
|
1102
|
-
const conditionalWait = step.conditionalWait === true
|
|
1103
|
-
const mayNeedWait = WAIT_MARKER_RE.test(stepPrompt) || requiresWait || conditionalWait
|
|
1104
|
-
|
|
1105
|
-
console.log(`\n### 完成后执行`)
|
|
1106
|
-
if (requiresWait) {
|
|
1107
|
-
console.log(`本步骤必须等待用户输入,不能直接 --done:`)
|
|
1108
|
-
console.log(`sillyspec run ${stageName} --wait --reason "${step.waitReason || '等待用户输入'}" --options "${(step.waitOptions || ['确认']).join(',')}"${changeFlag} --output "你的问题/方案摘要"`)
|
|
1109
|
-
console.log(``)
|
|
1110
|
-
console.log(`用户回答后执行:`)
|
|
1111
|
-
console.log(`sillyspec run ${stageName} --continue --answer "用户回答"${changeFlag}`)
|
|
1112
|
-
console.log(``)
|
|
1113
|
-
console.log(`收到回答并完成本步骤总结后,再执行:`)
|
|
1114
|
-
} else if (mayNeedWait) {
|
|
1115
|
-
console.log(`如果需要用户决策(选择方案/确认设计等):`)
|
|
1116
|
-
console.log(`sillyspec run ${stageName} --wait --reason "${step.waitReason || '等待原因'}" --options "${(step.waitOptions || ['选项1', '选项2']).join(',')}"${changeFlag} --output "你的摘要"`)
|
|
1117
|
-
console.log(``)
|
|
1118
|
-
console.log(`如果不需要用户决策,正常完成:`)
|
|
1119
|
-
}
|
|
1120
|
-
console.log(`sillyspec run ${stageName} --done${changeFlag} --input "用户原始需求/反馈" --output "你的摘要"`)
|
|
1121
|
-
}
|
|
1122
|
-
|
|
1123
|
-
/**
|
|
1124
|
-
* 估算源码规模(文件数 + 字节数)—— 跨平台 Node 遍历,替代 Unix `find`(原生 Windows 无 find,
|
|
1125
|
-
* 原 find 失效会让所有 Windows 项目永远 fallback 到 standard profile)。
|
|
1126
|
-
* 扩展名与排除目录与原 find 命令逐字一致;maxFiles 封顶避免超大仓库遍历过久
|
|
1127
|
-
* (超限时 fileCount 已远超 quick/standard 阈值,不影响 profile 判定)。
|
|
1128
|
-
*/
|
|
1129
|
-
function estimateSourceSize(cwd, maxFiles = 5000) {
|
|
1130
|
-
const sourceExts = new Set(['.js','.ts','.tsx','.py','.java','.go','.rs','.rb','.php','.c','.cpp','.h','.jsx','.vue','.svelte'])
|
|
1131
|
-
const skipDirs = new Set(['node_modules','.git','dist','build','__pycache__','.sillyspec','.claude'])
|
|
1132
|
-
let fileCount = 0
|
|
1133
|
-
let sourceBytes = 0
|
|
1134
|
-
const stack = [cwd]
|
|
1135
|
-
while (stack.length) {
|
|
1136
|
-
const dir = stack.pop()
|
|
1137
|
-
let entries
|
|
1138
|
-
try { entries = readdirSync(dir, { withFileTypes: true }) }
|
|
1139
|
-
catch { continue }
|
|
1140
|
-
for (const e of entries) {
|
|
1141
|
-
if (e.isDirectory()) {
|
|
1142
|
-
if (!skipDirs.has(e.name)) stack.push(join(dir, e.name))
|
|
1143
|
-
} else if (e.isFile() && sourceExts.has(extname(e.name))) {
|
|
1144
|
-
fileCount++
|
|
1145
|
-
try { sourceBytes += statSync(join(dir, e.name)).size } catch {}
|
|
1146
|
-
if (fileCount >= maxFiles) return { fileCount, sourceBytes }
|
|
1147
|
-
}
|
|
1148
|
-
}
|
|
1149
|
-
}
|
|
1150
|
-
return { fileCount, sourceBytes }
|
|
1151
|
-
}
|
|
1152
|
-
|
|
1153
|
-
/**
|
|
1154
|
-
* 根据 project 规模计算 scan profile
|
|
1155
|
-
* quick: fileCount≤30 && sourceBytes≤80KB && projectCount≤3 → 3 步,0 子代理,5 份文档
|
|
1156
|
-
* standard: fileCount≤200 && sourceBytes≤800KB → 压缩步骤,最多 1 子代理
|
|
1157
|
-
* deep: 大项目或 --deep → 完整流程
|
|
1158
|
-
*/
|
|
1159
|
-
function computeScanProfile(cwd, platformOpts) {
|
|
1160
|
-
// --deep 标志强制 deep
|
|
1161
|
-
const flags = process.argv.slice(2)
|
|
1162
|
-
if (flags.includes('--deep')) {
|
|
1163
|
-
return { mode: 'deep', reason: '用户指定 --deep', maxAgentCalls: 4, maxDocs: 99 }
|
|
1164
|
-
}
|
|
1165
|
-
|
|
1166
|
-
const specDir = platformOpts?.specRoot || join(cwd, '.sillyspec')
|
|
1167
|
-
const projectsDir = join(specDir, 'projects')
|
|
1168
|
-
let projectCount = 1
|
|
1169
|
-
try {
|
|
1170
|
-
if (existsSync(projectsDir)) {
|
|
1171
|
-
projectCount = readdirSync(projectsDir).filter(f => f.endsWith('.yaml')).length
|
|
1172
|
-
}
|
|
1173
|
-
} catch {}
|
|
1174
|
-
|
|
1175
|
-
// 快速估算源码规模(Node 遍历,跨平台;原 Unix find 在原生 Windows 上失效会永远 fallback standard)
|
|
1176
|
-
let fileCount = 0
|
|
1177
|
-
let sourceBytes = 0
|
|
1178
|
-
try {
|
|
1179
|
-
const est = estimateSourceSize(cwd)
|
|
1180
|
-
fileCount = est.fileCount
|
|
1181
|
-
sourceBytes = est.sourceBytes
|
|
1182
|
-
} catch {
|
|
1183
|
-
// 遍历异常时假设中等规模
|
|
1184
|
-
return { mode: 'standard', reason: '无法估算项目规模', maxAgentCalls: 1, maxDocs: 8 }
|
|
1185
|
-
}
|
|
1186
|
-
|
|
1187
|
-
if (fileCount <= 30 && sourceBytes <= 80_000 && projectCount <= 3) {
|
|
1188
|
-
return { mode: 'quick', reason: `${fileCount} 源文件, ${Math.round(sourceBytes / 1024)}KB`, maxAgentCalls: 0, maxDocs: 5, _fileCount: fileCount, _sourceBytes: sourceBytes, _projectCount: projectCount }
|
|
1189
|
-
}
|
|
1190
|
-
if (fileCount <= 200 && sourceBytes <= 800_000) {
|
|
1191
|
-
return { mode: 'standard', reason: `${fileCount} 源文件, ${Math.round(sourceBytes / 1024)}KB`, maxAgentCalls: 1, maxDocs: 8, _fileCount: fileCount, _sourceBytes: sourceBytes, _projectCount: projectCount }
|
|
1192
|
-
}
|
|
1193
|
-
return { mode: 'deep', reason: `${fileCount} 源文件, ${Math.round(sourceBytes / 1024)}KB`, maxAgentCalls: 4, maxDocs: 99, _fileCount: fileCount, _sourceBytes: sourceBytes, _projectCount: projectCount }
|
|
1194
|
-
}
|
|
1195
|
-
|
|
1196
|
-
/**
|
|
1197
|
-
* 根据 scanProfile 裁剪步骤
|
|
1198
|
-
* quick: 3 步 — CLI preflight / AI generate / CLI postcheck
|
|
1199
|
-
* standard: 跳过续扫检测(4), 跳过可选步骤(9)
|
|
1200
|
-
*/
|
|
1201
|
-
function applyScanProfileSteps(stageData, profile, cwd, platformOpts) {
|
|
1202
|
-
const steps = stageData.steps
|
|
1203
|
-
const mode = profile.mode
|
|
1204
|
-
|
|
1205
|
-
if (mode === 'quick') {
|
|
1206
|
-
const specBase = platformOpts?.specRoot || join(cwd, '.sillyspec')
|
|
1207
|
-
const projectName = basename(cwd)
|
|
1208
|
-
const docsRoot = join(specBase, 'docs', projectName)
|
|
1209
|
-
|
|
1210
|
-
// Step 1: CLI preflight(不调 AI,自动完成)
|
|
1211
|
-
const step1 = {
|
|
1212
|
-
name: '项目概览(自动探测)',
|
|
1213
|
-
status: 'pending',
|
|
1214
|
-
noAI: true,
|
|
1215
|
-
_cliAction: 'scanPreflight',
|
|
1216
|
-
prompt: '',
|
|
1217
|
-
outputHint: 'preflight 结果',
|
|
1218
|
-
optional: false
|
|
1219
|
-
}
|
|
1220
|
-
// Step 2: AI 生成核心文档(唯一 AI roundtrip)
|
|
1221
|
-
const step2 = {
|
|
1222
|
-
name: '生成核心文档',
|
|
1223
|
-
status: 'pending',
|
|
1224
|
-
prompt: `## Quick Scan — 核心文档生成
|
|
1225
|
-
|
|
1226
|
-
项目规模较小(quick profile),请一次性生成所有核心文档。
|
|
1227
|
-
|
|
1228
|
-
### 操作
|
|
1229
|
-
1. 读取项目结构和关键文件(package.json / pyproject.toml / README / 入口文件)
|
|
1230
|
-
2. 生成以下 4 份文档并写入 \`{DOCS_ROOT}/scan/\`:
|
|
1231
|
-
- **PROJECT.md** — 项目简介、技术栈、模块划分
|
|
1232
|
-
- **ARCHITECTURE.md** — 架构概览、模块关系、技术决策
|
|
1233
|
-
- **CONVENTIONS.md** — 代码风格、框架隐形规则
|
|
1234
|
-
- **STRUCTURE.md** — 目录树 + 模块说明
|
|
1235
|
-
3. 如发现子项目,注册到 \`{PROJECTS_ROOT}/\` 下
|
|
1236
|
-
|
|
1237
|
-
每份文档头必须包含 frontmatter:\`author\` 和 \`created_at\`。
|
|
1238
|
-
|
|
1239
|
-
### ⛔ 硬约束
|
|
1240
|
-
- **严禁使用子代理(Agent/Task 工具)。** 所有文档在一个 turn 内完成。
|
|
1241
|
-
- 不要搜索 .sillyspec/ .claude/ .git/ node_modules/ dist/ build/
|
|
1242
|
-
- --output 只需要列出生成的文件名,不要写长篇总结
|
|
1243
|
-
|
|
1244
|
-
### 输出
|
|
1245
|
-
生成的文件列表`,
|
|
1246
|
-
outputHint: '文件列表',
|
|
1247
|
-
optional: false
|
|
1248
|
-
}
|
|
1249
|
-
// Step 3: CLI postcheck(不调 AI,自动完成)
|
|
1250
|
-
const selfCheck = steps.find(s => s.name === '自检和提交') || {
|
|
1251
|
-
name: '自检和提交', status: 'pending', prompt: '', outputHint: '结果', optional: false
|
|
1252
|
-
}
|
|
1253
|
-
const step3 = { ...selfCheck, status: 'pending', noAI: true, _cliAction: 'scanPostcheck', prompt: '' }
|
|
1254
|
-
stageData.steps = [step1, step2, step3]
|
|
1255
|
-
return
|
|
1256
|
-
}
|
|
1257
|
-
|
|
1258
|
-
if (mode === 'standard') {
|
|
1259
|
-
// 跳过 Step 4(断点续扫检测),跳过 Step 9(flows+glossary,可选)
|
|
1260
|
-
const skipNames = ['断点续扫检测', '生成业务流程和术语表(可选)']
|
|
1261
|
-
for (const step of stageData.steps) {
|
|
1262
|
-
if (skipNames.includes(step.name) && step.status === 'pending') {
|
|
1263
|
-
step.status = 'skipped'
|
|
1264
|
-
step.skippedAt = new Date().toLocaleString('zh-CN', { hour12: false })
|
|
1265
|
-
}
|
|
1266
|
-
}
|
|
1267
|
-
}
|
|
1268
|
-
}
|
|
1269
|
-
|
|
1270
|
-
/**
|
|
1271
|
-
* CLI-only: quick scan preflight
|
|
1272
|
-
* 收集项目快照,打印 summary,不调 AI
|
|
1273
|
-
*/
|
|
1274
|
-
async function executeScanPreflight(cwd, platformOpts, scanProfile) {
|
|
1275
|
-
const specBase = platformOpts?.specRoot || join(cwd, '.sillyspec')
|
|
1276
|
-
const projectName = basename(cwd)
|
|
1277
|
-
console.log(` 📁 项目: ${projectName}`)
|
|
1278
|
-
console.log(` 📊 Profile: ${scanProfile.mode} (${scanProfile.reason})`)
|
|
1279
|
-
// 快速列出顶层结构(readdirSync 跨平台,避免 spawn shell + 5 个 grep 进程)
|
|
1280
|
-
try {
|
|
1281
|
-
const skip = new Set(['node_modules', '.git', '.sillyspec', '.claude'])
|
|
1282
|
-
const dirNames = readdirSync(cwd, { withFileTypes: true })
|
|
1283
|
-
.filter(d => d.isDirectory() && !skip.has(d.name) && !d.name.startsWith('.'))
|
|
1284
|
-
.slice(0, 20)
|
|
1285
|
-
.map(d => d.name)
|
|
1286
|
-
if (dirNames.length) {
|
|
1287
|
-
console.log(` 📂 目录: ${dirNames.join(', ')}`)
|
|
1288
|
-
}
|
|
1289
|
-
} catch {}
|
|
1290
|
-
console.log(` ✅ Preflight 完成,准备生成核心文档\n`)
|
|
1291
|
-
}
|
|
1292
|
-
|
|
1293
|
-
/**
|
|
1294
|
-
* CLI-only: quick scan postcheck
|
|
1295
|
-
* 执行文件存在性 + manifest 检查,不调 AI
|
|
1296
|
-
*/
|
|
1297
|
-
async function executeScanPostcheck(cwd, platformOpts, scanProfile) {
|
|
1298
|
-
const { runScanPostCheck, printScanPostCheckResult } = await import('./scan-postcheck.js')
|
|
1299
|
-
const specDir = platformOpts?.specRoot || null
|
|
1300
|
-
const result = runScanPostCheck({
|
|
1301
|
-
cwd,
|
|
1302
|
-
specDir,
|
|
1303
|
-
scanMeta: {
|
|
1304
|
-
projectListParsed: true,
|
|
1305
|
-
manifestWritten: undefined,
|
|
1306
|
-
},
|
|
1307
|
-
})
|
|
1308
|
-
printScanPostCheckResult(result)
|
|
1309
|
-
// 写 manifest(如果还没写)
|
|
1310
|
-
if (platformOpts?.specRoot) {
|
|
1311
|
-
try {
|
|
1312
|
-
const { mkdirSync, writeFileSync } = await import('fs')
|
|
1313
|
-
const { join: pJoin, basename: pBasename } = await import('path')
|
|
1314
|
-
const { execSync } = await import('child_process')
|
|
1315
|
-
const manifestDir = platformOpts.specRoot
|
|
1316
|
-
let sourceCommit = null
|
|
1317
|
-
let sourceCommitError = null
|
|
1318
|
-
try {
|
|
1319
|
-
const gitResult = safeGit(cwd, ['rev-parse', 'HEAD'])
|
|
1320
|
-
sourceCommit = gitResult.value
|
|
1321
|
-
sourceCommitError = gitResult.error
|
|
1322
|
-
} catch (e) {
|
|
1323
|
-
sourceCommitError = e.message
|
|
1324
|
-
}
|
|
1325
|
-
mkdirSync(manifestDir, { recursive: true })
|
|
1326
|
-
const manifest = {
|
|
1327
|
-
scan_profile: {
|
|
1328
|
-
mode: scanProfile.mode,
|
|
1329
|
-
file_count: scanProfile._fileCount || 0,
|
|
1330
|
-
source_bytes: scanProfile._sourceBytes || 0,
|
|
1331
|
-
project_count: scanProfile._projectCount || 0,
|
|
1332
|
-
reason: scanProfile.reason,
|
|
1333
|
-
},
|
|
1334
|
-
workspace_id: platformOpts.workspaceId || null,
|
|
1335
|
-
scan_run_id: platformOpts.scanRunId || null,
|
|
1336
|
-
source_commit: sourceCommit,
|
|
1337
|
-
source_commit_error: sourceCommit === null ? (sourceCommitError || 'unknown') : undefined,
|
|
1338
|
-
generated_at: new Date().toISOString(),
|
|
1339
|
-
schema_version: 2,
|
|
1340
|
-
}
|
|
1341
|
-
const manifestPath = pJoin(manifestDir, 'manifest.json')
|
|
1342
|
-
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n')
|
|
1343
|
-
console.log(` 📄 manifest.json 已写入: ${manifestPath}`)
|
|
1344
|
-
} catch (e) {
|
|
1345
|
-
console.warn(` ⚠️ manifest 写入失败: ${e.message}`)
|
|
1346
|
-
}
|
|
1347
|
-
}
|
|
1348
|
-
}
|
|
1349
|
-
|
|
1350
|
-
/**
|
|
1351
|
-
* Plan postcheck 的执行代理:委托给 plan-postcheck.js 模块
|
|
1352
|
-
*/
|
|
1353
|
-
async function executePlanPostcheck(cwd, platformOpts, progress) {
|
|
1354
|
-
// resolveChangeDir 是本模块 line 505 定义的本地函数;历史上曾误从 ./modules.js
|
|
1355
|
-
// 导入(该模块未导出此函数,得到 undefined),导致 plan-postcheck.js:388 抛
|
|
1356
|
-
// "resolveChangeDir is not a function"。详见 docs/sillyspec/plan-postcheck-resolvechangedir-not-a-function.md
|
|
1357
|
-
const { executePlanPostcheck: runPostcheck } = await import('./stages/plan-postcheck.js')
|
|
1358
|
-
await runPostcheck({
|
|
1359
|
-
cwd,
|
|
1360
|
-
specRoot: platformOpts?.specRoot,
|
|
1361
|
-
resolveChangeDir,
|
|
1362
|
-
progress
|
|
1363
|
-
})
|
|
1364
|
-
}
|
|
1365
|
-
|
|
1366
|
-
/**
|
|
1367
|
-
* 替换 prompt 文本中的路径根占位符 {SPEC_ROOT}/{DOCS_ROOT}/{PROJECTS_ROOT}/
|
|
1368
|
-
* {WORKFLOWS_ROOT}/{KNOWLEDGE_ROOT}。平台模式与常规模式共用:仅传入的 roots 值不同。
|
|
1369
|
-
* 占位符值都是绝对路径、互不包含其它占位符,故替换顺序不影响结果。
|
|
1370
|
-
*
|
|
1371
|
-
* 注:本函数由并行会话引入(outputStep 两处逐字重复逻辑的抽取),重构期间 run.js 一次
|
|
1372
|
-
* git checkout 误覆盖其未提交版本,此处按 test/prompt-placeholders.test.mjs 的契约重建,
|
|
1373
|
-
* 行为等价。
|
|
1374
|
-
*/
|
|
1375
|
-
export function applyRootPlaceholders(text, roots) {
|
|
1376
|
-
return text
|
|
1377
|
-
.replaceAll('{SPEC_ROOT}', roots.specRoot)
|
|
1378
|
-
.replaceAll('{DOCS_ROOT}', roots.docsRoot)
|
|
1379
|
-
.replaceAll('{PROJECTS_ROOT}', roots.projectsRoot)
|
|
1380
|
-
.replaceAll('{WORKFLOWS_ROOT}', roots.workflowsRoot)
|
|
1381
|
-
.replaceAll('{KNOWLEDGE_ROOT}', roots.knowledgeRoot)
|
|
1382
|
-
}
|
|
1383
|
-
|
|
1384
|
-
/**
|
|
1385
|
-
* sillyspec run <stage> 主命令
|
|
1386
|
-
*/
|
|
1387
|
-
export async function runCommand(args, cwd, specDir = null) {
|
|
1388
|
-
// 解析参数
|
|
1389
|
-
const stageName = args[0]
|
|
1390
|
-
const flags = args.slice(1)
|
|
1391
|
-
|
|
1392
|
-
if (!stageName) {
|
|
1393
|
-
console.error('❌ 请指定阶段,例如: sillyspec run brainstorm')
|
|
1394
|
-
console.error(`可选: ${Object.keys(stageRegistry).join(', ')}, auto`)
|
|
1395
|
-
process.exit(1)
|
|
1396
|
-
}
|
|
1397
|
-
|
|
1398
|
-
if (!stageRegistry[stageName] && stageName !== 'auto') {
|
|
1399
|
-
console.error(`❌ 未知阶段: ${stageName}`)
|
|
1400
|
-
console.error(`可选: ${Object.keys(stageRegistry).join(', ')}, auto`)
|
|
1401
|
-
process.exit(1)
|
|
1402
|
-
}
|
|
1403
|
-
|
|
1404
|
-
// ── cwd 纠正:向上查找真实项目根 ──
|
|
1405
|
-
// 防止多 project 工作区中 cwd 停在子目录(如 backend/)时
|
|
1406
|
-
// 状态写入子目录下误建的 .sillyspec,导致状态分裂
|
|
1407
|
-
if (!specDir && !existsSync(join(cwd, '.sillyspec-platform.json'))) {
|
|
1408
|
-
// 平台模式(cwd 有 .sillyspec-platform.json 指针)不做 cwd 纠正:指针已明确 specDir,
|
|
1409
|
-
// 向上找 .sillyspec 反而会撞到无关项目(如用户 home 的 .sillyspec),导致 --done 恢复
|
|
1410
|
-
// 读写 ~/.sillyspec-platform.json 出错的 specRoot。
|
|
1411
|
-
const resolvedRoot = resolveSpecDir(cwd)
|
|
1412
|
-
if (resolvedRoot && resolvedRoot !== join(cwd, '.sillyspec')) {
|
|
1413
|
-
const realRoot = dirname(resolvedRoot)
|
|
1414
|
-
if (realRoot !== cwd) {
|
|
1415
|
-
cwd = realRoot
|
|
1416
|
-
}
|
|
1417
|
-
}
|
|
1418
|
-
}
|
|
1419
|
-
|
|
1420
|
-
// 平台模式参数(供 SillyHub 等平台调用)
|
|
1421
|
-
// --spec-dir 是统一参数名,--spec-root 保留为向后兼容别名
|
|
1422
|
-
const getFlagValue = (name) => {
|
|
1423
|
-
const idx = flags.indexOf(name)
|
|
1424
|
-
return idx !== -1 && flags[idx + 1] ? flags[idx + 1] : null
|
|
1425
|
-
}
|
|
1426
|
-
const isDone = flags.includes('--done')
|
|
1427
|
-
const isSkip = flags.includes('--skip')
|
|
1428
|
-
const isStatus = flags.includes('--status')
|
|
1429
|
-
const isReset = flags.includes('--reset')
|
|
1430
|
-
const isReopen = flags.includes('--reopen')
|
|
1431
|
-
const fromStepValue = getFlagValue('--from-step')
|
|
1432
|
-
const isConfirm = flags.includes('--confirm')
|
|
1433
|
-
const isSkipApproval = flags.includes('--skip-approval')
|
|
1434
|
-
const isWait = flags.includes('--wait')
|
|
1435
|
-
const isContinue = flags.includes('--continue')
|
|
1436
|
-
const isNonInteractive = flags.includes('--non-interactive')
|
|
1437
|
-
const isInteractive = flags.includes('--interactive')
|
|
1438
|
-
const waitReason = getFlagValue('--reason')
|
|
1439
|
-
const waitOptions = getFlagValue('--options')
|
|
1440
|
-
const continueAnswer = getFlagValue('--answer')
|
|
1441
|
-
const confirmMode = getFlagValue('--confirm-mode')
|
|
1442
|
-
const resolvedSpecDir = specDir || getFlagValue('--spec-dir') || getFlagValue('--spec-root')
|
|
1443
|
-
const platformOpts = {
|
|
1444
|
-
specRoot: resolvedSpecDir ? resolve(resolvedSpecDir) : null,
|
|
1445
|
-
runtimeRoot: getFlagValue('--runtime-root') ? resolve(getFlagValue('--runtime-root')) : null,
|
|
1446
|
-
workspaceId: getFlagValue('--workspace-id'),
|
|
1447
|
-
scanRunId: getFlagValue('--scan-run-id'),
|
|
1448
|
-
}
|
|
1449
|
-
|
|
1450
|
-
// 跨 --done 生命周期:从 metadata 文件恢复 platformOpts
|
|
1451
|
-
// 首次 scan 时写入,所有后续调用(包括 run、--done、--skip)都读取
|
|
1452
|
-
// 优先在 specDir 下查找,否则回退到 cwd/.sillyspec/.runtime/
|
|
1453
|
-
let specRoot = platformOpts.specRoot || resolveSpecDir(cwd)
|
|
1454
|
-
// 平台参数恢复策略:
|
|
1455
|
-
// 1. 优先检查 cwd/.sillyspec-platform.json(轻量指针文件,不污染 .sillyspec 结构)
|
|
1456
|
-
// 2. 然后检查 specRoot/.runtime/platform-scan.json(首次 scan 写入)
|
|
1457
|
-
const platformPointer = join(cwd, '.sillyspec-platform.json')
|
|
1458
|
-
const platformScanFile = join(specRoot, '.runtime', 'platform-scan.json')
|
|
1459
|
-
let platformOptsFile = existsSync(platformPointer) ? platformPointer : platformScanFile
|
|
1460
|
-
let platformFileExists = existsSync(platformOptsFile)
|
|
1461
|
-
// 如果命令行没传 spec-root,尝试从持久化文件恢复
|
|
1462
|
-
if (!platformOpts.specRoot && !platformOpts.runtimeRoot) {
|
|
1463
|
-
if (platformFileExists) {
|
|
1464
|
-
try {
|
|
1465
|
-
const { readFileSync } = await import('fs')
|
|
1466
|
-
const saved = JSON.parse(readFileSync(platformOptsFile, 'utf8'))
|
|
1467
|
-
if (saved.specRoot) platformOpts.specRoot = saved.specRoot
|
|
1468
|
-
if (saved.runtimeRoot) platformOpts.runtimeRoot = saved.runtimeRoot
|
|
1469
|
-
if (saved.workspaceId) platformOpts.workspaceId = saved.workspaceId
|
|
1470
|
-
if (saved.scanRunId) platformOpts.scanRunId = saved.scanRunId
|
|
1471
|
-
// 平台模式 fail-fast:文件存在但缺少 specRoot
|
|
1472
|
-
if (!platformOpts.specRoot && !platformOpts.runtimeRoot) {
|
|
1473
|
-
console.error(`❌ 平台模式参数文件存在但缺少 specRoot/runtimeRoot: ${platformOptsFile}`)
|
|
1474
|
-
console.error(' 可能原因:platform-scan.json 损坏或写入不完整')
|
|
1475
|
-
console.error(' 解决:重新运行首次 scan 并传入 --spec-root')
|
|
1476
|
-
process.exit(1)
|
|
1477
|
-
}
|
|
1478
|
-
// 恢复成功:更新 specRoot(初始值可能是 cwd/.sillyspec,恢复后应为真实 specDir)
|
|
1479
|
-
specRoot = platformOpts.specRoot || specRoot
|
|
1480
|
-
} catch (e) {
|
|
1481
|
-
console.error(`❌ 平台模式参数文件读取失败: ${platformOptsFile}`)
|
|
1482
|
-
console.error(` 错误: ${e.message}`)
|
|
1483
|
-
console.error(' 可能原因:文件损坏')
|
|
1484
|
-
console.error(' 解决:删除该文件并重新运行首次 scan 传入 --spec-root')
|
|
1485
|
-
process.exit(1)
|
|
1486
|
-
}
|
|
1487
|
-
}
|
|
1488
|
-
}
|
|
1489
|
-
// 持久化 platformOpts
|
|
1490
|
-
// 在 specRoot/.runtime/ 写主文件,同时在 cwd/.sillyspec/.runtime/ 写恢复指针
|
|
1491
|
-
if (platformOpts.specRoot || platformOpts.runtimeRoot) {
|
|
1492
|
-
try {
|
|
1493
|
-
const { mkdirSync, writeFileSync } = await import('fs')
|
|
1494
|
-
mkdirSync(join(specRoot, '.runtime'), { recursive: true })
|
|
1495
|
-
writeFileSync(platformOptsFile, JSON.stringify({
|
|
1496
|
-
specRoot: platformOpts.specRoot,
|
|
1497
|
-
runtimeRoot: platformOpts.runtimeRoot,
|
|
1498
|
-
workspaceId: platformOpts.workspaceId,
|
|
1499
|
-
scanRunId: platformOpts.scanRunId,
|
|
1500
|
-
savedAt: new Date().toISOString(),
|
|
1501
|
-
}, null, 2) + '\n')
|
|
1502
|
-
// 恢复指针:在 cwd 下写 .sillyspec-platform.json(不在 .sillyspec 内,不污染源码结构)
|
|
1503
|
-
// 供后续 --done(不带 --spec-root)找到 specDir
|
|
1504
|
-
writeFileSync(join(cwd, '.sillyspec-platform.json'), JSON.stringify({
|
|
1505
|
-
specRoot: platformOpts.specRoot,
|
|
1506
|
-
runtimeRoot: platformOpts.runtimeRoot,
|
|
1507
|
-
workspaceId: platformOpts.workspaceId,
|
|
1508
|
-
scanRunId: platformOpts.scanRunId,
|
|
1509
|
-
savedAt: new Date().toISOString(),
|
|
1510
|
-
}, null, 2) + '\n')
|
|
1511
|
-
} catch {
|
|
1512
|
-
// 静默失败,不影响主流程
|
|
1513
|
-
}
|
|
1514
|
-
}
|
|
1515
|
-
|
|
1516
|
-
// 统一规范基路径:平台模式用 specRoot,本地模式用 cwd/.sillyspec
|
|
1517
|
-
// runCommand 后续所有 .sillyspec/ 操作必须用 specBase
|
|
1518
|
-
const specBase = platformOpts.specRoot || join(cwd, '.sillyspec')
|
|
1519
|
-
|
|
1520
|
-
// 平台模式:首次接入时清理旧版本残留的 cwd/.sillyspec/(防止源码污染)。
|
|
1521
|
-
// ⚠️ 同 init.js:必须保护真实资产(changes/、projects/、sillyspec.db)。
|
|
1522
|
-
// 只在「首次」执行一次——用 cwd 下的 .sillyspec-platform-cleaned 标记文件记录已处理,
|
|
1523
|
-
// 后续每次 run 直接跳过,避免重复检查 + 红叉噪声(此清理不阻塞流程、不动真实资产)。
|
|
1524
|
-
if (platformOpts.specRoot) {
|
|
1525
|
-
const legacyDir = join(cwd, '.sillyspec')
|
|
1526
|
-
const cleanedMarker = join(cwd, '.sillyspec-platform-cleaned')
|
|
1527
|
-
if (!existsSync(cleanedMarker)) {
|
|
1528
|
-
if (existsSync(legacyDir)) {
|
|
1529
|
-
let hasChanges = false;
|
|
1530
|
-
try {
|
|
1531
|
-
const cd = join(legacyDir, 'changes');
|
|
1532
|
-
if (existsSync(cd)) hasChanges = readdirSync(cd).length > 0;
|
|
1533
|
-
} catch {}
|
|
1534
|
-
let hasProjects = false;
|
|
1535
|
-
try {
|
|
1536
|
-
const pd = join(legacyDir, 'projects');
|
|
1537
|
-
if (existsSync(pd)) hasProjects = readdirSync(pd).length > 0;
|
|
1538
|
-
} catch {}
|
|
1539
|
-
const hasDb = existsSync(join(legacyDir, 'sillyspec.db'));
|
|
1540
|
-
|
|
1541
|
-
if (hasChanges || hasProjects || hasDb) {
|
|
1542
|
-
// 真实资产存在:只清运行时残留(白名单保留 worktrees/进度),不整删
|
|
1543
|
-
const { cleanupRuntimeResidue } = await import('./init.js')
|
|
1544
|
-
cleanupRuntimeResidue(legacyDir);
|
|
1545
|
-
console.log('ℹ️ [sillyspec] 源码目录 .sillyspec/ 含真实资产,已跳过整删,仅清理运行时残留(仅本次首次,后续不再提示)。');
|
|
1546
|
-
} else {
|
|
1547
|
-
try { rmSync(legacyDir, { recursive: true, force: true }) } catch {}
|
|
1548
|
-
if (!existsSync(legacyDir)) console.log('🧹 已清理旧版本残留的源码 .sillyspec/ 目录');
|
|
1549
|
-
}
|
|
1550
|
-
}
|
|
1551
|
-
// 标记本 cwd 已做平台残留清理决策,后续 run 跳过(即使之后 .sillyspec/ 被重建也不误清)
|
|
1552
|
-
try { writeFileSync(cleanedMarker, new Date().toISOString() + '\n') } catch {}
|
|
1553
|
-
}
|
|
1554
|
-
}
|
|
1555
|
-
|
|
1556
|
-
// 解析 --output
|
|
1557
|
-
let outputText = null
|
|
1558
|
-
const outputIdx = flags.indexOf('--output')
|
|
1559
|
-
if (outputIdx !== -1 && flags[outputIdx + 1]) {
|
|
1560
|
-
outputText = flags[outputIdx + 1]
|
|
1561
|
-
}
|
|
1562
|
-
|
|
1563
|
-
// 解析 --input
|
|
1564
|
-
let inputText = null
|
|
1565
|
-
const inputIdx = flags.indexOf('--input')
|
|
1566
|
-
if (inputIdx !== -1 && flags[inputIdx + 1]) {
|
|
1567
|
-
inputText = flags[inputIdx + 1]
|
|
1568
|
-
}
|
|
1569
|
-
|
|
1570
|
-
// 解析 --linked-changes <a,b|none>(quick 专用:显式声明关联变更,CI/脚本友好)
|
|
1571
|
-
// 与 --change 解耦:--linked-changes 语义清晰(关联变更),不与「指定变更名」混淆。
|
|
1572
|
-
// null = 未指定(走持久化/交互/兼容回退);[] = 显式 none(不关联);[...] = 显式列表
|
|
1573
|
-
let explicitLinked = null
|
|
1574
|
-
const linkedIdx = flags.indexOf('--linked-changes')
|
|
1575
|
-
if (linkedIdx !== -1 && flags[linkedIdx + 1]) {
|
|
1576
|
-
const v = flags[linkedIdx + 1].trim()
|
|
1577
|
-
explicitLinked = v.toLowerCase() === 'none' ? [] : v.split(',').map(s => s.trim()).filter(Boolean)
|
|
1578
|
-
}
|
|
1579
|
-
|
|
1580
|
-
// 解析 --change <name>(quick 阶段向后兼容:逗号分隔作为「关联变更」;
|
|
1581
|
-
// 历史写法,语义与「指定变更名」冲突,新用法建议改用 --linked-changes)
|
|
1582
|
-
let changeName = null
|
|
1583
|
-
let linkedChanges = []
|
|
1584
|
-
const changeIdx = flags.indexOf('--change')
|
|
1585
|
-
if (changeIdx !== -1 && flags[changeIdx + 1]) {
|
|
1586
|
-
changeName = flags[changeIdx + 1]
|
|
1587
|
-
if (stageName === 'quick') {
|
|
1588
|
-
linkedChanges = changeName.split(',').map(s => s.trim()).filter(Boolean)
|
|
1589
|
-
changeName = null
|
|
1590
|
-
}
|
|
1591
|
-
}
|
|
1592
|
-
// --linked-changes 优先于 --change(显式 > 隐式兼容)
|
|
1593
|
-
if (explicitLinked !== null) {
|
|
1594
|
-
linkedChanges = explicitLinked
|
|
1595
|
-
}
|
|
1596
|
-
// quick 会话隔离(D-001@v1 + D-003@v1 + §4.4 跨进程传递):每会话用 sessionId 作 changeName,
|
|
1597
|
-
// DB 分行 progress.quick-<uuid8>,避免并行 quick 会话共享单行 progress.default.quick 互相覆盖。
|
|
1598
|
-
// sessionId = crypto.randomUUID 前 8 hex(摒弃旧 quick-YYYYMMDD-HHMMSS 时间戳,同秒并发撞)。
|
|
1599
|
-
// crypto.randomUUID 从 node:crypto import(兼容 engines node>=18,不依赖 Node 19+ 全局)。
|
|
1600
|
-
//
|
|
1601
|
-
// --done 跨进程恢复 sessionId 的优先级(§4.4):
|
|
1602
|
-
// 1. --change quick-<uuid8>(单值且匹配 sessionId 形态)→ 精确指定本会话 sessionId
|
|
1603
|
-
// (quick 的 --change 历史被复用为 linkedChanges 见上 1370-1377;此处对「恰好一个 quick-<8hex>」
|
|
1604
|
-
// 特例识别为 sessionId,多值/不匹配仍走 linkedChanges 语义,向后兼容)
|
|
1605
|
-
// 2. 未识别出 sessionId 且为 --done → fallback 读 .runtime/current-quick-run-id(单会话兼容;
|
|
1606
|
-
// 多会话时可能拿到他者,文档声明建议带 --change)
|
|
1607
|
-
// 3. 仍读不到 → 生成新 UUID(兼容旧行为;进度可能命中空行,由后续 pm.read 兜底)
|
|
1608
|
-
const QUICK_SID_RE = /^quick-[0-9a-f]{8}$/
|
|
1609
|
-
let quickSessionId = null
|
|
1610
|
-
if (stageName === 'quick') {
|
|
1611
|
-
// 1373-1376 已把 quick 的 --change 值清进 linkedChanges、changeName 置 null。
|
|
1612
|
-
// 此处回看 --change 原始值:若恰好是单个 quick-<8hex> → 识别为本会话 sessionId(精确恢复),
|
|
1613
|
-
// 并撤销把它当 linkedChanges 的误判。多值或不匹配 → 维持 linkedChanges 语义(旧兼容)。
|
|
1614
|
-
const rawChange = changeIdx !== -1 && flags[changeIdx + 1] ? flags[changeIdx + 1].trim() : null
|
|
1615
|
-
const rawIsSingleSid = rawChange && rawChange.indexOf(',') === -1 && QUICK_SID_RE.test(rawChange)
|
|
1616
|
-
if (rawIsSingleSid) {
|
|
1617
|
-
// --done --change quick-<uuid8>:精确恢复(撤销 1373-1376 把它误当 linkedChanges)
|
|
1618
|
-
quickSessionId = rawChange
|
|
1619
|
-
changeName = rawChange
|
|
1620
|
-
linkedChanges = []
|
|
1621
|
-
} else if (!changeName) {
|
|
1622
|
-
// 未精确指定:非 --done 必生成新 sessionId;--done 先 fallback current-quick-run-id,读不到再生成
|
|
1623
|
-
const isDoneLike = isDone || isStatus || isSkip || isReset || isReopen
|
|
1624
|
-
if (isDoneLike) {
|
|
1625
|
-
try {
|
|
1626
|
-
const runtimeRoot = platformOpts.runtimeRoot || join(specRoot, '.runtime')
|
|
1627
|
-
const idFile = join(runtimeRoot, 'current-quick-run-id')
|
|
1628
|
-
if (existsSync(idFile)) {
|
|
1629
|
-
const v = readFileSync(idFile, 'utf8').trim()
|
|
1630
|
-
if (QUICK_SID_RE.test(v)) quickSessionId = v
|
|
1631
|
-
}
|
|
1632
|
-
} catch {}
|
|
1633
|
-
}
|
|
1634
|
-
if (!quickSessionId) {
|
|
1635
|
-
quickSessionId = 'quick-' + randomUUID().slice(0, 8)
|
|
1636
|
-
}
|
|
1637
|
-
changeName = quickSessionId
|
|
1638
|
-
} else {
|
|
1639
|
-
// 用户显式传了非 sessionId 形态的变更名 → 尊重,不生成 UUID(旧兼容路径)
|
|
1640
|
-
quickSessionId = changeName
|
|
1641
|
-
}
|
|
1642
|
-
}
|
|
1643
|
-
|
|
1644
|
-
// 解析 --files a.js,b.js(quick 专用:显式声明 allowedFiles)
|
|
1645
|
-
let quickFiles = []
|
|
1646
|
-
const filesIdx = flags.indexOf('--files')
|
|
1647
|
-
if (filesIdx !== -1 && flags[filesIdx + 1]) {
|
|
1648
|
-
quickFiles = flags[filesIdx + 1].split(',').map(f => f.trim()).filter(Boolean)
|
|
1649
|
-
}
|
|
1650
|
-
|
|
1651
|
-
const isAllowNew = flags.includes('--allow-new')
|
|
1652
|
-
const isForceBaseline = flags.includes('--force-baseline')
|
|
1653
|
-
const isForceRescan = flags.includes('--force-rescan')
|
|
1654
|
-
|
|
1655
|
-
// 未知参数 fail-fast
|
|
1656
|
-
const knownFlags = new Set([
|
|
1657
|
-
'--done', '--skip', '--status', '--reset', '--confirm', '--skip-approval',
|
|
1658
|
-
'--wait', '--continue', '--non-interactive', '--interactive',
|
|
1659
|
-
'--reason', '--options', '--answer', '--confirm-mode',
|
|
1660
|
-
'--output', '--input', '--change', '--linked-changes',
|
|
1661
|
-
'--spec-dir', '--spec-root', '--runtime-root', '--workspace-id', '--scan-run-id',
|
|
1662
|
-
'--files', '--allow-new', '--force-baseline', '--force-rescan',
|
|
1663
|
-
'--json', '--dir', '--help',
|
|
1664
|
-
'--reopen', '--from-step', '--mode',
|
|
1665
|
-
])
|
|
1666
|
-
for (let i = 0; i < flags.length; i++) {
|
|
1667
|
-
const f = flags[i]
|
|
1668
|
-
if (f.startsWith('--')) {
|
|
1669
|
-
if (!knownFlags.has(f)) {
|
|
1670
|
-
console.error(`❌ 未知参数: ${f}`)
|
|
1671
|
-
console.error(`已知参数: ${[...knownFlags].sort().join(', ')}`)
|
|
1672
|
-
process.exit(1)
|
|
1673
|
-
}
|
|
1674
|
-
// 跳过 value 参数
|
|
1675
|
-
i++
|
|
1676
|
-
}
|
|
1677
|
-
}
|
|
1678
|
-
|
|
1679
|
-
const isAuxiliary = auxiliaryStages.includes(stageName)
|
|
1680
|
-
// scan 元数据追踪(存储在 stageData.scanMeta 中,completeStep 通过 progress 访问)
|
|
1681
|
-
|
|
1682
|
-
const pm = new ProgressManager({ specDir: specRoot })
|
|
1683
|
-
|
|
1684
|
-
// quick 阶段:确定关联变更
|
|
1685
|
-
// 优先级:--linked-changes / --change 显式 > 已持久化 quick-guard.json(--done 复用)> 交互式 > 非交互 fallback
|
|
1686
|
-
// 关键:--done 收尾时复用首次 run 持久化的 linkedChanges,不在管道/CI 下重复弹交互 prompt。
|
|
1687
|
-
// explicitLinked === null 且未传 --change 时才进入此分支(显式 --linked-changes none 不应触发交互)。
|
|
1688
|
-
if (stageName === 'quick' && explicitLinked === null && linkedChanges.length === 0) {
|
|
1689
|
-
// D-002:guard 按 session 存(.runtime/quick-sessions/<sessionId>/guard.json)。
|
|
1690
|
-
// sessionId == changeName == quick-<uuid8>(上面参数解析已确定)。回退读旧单文件 quick-guard.json(task-03 前兼容)。
|
|
1691
|
-
let persistedLinked = null
|
|
1692
|
-
try {
|
|
1693
|
-
const sessionGuardFile = join(specRoot, '.runtime', 'quick-sessions', changeName, 'guard.json')
|
|
1694
|
-
const legacyGuardFile = join(specRoot, '.runtime', 'quick-guard.json')
|
|
1695
|
-
const g = existsSync(sessionGuardFile)
|
|
1696
|
-
? JSON.parse(readFileSync(sessionGuardFile, 'utf8'))
|
|
1697
|
-
: (existsSync(legacyGuardFile) ? JSON.parse(readFileSync(legacyGuardFile, 'utf8')) : null)
|
|
1698
|
-
if (g && Array.isArray(g.linkedChanges)) persistedLinked = g.linkedChanges
|
|
1699
|
-
} catch {}
|
|
1700
|
-
if (persistedLinked) {
|
|
1701
|
-
linkedChanges = persistedLinked
|
|
1702
|
-
} else {
|
|
1703
|
-
linkedChanges = await resolveQuickLinkedChanges({
|
|
1704
|
-
pm, cwd, specDir: specRoot, quickFiles,
|
|
1705
|
-
taskDescription: inputText || '',
|
|
1706
|
-
nonInteractive: isNonInteractive,
|
|
1707
|
-
})
|
|
1708
|
-
}
|
|
1709
|
-
}
|
|
1710
|
-
|
|
1711
|
-
// execute 阶段必须带 --change:不允许自动检测或默认值,变更名必须由 agent 显式传入
|
|
1712
|
-
// --status 豁免(纯查看不需要指定变更)
|
|
1713
|
-
if (stageName === 'execute' && !changeName && !isStatus) {
|
|
1714
|
-
console.error('❌ execute 阶段必须用 --change <变更名> 指定要操作的变更。')
|
|
1715
|
-
console.error(' agent 必须传参,不设默认值、不做自动检测。')
|
|
1716
|
-
console.error(' 请加 --change <变更名> 重新执行。')
|
|
1717
|
-
process.exit(1)
|
|
1718
|
-
}
|
|
1719
|
-
|
|
1720
|
-
// --change 变更名存在性校验(治 cwd 漂移误匹配,缺陷 execute-in-place-windows-pitfalls 坑5):
|
|
1721
|
-
// 必须在 pm.read/initChange 之前——initChange 会为 --change 指定的新名字创建 changes/ 目录,
|
|
1722
|
-
// 校验若在其后则目录已存在、永远通过。用原始 changeName(--change 参数),覆盖 plan/execute/
|
|
1723
|
-
// verify/archive;quick-<8hex> sessionId 与 brainstorm 新建豁免(见 validateChangeExists)。
|
|
1724
|
-
const changeMissing = validateChangeExists(specBase, stageName, changeName)
|
|
1725
|
-
if (changeMissing) {
|
|
1726
|
-
console.error(`❌ ${changeMissing.message}`)
|
|
1727
|
-
console.error(` 可能 cwd 漂移——当前 cwd=${cwd},命中的 spec=${specBase}。`)
|
|
1728
|
-
console.error(` 若意图操作别的项目,请 cd 到对应项目根,或用 --spec-dir 指定正确的 spec 目录。`)
|
|
1729
|
-
process.exit(1)
|
|
1730
|
-
}
|
|
1731
|
-
|
|
1732
|
-
let progress = await pm.read(cwd, changeName)
|
|
1733
|
-
|
|
1734
|
-
if (!progress) {
|
|
1735
|
-
// 如果指定了变更名或有变更目录,自动初始化变更的 progress
|
|
1736
|
-
const autoChange = changeName || resolveChangeNameAuto(cwd, specRoot)
|
|
1737
|
-
if (autoChange) {
|
|
1738
|
-
progress = await pm.initChange(cwd, autoChange)
|
|
1739
|
-
} else if (isAuxiliary) {
|
|
1740
|
-
let autoName = changeName || resolveChangeNameAuto(cwd, specRoot) || 'default'
|
|
1741
|
-
// archive 特例:归档后变更从活跃列表排除(listChanges WHERE status='active'),
|
|
1742
|
-
// 不带 --change 回退 default 会读错变更。无活跃变更时取最新归档变更,读其现有 progress。
|
|
1743
|
-
if (stageName === 'archive' && autoName === 'default') {
|
|
1744
|
-
try {
|
|
1745
|
-
const archiveDir = join(specBase, 'changes', 'archive')
|
|
1746
|
-
if (existsSync(archiveDir)) {
|
|
1747
|
-
const latest = readdirSync(archiveDir)
|
|
1748
|
-
.filter(d => /^\d{4}-\d{2}-\d{2}-.+/.test(d))
|
|
1749
|
-
.sort()
|
|
1750
|
-
.pop()
|
|
1751
|
-
if (latest) {
|
|
1752
|
-
autoName = latest.replace(/^\d{4}-\d{2}-\d{2}-/, '')
|
|
1753
|
-
progress = await pm.read(cwd, autoName)
|
|
1754
|
-
}
|
|
1755
|
-
}
|
|
1756
|
-
} catch {}
|
|
1757
|
-
}
|
|
1758
|
-
changeName = autoName
|
|
1759
|
-
if (!progress) {
|
|
1760
|
-
progress = await pm.initChange(cwd, autoName)
|
|
1761
|
-
// initChange 可能因 project 表为空返回 null
|
|
1762
|
-
if (!progress) {
|
|
1763
|
-
progress = { currentStage: stageName, stages: {}, lastActive: new Date().toLocaleString('zh-CN', { hour12: false }), project: '' }
|
|
1764
|
-
}
|
|
1765
|
-
}
|
|
1766
|
-
} else {
|
|
1767
|
-
// brainstorm 作为流程入口,自动生成变更名并初始化
|
|
1768
|
-
if (stageName === 'brainstorm') {
|
|
1769
|
-
if (isDone) {
|
|
1770
|
-
console.error('❌ --done 找不到变更进度数据。')
|
|
1771
|
-
console.error(' 请用 --change <变更名> 指定要完成的变更,')
|
|
1772
|
-
console.error(' 或先运行 sillyspec run brainstorm --change <变更名> 初始化。')
|
|
1773
|
-
process.exit(1)
|
|
1774
|
-
}
|
|
1775
|
-
const date = new Date().toISOString().slice(0, 10)
|
|
1776
|
-
const autoName = `${date}-new-change-${randomBytes(4).toString('hex')}`
|
|
1777
|
-
console.log(`🔄 自动创建变更:${autoName}`)
|
|
1778
|
-
console.log(` 提示:可以用 --change <名称> 指定自定义变更名`)
|
|
1779
|
-
console.log(` 或事后重命名:sillyspec change-rename ${autoName} <新名称>`)
|
|
1780
|
-
progress = await pm.initChange(cwd, autoName)
|
|
1781
|
-
changeName = autoName
|
|
1782
|
-
} else {
|
|
1783
|
-
console.error('❌ 未找到进度数据,请先运行 sillyspec init 或指定 --change <变更名>')
|
|
1784
|
-
process.exit(1)
|
|
1785
|
-
}
|
|
1786
|
-
}
|
|
1787
|
-
}
|
|
1788
|
-
|
|
1789
|
-
// 确保 progress 有 currentChange
|
|
1790
|
-
const effectiveChange = changeName || progress.currentChange || resolveChangeName(cwd, progress, specRoot)
|
|
1791
|
-
|
|
1792
|
-
// -- auto 模式:自动推进所有流程阶段
|
|
1793
|
-
if (stageName === 'auto') {
|
|
1794
|
-
return await runAutoMode(pm, progress, cwd, flags, effectiveChange, platformOpts)
|
|
1795
|
-
}
|
|
1796
|
-
|
|
1797
|
-
// --change 只作为变更名标识,不再拦截流程
|
|
1798
|
-
// 注册变更到全局活跃列表(如果尚未注册)
|
|
1799
|
-
if (effectiveChange) {
|
|
1800
|
-
await pm.registerChange(cwd, effectiveChange)
|
|
1801
|
-
}
|
|
1802
|
-
|
|
1803
|
-
// --reset
|
|
1804
|
-
if (isReset) {
|
|
1805
|
-
return await resetStage(pm, progress, stageName, cwd, effectiveChange, platformOpts)
|
|
1806
|
-
}
|
|
1807
|
-
|
|
1808
|
-
// ── 规则 1:completed 阶段直接 run,拒绝(但 --status 放行)──
|
|
1809
|
-
const stageStatus = progress.stages[stageName]?.status
|
|
1810
|
-
if (stageStatus === 'completed' && !isReopen && !isStatus) {
|
|
1811
|
-
console.error(`\n❌ ${stageName} 阶段已完成。`)
|
|
1812
|
-
console.error(` 使用 --reopen 进行修订,或 --reset 从头开始。`)
|
|
1813
|
-
console.error(` 修订示例: sillyspec run ${stageName} --reopen --from-step <步骤序号或名称>`)
|
|
1814
|
-
process.exit(1)
|
|
1815
|
-
}
|
|
1816
|
-
|
|
1817
|
-
// ── 规则 5:stale 阶段直接 run,拒绝(但 --status / --reset 放行)──
|
|
1818
|
-
if (stageStatus === 'stale' && !isReopen && !isStatus && !isReset) {
|
|
1819
|
-
const staleReason = progress.stages[stageName]?.staleReason || '上游阶段已修订'
|
|
1820
|
-
console.error(`\n⚠️ ${stageName} 阶段已失效(stale)。`)
|
|
1821
|
-
console.error(` 原因:${staleReason}`)
|
|
1822
|
-
console.error(` 使用 --reopen --from-step <步骤> 进行修订,或 --reset 从头开始。`)
|
|
1823
|
-
process.exit(1)
|
|
1824
|
-
}
|
|
1825
|
-
|
|
1826
|
-
// ── --reopen 处理 ──
|
|
1827
|
-
if (isReopen) {
|
|
1828
|
-
// stale/revising 阶段可能 steps 为空,或者 execute 阶段的 steps 需要从最新 plan.md 刷新
|
|
1829
|
-
const stageDataPre = progress.stages[stageName]
|
|
1830
|
-
const needsInit = !stageDataPre || !stageDataPre.steps || stageDataPre.steps.length === 0
|
|
1831
|
-
// execute 阶段在 reopen 时需要从最新 plan.md 重新解析 steps(plan 可能已变更)
|
|
1832
|
-
if (needsInit || stageName === 'execute') {
|
|
1833
|
-
const freshSteps = await getStageSteps(stageName, cwd, progress, specRoot)
|
|
1834
|
-
if (freshSteps && freshSteps.length > 0) {
|
|
1835
|
-
if (!progress.stages[stageName]) progress.stages[stageName] = { status: 'stale', steps: [] }
|
|
1836
|
-
progress.stages[stageName].steps = freshSteps.map(s => ({ name: s.name, status: 'pending' }))
|
|
1837
|
-
await pm._write(cwd, progress, effectiveChange)
|
|
1838
|
-
progress = await pm.read(cwd, effectiveChange) || progress
|
|
1839
|
-
}
|
|
1840
|
-
}
|
|
1841
|
-
|
|
1842
|
-
const result = await pm.reopenStage(cwd, stageName, {
|
|
1843
|
-
fromStep: fromStepValue,
|
|
1844
|
-
changeName: effectiveChange,
|
|
1845
|
-
})
|
|
1846
|
-
if (!result.ok) {
|
|
1847
|
-
console.error(`\n❌ ${result.error}`)
|
|
1848
|
-
if (stageStatus === 'completed') {
|
|
1849
|
-
console.error(`\n 提示:sillyspec run ${stageName} --reopen --from-step <步骤序号或名称>`)
|
|
1850
|
-
}
|
|
1851
|
-
process.exit(1)
|
|
1852
|
-
}
|
|
1853
|
-
console.log(`\n🔧 ${stageName} 阶段已重新打开(revision ${result.revision})`)
|
|
1854
|
-
console.log(` 从步骤「${result.fromStep}」开始修订`)
|
|
1855
|
-
console.log(` 该步骤及之后的产出需要重新生成。`)
|
|
1856
|
-
if (stageName === 'execute') console.log(` ⚡ execute 步骤已从最新 plan.md 重新解析。`)
|
|
1857
|
-
console.log('')
|
|
1858
|
-
|
|
1859
|
-
// 重新读取 progress
|
|
1860
|
-
progress = await pm.read(cwd, effectiveChange) || progress
|
|
1861
|
-
|
|
1862
|
-
// 注入 revision context 到 platformOpts,供 outputStep 使用
|
|
1863
|
-
const stageData = progress.stages[stageName]
|
|
1864
|
-
if (stageData && stageData.revision > 0) {
|
|
1865
|
-
platformOpts._revision = {
|
|
1866
|
-
revision: stageData.revision,
|
|
1867
|
-
fromStep: stageData.reopenedFromStep,
|
|
1868
|
-
}
|
|
1869
|
-
}
|
|
1870
|
-
} else {
|
|
1871
|
-
// 非 reopen 的正常执行:如果阶段处于 revising 状态,也注入 revision context
|
|
1872
|
-
const revStageData = progress.stages[stageName]
|
|
1873
|
-
if (revStageData && revStageData.status === 'revising' && revStageData.revision > 0 && !platformOpts._revision) {
|
|
1874
|
-
platformOpts._revision = {
|
|
1875
|
-
revision: revStageData.revision,
|
|
1876
|
-
fromStep: revStageData.reopenedFromStep,
|
|
1877
|
-
}
|
|
1878
|
-
}
|
|
1879
|
-
}
|
|
1880
|
-
|
|
1881
|
-
// quick 启动(非 --done):reset steps + 写 current-quick-run-id(本会话 sessionId,作 --done fallback)。
|
|
1882
|
-
// sessionId 已在参数解析阶段生成(quickSessionId == changeName == quick-<uuid8>),
|
|
1883
|
-
// 这里复用同一值写 current-quick-run-id,保证两处一致。
|
|
1884
|
-
// 旧 quick-YYYYMMDD-HHMMSS 时间戳已摒弃(D-003@v1:同秒并发撞)。
|
|
1885
|
-
if (stageName === 'quick' && !isDone && !isStatus && !isSkip && !isReset && !isReopen) {
|
|
1886
|
-
// 不再无条件 reset steps:原逻辑把任何 quick 非 --done 启动都重置为 pending,致 in-progress
|
|
1887
|
-
// 的 quick 中途查 prompt(sillyspec run quick)丢已 done 的 step 进度(报告
|
|
1888
|
-
// quick-state-reset-platform-mode)。现保留进度——completed 重跑由 runStage 内
|
|
1889
|
-
// currentIdx === -1 自动重置(~1944 行);in-progress 输出当前 step;全新由
|
|
1890
|
-
// ensureStageSteps 初始化;显式从头用 --reset(已由 !isReset 排除出本分支)。
|
|
1891
|
-
try {
|
|
1892
|
-
const runtimeRoot = platformOpts.runtimeRoot || join(specRoot, '.runtime')
|
|
1893
|
-
mkdirSync(runtimeRoot, { recursive: true })
|
|
1894
|
-
writeFileSync(join(runtimeRoot, 'current-quick-run-id'), quickSessionId + '\n')
|
|
1895
|
-
} catch {}
|
|
1896
|
-
// 显式告知 agent 本会话 sessionId + --done 需带 --change(CLI 短进程,run/done 独立进程,
|
|
1897
|
-
// sessionId 靠 --change 跨进程传递;不带 --change 时 fallback 读 current-quick-run-id,多会话不可靠)
|
|
1898
|
-
console.log(`📌 本 quick 会话 sessionId: ${quickSessionId}`)
|
|
1899
|
-
console.log(` 完成时用: sillyspec run quick --done --change ${quickSessionId} --output "..."`)
|
|
1900
|
-
}
|
|
1901
|
-
|
|
1902
|
-
// 确保步骤已初始化
|
|
1903
|
-
const changed = await ensureStageSteps(progress, stageName, cwd, specRoot)
|
|
1904
|
-
if (changed && effectiveChange) {
|
|
1905
|
-
await pm._write(cwd, progress, effectiveChange)
|
|
1906
|
-
triggerSync(cwd, effectiveChange, platformOpts)
|
|
1907
|
-
progress = await pm.read(cwd, effectiveChange) || progress
|
|
1908
|
-
}
|
|
1909
|
-
|
|
1910
|
-
// --status
|
|
1911
|
-
if (isStatus) {
|
|
1912
|
-
return showStatus(progress, stageName)
|
|
1913
|
-
}
|
|
1914
|
-
|
|
1915
|
-
// --skip
|
|
1916
|
-
if (isSkip) {
|
|
1917
|
-
return await skipStep(pm, progress, stageName, cwd, effectiveChange, platformOpts)
|
|
1918
|
-
}
|
|
1919
|
-
|
|
1920
|
-
// --wait: 将 step 设为 waiting(独立于 --done)
|
|
1921
|
-
if (isWait) {
|
|
1922
|
-
return await waitStep(pm, progress, stageName, cwd, outputText, waitReason, waitOptions, { changeName: effectiveChange, nonInteractive: isNonInteractive && !isInteractive, platformOpts })
|
|
1923
|
-
}
|
|
1924
|
-
|
|
1925
|
-
// --continue: 从 waiting 恢复
|
|
1926
|
-
if (isContinue) {
|
|
1927
|
-
return await continueStep(pm, progress, stageName, cwd, continueAnswer, { changeName: effectiveChange, nonInteractive: isNonInteractive && !isInteractive, platformOpts })
|
|
1928
|
-
}
|
|
1929
|
-
|
|
1930
|
-
// --done
|
|
1931
|
-
if (isDone) {
|
|
1932
|
-
const doneAnswer = getFlagValue('--answer')
|
|
1933
|
-
return await completeStep(pm, progress, stageName, cwd, outputText, inputText, { confirm: isConfirm, changeName: effectiveChange, nonInteractive: isNonInteractive && !isInteractive, platformOpts, confirmMode, doneAnswer, isForceBaseline, isAllowNew })
|
|
1934
|
-
}
|
|
1935
|
-
|
|
1936
|
-
// 默认:输出当前步骤
|
|
1937
|
-
return await runStage(pm, progress, stageName, cwd, effectiveChange, isSkipApproval, platformOpts, { quickFiles, isAllowNew, isForceBaseline, isForceRescan, linkedChanges, taskDescription: inputText })
|
|
1938
|
-
}
|
|
1939
|
-
|
|
1940
|
-
/**
|
|
1941
|
-
* 自动推导变更名(不依赖 progress)
|
|
1942
|
-
*/
|
|
1943
|
-
function resolveChangeNameAuto(cwd, specDir = null) {
|
|
1944
|
-
const changesDir = join(specDir || resolveSpecDir(cwd), 'changes')
|
|
1945
|
-
if (!existsSync(changesDir)) return null
|
|
1946
|
-
const entries = readdirSync(changesDir, { withFileTypes: true })
|
|
1947
|
-
.filter(e => e.isDirectory() && e.name !== 'archive')
|
|
1948
|
-
if (entries.length === 1) return entries[0].name
|
|
1949
|
-
return null
|
|
1950
|
-
}
|
|
1951
|
-
|
|
1952
|
-
/**
|
|
1953
|
-
* quick 阶段多变更交互式选择关联变更。
|
|
1954
|
-
* - 0 活跃变更 → [](仅记 QUICKLOG,不关联)
|
|
1955
|
-
* - 1 活跃变更 → 默认关联它(保持现状友好,不弹交互)
|
|
1956
|
-
* - ≥2 活跃变更 + 交互 → checkbox 多选(推荐项默认勾,空选 = 不关联)
|
|
1957
|
-
* - ≥2 活跃变更 + 非交互 → [](不关联)+ 提示用 --change a,b
|
|
1958
|
-
*/
|
|
1959
|
-
async function resolveQuickLinkedChanges({ pm, cwd, specDir, quickFiles, taskDescription, nonInteractive }) {
|
|
1960
|
-
let activeChanges = []
|
|
1961
|
-
try {
|
|
1962
|
-
activeChanges = await pm.listChanges(cwd)
|
|
1963
|
-
} catch {
|
|
1964
|
-
activeChanges = []
|
|
1965
|
-
}
|
|
1966
|
-
if (activeChanges.length === 0) return []
|
|
1967
|
-
if (activeChanges.length === 1) return [activeChanges[0]]
|
|
1968
|
-
|
|
1969
|
-
if (nonInteractive || !process.stdin.isTTY) {
|
|
1970
|
-
console.log('💡 非交互环境,已默认不关联变更;如需关联请用 --change a,b')
|
|
1971
|
-
return []
|
|
1972
|
-
}
|
|
1973
|
-
|
|
1974
|
-
// 脏文件(推荐信号之一)
|
|
1975
|
-
let baselineFiles = []
|
|
1976
|
-
try {
|
|
1977
|
-
const { execSync } = await import('child_process')
|
|
1978
|
-
const gs = execSync('git status --porcelain', { cwd, encoding: 'utf8', timeout: 10000 })
|
|
1979
|
-
baselineFiles = gs.split('\n').filter(Boolean)
|
|
1980
|
-
.map(l => parsePorcelainPath(l))
|
|
1981
|
-
.filter(Boolean)
|
|
1982
|
-
.filter(f => !f.startsWith('.sillyspec/'))
|
|
1983
|
-
} catch {}
|
|
1984
|
-
|
|
1985
|
-
// 推荐打分(脏文件 + 任务描述双信号)
|
|
1986
|
-
let recommendations = []
|
|
1987
|
-
try {
|
|
1988
|
-
const { recommendChanges } = await import('./quick-recommend.js')
|
|
1989
|
-
recommendations = recommendChanges({ activeChanges, specDir, baselineFiles, quickFiles, taskDescription })
|
|
1990
|
-
} catch {
|
|
1991
|
-
recommendations = activeChanges.map(name => ({ name, score: 0, reasons: [] }))
|
|
1992
|
-
}
|
|
1993
|
-
const scoreMap = new Map(recommendations.map(r => [r.name, r.score]))
|
|
1994
|
-
const reasonMap = new Map(recommendations.map(r => [r.name, r.reasons]))
|
|
1995
|
-
const recommendedSet = new Set(recommendations.filter(r => r.score > 0).map(r => r.name))
|
|
1996
|
-
|
|
1997
|
-
// 按推荐分降序展示
|
|
1998
|
-
const ordered = [...activeChanges].sort((a, b) =>
|
|
1999
|
-
(scoreMap.get(b) || 0) - (scoreMap.get(a) || 0) || a.localeCompare(b))
|
|
2000
|
-
|
|
2001
|
-
const choices = ordered.map(name => {
|
|
2002
|
-
const reasons = reasonMap.get(name) || []
|
|
2003
|
-
const isRec = recommendedSet.has(name)
|
|
2004
|
-
return {
|
|
2005
|
-
name: `${isRec ? '⭐ ' : ' '}${name}`,
|
|
2006
|
-
value: name,
|
|
2007
|
-
description: reasons.length > 0 ? reasons.join(';') : '无推荐信号',
|
|
2008
|
-
checked: isRec,
|
|
2009
|
-
}
|
|
2010
|
-
})
|
|
2011
|
-
|
|
2012
|
-
console.log('🔗 检测到多个活跃变更,选择本次 quick 关联哪些(可多选;不勾选任何项 = 仅记 QUICKLOG,不关联变更)')
|
|
2013
|
-
if (recommendedSet.size > 0) {
|
|
2014
|
-
console.log(' ⭐ = 基于脏文件/任务描述推荐,已默认勾选')
|
|
2015
|
-
}
|
|
2016
|
-
const selected = await checkbox({ message: '关联变更(空格切换,回车确认)', choices })
|
|
2017
|
-
return selected
|
|
2018
|
-
}
|
|
2019
|
-
|
|
2020
|
-
async function runStage(pm, progress, stageName, cwd, changeName, skipApproval = false, platformOpts = {}, quickOpts = {}) {
|
|
2021
|
-
const specBase = platformOpts.specRoot || join(cwd, '.sillyspec')
|
|
2022
|
-
// 状态转换校验
|
|
2023
|
-
const prevStage = progress.currentStage || ''
|
|
2024
|
-
// task-07: 提取 prevStage 的 stageData,传给 checkTransition 检测 failed_post_check 门控
|
|
2025
|
-
const fromStageData = (progress.stages && prevStage && progress.stages[prevStage]) || undefined
|
|
2026
|
-
const transition = checkTransition(prevStage, stageName, fromStageData ? { fromStageData } : {})
|
|
2027
|
-
if (!transition.allowed) {
|
|
2028
|
-
console.error(`❌ 阶段转换不允许: ${prevStage || '(起始)'} → ${stageName}`)
|
|
2029
|
-
console.error(` 原因: ${transition.reason}`)
|
|
2030
|
-
console.error(` 提示: 使用 --skip-approval 绕过(需明确意图)`)
|
|
2031
|
-
if (!skipApproval) {
|
|
2032
|
-
process.exit(1)
|
|
2033
|
-
}
|
|
2034
|
-
}
|
|
2035
|
-
|
|
2036
|
-
// execute 阶段启动前检查审批
|
|
2037
|
-
if (stageName === 'execute' && !skipApproval) {
|
|
2038
|
-
const approval = await checkApproval(cwd, changeName, platformOpts)
|
|
2039
|
-
if (approval) {
|
|
2040
|
-
if (approval.status === 'rejected') {
|
|
2041
|
-
console.error(`❌ 变更 ${changeName} 的执行已被拒绝:${approval.reason || '无原因'}`)
|
|
2042
|
-
process.exit(1)
|
|
2043
|
-
}
|
|
2044
|
-
if (approval.status === 'pending') {
|
|
2045
|
-
console.log(`⏳ 变更 ${changeName} 的执行审批待处理中...`)
|
|
2046
|
-
console.log(' 提示:使用 --skip-approval 跳过审批检查')
|
|
2047
|
-
}
|
|
2048
|
-
}
|
|
2049
|
-
}
|
|
2050
|
-
|
|
2051
|
-
// execute 阶段:CLI 自动创建 worktree(不等 AI agent)
|
|
2052
|
-
if (stageName === 'execute' && changeName) {
|
|
2053
|
-
const effectiveChange = changeName
|
|
2054
|
-
const { WorktreeManager } = await import('./worktree.js')
|
|
2055
|
-
const wm = new WorktreeManager({ cwd })
|
|
2056
|
-
const existingMeta = wm.getMeta(effectiveChange)
|
|
2057
|
-
if (existingMeta) {
|
|
2058
|
-
console.log(`🔗 worktree 已存在: ${existingMeta.worktreePath} (${existingMeta.mode})`)
|
|
2059
|
-
} else {
|
|
2060
|
-
try {
|
|
2061
|
-
const result = wm.create(effectiveChange)
|
|
2062
|
-
console.log(`🔗 worktree 已创建: ${result.worktreePath} (分支: ${result.branch}, 模式: ${result.mode})`)
|
|
2063
|
-
} catch (e) {
|
|
2064
|
-
console.error(`❌ worktree 创建失败: ${e.message}`)
|
|
2065
|
-
console.error(` 修复建议:`)
|
|
2066
|
-
console.error(` 1. 运行 sillyspec worktree doctor --fix 检查并修复 worktree 状态`)
|
|
2067
|
-
console.error(` 2. 或手动清理残留:git worktree prune && git branch -D sillyspec/${effectiveChange}`)
|
|
2068
|
-
console.error(` 3. 必要时删除残留目录 .sillyspec/.runtime/worktrees/${effectiveChange}/ 后重试`)
|
|
2069
|
-
process.exit(1)
|
|
2070
|
-
}
|
|
2071
|
-
}
|
|
2072
|
-
// 入口 deps 自检(D-002):已存在 worktree(create short-circuit 不供给)时重供给
|
|
2073
|
-
if (existingMeta) {
|
|
2074
|
-
const execSpecBase = platformOpts?.specRoot || join(cwd, '.sillyspec')
|
|
2075
|
-
await ensureDepsFreshness(cwd, effectiveChange, execSpecBase, existingMeta)
|
|
2076
|
-
}
|
|
2077
|
-
}
|
|
2078
|
-
|
|
2079
|
-
// ── execute 阶段启动时固定 executeRunId(绑定变更名,避免跨变更复用) ──
|
|
2080
|
-
let currentExecuteRunId = null
|
|
2081
|
-
if (stageName === 'execute') {
|
|
2082
|
-
const { generateExecuteRunId } = await import('./task-review.js')
|
|
2083
|
-
const execSpecBase = platformOpts?.specRoot || join(cwd, '.sillyspec')
|
|
2084
|
-
const runtimeRoot = platformOpts?.runtimeRoot || join(execSpecBase, '.runtime')
|
|
2085
|
-
const runIdFile = join(runtimeRoot, `current-execute-run-id-${changeName}`)
|
|
2086
|
-
mkdirSync(runtimeRoot, { recursive: true })
|
|
2087
|
-
// 优先读取已有的变更专属标记文件
|
|
2088
|
-
try {
|
|
2089
|
-
if (existsSync(runIdFile)) {
|
|
2090
|
-
currentExecuteRunId = readFileSync(runIdFile, 'utf8').trim()
|
|
2091
|
-
}
|
|
2092
|
-
} catch {}
|
|
2093
|
-
if (!currentExecuteRunId) {
|
|
2094
|
-
currentExecuteRunId = generateExecuteRunId()
|
|
2095
|
-
writeFileSync(runIdFile, currentExecuteRunId + '\n')
|
|
2096
|
-
}
|
|
2097
|
-
}
|
|
2098
|
-
|
|
2099
|
-
// 自动探测 currentChange
|
|
2100
|
-
if (autoDetectChange(progress, cwd)) {
|
|
2101
|
-
progress.lastActive = new Date().toLocaleString('zh-CN', { hour12: false })
|
|
2102
|
-
await pm._write(cwd, progress, changeName)
|
|
2103
|
-
triggerSync(cwd, changeName, platformOpts)
|
|
2104
|
-
}
|
|
2105
|
-
|
|
2106
|
-
const stageData = progress.stages[stageName]
|
|
2107
|
-
if (!stageData || !stageData.steps) {
|
|
2108
|
-
console.error(`❌ 阶段 ${stageName} 未初始化`)
|
|
2109
|
-
process.exit(1)
|
|
2110
|
-
}
|
|
2111
|
-
|
|
2112
|
-
// 用户显式调用 sillyspec run <stage>:把它标记为当前阶段
|
|
2113
|
-
if (progress.currentStage !== stageName) {
|
|
2114
|
-
progress.currentStage = stageName
|
|
2115
|
-
progress.lastActive = new Date().toLocaleString('zh-CN',{hour12:false})
|
|
2116
|
-
await pm._write(cwd, progress, changeName)
|
|
2117
|
-
triggerSync(cwd, changeName, platformOpts)
|
|
2118
|
-
}
|
|
2119
|
-
|
|
2120
|
-
const steps = stageData.steps
|
|
2121
|
-
// ── 检查是否有 waiting step 需要先处理 ──
|
|
2122
|
-
const waitingIdx = steps.findIndex(s => s.status === 'waiting')
|
|
2123
|
-
if (waitingIdx !== -1) {
|
|
2124
|
-
const ws = steps[waitingIdx]
|
|
2125
|
-
console.error(`\n⏸️ Step ${waitingIdx + 1}/${steps.length} 正在等待用户输入:${ws.name}`)
|
|
2126
|
-
if (ws.waitReason) console.error(` 原因:${ws.waitReason}`)
|
|
2127
|
-
if (ws.waitOptions) console.error(` 选项:${formatWaitOptions(ws.waitOptions)}`)
|
|
2128
|
-
console.error(`\n 普通运行无法跳过等待中的步骤。请先处理:`)
|
|
2129
|
-
console.error(` sillyspec run ${stageName} --continue --answer "你的选择"${changeName ? ` --change ${changeName}` : ''}`)
|
|
2130
|
-
process.exit(1)
|
|
2131
|
-
}
|
|
2132
|
-
|
|
2133
|
-
let currentIdx = steps.findIndex(s => s.status !== 'completed' && s.status !== 'skipped')
|
|
2134
|
-
|
|
2135
|
-
// stale 步骤视为可执行(等同于 pending)
|
|
2136
|
-
if (currentIdx !== -1 && steps[currentIdx].status === 'stale') {
|
|
2137
|
-
steps[currentIdx].status = 'pending'
|
|
2138
|
-
await pm._write(cwd, progress, changeName)
|
|
2139
|
-
triggerSync(cwd, changeName, platformOpts)
|
|
2140
|
-
}
|
|
2141
|
-
|
|
2142
|
-
// ── scanProfile: 根据 project 规模动态裁剪步骤 ──
|
|
2143
|
-
let scanProfile = null
|
|
2144
|
-
if (stageName === 'scan' && steps.length > 0 && currentIdx === 0) {
|
|
2145
|
-
scanProfile = computeScanProfile(cwd, platformOpts)
|
|
2146
|
-
console.log(`\n📊 Scan Profile: ${scanProfile.mode} (原因: ${scanProfile.reason})`)
|
|
2147
|
-
if (scanProfile.mode !== 'deep') {
|
|
2148
|
-
applyScanProfileSteps(stageData, scanProfile, cwd, platformOpts)
|
|
2149
|
-
// 步骤被裁剪后 currentIdx 需要重新计算
|
|
2150
|
-
currentIdx = 0
|
|
2151
|
-
}
|
|
2152
|
-
// 保存 profile 供后续 postcheck 使用
|
|
2153
|
-
stageData.scanProfile = scanProfile
|
|
2154
|
-
await pm._write(cwd, progress, changeName)
|
|
2155
|
-
} else if (stageName === 'scan' && stageData.scanProfile) {
|
|
2156
|
-
scanProfile = stageData.scanProfile
|
|
2157
|
-
}
|
|
2158
|
-
if (scanProfile) platformOpts.scanProfile = scanProfile
|
|
2159
|
-
|
|
2160
|
-
if (stageName === 'scan') {
|
|
2161
|
-
try {
|
|
2162
|
-
const gitResult = safeGit(cwd, ['rev-parse', 'HEAD'])
|
|
2163
|
-
const scanGuard = {
|
|
2164
|
-
name_zh: '扫描守卫',
|
|
2165
|
-
sourceCommit: gitResult.value,
|
|
2166
|
-
sourceCommitError: gitResult.error,
|
|
2167
|
-
startedAt: new Date().toISOString(),
|
|
2168
|
-
forceRescan: quickOpts?.isForceRescan || false,
|
|
2169
|
-
}
|
|
2170
|
-
const guardFile = join(specBase, '.runtime', 'scan-guard.json')
|
|
2171
|
-
mkdirSync(dirname(guardFile), { recursive: true })
|
|
2172
|
-
writeFileSync(guardFile, JSON.stringify(scanGuard, null, 2) + '\n')
|
|
2173
|
-
if (scanGuard.forceRescan) {
|
|
2174
|
-
console.log('🛡️ scan 覆盖保护已记录: --force-rescan 已开启')
|
|
2175
|
-
} else {
|
|
2176
|
-
console.log('🛡️ scan 覆盖保护已记录: existing scan docs require current source_commit/updated_at')
|
|
2177
|
-
}
|
|
2178
|
-
} catch (e) {
|
|
2179
|
-
console.warn(`⚠️ scan 覆盖保护记录失败: ${e.message}`)
|
|
2180
|
-
}
|
|
2181
|
-
}
|
|
2182
|
-
|
|
2183
|
-
if (currentIdx === -1) {
|
|
2184
|
-
// 所有步骤已 completed/skipped,但阶段未盖到 completed —— 这是上次最后一步完成后、
|
|
2185
|
-
// stage 升级事务未提交的崩溃中间态(正常完成会被上方 stageStatus==='completed' 守卫拦下)。
|
|
2186
|
-
// 旧逻辑无条件清空 steps 为 pending 会丢掉已完成的进度且不可恢复。改为走正式
|
|
2187
|
-
// completeStage:产物齐则补盖完成戳,不齐则给出 actionable 提示,永不静默清空步骤。
|
|
2188
|
-
console.log(`\nℹ️ ${stageName} 所有步骤已完成,但阶段未标记完成(上次可能中断)。尝试补盖完成戳…`)
|
|
2189
|
-
await pm.completeStage(cwd, stageName, changeName)
|
|
2190
|
-
const after = await pm.read(cwd, changeName)
|
|
2191
|
-
if (after?.stages?.[stageName]?.status === 'completed') {
|
|
2192
|
-
console.log(` ✅ 已补盖完成戳。下一步: sillyspec run <下一阶段>,或 sillyspec run ${stageName} --status 查看。`)
|
|
2193
|
-
process.exit(0)
|
|
2194
|
-
}
|
|
2195
|
-
// completeStage 已打印产物校验失败的明细;这里只补充恢复指引
|
|
2196
|
-
console.error(`\n ⚠️ 已保留步骤进度(未清空)。修复产物后重跑,或:`)
|
|
2197
|
-
console.error(` - 重新开始: sillyspec run ${stageName} --reset`)
|
|
2198
|
-
console.error(` - 强制补盖: sillyspec progress complete-stage ${stageName}${changeName ? ` --change ${changeName}` : ''} --force`)
|
|
2199
|
-
process.exit(1)
|
|
2200
|
-
}
|
|
2201
|
-
|
|
2202
|
-
// quick 阶段:记录 baselineFiles + 分配 ql-ID(CLI 接管 QUICKLOG 写入)
|
|
2203
|
-
// 幂等判据是 session guard.json 文件(跨进程可靠),不是 progress.quickGuard
|
|
2204
|
-
// (D-003@v1:顶层 quickGuard 不跨进程持久化;agent 在 step 间用 `run quick` 取下一步
|
|
2205
|
-
// prompt 时每个新进程都进此块,按文件判幂等才不会重复分配 ql-ID / 重复写条目)。
|
|
2206
|
-
if (stageName === 'quick') {
|
|
2207
|
-
const sessionGuardDir = join(specBase, '.runtime', 'quick-sessions', changeName)
|
|
2208
|
-
const guardFile = join(sessionGuardDir, 'guard.json')
|
|
2209
|
-
let existingGuard = null
|
|
2210
|
-
try {
|
|
2211
|
-
if (existsSync(guardFile)) existingGuard = JSON.parse(readFileSync(guardFile, 'utf8'))
|
|
2212
|
-
} catch {}
|
|
2213
|
-
if (existingGuard) {
|
|
2214
|
-
// 跨进程重入:复用已分配的 ql-ID,跳过 baseline 重捕与分配(幂等)
|
|
2215
|
-
progress.quickGuard = existingGuard
|
|
2216
|
-
} else {
|
|
2217
|
-
try {
|
|
2218
|
-
const { execSync } = await import('child_process')
|
|
2219
|
-
const gitStatus = execSync('git status --porcelain', { cwd, encoding: 'utf8', timeout: 10000 })
|
|
2220
|
-
// 记录全部预存脏文件(含 untracked + .sillyspec/ 路径)。quick 会话期间自身写入的元数据
|
|
2221
|
-
// (quicklog/.runtime/modules/_module-map 等)由 auditQuickCompletion 的 isQuickMetadata 精确豁免,
|
|
2222
|
-
// 不需要这里粗放过滤 .sillyspec/——旧过滤致预存 untracked .sillyspec/changes/ 不进 baseline,
|
|
2223
|
-
// 却在 audit 被当「危险(.sillyspec/)+新增」误判永久 blocked(ql-20260713-002-7628 修复)。
|
|
2224
|
-
const baselineFiles = gitStatus
|
|
2225
|
-
.split('\n').filter(Boolean)
|
|
2226
|
-
.map(line => parsePorcelainPath(line))
|
|
2227
|
-
.filter(Boolean)
|
|
2228
|
-
const allowedFiles = quickOpts?.quickFiles || []
|
|
2229
|
-
const allowNew = quickOpts?.isAllowNew || false
|
|
2230
|
-
const forceBaseline = quickOpts?.isForceBaseline || false
|
|
2231
|
-
const linkedChanges = Array.isArray(quickOpts?.linkedChanges) ? quickOpts.linkedChanges : []
|
|
2232
|
-
// CLI 接管:分配 ql-ID + 写 QUICKLOG「进行中」条目 + 关联 tasks.md(持锁、当天唯一)
|
|
2233
|
-
const gitUser = safeGit(cwd, ['config', 'user.name']).value || 'unknown'
|
|
2234
|
-
const { qlId } = await allocateQuicklogEntry(specBase, gitUser, {
|
|
2235
|
-
description: quickOpts?.taskDescription || '',
|
|
2236
|
-
linkedChanges,
|
|
2237
|
-
allowedFiles,
|
|
2238
|
-
})
|
|
2239
|
-
progress.quickGuard = {
|
|
2240
|
-
sessionId: changeName,
|
|
2241
|
-
name_zh: '快速任务守卫',
|
|
2242
|
-
baselineCommit: safeGit(cwd, ['rev-parse', 'HEAD']).value,
|
|
2243
|
-
baselineFiles,
|
|
2244
|
-
allowedFiles,
|
|
2245
|
-
allowNew,
|
|
2246
|
-
forceBaseline,
|
|
2247
|
-
linkedChanges,
|
|
2248
|
-
quicklogId: qlId,
|
|
2249
|
-
startedAt: new Date().toISOString(),
|
|
2250
|
-
}
|
|
2251
|
-
// 写入 .runtime/quick-sessions/<sessionId>/guard.json 供 worktree-guard hook 读取
|
|
2252
|
-
// (D-002:按 session 存,多会话各自 guard 不互覆盖。runStage 作用域内 sessionId == changeName == quick-<uuid8>,见 §4.4/4.5)
|
|
2253
|
-
mkdirSync(sessionGuardDir, { recursive: true })
|
|
2254
|
-
writeAtomicSync(guardFile, JSON.stringify(progress.quickGuard, null, 2))
|
|
2255
|
-
const parts = [`${baselineFiles.length} 个已有脏文件`]
|
|
2256
|
-
if (allowedFiles.length > 0) parts.push(`${allowedFiles.length} 个 allowedFiles`)
|
|
2257
|
-
if (allowNew) parts.push('允许新增文件')
|
|
2258
|
-
console.log(`🛡️ quick 变更边界已记录: ${parts.join(', ')}`)
|
|
2259
|
-
console.log(`📝 QUICKLOG 条目已创建: ${qlId}`)
|
|
2260
|
-
await pm._write(cwd, progress, changeName)
|
|
2261
|
-
} catch (e) {
|
|
2262
|
-
console.warn(`⚠️ baseline 记录失败: ${e.message}`)
|
|
2263
|
-
}
|
|
2264
|
-
}
|
|
2265
|
-
}
|
|
2266
|
-
|
|
2267
|
-
if (currentIdx > 0) {
|
|
2268
|
-
const completed = currentIdx
|
|
2269
|
-
const total = steps.length
|
|
2270
|
-
console.log(`⚠️ ${stageName} 已进行到第 ${currentIdx + 1}/${total} 步(前 ${completed} 步已完成)。`)
|
|
2271
|
-
console.log(` 继续执行将从中断处恢复,用 --reset 可重新开始。\n`)
|
|
2272
|
-
}
|
|
2273
|
-
|
|
2274
|
-
// ── Brainstorm → Plan Contract:plan 启动前校验 design.md ──
|
|
2275
|
-
if (stageName === 'plan' && currentIdx === 0) {
|
|
2276
|
-
const changeDir = resolveChangeDir(cwd, progress, platformOpts?.specRoot || null)
|
|
2277
|
-
const designPath = changeDir ? join(changeDir, 'design.md') : null
|
|
2278
|
-
if (designPath && existsSync(designPath)) {
|
|
2279
|
-
const { validateDesignForPlan } = await import('./stages/plan.js')
|
|
2280
|
-
const designContent = readFileSync(designPath, 'utf8')
|
|
2281
|
-
const designValidation = validateDesignForPlan(designContent)
|
|
2282
|
-
if (!designValidation.ok) {
|
|
2283
|
-
console.error(`\n❌ Brainstorm → Plan Contract 校验失败:`)
|
|
2284
|
-
for (const err of designValidation.errors) console.error(` - ${err}`)
|
|
2285
|
-
console.error(`\n design.md 不满足 plan 契约,请先修复后重试。`)
|
|
2286
|
-
console.error(` 提示:sillyspec run brainstorm --reopen --from-step <步骤> 修订设计文档`)
|
|
2287
|
-
process.exit(1)
|
|
2288
|
-
}
|
|
2289
|
-
if (designValidation.warnings.length > 0) {
|
|
2290
|
-
console.log(`⚠️ Design contract 警告(不阻断):`)
|
|
2291
|
-
for (const w of designValidation.warnings) console.log(` - ${w}`)
|
|
2292
|
-
console.log()
|
|
2293
|
-
}
|
|
2294
|
-
}
|
|
2295
|
-
}
|
|
2296
|
-
|
|
2297
|
-
const defSteps = await getStageSteps(stageName, cwd, progress, platformOpts?.specRoot || null)
|
|
2298
|
-
if (defSteps && defSteps[currentIdx]) {
|
|
2299
|
-
// noAI 步骤自动完成(CLI-only,不需要 Agent 参与)
|
|
2300
|
-
if (defSteps[currentIdx].noAI || stageData.steps[currentIdx]?.noAI) {
|
|
2301
|
-
const stepName = defSteps[currentIdx].name
|
|
2302
|
-
const cliAction = defSteps[currentIdx]._cliAction || stageData.steps[currentIdx]?._cliAction
|
|
2303
|
-
console.log(`⚙️ Step ${currentIdx + 1}/${stageData.steps.length}: ${stepName}(CLI 自动执行,无需 Agent)`)
|
|
2304
|
-
if (cliAction === 'scanPreflight') {
|
|
2305
|
-
await executeScanPreflight(cwd, platformOpts, scanProfile)
|
|
2306
|
-
} else if (cliAction === 'scanPostcheck') {
|
|
2307
|
-
await executeScanPostcheck(cwd, platformOpts, scanProfile)
|
|
2308
|
-
} else if (cliAction === 'planPostcheck') {
|
|
2309
|
-
await executePlanPostcheck(cwd, platformOpts, progress)
|
|
2310
|
-
}
|
|
2311
|
-
stageData.steps[currentIdx].status = 'completed'
|
|
2312
|
-
stageData.steps[currentIdx].completedAt = new Date().toLocaleString('zh-CN', { hour12: false })
|
|
2313
|
-
await pm._write(cwd, progress, changeName)
|
|
2314
|
-
// 自动前进到下一步
|
|
2315
|
-
const nextIdx = stageData.steps.findIndex(s => s.status === 'pending' || s.status === 'in-progress')
|
|
2316
|
-
if (nextIdx !== -1 && defSteps[nextIdx]) {
|
|
2317
|
-
console.log('')
|
|
2318
|
-
await outputStep(stageName, nextIdx, defSteps, cwd, changeName, progress.project || null, platformOpts)
|
|
2319
|
-
} else {
|
|
2320
|
-
// 所有步骤完成
|
|
2321
|
-
stageData.status = 'completed'
|
|
2322
|
-
stageData.completedAt = new Date().toLocaleString('zh-CN', { hour12: false })
|
|
2323
|
-
await pm._write(cwd, progress, changeName)
|
|
2324
|
-
console.log(`\n✅ ${stageName} 阶段全部完成。`)
|
|
2325
|
-
}
|
|
2326
|
-
return
|
|
2327
|
-
}
|
|
2328
|
-
await outputStep(stageName, currentIdx, defSteps, cwd, changeName, progress.project || null, platformOpts)
|
|
2329
|
-
}
|
|
2330
|
-
}
|
|
2331
|
-
|
|
2332
|
-
function validateMetadata(cwd, stageName, specBase) {
|
|
2333
|
-
const changesDir = join(specBase, 'changes')
|
|
2334
|
-
if (!existsSync(changesDir)) return
|
|
2335
|
-
|
|
2336
|
-
const cutoff = Date.now() - 10 * 60 * 1000
|
|
2337
|
-
const missing = []
|
|
2338
|
-
|
|
2339
|
-
function walk(dir) {
|
|
2340
|
-
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
2341
|
-
const full = join(dir, entry.name)
|
|
2342
|
-
try {
|
|
2343
|
-
if (entry.isDirectory()) { walk(full); continue }
|
|
2344
|
-
if (!/\.(md|yaml|yml)$/.test(entry.name)) continue
|
|
2345
|
-
const mtime = statSync(full).mtimeMs
|
|
2346
|
-
if (mtime < cutoff) continue
|
|
2347
|
-
const content = readFileSync(full, 'utf-8')
|
|
2348
|
-
if (!content.includes('author:') && !content.includes('author:')) missing.push(full)
|
|
2349
|
-
if (!content.includes('created_at:') && !content.includes('created_at:')) missing.push(full)
|
|
2350
|
-
} catch (e) { /* skip unreadable files */ }
|
|
2351
|
-
}
|
|
2352
|
-
}
|
|
2353
|
-
|
|
2354
|
-
walk(changesDir)
|
|
2355
|
-
const unique = [...new Set(missing)]
|
|
2356
|
-
if (unique.length > 0) {
|
|
2357
|
-
console.log(`\n⚠️ 以下文件缺少 author 或 created_at 元数据:`)
|
|
2358
|
-
unique.forEach(f => console.log(` - ${f.replace(cwd + '/', '')}`))
|
|
2359
|
-
console.log('请在文件头部添加 author(git 用户名)和 created_at(精确到秒)')
|
|
2360
|
-
}
|
|
2361
|
-
}
|
|
2362
|
-
|
|
2363
|
-
/**
|
|
2364
|
-
* 验证关键文件是否存在于正确的变更目录下
|
|
2365
|
-
* 防止 AI 将文件写到错误的路径
|
|
2366
|
-
*/
|
|
2367
|
-
function validateFileLocations(cwd, stageName, progress, changeName, specBase) {
|
|
2368
|
-
const effectiveChange = changeName || progress.currentChange
|
|
2369
|
-
if (!effectiveChange) return
|
|
2370
|
-
|
|
2371
|
-
const changeDir = join(specBase, 'changes', effectiveChange)
|
|
2372
|
-
if (!existsSync(changeDir)) return
|
|
2373
|
-
|
|
2374
|
-
// 每个阶段完成后预期存在的文件
|
|
2375
|
-
const expectedFiles = {
|
|
2376
|
-
brainstorm: ['design.md', 'proposal.md', 'requirements.md', 'tasks.md'],
|
|
2377
|
-
plan: ['plan.md'],
|
|
2378
|
-
archive: ['module-impact.md'],
|
|
2379
|
-
}
|
|
2380
|
-
|
|
2381
|
-
const expected = expectedFiles[stageName]
|
|
2382
|
-
if (!expected) return
|
|
2383
|
-
|
|
2384
|
-
const missing = []
|
|
2385
|
-
for (const file of expected) {
|
|
2386
|
-
if (!existsSync(join(changeDir, file))) {
|
|
2387
|
-
missing.push(file)
|
|
2388
|
-
}
|
|
2389
|
-
}
|
|
2390
|
-
|
|
2391
|
-
if (missing.length > 0) {
|
|
2392
|
-
console.log(`\n⚠️ 文件位置验证:以下文件未在变更目录中找到`)
|
|
2393
|
-
console.log(` 变更目录:${changeDir.replace(cwd + '/', '')}/`)
|
|
2394
|
-
for (const f of missing) {
|
|
2395
|
-
// 检查是否写到了错误的位置
|
|
2396
|
-
const wrongPath = join(specBase, 'changes', 'change', effectiveChange, f)
|
|
2397
|
-
if (existsSync(wrongPath)) {
|
|
2398
|
-
console.log(` ❌ ${f} — 不存在,但发现了错误路径:${wrongPath.replace(cwd + '/', '')}`)
|
|
2399
|
-
console.log(` 提示:应该写入 ${changeDir.replace(cwd + '/', '')}/${f}`)
|
|
2400
|
-
} else {
|
|
2401
|
-
console.log(` ⬜ ${f} — 未找到(该阶段可能未产出此文件)`)
|
|
2402
|
-
}
|
|
2403
|
-
}
|
|
2404
|
-
} else {
|
|
2405
|
-
console.log(`\n✅ 文件位置验证:所有 ${expected.length} 个预期文件均在变更目录中`)
|
|
2406
|
-
}
|
|
2407
|
-
}
|
|
2408
|
-
|
|
2409
|
-
async function archiveChangeDirectory(pm, cwd, progress, specBase) {
|
|
2410
|
-
const { renameSync } = await import('fs')
|
|
2411
|
-
const archiveChangeName = progress.currentChange
|
|
2412
|
-
if (!archiveChangeName) {
|
|
2413
|
-
console.error('❌ 归档失败:未找到当前变更名(currentChange)')
|
|
2414
|
-
process.exit(1)
|
|
2415
|
-
}
|
|
2416
|
-
const changesDir = join(specBase, 'changes')
|
|
2417
|
-
const archiveDir = join(changesDir, 'archive')
|
|
2418
|
-
const srcDir = join(changesDir, archiveChangeName)
|
|
2419
|
-
const date = new Date().toISOString().slice(0, 10)
|
|
2420
|
-
const destDir = join(archiveDir, `${date}-${archiveChangeName}`)
|
|
2421
|
-
|
|
2422
|
-
if (!existsSync(srcDir)) {
|
|
2423
|
-
console.error(`❌ 归档失败:源目录不存在 ${srcDir}`)
|
|
2424
|
-
process.exit(1)
|
|
2425
|
-
}
|
|
2426
|
-
// 移动前硬校验:变更包必须含 plan.md,否则不该归档。
|
|
2427
|
-
// 在移动前阻断(而非移动后),目录尚未动,用户可直接修复后重试。
|
|
2428
|
-
if (!existsSync(join(srcDir, 'plan.md'))) {
|
|
2429
|
-
console.error(`❌ 归档失败:变更目录缺少 plan.md(${srcDir})`)
|
|
2430
|
-
console.error(` plan.md 是归档的必需产物。请先补全 plan 阶段产出再归档。`)
|
|
2431
|
-
process.exit(1)
|
|
2432
|
-
}
|
|
2433
|
-
if (existsSync(destDir)) {
|
|
2434
|
-
console.error(`❌ 归档失败:目标目录已存在 ${destDir}`)
|
|
2435
|
-
process.exit(1)
|
|
2436
|
-
}
|
|
2437
|
-
mkdirSync(archiveDir, { recursive: true })
|
|
2438
|
-
try {
|
|
2439
|
-
renameSyncRetry(srcDir, destDir)
|
|
2440
|
-
} catch (e) {
|
|
2441
|
-
console.error(`❌ 归档失败:移动变更目录时出错(${e.message})`)
|
|
2442
|
-
console.error(` 常见原因:变更目录内文件被 IDE / 杀毒 / 索引占用,已重试 5 次仍失败。请关闭相关程序后重试。`)
|
|
2443
|
-
process.exit(1)
|
|
2444
|
-
}
|
|
2445
|
-
|
|
2446
|
-
if (!existsSync(destDir) || existsSync(srcDir)) {
|
|
2447
|
-
console.error('❌ 归档校验失败:移动操作异常')
|
|
2448
|
-
process.exit(1)
|
|
2449
|
-
}
|
|
2450
|
-
|
|
2451
|
-
await pm.unregisterChange(cwd, archiveChangeName)
|
|
2452
|
-
|
|
2453
|
-
// 归档时清理可能残留的 worktree(execute 自动清理未走到 / 有未 apply 变更被遗弃)。
|
|
2454
|
-
// 安全策略:有未 apply 变更时保留 worktree 并警告,避免误删用户未应用的代码。
|
|
2455
|
-
try {
|
|
2456
|
-
const { WorktreeManager } = await import('./worktree.js')
|
|
2457
|
-
const wm = new WorktreeManager({ cwd })
|
|
2458
|
-
const meta = wm.getMeta(archiveChangeName)
|
|
2459
|
-
if (meta) {
|
|
2460
|
-
const check = meta.mode !== 'in-place-fallback' ? wm.hasUnappliedChanges(archiveChangeName) : { hasChanges: false }
|
|
2461
|
-
if (check.hasChanges) {
|
|
2462
|
-
console.warn(`⚠️ 归档时 worktree 仍有 ${check.changedFiles.length} 个未 apply 变更,保留 worktree`)
|
|
2463
|
-
console.warn(` 确认不需要后手动清理: sillyspec worktree cleanup ${archiveChangeName} --force`)
|
|
2464
|
-
} else {
|
|
2465
|
-
const cleanResult = wm.cleanup(archiveChangeName)
|
|
2466
|
-
if (cleanResult.residual?.length > 0) {
|
|
2467
|
-
console.warn(`⚠️ 归档 worktree 清理残留: ${cleanResult.residual.join('; ')}`)
|
|
2468
|
-
console.warn(` 手动处理: sillyspec worktree cleanup ${archiveChangeName} --force`)
|
|
2469
|
-
}
|
|
2470
|
-
}
|
|
2471
|
-
}
|
|
2472
|
-
} catch (e) {
|
|
2473
|
-
console.warn(`⚠️ 归档 worktree 清理失败(不阻断归档): ${e.message}`)
|
|
2474
|
-
}
|
|
2475
|
-
|
|
2476
|
-
console.log(`📦 已归档:${archiveChangeName} → archive/${date}-${archiveChangeName}/`)
|
|
2477
|
-
return destDir
|
|
2478
|
-
}
|
|
2479
|
-
|
|
2480
|
-
// ── Wait Step ──
|
|
2481
|
-
|
|
2482
|
-
async function waitStep(pm, progress, stageName, cwd, outputText, waitReason, waitOptions, options = {}) {
|
|
2483
|
-
const { changeName, nonInteractive = false, platformOpts = {} } = options
|
|
2484
|
-
const specBase = platformOpts.specRoot || join(cwd, '.sillyspec')
|
|
2485
|
-
const stageData = progress.stages[stageName]
|
|
2486
|
-
|
|
2487
|
-
if (!stageData || !stageData.steps) {
|
|
2488
|
-
console.error(`❌ 阶段 ${stageName} 未初始化`)
|
|
2489
|
-
process.exit(1)
|
|
2490
|
-
}
|
|
2491
|
-
|
|
2492
|
-
// 查找下一个 pending 或 in-progress 的步骤
|
|
2493
|
-
const currentIdx = stageData.steps.findIndex(s => s.status === 'pending' || s.status === 'in-progress')
|
|
2494
|
-
if (currentIdx === -1) {
|
|
2495
|
-
console.error('没有可以等待的步骤')
|
|
2496
|
-
process.exit(1)
|
|
2497
|
-
}
|
|
2498
|
-
|
|
2499
|
-
// 前置检查:不允许已有 waiting 步骤时再 --wait
|
|
2500
|
-
const existingWaitingIdx = stageData.steps.findIndex(s => s.status === 'waiting')
|
|
2501
|
-
if (existingWaitingIdx !== -1) {
|
|
2502
|
-
const ws = stageData.steps[existingWaitingIdx]
|
|
2503
|
-
console.error(`❌ 已有步骤处于等待状态:Step ${existingWaitingIdx + 1} "${ws.name}"`)
|
|
2504
|
-
console.error(` 请先 --continue 或 --reset 该步骤,再开始新的 --wait`)
|
|
2505
|
-
process.exit(1)
|
|
2506
|
-
}
|
|
2507
|
-
|
|
2508
|
-
// maxWaitRounds 硬上限:达到后拒绝继续 --wait
|
|
2509
|
-
const currentStep = stageData.steps[currentIdx]
|
|
2510
|
-
const defSteps = await getStageSteps(stageName, cwd, progress, platformOpts?.specRoot || null)
|
|
2511
|
-
const stepDef = defSteps?.[currentIdx] || {}
|
|
2512
|
-
const maxWaitRounds = currentStep.maxWaitRounds ?? stepDef.maxWaitRounds
|
|
2513
|
-
const currentWaitRound = currentStep.waitRound || 0
|
|
2514
|
-
if (maxWaitRounds && currentWaitRound >= maxWaitRounds) {
|
|
2515
|
-
console.error(`❌ Step "${currentStep.name}" 已达到最大等待轮次(maxWaitRounds=${maxWaitRounds})`)
|
|
2516
|
-
console.error(` 请基于已有回答完成本步骤:`)
|
|
2517
|
-
console.error(` sillyspec run ${stageName} --done${changeName ? ` --change ${changeName}` : ''} --output "需求理解摘要"`)
|
|
2518
|
-
process.exit(1)
|
|
2519
|
-
}
|
|
2520
|
-
|
|
2521
|
-
// 非交互模式下拒绝等待
|
|
2522
|
-
if (nonInteractive) {
|
|
2523
|
-
console.error(`❌ Human decision required in non-interactive mode.`)
|
|
2524
|
-
console.error(` Reason: ${waitReason || '(unknown)'}`)
|
|
2525
|
-
if (waitOptions) console.error(` Options: ${formatWaitOptions(waitOptions)}`)
|
|
2526
|
-
console.error(` Fix: rerun with --interactive or provide decision via sillyspec run ${stageName} --continue --answer "..."`)
|
|
2527
|
-
process.exit(2)
|
|
2528
|
-
}
|
|
2529
|
-
|
|
2530
|
-
const now = new Date().toLocaleString('zh-CN', { hour12: false })
|
|
2531
|
-
stageData.steps[currentIdx].status = 'waiting'
|
|
2532
|
-
stageData.steps[currentIdx].waitedAt = now
|
|
2533
|
-
if (outputText) {
|
|
2534
|
-
const MAX_OUTPUT = 200
|
|
2535
|
-
stageData.steps[currentIdx].output = outputText.length > MAX_OUTPUT
|
|
2536
|
-
? outputText.slice(0, MAX_OUTPUT) + '…' : outputText
|
|
2537
|
-
}
|
|
2538
|
-
if (waitReason) stageData.steps[currentIdx].waitReason = waitReason
|
|
2539
|
-
if (waitOptions) {
|
|
2540
|
-
// 统一存为 JSON 数组
|
|
2541
|
-
try {
|
|
2542
|
-
const parsed = JSON.parse(waitOptions)
|
|
2543
|
-
if (Array.isArray(parsed)) {
|
|
2544
|
-
stageData.steps[currentIdx].waitOptions = JSON.stringify(parsed)
|
|
2545
|
-
} else {
|
|
2546
|
-
stageData.steps[currentIdx].waitOptions = JSON.stringify(waitOptions.split(',').map(o => o.trim()))
|
|
2547
|
-
}
|
|
2548
|
-
} catch {
|
|
2549
|
-
stageData.steps[currentIdx].waitOptions = JSON.stringify(waitOptions.split(',').map(o => o.trim()))
|
|
2550
|
-
}
|
|
2551
|
-
}
|
|
2552
|
-
|
|
2553
|
-
progress.lastActive = now
|
|
2554
|
-
await pm._write(cwd, progress, changeName)
|
|
2555
|
-
triggerSync(cwd, changeName, platformOpts)
|
|
2556
|
-
|
|
2557
|
-
console.log(`⏸️ Step ${currentIdx + 1}/${stageData.steps.length} 已暂停等待:${stageData.steps[currentIdx].name}`)
|
|
2558
|
-
if (waitReason) console.log(` 原因:${waitReason}`)
|
|
2559
|
-
if (waitOptions) console.log(` 选项:${formatWaitOptions(waitOptions)}`)
|
|
2560
|
-
console.log(` 继续时执行:sillyspec run ${stageName} --continue --answer "你的选择"${changeName ? ` --change ${changeName}` : ''}`)
|
|
2561
|
-
}
|
|
2562
|
-
|
|
2563
|
-
// ── Continue Step ──
|
|
2564
|
-
|
|
2565
|
-
async function continueStep(pm, progress, stageName, cwd, answer, options = {}) {
|
|
2566
|
-
const { changeName, platformOpts = {} } = options
|
|
2567
|
-
const specBase = platformOpts.specRoot || join(cwd, '.sillyspec')
|
|
2568
|
-
const stageData = progress.stages[stageName]
|
|
2569
|
-
|
|
2570
|
-
if (!stageData || !stageData.steps) {
|
|
2571
|
-
console.error(`❌ 阶段 ${stageName} 未初始化`)
|
|
2572
|
-
process.exit(1)
|
|
2573
|
-
}
|
|
2574
|
-
|
|
2575
|
-
if (!answer) {
|
|
2576
|
-
console.error('❌ --continue 需要 --answer 参数')
|
|
2577
|
-
process.exit(1)
|
|
2578
|
-
}
|
|
2579
|
-
|
|
2580
|
-
// 查找 waiting 的步骤
|
|
2581
|
-
const waitingSteps = stageData.steps.map((s, i) => ({ ...s, idx: i })).filter(s => s.status === 'waiting')
|
|
2582
|
-
if (waitingSteps.length === 0) {
|
|
2583
|
-
console.error('没有处于等待状态的步骤')
|
|
2584
|
-
process.exit(1)
|
|
2585
|
-
}
|
|
2586
|
-
if (waitingSteps.length > 1) {
|
|
2587
|
-
console.error(`❌ 检测到 ${waitingSteps.length} 个等待中的步骤,无法确定恢复目标:`)
|
|
2588
|
-
for (const ws of waitingSteps) {
|
|
2589
|
-
console.error(` Step ${ws.idx + 1}: ${ws.name}${ws.waitReason ? `(${ws.waitReason})` : ''}`)
|
|
2590
|
-
}
|
|
2591
|
-
console.error(` 请使用 --reset 重置,或手动修复 DB`)
|
|
2592
|
-
process.exit(1)
|
|
2593
|
-
}
|
|
2594
|
-
const currentIdx = waitingSteps[0].idx
|
|
2595
|
-
const defSteps = await getStageSteps(stageName, cwd, progress, platformOpts?.specRoot || null)
|
|
2596
|
-
const currentStepDef = defSteps?.[currentIdx] || {}
|
|
2597
|
-
const currentStep = stageData.steps[currentIdx]
|
|
2598
|
-
const isRepeatableWait = currentStepDef.repeatableWait === true || currentStep.repeatableWait === true
|
|
2599
|
-
const requiresWait = currentStepDef.requiresWait === true || currentStep.requiresWait === true
|
|
2600
|
-
const shouldReturnToCurrentStep = isRepeatableWait || requiresWait
|
|
2601
|
-
|
|
2602
|
-
const now = new Date().toLocaleString('zh-CN', { hour12: false })
|
|
2603
|
-
const prevOutput = currentStep.output || ''
|
|
2604
|
-
const waitRound = (currentStep.waitRound || 0) + 1
|
|
2605
|
-
currentStep.waitRound = waitRound
|
|
2606
|
-
currentStep.waitAnswer = answer
|
|
2607
|
-
currentStep.waitAnswers = Array.isArray(currentStep.waitAnswers) ? currentStep.waitAnswers : []
|
|
2608
|
-
currentStep.waitAnswers.push({
|
|
2609
|
-
round: waitRound,
|
|
2610
|
-
answer,
|
|
2611
|
-
question: prevOutput || null,
|
|
2612
|
-
answeredAt: now,
|
|
2613
|
-
})
|
|
2614
|
-
currentStep.maxWaitRounds = currentStepDef.maxWaitRounds ?? currentStep.maxWaitRounds
|
|
2615
|
-
|
|
2616
|
-
// 合并 waiting 信息到 output
|
|
2617
|
-
const waitInfo = currentStep.waitReason || ''
|
|
2618
|
-
if (waitInfo) {
|
|
2619
|
-
currentStep.output = prevOutput
|
|
2620
|
-
? `${prevOutput} | 用户回答#${waitRound}:${answer}`
|
|
2621
|
-
: `用户回答#${waitRound}:${answer}`
|
|
2622
|
-
}
|
|
2623
|
-
|
|
2624
|
-
// 清除等待状态
|
|
2625
|
-
delete currentStep.waitReason
|
|
2626
|
-
delete currentStep.waitOptions
|
|
2627
|
-
delete currentStep.waitedAt
|
|
2628
|
-
|
|
2629
|
-
if (shouldReturnToCurrentStep) {
|
|
2630
|
-
currentStep.status = 'pending'
|
|
2631
|
-
currentStep.completedAt = null
|
|
2632
|
-
} else {
|
|
2633
|
-
currentStep.status = 'completed'
|
|
2634
|
-
currentStep.completedAt = now
|
|
2635
|
-
}
|
|
2636
|
-
|
|
2637
|
-
progress.lastActive = now
|
|
2638
|
-
await pm._write(cwd, progress, changeName)
|
|
2639
|
-
triggerSync(cwd, changeName, platformOpts)
|
|
2640
|
-
|
|
2641
|
-
console.log(`✅ Step ${currentIdx + 1}/${stageData.steps.length} 已继续:${currentStep.name}`)
|
|
2642
|
-
console.log(` 回答:${answer}`)
|
|
2643
|
-
|
|
2644
|
-
// Append to user-inputs.md
|
|
2645
|
-
const inputsPath = join(specBase, '.runtime', 'user-inputs.md')
|
|
2646
|
-
const entry = `\n## ${now} | ${changeName || '?'} | ${stageName}: ${currentStep.name} [CONTINUED]\n- 回答:${answer}\n`
|
|
2647
|
-
appendFileSync(inputsPath, entry)
|
|
2648
|
-
|
|
2649
|
-
// shouldReturnToCurrentStep: 回到当前步骤继续执行(repeatable=多轮探索,requiresWait=确认后执行动作)
|
|
2650
|
-
if (shouldReturnToCurrentStep) {
|
|
2651
|
-
console.log(`\n🔁 Step ${currentIdx + 1}/${stageData.steps.length} 已收到用户输入,回到当前步骤继续执行。`)
|
|
2652
|
-
if (isRepeatableWait) {
|
|
2653
|
-
console.log(` 已收集回答轮次:${waitRound}${currentStep.maxWaitRounds ? `/${currentStep.maxWaitRounds}` : ''}`)
|
|
2654
|
-
}
|
|
2655
|
-
if (defSteps && defSteps[currentIdx]) {
|
|
2656
|
-
console.log('')
|
|
2657
|
-
await outputStep(
|
|
2658
|
-
stageName,
|
|
2659
|
-
currentIdx,
|
|
2660
|
-
defSteps,
|
|
2661
|
-
cwd,
|
|
2662
|
-
changeName,
|
|
2663
|
-
progress.project || null,
|
|
2664
|
-
platformOpts,
|
|
2665
|
-
formatWaitHistory(currentStep)
|
|
2666
|
-
)
|
|
2667
|
-
}
|
|
2668
|
-
return { stageCompleted: false, currentIdx, nextPendingIdx: currentIdx }
|
|
2669
|
-
}
|
|
2670
|
-
|
|
2671
|
-
// 检查阶段是否全部完成
|
|
2672
|
-
const nextPendingIdx = stageData.steps.findIndex(s => s.status === 'pending')
|
|
2673
|
-
const nextWaitingIdx = stageData.steps.findIndex(s => s.status === 'waiting')
|
|
2674
|
-
if (nextPendingIdx === -1 && nextWaitingIdx === -1) {
|
|
2675
|
-
stageData.status = 'completed'
|
|
2676
|
-
stageData.completedAt = now
|
|
2677
|
-
await pm._write(cwd, progress, changeName)
|
|
2678
|
-
console.log(`\n✅ ${stageName} 阶段已完成(${stageData.steps.length}/${stageData.steps.length} 步)`)
|
|
2679
|
-
// ── execute 阶段完成时条件性清理 worktree ──
|
|
2680
|
-
if (stageName === 'execute' && changeName) {
|
|
2681
|
-
try {
|
|
2682
|
-
const { WorktreeManager } = await import('./worktree.js');
|
|
2683
|
-
const wm = new WorktreeManager({ cwd });
|
|
2684
|
-
const meta = wm.getMeta(changeName);
|
|
2685
|
-
if (!meta) {
|
|
2686
|
-
console.log('🔗 Worktree: n/a (no meta)');
|
|
2687
|
-
} else if (meta.mode === 'native-worktree') {
|
|
2688
|
-
console.log('🔗 Worktree: kept (外部隔离环境)');
|
|
2689
|
-
} else {
|
|
2690
|
-
// in-place 模式不再短路:cleanup 现在能安全处理 in-place(只清 meta,不碰主工作区)
|
|
2691
|
-
const check = wm.hasUnappliedChanges(changeName);
|
|
2692
|
-
if (check.hasChanges) {
|
|
2693
|
-
console.log(`🔗 Worktree: pending apply (${check.changedFiles.length} 个未应用变更)`);
|
|
2694
|
-
console.log(` 下一步: sillyspec worktree apply ${changeName}`);
|
|
2695
|
-
} else {
|
|
2696
|
-
const cleanResult = wm.cleanup(changeName);
|
|
2697
|
-
console.log(`🔗 Worktree: ${cleanResult.result}`);
|
|
2698
|
-
if (cleanResult.residual?.length > 0) {
|
|
2699
|
-
console.warn(` ⚠️ 清理残留: ${cleanResult.residual.join('; ')}`);
|
|
2700
|
-
console.warn(` 手动处理: sillyspec worktree cleanup ${changeName} --force`);
|
|
2701
|
-
}
|
|
2702
|
-
}
|
|
2703
|
-
}
|
|
2704
|
-
} catch (e) {
|
|
2705
|
-
console.warn(`🔗 Worktree: check failed — ${e.message}`);
|
|
2706
|
-
}
|
|
2707
|
-
}
|
|
2708
|
-
// 阶段完成后明确下一步(agent 常卡:stageData completed 但不知要 run <下一阶段> 推进 currentStage)
|
|
2709
|
-
const nextStageHint = { brainstorm: 'plan', plan: 'execute', execute: 'verify', verify: 'archive' }[stageName]
|
|
2710
|
-
if (nextStageHint) {
|
|
2711
|
-
console.log(`\n👉 ${stageName} 已完成。下一步:sillyspec run ${nextStageHint}${changeName ? ` --change ${changeName}` : ''}`)
|
|
2712
|
-
if (stageName === 'execute') {
|
|
2713
|
-
console.log(` ⚠️ 若 worktree 改动还没 apply 到主工作区,先:sillyspec worktree apply ${changeName}`)
|
|
2714
|
-
console.log(` (apply 不需要先 commit,支持 working tree 未提交改动)`)
|
|
2715
|
-
// plan.md checkbox auto-check:execute 完成 + review.json pass → 自动勾选(治本,比警告可靠)
|
|
2716
|
-
try {
|
|
2717
|
-
const specBaseLc = platformOpts.specRoot || join(cwd, '.sillyspec')
|
|
2718
|
-
const changeDir = join(specBaseLc, 'changes', changeName)
|
|
2719
|
-
const planPath = join(changeDir, 'plan.md')
|
|
2720
|
-
const runtimeRoot = platformOpts.runtimeRoot || join(specBaseLc, '.runtime')
|
|
2721
|
-
const runIdFile = join(runtimeRoot, `current-execute-run-id-${changeName}`)
|
|
2722
|
-
if (existsSync(planPath) && existsSync(runIdFile)) {
|
|
2723
|
-
const executeRunId = readFileSync(runIdFile, 'utf8').trim()
|
|
2724
|
-
const planContent = readFileSync(planPath, 'utf8')
|
|
2725
|
-
const { readReview } = await import('./task-review.js')
|
|
2726
|
-
let checkedCount = 0
|
|
2727
|
-
let skippedCount = 0
|
|
2728
|
-
const updated = planContent.replace(/^(\s*[-*]\s*\[)\s(\]\s*task-\d+)/gim, (match, p1, p2) => {
|
|
2729
|
-
const taskNum = match.match(/task-(\d+)/)[1].padStart(2, '0')
|
|
2730
|
-
const reviewPath = join(runtimeRoot, 'execute-runs', executeRunId, 'tasks', `task-${taskNum}`, 'review.json')
|
|
2731
|
-
const r = readReview(reviewPath)
|
|
2732
|
-
if (r.ok && r.review?.specVerdict !== 'fail' && r.review?.qualityVerdict !== 'fail') {
|
|
2733
|
-
checkedCount++
|
|
2734
|
-
return `${p1}x${p2}` // 勾选
|
|
2735
|
-
}
|
|
2736
|
-
skippedCount++
|
|
2737
|
-
return match // 不勾
|
|
2738
|
-
})
|
|
2739
|
-
if (checkedCount > 0) {
|
|
2740
|
-
writeFileSync(planPath, updated)
|
|
2741
|
-
console.log(` ✅ 自动勾选 ${checkedCount} 个 task checkbox(基于 review.json pass)`)
|
|
2742
|
-
}
|
|
2743
|
-
if (skippedCount > 0) {
|
|
2744
|
-
console.warn(` ⚠️ ${skippedCount} 个 task 未勾(review.json 缺失/fail)→ archive 会拦。补 review 后重跑 execute --done 触发自动勾`)
|
|
2745
|
-
}
|
|
2746
|
-
}
|
|
2747
|
-
} catch {}
|
|
2748
|
-
}
|
|
2749
|
-
}
|
|
2750
|
-
return { stageCompleted: true, currentIdx, nextPendingIdx: -1 }
|
|
2751
|
-
}
|
|
2752
|
-
|
|
2753
|
-
// 输出下一步
|
|
2754
|
-
if (nextPendingIdx !== -1 && defSteps) {
|
|
2755
|
-
console.log('')
|
|
2756
|
-
await outputStep(stageName, nextPendingIdx, defSteps, cwd, changeName, progress.project || null, platformOpts, answer)
|
|
2757
|
-
} else if (nextWaitingIdx !== -1 && defSteps) {
|
|
2758
|
-
// 下一个步骤也在等待状态
|
|
2759
|
-
const ws = stageData.steps[nextWaitingIdx]
|
|
2760
|
-
console.log(`\n⏸️ Step ${nextWaitingIdx + 1}/${stageData.steps.length} 仍在等待:${ws.name}`)
|
|
2761
|
-
if (ws.waitReason) console.log(` 原因:${ws.waitReason}`)
|
|
2762
|
-
console.log(` 继续:sillyspec run ${stageName} --continue --answer "..."${changeName ? ` --change ${changeName}` : ''}`)
|
|
2763
|
-
}
|
|
2764
|
-
|
|
2765
|
-
return { stageCompleted: false, currentIdx, nextPendingIdx: nextPendingIdx }
|
|
2766
|
-
}
|
|
2767
|
-
|
|
2768
|
-
/**
|
|
2769
|
-
* 判断当前 execute step 所在 wave 是否全部 task 都声明 no_deps_verify: true(D-006@v2)。
|
|
2770
|
-
* 仅 wave 执行步骤(名如 "Wave N 执行")可 opt-out;非 wave 步骤恒返回 false(保守过门)。
|
|
2771
|
-
*/
|
|
2772
|
-
function isCurrentWaveAllNoDepsVerify(stepName, changeDir) {
|
|
2773
|
-
if (!stepName) return false
|
|
2774
|
-
const m = String(stepName).match(/^Wave\s+(\d+)/i)
|
|
2775
|
-
if (!m) return false
|
|
2776
|
-
const waveN = parseInt(m[1], 10)
|
|
2777
|
-
if (!changeDir) return false
|
|
2778
|
-
const planPath = join(changeDir, 'plan.md')
|
|
2779
|
-
if (!existsSync(planPath)) return false
|
|
2780
|
-
const plan = readFileSync(planPath, 'utf8')
|
|
2781
|
-
const waveRe = new RegExp(`^##\\s*Wave\\s+${waveN}\\b[^\\n]*\\n([\\s\\S]*?)(?=\\n##\\s|\\n#\\s|$)`, 'm')
|
|
2782
|
-
const waveMatch = plan.match(waveRe)
|
|
2783
|
-
if (!waveMatch) return false
|
|
2784
|
-
const taskIds = [...waveMatch[1].matchAll(/^[-*]\s*\[[ x]\]\s*(task-\d+)/gim)].map(x => x[1])
|
|
2785
|
-
if (taskIds.length === 0) return false
|
|
2786
|
-
for (const id of taskIds) {
|
|
2787
|
-
const cardPath = join(changeDir, 'tasks', `${id}.md`)
|
|
2788
|
-
if (!existsSync(cardPath)) return false // 卡片缺失 → 保守不跳
|
|
2789
|
-
const card = readFileSync(cardPath, 'utf8')
|
|
2790
|
-
const fm = card.match(/^---\n([\s\S]*?)\n---/)
|
|
2791
|
-
if (!fm) return false
|
|
2792
|
-
if (!/no_deps_verify:\s*true/i.test(fm[1])) return false
|
|
2793
|
-
}
|
|
2794
|
-
return true
|
|
2795
|
-
}
|
|
2796
|
-
|
|
2797
|
-
/**
|
|
2798
|
-
* execute deps 验证硬门(change 2026-06-28-worktree-deps-provision / D-001@v1, D-003@v1, D-006@v2)。
|
|
2799
|
-
* depsStatus 不达标且非 wave 级 opt-out 时阻断 --done:置 step=blocked + exit(1),与 requiresWait 同范式。
|
|
2800
|
-
* 放行返回 true;阻断时 process.exit(1) 不返回。
|
|
2801
|
-
*/
|
|
2802
|
-
async function enforceDepsGate(stageName, cwd, changeName, step, steps, currentIdx, specBase, platformOpts) {
|
|
2803
|
-
if (stageName !== 'execute') return true
|
|
2804
|
-
let meta = null
|
|
2805
|
-
let wm = null
|
|
2806
|
-
try {
|
|
2807
|
-
const { WorktreeManager } = await import('./worktree.js')
|
|
2808
|
-
wm = new WorktreeManager({ cwd })
|
|
2809
|
-
meta = wm.getMeta(changeName)
|
|
2810
|
-
} catch (e) {
|
|
2811
|
-
// WorktreeManager 构造/读取失败(git 不可用 / worktree 元数据损坏)不阻断 deps gate;
|
|
2812
|
-
// 下游已有物理目录存在性判定兜底(G2/R3),这里 warn 留下根因线索,避免静默误诊。
|
|
2813
|
-
console.warn(`⚠️ worktree meta 读取失败(deps gate 将走物理目录判定): ${e.message}`)
|
|
2814
|
-
}
|
|
2815
|
-
const depsStatus = meta?.depsStatus
|
|
2816
|
-
if (['linked', 'installed', 'n/a'].includes(depsStatus)) return true
|
|
2817
|
-
const changeDir = changeName ? join(specBase, 'changes', changeName) : null
|
|
2818
|
-
if (isCurrentWaveAllNoDepsVerify(step?.name, changeDir)) return true
|
|
2819
|
-
if (steps && steps[currentIdx]) steps[currentIdx].status = 'blocked'
|
|
2820
|
-
// ── 诊断分支(Phase 2,G2/R3 修正:判定基于物理目录而非 !meta)──
|
|
2821
|
-
// getMeta 对"目录不存在"与"meta 损坏"都返回 null,后者会误判终态 → 用物理目录存在性判定。
|
|
2822
|
-
let worktreeGone = true
|
|
2823
|
-
try {
|
|
2824
|
-
worktreeGone = !!(wm && changeName) && !existsSync(wm.getWorktreePath(changeName))
|
|
2825
|
-
} catch {}
|
|
2826
|
-
// ── fail-loud 块(Phase 3,D-005@v1:仅改拒绝侧 stderr)──
|
|
2827
|
-
console.error('❌ ── deps 门控阻断(本次 --done 未完成,进度未推进)──')
|
|
2828
|
-
if (worktreeGone) {
|
|
2829
|
-
console.error(' worktree 不可用(已 cleanup 或目录不存在)。')
|
|
2830
|
-
console.error(` 修复:sillyspec doctor --align-execute-progress${changeName ? ` --change ${changeName}` : ''} 按 plan.md 对齐进度`)
|
|
2831
|
-
console.error(` 或: sillyspec worktree create ${changeName || '<change>'} 重建 worktree 继续跑`)
|
|
2832
|
-
} else {
|
|
2833
|
-
console.error(` 原因:依赖未就绪(depsStatus=${depsStatus || 'unknown'}),不得在无构建/测试能力时声称完成。`)
|
|
2834
|
-
console.error(` 修复:sillyspec worktree doctor --fix${changeName ? ` --change ${changeName}` : ''}`)
|
|
2835
|
-
console.error(' 或在 worktree 内手动安装依赖后重试。')
|
|
2836
|
-
if (meta?.depsError) console.error(` 上次供给错误:${meta.depsError}`)
|
|
2837
|
-
}
|
|
2838
|
-
process.exit(1)
|
|
2839
|
-
}
|
|
2840
|
-
|
|
2841
|
-
/**
|
|
2842
|
-
* execute 入口 deps 自检(D-002,change 2026-06-28-worktree-deps-provision)。
|
|
2843
|
-
* 已存在 worktree(create short-circuit 不供给)时,校验 depsStatus 缺失 / node_modules
|
|
2844
|
-
* 丢失(missing) / lockfile 变化(stale) → 触发 provisionDeps 重供给并写回 meta。
|
|
2845
|
-
*/
|
|
2846
|
-
async function ensureDepsFreshness(cwd, changeName, specBase, worktreeMeta) {
|
|
2847
|
-
if (!worktreeMeta || !worktreeMeta.worktreePath) return
|
|
2848
|
-
const { provisionDeps, lockfileHash } = await import('./worktree-deps.js')
|
|
2849
|
-
const wtPath = worktreeMeta.worktreePath
|
|
2850
|
-
const nodeModulesExists = existsSync(join(wtPath, 'node_modules'))
|
|
2851
|
-
const currentHash = lockfileHash(wtPath)
|
|
2852
|
-
const noStatus = !worktreeMeta.depsStatus
|
|
2853
|
-
const missing = ['linked', 'installed'].includes(worktreeMeta.depsStatus) && !nodeModulesExists
|
|
2854
|
-
const stale = !!(worktreeMeta.depsLockHash && currentHash && currentHash !== worktreeMeta.depsLockHash)
|
|
2855
|
-
if (!noStatus && !missing && !stale) return
|
|
2856
|
-
const reason = noStatus ? 'depsStatus 缺失' : (missing ? 'node_modules 丢失' : 'lockfile 变化')
|
|
2857
|
-
console.log(`🔄 worktree deps 自检:${reason}(depsStatus=${worktreeMeta.depsStatus || 'unknown'}),重新供给...`)
|
|
2858
|
-
let deps = {}
|
|
2859
|
-
try {
|
|
2860
|
-
deps = provisionDeps(wtPath, cwd, { specBase }) || {}
|
|
2861
|
-
} catch (e) {
|
|
2862
|
-
deps = { depsStatus: 'failed', depsError: `provisionDeps crashed: ${e.message}` }
|
|
2863
|
-
}
|
|
2864
|
-
try {
|
|
2865
|
-
const { WorktreeManager } = await import('./worktree.js')
|
|
2866
|
-
const metaPath = join(new WorktreeManager({ cwd }).getWorktreePath(changeName), 'meta.json')
|
|
2867
|
-
const updated = { ...worktreeMeta, ...deps }
|
|
2868
|
-
writeFileSync(metaPath, JSON.stringify(updated, null, 2) + '\n')
|
|
2869
|
-
console.log(`✅ deps 重新供给完成:depsStatus=${deps.depsStatus}`)
|
|
2870
|
-
} catch (e) {
|
|
2871
|
-
console.warn(`⚠️ deps meta 写回失败:${e.message}`)
|
|
2872
|
-
}
|
|
2873
|
-
}
|
|
2874
|
-
|
|
2875
|
-
/**
|
|
2876
|
-
* 阶段完成校验失败时回滚状态。
|
|
2877
|
-
*
|
|
2878
|
-
* completeStep 在跑 validator 之前就把 stageData.status 写成 'completed',
|
|
2879
|
-
* 若校验失败不回滚,DB 会与真实产物不一致(hook/doctor/下游阶段全部误判),
|
|
2880
|
-
* 且所有步骤都是 completed 时 agent 无法重新 --done("没有待完成的步骤")。
|
|
2881
|
-
* 此处将 stage 回滚为 in-progress,最后一步重置为 pending,供修复产物后重做。
|
|
2882
|
-
*/
|
|
2883
|
-
function rollbackStageCompletion(stageData, steps, currentIdx) {
|
|
2884
|
-
// 辅助阶段在 validator 前已被重置为 pending(steps 也换成了新数组),不要覆盖
|
|
2885
|
-
if (stageData.status === 'completed') {
|
|
2886
|
-
stageData.status = 'in-progress'
|
|
2887
|
-
stageData.completedAt = null
|
|
2888
|
-
}
|
|
2889
|
-
if (steps[currentIdx] && steps[currentIdx].status === 'completed') {
|
|
2890
|
-
steps[currentIdx].status = 'pending'
|
|
2891
|
-
steps[currentIdx].completedAt = null
|
|
2892
|
-
}
|
|
2893
|
-
}
|
|
2894
|
-
|
|
2895
|
-
/**
|
|
2896
|
-
* 阶段完成校验失败后的统一收尾:回滚 stage/step 状态 + 落盘 + sync + 返回「未完成」。
|
|
2897
|
-
*
|
|
2898
|
-
* completeStep 的各 gate(runValidators / verify-test 对账 / plan→execute contract /
|
|
2899
|
-
* Stage Review / Execute Task Review)失败时都走这套动作;原先每个失败分支手写重复
|
|
2900
|
-
* ~7 次(含 lastActive 落盘 + triggerSync + return 结构),统一进来消坑,避免某分支
|
|
2901
|
-
* 漏写 triggerSync / 写错 return 结构导致行为分裂。返回 nextPendingIdx=currentIdx,
|
|
2902
|
-
* 让上层走「完成但不推进」分支,--done 被拒、agent 修复产物后重跑。
|
|
2903
|
-
*/
|
|
2904
|
-
async function rollbackCompletionAndReturn(pm, progress, stageData, steps, currentIdx, cwd, changeName, platformOpts) {
|
|
2905
|
-
rollbackStageCompletion(stageData, steps, currentIdx)
|
|
2906
|
-
progress.lastActive = new Date().toLocaleString('zh-CN', { hour12: false })
|
|
2907
|
-
await pm._write(cwd, progress, changeName)
|
|
2908
|
-
triggerSync(cwd, changeName, platformOpts)
|
|
2909
|
-
return { stageCompleted: false, currentIdx, nextPendingIdx: currentIdx }
|
|
2910
|
-
}
|
|
2911
|
-
|
|
2912
|
-
/**
|
|
2913
|
-
* archive 阶段「确认归档」步骤的收尾处理器(从 completeStep 抽出,行为保持)。
|
|
2914
|
-
*
|
|
2915
|
-
* 两件事:
|
|
2916
|
-
* 1. --confirm 门控:缺 --confirm → 回退 step 状态、提示、返回 early-return 对象(completeStep 透传)
|
|
2917
|
-
* 2. --confirm 通过 → archiveChangeDirectory 移动变更目录 + 推荐文档(design.md / module-impact.md)校验
|
|
2918
|
-
* (contracts.archive.validators 为空,两个 validator 生效窗口互斥;plan.md 已在移动前硬校验阻断)
|
|
2919
|
-
*
|
|
2920
|
-
* @returns {{stageCompleted:false,currentIdx,nextPendingIdx:number}|null}
|
|
2921
|
-
*/
|
|
2922
|
-
async function handleArchiveConfirmStep({ stageName, steps, currentIdx, confirm, outputText, pm, cwd, progress, changeName, specBase }) {
|
|
2923
|
-
if (stageName !== 'archive' || steps[currentIdx]?.name !== '确认归档') return null
|
|
2924
|
-
if (!confirm) {
|
|
2925
|
-
steps[currentIdx].status = 'pending'
|
|
2926
|
-
steps[currentIdx].completedAt = null
|
|
2927
|
-
if (outputText) steps[currentIdx].output = null
|
|
2928
|
-
await pm._write(cwd, progress, changeName)
|
|
2929
|
-
console.log('⚠️ 请添加 --confirm 确认归档,例如:sillyspec run archive --done --confirm --output "确认归档"')
|
|
2930
|
-
return { stageCompleted: false, currentIdx, nextPendingIdx: currentIdx }
|
|
2931
|
-
}
|
|
2932
|
-
const archivedDir = await archiveChangeDirectory(pm, cwd, progress, specBase)
|
|
2933
|
-
if (archivedDir && existsSync(archivedDir)) {
|
|
2934
|
-
const recommendedDocs = ['design.md', 'module-impact.md']
|
|
2935
|
-
const missingRecommended = recommendedDocs.filter(d => !existsSync(join(archivedDir, d)))
|
|
2936
|
-
if (missingRecommended.length > 0) {
|
|
2937
|
-
console.warn(`\n⚠️ 归档校验警告:归档目录缺少推荐文档`)
|
|
2938
|
-
for (const d of missingRecommended) console.warn(` - ${d}(${archivedDir})`)
|
|
2939
|
-
} else {
|
|
2940
|
-
console.log(`\n✅ 归档校验通过:核心文档齐全`)
|
|
2941
|
-
}
|
|
2942
|
-
}
|
|
2943
|
-
return null
|
|
2944
|
-
}
|
|
2945
|
-
|
|
2946
|
-
/**
|
|
2947
|
-
* plan 阶段「generate_plan」步骤完成后,动态插入任务蓝图(coordinator)+ postcheck 步骤
|
|
2948
|
-
* (从 completeStep 抽出,行为保持)。使用稳定 id 匹配,不依赖中文标题。
|
|
2949
|
-
*
|
|
2950
|
-
* plan.md 已含任务时,buildPlanSteps 返回 [fixedPrefix(classify/generate_plan/review_plan),
|
|
2951
|
-
* coordinator, postcheck];本函数把 generate_plan 之后的 coordinator+postcheck 插到当前步后。
|
|
2952
|
-
*/
|
|
2953
|
-
async function handlePlanGeneratePlanStep({ stageName, steps, currentIdx, defStepsForCurrent, cwd, progress }) {
|
|
2954
|
-
if (stageName !== 'plan') return
|
|
2955
|
-
const currentStepDef = defStepsForCurrent?.[currentIdx]
|
|
2956
|
-
const currentStepEntry = steps[currentIdx]
|
|
2957
|
-
const stepId = currentStepDef?.id || currentStepEntry?.id || currentStepEntry?._stepId
|
|
2958
|
-
if (stepId !== 'generate_plan') return
|
|
2959
|
-
const changeDir = resolveChangeDir(cwd, progress)
|
|
2960
|
-
if (!changeDir) return
|
|
2961
|
-
const planFile = join(changeDir, 'plan.md')
|
|
2962
|
-
if (!existsSync(planFile)) return
|
|
2963
|
-
const planContent = readFileSync(planFile, 'utf8')
|
|
2964
|
-
const { buildPlanSteps, fixedPrefix, fixedSuffix } = await import('./stages/plan.js')
|
|
2965
|
-
const fullSteps = buildPlanSteps(changeDir, planContent)
|
|
2966
|
-
const prefixLen = fixedPrefix.length
|
|
2967
|
-
const suffixLen = fixedSuffix.length
|
|
2968
|
-
// 新结构:[...fixedPrefix, coordinatorStep?, postcheckStep?];fixedSuffix 为空,coordinator+postcheck 都在 prefix 之后
|
|
2969
|
-
const coordinatorSteps = fullSteps.slice(prefixLen, suffixLen > 0 ? -suffixLen : undefined)
|
|
2970
|
-
if (coordinatorSteps.length === 0) return
|
|
2971
|
-
for (let i = 0; i < coordinatorSteps.length; i++) {
|
|
2972
|
-
const stepDef = coordinatorSteps[i]
|
|
2973
|
-
const stepEntry = {
|
|
2974
|
-
id: stepDef.id,
|
|
2975
|
-
name: stepDef.name,
|
|
2976
|
-
status: 'pending',
|
|
2977
|
-
prompt: stepDef.prompt || '',
|
|
2978
|
-
outputHint: stepDef.outputHint,
|
|
2979
|
-
optional: stepDef.optional
|
|
2980
|
-
}
|
|
2981
|
-
// 传递 noAI / _cliAction 属性
|
|
2982
|
-
if (stepDef.noAI) stepEntry.noAI = true
|
|
2983
|
-
if (stepDef._cliAction) stepEntry._cliAction = stepDef._cliAction
|
|
2984
|
-
steps.splice(currentIdx + 1 + i, 0, stepEntry)
|
|
2985
|
-
}
|
|
2986
|
-
console.log(` 📝 已动态插入 ${coordinatorSteps.length} 个步骤(${coordinatorSteps.map(s => s.name).join(', ')})`)
|
|
2987
|
-
}
|
|
2988
|
-
|
|
2989
|
-
/**
|
|
2990
|
-
* scan 阶段 step 2「构建扫描项目列表」完成后,按项目展开 perProject 步骤(从 completeStep 抽出,行为保持)。
|
|
2991
|
-
*
|
|
2992
|
-
* 只接受结构化输出(scan_projects YAML block 或 BEGIN_PROJECT_LIST 标记块),校验通过后
|
|
2993
|
-
* 自动注册 projects/<id>.yaml + 写 scan-projects.json + 把 perProject 步骤按项目展开。
|
|
2994
|
-
* 不展开 completeStep 提前 return(失败只记 validationError,由 completeStep 继续推进下一步)。
|
|
2995
|
-
*/
|
|
2996
|
-
async function handleScanProjectListStep({ stageName, steps, currentIdx, outputText, stageData, specBase, cwd, platformOpts }) {
|
|
2997
|
-
if (stageName !== 'scan' || steps[currentIdx]?.name !== '构建扫描项目列表') return
|
|
2998
|
-
// 解析项目列表:只接受结构化输出(YAML block 或 BEGIN_PROJECT_LIST 标记)
|
|
2999
|
-
// 不再从自由文本猜测项目名——自由文本列表的误解析会导致垃圾项目落盘
|
|
3000
|
-
let parsedProjects = [] // Array<{id, path?}>
|
|
3001
|
-
let parsedFromStructuredOutput = false
|
|
3002
|
-
if (outputText) {
|
|
3003
|
-
// 格式 A: YAML block — 匹配 scan_projects: 下所有 - id: xxx 条目(含多行属性)
|
|
3004
|
-
const yamlBlock = outputText.match(/scan_projects:\s*\n([\s\S]+?)(?=$|\n[^\s])/)
|
|
3005
|
-
if (yamlBlock) {
|
|
3006
|
-
const entries = [...yamlBlock[1].matchAll(/-\s+id:\s*(\S+)(?:[\s\S]*?)(?=\n\s+-\s+id:|$)/g)]
|
|
3007
|
-
for (const m of entries) {
|
|
3008
|
-
const id = sanitizeProjectName(m[1])
|
|
3009
|
-
if (!id) continue
|
|
3010
|
-
// 提取可选 path 字段
|
|
3011
|
-
const pathMatch = m[0].match(/path:\s*(\S+)/)
|
|
3012
|
-
const entry = pathMatch ? { id, path: pathMatch[1].trim() } : { id }
|
|
3013
|
-
parsedProjects.push(entry)
|
|
3014
|
-
}
|
|
3015
|
-
parsedFromStructuredOutput = parsedProjects.length > 0
|
|
3016
|
-
}
|
|
3017
|
-
// 格式 B: BEGIN_PROJECT_LIST ... END_PROJECT_LIST 标记块
|
|
3018
|
-
if (!parsedFromStructuredOutput) {
|
|
3019
|
-
const blockMatch = outputText.match(/BEGIN_PROJECT_LIST\s*\n([\s\S]*?)\n*END_PROJECT_LIST/)
|
|
3020
|
-
if (blockMatch) {
|
|
3021
|
-
const raw = [...blockMatch[1].matchAll(/^-\s+(\S+)/gm)].map(m => m[1])
|
|
3022
|
-
parsedProjects = raw.map(s => sanitizeProjectName(s)).filter(Boolean).map(id => ({ id }))
|
|
3023
|
-
parsedFromStructuredOutput = parsedProjects.length > 0
|
|
3024
|
-
}
|
|
3025
|
-
}
|
|
3026
|
-
}
|
|
3027
|
-
|
|
3028
|
-
const projectNames = parsedProjects.map(p => p.id)
|
|
3029
|
-
|
|
3030
|
-
if (parsedFromStructuredOutput) {
|
|
3031
|
-
stageData.scanMeta = stageData.scanMeta || {}
|
|
3032
|
-
stageData.scanMeta.projectListParsed = true
|
|
3033
|
-
} else {
|
|
3034
|
-
// 结构化输出未解析到 → 回退读取已有 projects/*.yaml
|
|
3035
|
-
// 读取时也校验:path 不存在的 yaml 视为垃圾,直接跳过
|
|
3036
|
-
console.warn('⚠️ step 2 未输出结构化项目列表,回退扫描已注册项目')
|
|
3037
|
-
stageData.scanMeta = stageData.scanMeta || {}
|
|
3038
|
-
stageData.scanMeta.projectListParsed = false
|
|
3039
|
-
const projectsDir = join(specBase, 'projects')
|
|
3040
|
-
if (existsSync(projectsDir)) {
|
|
3041
|
-
const yamlFiles = readdirSync(projectsDir).filter(f => f.endsWith('.yaml'))
|
|
3042
|
-
const fallbackProjects = []
|
|
3043
|
-
const fallbackSkipped = []
|
|
3044
|
-
for (const yf of yamlFiles) {
|
|
3045
|
-
const pName = yf.replace(/\.yaml$/, '')
|
|
3046
|
-
const yamlContent = readFileSync(join(projectsDir, yf), 'utf8')
|
|
3047
|
-
const pathMatch = yamlContent.match(/^path:\s*(.+)/m)
|
|
3048
|
-
const pPath = pathMatch ? pathMatch[1].trim() : pName
|
|
3049
|
-
// 校验 path 是否存在且在 source_root 内
|
|
3050
|
-
const absPath = resolve(cwd, pPath)
|
|
3051
|
-
if (pPath.includes('..') || (!absPath.startsWith(resolve(cwd)) && absPath !== resolve(cwd))) {
|
|
3052
|
-
fallbackSkipped.push(`${pName} (path 越界: ${pPath})`)
|
|
3053
|
-
continue
|
|
3054
|
-
}
|
|
3055
|
-
if (!existsSync(absPath)) {
|
|
3056
|
-
fallbackSkipped.push(`${pName} (path 不存在: ${pPath})`)
|
|
3057
|
-
continue
|
|
3058
|
-
}
|
|
3059
|
-
fallbackProjects.push({ id: pName, path: pPath })
|
|
3060
|
-
}
|
|
3061
|
-
if (fallbackSkipped.length > 0) {
|
|
3062
|
-
console.warn(`⚠️ 跳过 ${fallbackSkipped.length} 个垃圾/过期项目配置:${fallbackSkipped.join(', ')}`)
|
|
3063
|
-
console.warn(' 建议清理 projects/ 下的无效 yaml 文件')
|
|
3064
|
-
}
|
|
3065
|
-
parsedProjects = fallbackProjects
|
|
3066
|
-
projectNames.length = 0
|
|
3067
|
-
projectNames.push(...fallbackProjects.map(p => p.id))
|
|
3068
|
-
}
|
|
3069
|
-
if (parsedProjects.length === 0) {
|
|
3070
|
-
// 无结构化输出 + 无合法已有项目 → step 2 失败
|
|
3071
|
-
console.error('❌ step 2 未输出结构化项目列表,且 projects/ 下无合法项目配置')
|
|
3072
|
-
console.error(' 请在 --output 中输出 scan_projects YAML block 或 BEGIN_PROJECT_LIST 标记块')
|
|
3073
|
-
steps[currentIdx].validationError = '未输出结构化项目列表且无合法 fallback'
|
|
3074
|
-
// 不展开 perProject 步骤,直接跳到下一步
|
|
3075
|
-
}
|
|
3076
|
-
}
|
|
3077
|
-
|
|
3078
|
-
// 校验解析出的项目列表(原子守卫:不通过就不落盘)
|
|
3079
|
-
const validation = validateParsedProjects(parsedProjects, cwd)
|
|
3080
|
-
if (!validation.ok) {
|
|
3081
|
-
console.error(`❌ 项目列表校验失败: ${validation.errors.join('; ')}`)
|
|
3082
|
-
console.error(' step 2 完成,但不展开 perProject 步骤。请检查 --output 中的项目列表。')
|
|
3083
|
-
steps[currentIdx].validationError = validation.errors.join('; ')
|
|
3084
|
-
}
|
|
3085
|
-
|
|
3086
|
-
// 自动注册 + 保存 runtime + 展开 perProject 步骤(仅在校验通过时)
|
|
3087
|
-
const projectsDir = join(specBase, 'projects')
|
|
3088
|
-
if (validation.ok) {
|
|
3089
|
-
for (const proj of parsedProjects) {
|
|
3090
|
-
const pName = proj.id
|
|
3091
|
-
const projYaml = join(projectsDir, `${pName}.yaml`)
|
|
3092
|
-
if (!existsSync(projYaml)) {
|
|
3093
|
-
mkdirSync(projectsDir, { recursive: true })
|
|
3094
|
-
const candidates = [
|
|
3095
|
-
join(cwd, pName),
|
|
3096
|
-
join(cwd, 'backend', pName),
|
|
3097
|
-
join(cwd, 'packages', pName),
|
|
3098
|
-
join(cwd, 'apps', pName),
|
|
3099
|
-
join(cwd, 'services', pName),
|
|
3100
|
-
]
|
|
3101
|
-
const detected = candidates.find(c => existsSync(c))
|
|
3102
|
-
const regPath = detected || join(cwd, pName)
|
|
3103
|
-
writeFileSync(projYaml, `name: ${pName}\npath: ${regPath}\nstatus: active\n`)
|
|
3104
|
-
console.log(` 📝 自动注册子项目: ${pName} → ${regPath}`)
|
|
3105
|
-
}
|
|
3106
|
-
}
|
|
3107
|
-
|
|
3108
|
-
// 保存 runtime 状态
|
|
3109
|
-
const scanStatePath = join(specBase, '.runtime', 'scan-projects.json')
|
|
3110
|
-
mkdirSync(join(specBase, '.runtime'), { recursive: true })
|
|
3111
|
-
let scanState = { projects: projectNames, expanded: false }
|
|
3112
|
-
if (existsSync(scanStatePath)) {
|
|
3113
|
-
try { scanState = JSON.parse(readFileSync(scanStatePath, 'utf8')) } catch {}
|
|
3114
|
-
}
|
|
3115
|
-
|
|
3116
|
-
// 收集当前步骤之后所有 perProject 步骤
|
|
3117
|
-
const stageDef = stageRegistry[stageName]
|
|
3118
|
-
const allSteps = stageDef?.steps || []
|
|
3119
|
-
const perProjectSteps = allSteps.filter(s => s.perProject)
|
|
3120
|
-
|
|
3121
|
-
// 防重复展开
|
|
3122
|
-
const alreadyExpanded = scanState.expanded || steps.some(s => s.name?.match(/\[.+\]\s*$/))
|
|
3123
|
-
if (!alreadyExpanded && perProjectSteps.length > 0) {
|
|
3124
|
-
// 找到当前步骤(step 2)在动态 steps 中的位置
|
|
3125
|
-
const insertBase = currentIdx + 1
|
|
3126
|
-
let insertPos = insertBase
|
|
3127
|
-
for (const pName of projectNames) {
|
|
3128
|
-
// 读取项目配置获取 projectRoot
|
|
3129
|
-
const projYaml = join(specBase, 'projects', `${pName}.yaml`)
|
|
3130
|
-
let projectRoot = '.'
|
|
3131
|
-
if (existsSync(projYaml)) {
|
|
3132
|
-
const yamlContent = readFileSync(projYaml, 'utf8')
|
|
3133
|
-
const pathMatch = yamlContent.match(/^path:\s*(.+)/m)
|
|
3134
|
-
if (pathMatch) projectRoot = pathMatch[1].trim()
|
|
3135
|
-
}
|
|
3136
|
-
const docOutputDir = platformOpts.specRoot ? `${specBase}/docs/${pName}` : `.sillyspec/docs/${pName}`
|
|
3137
|
-
const contextPrefix = `\n---\n## 当前项目\n- **项目名**: ${pName}\n- **项目路径**: ${projectRoot}\n- **文档输出**: ${docOutputDir}\n\n⚠️ 本步骤只处理上面这个项目,不要处理其他项目。\n---\n\n`
|
|
3138
|
-
|
|
3139
|
-
for (const ppStep of perProjectSteps) {
|
|
3140
|
-
steps.splice(insertPos, 0, {
|
|
3141
|
-
name: `${ppStep.name} [${pName}]`,
|
|
3142
|
-
project: pName,
|
|
3143
|
-
status: 'pending',
|
|
3144
|
-
prompt: contextPrefix + ppStep.prompt,
|
|
3145
|
-
outputHint: ppStep.outputHint,
|
|
3146
|
-
optional: ppStep.optional
|
|
3147
|
-
})
|
|
3148
|
-
insertPos++
|
|
3149
|
-
}
|
|
3150
|
-
}
|
|
3151
|
-
// 移除原始的 perProject 步骤(未展开的版本)
|
|
3152
|
-
for (let i = steps.length - 1; i >= 0; i--) {
|
|
3153
|
-
if (steps[i].perProject && !steps[i].name?.includes('[')) {
|
|
3154
|
-
steps.splice(i, 1)
|
|
3155
|
-
}
|
|
3156
|
-
}
|
|
3157
|
-
console.log(` 📝 已按项目展开 ${perProjectSteps.length} 个步骤 × ${projectNames.length} 个项目 = ${perProjectSteps.length * projectNames.length} 个项目步骤`)
|
|
3158
|
-
console.log(` 📁 扫描项目:${projectNames.join(', ')}`)
|
|
3159
|
-
// 标记已展开,防止 resume 重复插入
|
|
3160
|
-
scanState.expanded = true
|
|
3161
|
-
writeFileSync(scanStatePath, JSON.stringify(scanState))
|
|
3162
|
-
} // end !alreadyExpanded
|
|
3163
|
-
} // end validation.ok
|
|
3164
|
-
}
|
|
3165
|
-
|
|
3166
|
-
/**
|
|
3167
|
-
* 阶段完成校验 gate 级联(从 completeStep 抽出,行为保持)。仅当所有步骤确实标记为 completed 时
|
|
3168
|
-
* 由 completeStep 调用。顺序:runValidators → verify-test 对账 → Plan→Execute contract →
|
|
3169
|
-
* Stage Review Gate → Execute Task Review Gate。任一 gate 失败 → rollbackCompletionAndReturn
|
|
3170
|
-
* (统一回滚 + 返回 early-return 对象);全部通过 → 返回 null(completeStep 继续收尾)。
|
|
3171
|
-
*
|
|
3172
|
-
* @returns {{stageCompleted:false,currentIdx,nextPendingIdx:number}|null}
|
|
3173
|
-
*/
|
|
3174
|
-
async function runStageCompletionGates({ stageName, cwd, changeName, platformOpts, specBase, progress, pm, stageData, steps, currentIdx }) {
|
|
3175
|
-
const projectName = progress.project || basename(cwd)
|
|
3176
|
-
const contractResult = runValidators(stageName, cwd, changeName, { projectName, specRoot: platformOpts?.specRoot })
|
|
3177
|
-
if (contractResult.errors.length > 0) {
|
|
3178
|
-
console.error(`\n❌ 阶段 ${stageName} 校验失败:`)
|
|
3179
|
-
for (const err of contractResult.errors) {
|
|
3180
|
-
console.error(` - ${err}`)
|
|
3181
|
-
}
|
|
3182
|
-
console.error(`\n 提示:修复缺失产物后重新完成此步骤(--skip-approval 只跳过阶段转换/审批检查,不能跳过产物校验)`)
|
|
3183
|
-
// 产物校验失败必须阻断完成 —— 否则 validator 形同虚设,
|
|
3184
|
-
// verify 会带着 FAIL/缺 verify-result.md 被 ✅ 标记完成(历史教训)。
|
|
3185
|
-
// plan/execute 的专项契约校验(下方)在产物齐全后才需要继续跑,故此处先 return。
|
|
3186
|
-
return await rollbackCompletionAndReturn(pm, progress, stageData, steps, currentIdx, cwd, changeName, platformOpts)
|
|
3187
|
-
}
|
|
3188
|
-
if (contractResult.warnings.length > 0) {
|
|
3189
|
-
console.warn(`\n⚠️ 阶段 ${stageName} 校验警告:`)
|
|
3190
|
-
for (const w of contractResult.warnings) {
|
|
3191
|
-
console.warn(` - ${w}`)
|
|
3192
|
-
}
|
|
3193
|
-
}
|
|
3194
|
-
|
|
3195
|
-
// verify 产物校验通过 + 结论非 FAIL(否则上面已阻断)。
|
|
3196
|
-
// 再由 CLI 亲自执行 local.yaml 的测试命令,与 verify-result.md 的自报告对账:
|
|
3197
|
-
// 自报告 PASS 但实测失败 → 阻断(防止"文案通过"绕过验证)。
|
|
3198
|
-
if (stageName === 'verify') {
|
|
3199
|
-
const { runVerifyTestCheck, printVerifyTestCheck } = await import('./verify-postcheck.js')
|
|
3200
|
-
const testCheck = runVerifyTestCheck({ cwd, specBase, changeName })
|
|
3201
|
-
printVerifyTestCheck(testCheck)
|
|
3202
|
-
if (testCheck.status === 'failed') {
|
|
3203
|
-
console.error('\n❌ verify 阶段被阻断:verify-result.md 自报告通过,但 CLI 实测测试失败。')
|
|
3204
|
-
console.error(' 请修复失败的测试并更新 verify-result.md 后重新完成此步骤。')
|
|
3205
|
-
return await rollbackCompletionAndReturn(pm, progress, stageData, steps, currentIdx, cwd, changeName, platformOpts)
|
|
3206
|
-
}
|
|
3207
|
-
// 契约 parity 对账:扫前端 API 调用 vs execute 提取的 provider endpoint artifact。
|
|
3208
|
-
// 接线自 contract-matrix pipeline(verifyApiParity 的 CLI 入口)。
|
|
3209
|
-
const { runVerifyParityCheck, printVerifyParityCheck } = await import('./verify-postcheck.js')
|
|
3210
|
-
const parityCheck = runVerifyParityCheck({ cwd, specBase, changeName, runtimeRoot: platformOpts?.runtimeRoot })
|
|
3211
|
-
printVerifyParityCheck(parityCheck)
|
|
3212
|
-
console.log('\n✅ 验证通过,下一步:sillyspec run archive')
|
|
3213
|
-
}
|
|
3214
|
-
|
|
3215
|
-
// ── Plan postcheck contract:plan.md 必须满足 execute 契约 ──
|
|
3216
|
-
if (stageName === 'plan') {
|
|
3217
|
-
const planFile = resolveChangeDir(cwd, progress, platformOpts?.specRoot)
|
|
3218
|
-
const planPath = planFile ? join(planFile, 'plan.md') : null
|
|
3219
|
-
if (planPath && existsSync(planPath)) {
|
|
3220
|
-
const { validatePlanForExecute } = await import('./stages/execute.js')
|
|
3221
|
-
const planContent = readFileSync(planPath, 'utf8')
|
|
3222
|
-
const planValidation = validatePlanForExecute(planContent)
|
|
3223
|
-
if (!planValidation.ok) {
|
|
3224
|
-
console.error(`\n❌ Plan → Execute Contract 校验失败:`)
|
|
3225
|
-
for (const err of planValidation.errors) console.error(` - ${err}`)
|
|
3226
|
-
console.error(`\n plan.md 不满足 execute 契约,请修复后重新完成此步骤。`)
|
|
3227
|
-
// 阻断 completed
|
|
3228
|
-
return await rollbackCompletionAndReturn(pm, progress, stageData, steps, currentIdx, cwd, changeName, platformOpts)
|
|
3229
|
-
}
|
|
3230
|
-
if (planValidation.warnings.length > 0) {
|
|
3231
|
-
console.warn(`\n⚠️ Plan contract 警告(不阻断完成):`)
|
|
3232
|
-
for (const w of planValidation.warnings) console.warn(` - ${w}`)
|
|
3233
|
-
}
|
|
3234
|
-
if (planValidation.ok) {
|
|
3235
|
-
console.log(`\n✅ Plan → Execute Contract 校验通过(${planValidation.tasks.length} tasks, ${planValidation.waves.length} waves)`)
|
|
3236
|
-
}
|
|
3237
|
-
}
|
|
3238
|
-
}
|
|
3239
|
-
|
|
3240
|
-
// ── Stage Review Gate:brainstorm/plan/propose/execute 独立审查(按 tier 分级)──
|
|
3241
|
-
// tier=self 放行+审计打印;tier=independent 必须 review.json 且 verdict 非 fail,fail-closed
|
|
3242
|
-
if (['brainstorm', 'plan', 'propose', 'execute'].includes(stageName)) {
|
|
3243
|
-
try {
|
|
3244
|
-
const { classifyReviewTier } = await import('./review-tier.js')
|
|
3245
|
-
const { validateStageReview, getLatestStageReviewRunId, printStageReviewResult } = await import('./stage-review.js')
|
|
3246
|
-
const effectiveSpecBase = platformOpts?.specRoot || specBase
|
|
3247
|
-
const reviewChangeDir = resolveChangeDir(cwd, progress, platformOpts?.specRoot)
|
|
3248
|
-
const designPath = reviewChangeDir ? join(reviewChangeDir, 'design.md') : null
|
|
3249
|
-
let planLevel = null
|
|
3250
|
-
if (reviewChangeDir) {
|
|
3251
|
-
const planPath = join(reviewChangeDir, 'plan.md')
|
|
3252
|
-
if (existsSync(planPath)) {
|
|
3253
|
-
const fmLine = readFileSync(planPath, 'utf8').split('\n').find(l => l.trim().startsWith('plan_level:'))
|
|
3254
|
-
if (fmLine) planLevel = fmLine.split(':')[1].trim()
|
|
3255
|
-
}
|
|
3256
|
-
}
|
|
3257
|
-
const tier = classifyReviewTier({ planLevel, designPath })
|
|
3258
|
-
const runtimeRoot = platformOpts?.runtimeRoot || join(effectiveSpecBase, '.runtime')
|
|
3259
|
-
|
|
3260
|
-
if (tier.tier === 'self') {
|
|
3261
|
-
console.log('\nℹ️ Stage Review: ' + stageName + ' tier=self(' + tier.reason + '),已降级为当前 agent 自审,不强制独立子代理。')
|
|
3262
|
-
} else {
|
|
3263
|
-
const reviewRunId = getLatestStageReviewRunId(runtimeRoot, stageName)
|
|
3264
|
-
const reviewType = stageName === 'brainstorm' ? 'design'
|
|
3265
|
-
: stageName === 'plan' ? 'plan'
|
|
3266
|
-
: stageName === 'propose' ? 'proposal'
|
|
3267
|
-
: 'acceptance'
|
|
3268
|
-
const searchDirs = [effectiveSpecBase, reviewChangeDir, cwd].filter(Boolean)
|
|
3269
|
-
const reviewResult = validateStageReview({ stage: stageName, reviewType, runtimeRoot, reviewRunId, searchDirs })
|
|
3270
|
-
printStageReviewResult(reviewResult, { stage: stageName })
|
|
3271
|
-
if (!reviewResult.ok) {
|
|
3272
|
-
return await rollbackCompletionAndReturn(pm, progress, stageData, steps, currentIdx, cwd, changeName, platformOpts)
|
|
3273
|
-
}
|
|
3274
|
-
}
|
|
3275
|
-
} catch (e) {
|
|
3276
|
-
// fail-closed:Gate 自身异常阻断完成,不静默放行(与 Task Review Gate 一致)
|
|
3277
|
-
console.error('❌ Stage Review Gate 异常,阻断 ' + stageName + ' 完成: ' + e.message)
|
|
3278
|
-
return await rollbackCompletionAndReturn(pm, progress, stageData, steps, currentIdx, cwd, changeName, platformOpts)
|
|
3279
|
-
}
|
|
3280
|
-
}
|
|
3281
|
-
|
|
3282
|
-
// ── Execute Task Review Gate:所有 task 必须有 review.json 且 verdict 通过 ──
|
|
3283
|
-
if (stageName === 'execute') {
|
|
3284
|
-
try {
|
|
3285
|
-
const { validateTaskReviews, printReviewResult, writeVerifyRequiredEvidence } = await import('./task-review.js')
|
|
3286
|
-
const effectiveSpecBase = platformOpts?.specRoot || specBase
|
|
3287
|
-
const planFile = resolveChangeDir(cwd, progress, platformOpts?.specRoot)
|
|
3288
|
-
const planPath = planFile ? join(planFile, 'plan.md') : null
|
|
3289
|
-
|
|
3290
|
-
if (planPath && existsSync(planPath)) {
|
|
3291
|
-
const planContent = readFileSync(planPath, 'utf8')
|
|
3292
|
-
const runtimeRoot = platformOpts?.runtimeRoot || join(effectiveSpecBase, '.runtime')
|
|
3293
|
-
|
|
3294
|
-
// execute run id:从变更专属标记文件读取
|
|
3295
|
-
const runIdFile = join(runtimeRoot, `current-execute-run-id-${changeName}`)
|
|
3296
|
-
let executeRunId = ''
|
|
3297
|
-
try {
|
|
3298
|
-
if (existsSync(runIdFile)) {
|
|
3299
|
-
executeRunId = readFileSync(runIdFile, 'utf8').trim()
|
|
3300
|
-
}
|
|
3301
|
-
} catch {}
|
|
3302
|
-
if (!executeRunId) {
|
|
3303
|
-
const { generateExecuteRunId } = await import('./task-review.js')
|
|
3304
|
-
executeRunId = generateExecuteRunId()
|
|
3305
|
-
// 落盘(marker 缺失时 fallback 生成后写盘,保证后续 checkbox/gate 读到同一 ID)
|
|
3306
|
-
try { mkdirSync(runtimeRoot, { recursive: true }); writeFileSync(runIdFile, executeRunId + '\n') } catch {}
|
|
3307
|
-
}
|
|
3308
|
-
|
|
3309
|
-
// git 真实性校验目录:worktree 存在则用 worktree(base/head commit 在其中),否则主仓库
|
|
3310
|
-
let reviewGitDir = cwd
|
|
3311
|
-
try {
|
|
3312
|
-
const { WorktreeManager } = await import('./worktree.js')
|
|
3313
|
-
const wm = new WorktreeManager({ cwd })
|
|
3314
|
-
const meta = wm.getMeta(changeName)
|
|
3315
|
-
if (meta?.worktreePath && meta.mode !== 'in-place-fallback' && existsSync(meta.worktreePath)) {
|
|
3316
|
-
reviewGitDir = meta.worktreePath
|
|
3317
|
-
}
|
|
3318
|
-
} catch {}
|
|
3319
|
-
|
|
3320
|
-
const reviewResult = validateTaskReviews({ planContent, runtimeRoot, executeRunId, changeDir: planFile, gitDir: reviewGitDir })
|
|
3321
|
-
printReviewResult(reviewResult, { runtimeRoot, executeRunId })
|
|
3322
|
-
|
|
3323
|
-
if (!reviewResult.ok) {
|
|
3324
|
-
// Task review 校验失败,阻断 execute 完成
|
|
3325
|
-
// 检查是否存在 checkbox 已勾但 review 不通过的情况
|
|
3326
|
-
const uncheckedTasks = reviewResult.errors.filter(e => e.includes('缺少 review.json'))
|
|
3327
|
-
if (uncheckedTasks.length > 0) {
|
|
3328
|
-
console.error('\n⚠️ 部分任务已在 plan.md 中勾选,但 review.json 不存在。')
|
|
3329
|
-
console.error(` 请取消勾选这些任务的 checkbox,或补充对应的 review.json(execute run ID: ${executeRunId})。`)
|
|
3330
|
-
}
|
|
3331
|
-
return await rollbackCompletionAndReturn(pm, progress, stageData, steps, currentIdx, cwd, changeName, platformOpts)
|
|
3332
|
-
}
|
|
3333
|
-
|
|
3334
|
-
// cannot_verify 的 requiredEvidence 写入 change 目录,供 verify 阶段消费
|
|
3335
|
-
if (reviewResult.requiredEvidence.length > 0) {
|
|
3336
|
-
const evidencePath = writeVerifyRequiredEvidence(join(effectiveSpecBase, 'changes', changeName), reviewResult.requiredEvidence)
|
|
3337
|
-
if (evidencePath) {
|
|
3338
|
-
console.log(`📄 verify-required-evidence.json 已写入: ${evidencePath}`)
|
|
3339
|
-
console.log(' verify 阶段必须满足这些证据要求。')
|
|
3340
|
-
}
|
|
3341
|
-
}
|
|
3342
|
-
}
|
|
3343
|
-
} catch (e) {
|
|
3344
|
-
// fail-closed:Gate 自身异常时不能默认放行,否则异常成了绕过评审的通道
|
|
3345
|
-
console.error(`❌ Task Review Gate 异常,阻断 execute 完成: ${e.message}`)
|
|
3346
|
-
console.error(' 请检查 review.json / plan.md 是否可读,修复后重新完成此步骤。')
|
|
3347
|
-
return await rollbackCompletionAndReturn(pm, progress, stageData, steps, currentIdx, cwd, changeName, platformOpts)
|
|
3348
|
-
}
|
|
3349
|
-
}
|
|
3350
|
-
return null
|
|
3351
|
-
}
|
|
3352
|
-
|
|
3353
|
-
async function completeStep(pm, progress, stageName, cwd, outputText, inputText = null, options = {}) {
|
|
3354
|
-
const { printNext = true, confirm = false, changeName, platformOpts = {}, nonInteractive = false, confirmMode = null, isForceBaseline = false, isAllowNew = false } = options
|
|
3355
|
-
const specBase = platformOpts.specRoot || join(cwd, '.sillyspec')
|
|
3356
|
-
const stageData = progress.stages[stageName]
|
|
3357
|
-
const scanProfile = stageData?.scanProfile || null
|
|
3358
|
-
|
|
3359
|
-
// ── WAIT MARKER 硬校验 ──
|
|
3360
|
-
// 如果 output 包含等待标记,拒绝 --done 推进
|
|
3361
|
-
if (outputText) {
|
|
3362
|
-
const match = WAIT_MARKER_RE.exec(outputText)
|
|
3363
|
-
if (match) {
|
|
3364
|
-
console.error(`❌ Refused: step output contains ${match[1]} — human input required.`)
|
|
3365
|
-
console.error(` 使用 --wait 替代 --done,例如:`)
|
|
3366
|
-
console.error(` sillyspec run ${stageName} --wait --reason "等待用户决策" --output "你的摘要"${changeName ? ` --change ${changeName}` : ''}`)
|
|
3367
|
-
process.exit(1)
|
|
3368
|
-
}
|
|
3369
|
-
}
|
|
3370
|
-
|
|
3371
|
-
// scanProfile 非 deep 模式:截断 outputText 减少 token 传递
|
|
3372
|
-
let effectiveOutput = outputText
|
|
3373
|
-
if (scanProfile && scanProfile.mode !== 'deep' && outputText && outputText.length > 1000) {
|
|
3374
|
-
effectiveOutput = outputText.slice(0, 1000) + '\n\n…[输出已截断,完整内容见 artifact]'
|
|
3375
|
-
}
|
|
3376
|
-
if (!stageData || !stageData.steps) {
|
|
3377
|
-
console.error(`❌ 阶段 ${stageName} 未初始化`)
|
|
3378
|
-
process.exit(1)
|
|
3379
|
-
}
|
|
3380
|
-
|
|
3381
|
-
const steps = stageData.steps
|
|
3382
|
-
const currentIdx = steps.findIndex(s => s.status === 'pending' || s.status === 'in-progress')
|
|
3383
|
-
if (currentIdx === -1) {
|
|
3384
|
-
console.error('没有待完成的步骤')
|
|
3385
|
-
process.exit(1)
|
|
3386
|
-
}
|
|
3387
|
-
|
|
3388
|
-
// ── requiresWait 硬门控 ──
|
|
3389
|
-
const defStepsForCurrent = await getStageSteps(stageName, cwd, progress, platformOpts?.specRoot || null)
|
|
3390
|
-
const currentStepDef = defStepsForCurrent?.[currentIdx] || {}
|
|
3391
|
-
const currentStep = steps[currentIdx]
|
|
3392
|
-
if (currentStepDef.requiresWait === true && !currentStep.waitAnswer) {
|
|
3393
|
-
// 检查 --done 是否带了 --answer:如果是,自动补全 waitAnswer 状态,一步完成
|
|
3394
|
-
const doneAnswer = typeof options !== 'undefined' && options.doneAnswer ? options.doneAnswer : null
|
|
3395
|
-
// B4: 前置 step 已对同一问题确认则自动跳过重复 wait。
|
|
3396
|
-
// 归一化去掉「最终/再次/重复」等修饰词,避免 step N「确认 X」与 step M「最终确认 X」重复打断。
|
|
3397
|
-
const normalizeReason = (r) => (r || '').replace(/(最终|再次|重复|最后|首|初次|首次)/g, '').trim()
|
|
3398
|
-
const currentReason = normalizeReason(currentStepDef.waitReason)
|
|
3399
|
-
const priorConfirmed = currentReason && steps.slice(0, currentIdx).some(s =>
|
|
3400
|
-
s.waitAnswer && s.status === 'completed' && normalizeReason(s.waitReason) === currentReason
|
|
3401
|
-
)
|
|
3402
|
-
if (priorConfirmed) {
|
|
3403
|
-
currentStep.status = 'waiting'
|
|
3404
|
-
currentStep.waitAnswer = '前置步骤已对同一问题确认 — 自动跳过重复 wait'
|
|
3405
|
-
currentStep.waitReason = currentStepDef.waitReason || '等待用户输入'
|
|
3406
|
-
console.log(`⚠️ Step "${currentStep.name}" 的确认(${currentStepDef.waitReason})已在前置步骤完成,自动跳过。`)
|
|
3407
|
-
} else if (doneAnswer) {
|
|
3408
|
-
currentStep.status = 'waiting'
|
|
3409
|
-
currentStep.waitAnswer = doneAnswer
|
|
3410
|
-
currentStep.waitReason = currentStepDef.waitReason || '等待用户输入'
|
|
3411
|
-
console.log(`⚠️ Step "${currentStep.name}" 需要 wait,但 --done 带了 --answer,自动补全 wait 状态。`)
|
|
3412
|
-
} else {
|
|
3413
|
-
console.error(`❌ Step "${currentStep.name}" 必须先等待用户输入,不能直接 --done。`)
|
|
3414
|
-
console.error(` 原因:${currentStepDef.waitReason || '该步骤需要人工确认/回答'}`)
|
|
3415
|
-
if (currentStepDef.waitOptions) {
|
|
3416
|
-
console.error(` 选项:${currentStepDef.waitOptions.join(', ')}`)
|
|
3417
|
-
}
|
|
3418
|
-
console.error(` 请先执行:`)
|
|
3419
|
-
console.error(` sillyspec run ${stageName} --wait --reason "${currentStepDef.waitReason || '等待用户输入'}" --options "${(currentStepDef.waitOptions || ['确认']).join(',')}"${changeName ? ` --change ${changeName}` : ''} --output "你的问题/方案摘要"`)
|
|
3420
|
-
console.error(` 或使用 --done --answer "用户回答" 一步完成 wait + done`)
|
|
3421
|
-
process.exit(1)
|
|
3422
|
-
}
|
|
3423
|
-
}
|
|
3424
|
-
|
|
3425
|
-
// ── execute deps 验证硬门(change 2026-06-28-worktree-deps-provision)──
|
|
3426
|
-
await enforceDepsGate(stageName, cwd, changeName, steps[currentIdx], steps, currentIdx, specBase, platformOpts)
|
|
3427
|
-
|
|
3428
|
-
steps[currentIdx].status = 'completed'
|
|
3429
|
-
steps[currentIdx].completedAt = new Date().toLocaleString('zh-CN',{hour12:false})
|
|
3430
|
-
if (outputText) {
|
|
3431
|
-
const MAX_OUTPUT = 200
|
|
3432
|
-
if (outputText.length > MAX_OUTPUT) {
|
|
3433
|
-
steps[currentIdx].output = outputText.slice(0, MAX_OUTPUT) + '…'
|
|
3434
|
-
steps[currentIdx].output_truncated = true
|
|
3435
|
-
steps[currentIdx].output_original_length = outputText.length
|
|
3436
|
-
// 平台模式:artifact 写入 runtime-root,否则写 .sillyspec/.runtime/artifacts
|
|
3437
|
-
const artifactBase = platformOpts?.runtimeRoot
|
|
3438
|
-
? join(platformOpts.runtimeRoot, 'scan-runs', platformOpts.scanRunId || 'unknown')
|
|
3439
|
-
: join(specBase, '.runtime', 'artifacts')
|
|
3440
|
-
mkdirSync(artifactBase, { recursive: true })
|
|
3441
|
-
const ts = new Date().toISOString().slice(0,19).replace(/[-T:]/g, '')
|
|
3442
|
-
writeFileSync(join(artifactBase, `${changeName || 'unknown'}-${stageName}-step${currentIdx + 1}-${ts}.txt`), outputText)
|
|
3443
|
-
} else {
|
|
3444
|
-
steps[currentIdx].output = outputText
|
|
3445
|
-
}
|
|
3446
|
-
}
|
|
3447
|
-
|
|
3448
|
-
// archive「确认归档」收尾:--confirm 门控 + archiveChangeDirectory 移动 + 推荐文档校验
|
|
3449
|
-
// (抽成 handleArchiveConfirmStep;缺 --confirm 时返回 early-return 对象,completeStep 透传)
|
|
3450
|
-
{
|
|
3451
|
-
const _archiveEarlyReturn = await handleArchiveConfirmStep({ stageName, steps, currentIdx, confirm, outputText, pm, cwd, progress, changeName, specBase })
|
|
3452
|
-
if (_archiveEarlyReturn) return _archiveEarlyReturn
|
|
3453
|
-
}
|
|
3454
|
-
|
|
3455
|
-
// plan 阶段 "generate_plan" 完成后,动态插入任务蓝图 + postcheck 步骤(抽成 handlePlanGeneratePlanStep)
|
|
3456
|
-
await handlePlanGeneratePlanStep({ stageName, steps, currentIdx, defStepsForCurrent, cwd, progress })
|
|
3457
|
-
|
|
3458
|
-
// scan 阶段 step 2「构建扫描项目列表」完成后,按项目展开 perProject 步骤(抽成 handleScanProjectListStep)
|
|
3459
|
-
await handleScanProjectListStep({ stageName, steps, currentIdx, outputText, stageData, specBase, cwd, platformOpts })
|
|
3460
|
-
|
|
3461
|
-
// execute「Wave N 执行」步骤完成 → 扫 worktree 提取 provider endpoint artifact
|
|
3462
|
-
// (供 verify 阶段 parity 对账 + consumer task 上游契约注入)。接线自 contract-matrix pipeline。
|
|
3463
|
-
if (stageName === 'execute' && /^Wave \d+ 执行$/.test(steps[currentIdx]?.name || '')) {
|
|
3464
|
-
try {
|
|
3465
|
-
const { extractArtifactsForChange } = await import('./contract-matrix.js')
|
|
3466
|
-
let worktreePath = null
|
|
3467
|
-
try {
|
|
3468
|
-
const { WorktreeManager } = await import('./worktree.js')
|
|
3469
|
-
const meta = new WorktreeManager({ cwd }).getMeta(changeName)
|
|
3470
|
-
if (meta?.worktreePath && existsSync(meta.worktreePath)) worktreePath = meta.worktreePath
|
|
3471
|
-
} catch {}
|
|
3472
|
-
const msg = extractArtifactsForChange({ changeDir: join(specBase, 'changes', changeName), specBase, changeName, worktreePath })
|
|
3473
|
-
if (msg) console.log(msg)
|
|
3474
|
-
} catch (e) { console.warn(`⚠️ 契约 artifact 提取跳过: ${e?.message || e}`) }
|
|
3475
|
-
}
|
|
3476
|
-
|
|
3477
|
-
const nextPendingIdx = steps.findIndex(s => s.status === 'pending' || s.status === 'in-progress')
|
|
3478
|
-
|
|
3479
|
-
if (nextPendingIdx === -1) {
|
|
3480
|
-
// 也检查是否有 waiting 的步骤
|
|
3481
|
-
const hasWaiting = steps.some(s => s.status === 'waiting')
|
|
3482
|
-
if (hasWaiting) {
|
|
3483
|
-
// 有等待中的步骤,阶段未完成
|
|
3484
|
-
progress.lastActive = new Date().toLocaleString('zh-CN',{hour12:false})
|
|
3485
|
-
await pm._write(cwd, progress, changeName)
|
|
3486
|
-
const wsIdx = steps.findIndex(s => s.status === 'waiting')
|
|
3487
|
-
console.log(`\n⏸️ 阶段暂停:Step ${wsIdx + 1} 等待用户输入`)
|
|
3488
|
-
if (steps[wsIdx].waitReason) console.log(` 原因:${steps[wsIdx].waitReason}`)
|
|
3489
|
-
return { stageCompleted: false, currentIdx, nextPendingIdx: -1 }
|
|
3490
|
-
}
|
|
3491
|
-
// quick 收尾:强校验 QUICKLOG 条目 + 翻状态 + 勾 tasks.md(CLI 接管)
|
|
3492
|
-
if (stageName === 'quick') {
|
|
3493
|
-
// §4.6 从 session guard.json 读 guard(不依赖 progress.quickGuard)。
|
|
3494
|
-
// D-003@v1:progress._write 不持久化顶层 quickGuard,跨进程 --done 时读出的 progress 无 quickGuard,
|
|
3495
|
-
// 若仍用 if (progress.quickGuard) 驱动收尾会整体跳过,导致 .runtime/quick-sessions/<sessionId>/ 残留僵尸。
|
|
3496
|
-
// 改为从文件读 guard:优先 session 目录 guard.json,回退旧单文件 quick-guard.json(task-03 前兼容)。
|
|
3497
|
-
// sessionId == changeName == quick-<uuid8>(completeStep 作用域内 changeName 已解构自 options)。
|
|
3498
|
-
const runtimeBase = platformOpts.runtimeRoot || join(specBase, '.runtime')
|
|
3499
|
-
const sessionGuardFile = join(runtimeBase, 'quick-sessions', changeName, 'guard.json')
|
|
3500
|
-
const legacyGuardFile = join(specBase, '.runtime', 'quick-guard.json')
|
|
3501
|
-
let guard = null
|
|
3502
|
-
try {
|
|
3503
|
-
guard = existsSync(sessionGuardFile)
|
|
3504
|
-
? JSON.parse(readFileSync(sessionGuardFile, 'utf8'))
|
|
3505
|
-
: (existsSync(legacyGuardFile) ? JSON.parse(readFileSync(legacyGuardFile, 'utf8')) : null)
|
|
3506
|
-
} catch {}
|
|
3507
|
-
|
|
3508
|
-
// 强校验 / 收尾:本会话必须有一条真实 QUICKLOG 条目(治「报 SAFE 但漏写」bug)。
|
|
3509
|
-
// guard 缺失(brownfield:新代码前启动的会话)不阻断——兜底补写一条记录,保住「完成必有记录」不变量。
|
|
3510
|
-
const gitUser = safeGit(cwd, ['config', 'user.name']).value || 'unknown'
|
|
3511
|
-
let qlId = guard?.quicklogId || null
|
|
3512
|
-
const linkedChanges = Array.isArray(guard?.linkedChanges) ? guard.linkedChanges : []
|
|
3513
|
-
|
|
3514
|
-
// 审计:仅在有 guard 时跑(brownfield 无 guard 跳过,兼容 D-003 brownfield 行为)。
|
|
3515
|
-
if (guard) {
|
|
3516
|
-
// --done 的 --force-baseline/--allow-new 并入 guard(与 step1 持久化值取或)。
|
|
3517
|
-
// 修复 ql-20260713-002-7628:旧代码解析了这两个 flag 但只传 {isConfirm} 给审计,
|
|
3518
|
-
// 致 --done --force-baseline 静默无效、用户被误导「重跑 --confirm」也无法解锁。
|
|
3519
|
-
const mergedGuard = {
|
|
3520
|
-
...guard,
|
|
3521
|
-
forceBaseline: guard.forceBaseline || isForceBaseline,
|
|
3522
|
-
allowNew: guard.allowNew || isAllowNew,
|
|
3523
|
-
}
|
|
3524
|
-
const review = await auditQuickCompletion(cwd, mergedGuard, { isConfirm: confirm })
|
|
3525
|
-
printQuickAuditReview(review)
|
|
3526
|
-
if (review.status === 'blocked') {
|
|
3527
|
-
steps[currentIdx].status = 'pending'
|
|
3528
|
-
steps[currentIdx].completedAt = null
|
|
3529
|
-
if (outputText) steps[currentIdx].output = null
|
|
3530
|
-
process.exit(1)
|
|
3531
|
-
}
|
|
3532
|
-
progress.lastQuickReview = review
|
|
3533
|
-
}
|
|
3534
|
-
|
|
3535
|
-
// 结果摘要结构校验(最后一步、isDone 且带了 --output 时):--output 是 QUICKLOG「结果:」
|
|
3536
|
-
// 归档的唯一来源,要求按 需求/根因/方案/结果 模板给全(见 stages/quick.js step3 prompt)。
|
|
3537
|
-
// 确定性校验:只查必填字段是否齐全,不判内容质量。缺字段 → 本次不完成(回滚 step 状态 +
|
|
3538
|
-
// exit 1),保留「进行中」条目,agent 补全 --output 后重跑 --done 即可,不丢进度。
|
|
3539
|
-
// 仅 completeQuicklogEntry 会实际持久化时才校验;前两个 step 的 --done output 不入 QUICKLOG,不校验。
|
|
3540
|
-
if (outputText) {
|
|
3541
|
-
const resultCheck = validateQuickResult(outputText)
|
|
3542
|
-
if (!resultCheck.ok) {
|
|
3543
|
-
console.error(`\n❌ quick 结果摘要结构不完整:缺少字段 ${resultCheck.missing.join('、')}`)
|
|
3544
|
-
console.error(` --output 是 QUICKLOG「结果:」归档的唯一来源,四个标签必须放在 --output 里(不是 --input)。`)
|
|
3545
|
-
console.error(` 补全后重跑 --done(不丢进度),直接照抄此模板:`)
|
|
3546
|
-
console.error(` sillyspec run quick --done --change <changeName> --output "需求:用户/任务要什么`)
|
|
3547
|
-
console.error(` 根因:为什么这样改(纯新增/样式则写「无,纯新增/纯样式」)`)
|
|
3548
|
-
console.error(` 方案:怎么改的`)
|
|
3549
|
-
console.error(` 结果:验证情况(测试数 / lint / typecheck / 部署状态)"`)
|
|
3550
|
-
steps[currentIdx].status = 'pending'
|
|
3551
|
-
steps[currentIdx].completedAt = null
|
|
3552
|
-
if (outputText) steps[currentIdx].output = null
|
|
3553
|
-
process.exit(1)
|
|
3554
|
-
}
|
|
3555
|
-
}
|
|
3556
|
-
|
|
3557
|
-
if (!qlId) {
|
|
3558
|
-
// 无 ql-ID(guard 缺失或 brownfield 无 quicklogId):补分配后立即完成,不阻断。
|
|
3559
|
-
try {
|
|
3560
|
-
const alloc = await allocateQuicklogEntry(specBase, gitUser, {
|
|
3561
|
-
description: guard?.taskDescription || '(补分配)',
|
|
3562
|
-
linkedChanges,
|
|
3563
|
-
allowedFiles: Array.isArray(guard?.allowedFiles) ? guard.allowedFiles : [],
|
|
3564
|
-
})
|
|
3565
|
-
qlId = alloc.qlId
|
|
3566
|
-
console.log(`📝 QUICKLOG 兜底补写: ${qlId}(guard 缺失/brownfield 会话)`)
|
|
3567
|
-
} catch (e) {
|
|
3568
|
-
console.error(`\n❌ QUICKLOG 补分配失败: ${e.message}`)
|
|
3569
|
-
steps[currentIdx].status = 'pending'
|
|
3570
|
-
steps[currentIdx].completedAt = null
|
|
3571
|
-
if (outputText) steps[currentIdx].output = null
|
|
3572
|
-
process.exit(1)
|
|
3573
|
-
}
|
|
3574
|
-
}
|
|
3575
|
-
if (!findQuicklogEntry(specBase, gitUser, qlId)) {
|
|
3576
|
-
console.error(`\n❌ quick 阶段完成校验失败:QUICKLOG 条目 ${qlId} 不存在。`)
|
|
3577
|
-
console.error(` 会话期间记录被删除或从未写入。请检查 .sillyspec/quicklog/ 后重跑 --done。`)
|
|
3578
|
-
steps[currentIdx].status = 'pending'
|
|
3579
|
-
steps[currentIdx].completedAt = null
|
|
3580
|
-
if (outputText) steps[currentIdx].output = null
|
|
3581
|
-
process.exit(1)
|
|
3582
|
-
}
|
|
3583
|
-
// 翻状态进行中→已完成 + 追加结果 + 勾选关联 tasks.md
|
|
3584
|
-
// resultText 不再截断:结构化结果块(需求/根因/方案/结果)完整落盘,多行写成字段化块。
|
|
3585
|
-
try {
|
|
3586
|
-
await completeQuicklogEntry(specBase, gitUser, qlId, {
|
|
3587
|
-
resultText: outputText || '',
|
|
3588
|
-
linkedChanges,
|
|
3589
|
-
})
|
|
3590
|
-
console.log(`📝 QUICKLOG 条目 ${qlId} 已标记完成`)
|
|
3591
|
-
} catch (e) {
|
|
3592
|
-
console.warn(`⚠️ QUICKLOG 完成态写入失败: ${e.message}`)
|
|
3593
|
-
}
|
|
3594
|
-
|
|
3595
|
-
// 清理 session 目录(rmSync/unlinkSync 容忍不存在)。
|
|
3596
|
-
try {
|
|
3597
|
-
const { unlinkSync } = await import('fs')
|
|
3598
|
-
if (changeName) {
|
|
3599
|
-
const sessionDir = join(runtimeBase, 'quick-sessions', changeName)
|
|
3600
|
-
rmSync(sessionDir, { recursive: true, force: true })
|
|
3601
|
-
}
|
|
3602
|
-
if (existsSync(legacyGuardFile)) unlinkSync(legacyGuardFile)
|
|
3603
|
-
} catch {}
|
|
3604
|
-
}
|
|
3605
|
-
|
|
3606
|
-
stageData.status = 'completed'
|
|
3607
|
-
stageData.completedAt = new Date().toLocaleString('zh-CN',{hour12:false})
|
|
3608
|
-
progress.lastActive = new Date().toLocaleString('zh-CN',{hour12:false})
|
|
3609
|
-
await pm._write(cwd, progress, changeName)
|
|
3610
|
-
triggerSync(cwd, changeName, platformOpts)
|
|
3611
|
-
|
|
3612
|
-
// Append to user-inputs.md
|
|
3613
|
-
if (outputText) {
|
|
3614
|
-
const inputsPath = join(specBase, '.runtime', 'user-inputs.md')
|
|
3615
|
-
const entry = `\n## ${new Date().toLocaleString('zh-CN',{hour12:false})} | ${changeName || '?'} | ${stageName}: ${steps[currentIdx].name}\n${inputText ? "- 输入:" + inputText + "\n" : ""}- 输出:${outputText}\n`
|
|
3616
|
-
appendFileSync(inputsPath, entry)
|
|
3617
|
-
}
|
|
3618
|
-
|
|
3619
|
-
// 平台模式:scan 完成后生成 manifest.json + post-check
|
|
3620
|
-
if (stageName === 'scan' && (platformOpts.specRoot || platformOpts.runtimeRoot)) {
|
|
3621
|
-
try {
|
|
3622
|
-
stageData.scanMeta = stageData.scanMeta || {}; stageData.scanMeta.manifestWritten = false; // 默认失败
|
|
3623
|
-
const { mkdirSync, writeFileSync, readFileSync: _readFileSync } = await import('fs')
|
|
3624
|
-
const { join } = await import('path')
|
|
3625
|
-
const { execSync } = await import('child_process')
|
|
3626
|
-
const manifestDir = platformOpts.specRoot
|
|
3627
|
-
mkdirSync(manifestDir, { recursive: true })
|
|
3628
|
-
let sourceCommit = null
|
|
3629
|
-
let sourceCommitError = null
|
|
3630
|
-
try {
|
|
3631
|
-
const gitResult = safeGit(cwd, ['rev-parse', 'HEAD'])
|
|
3632
|
-
sourceCommit = gitResult.value
|
|
3633
|
-
sourceCommitError = gitResult.error
|
|
3634
|
-
} catch (e) {
|
|
3635
|
-
sourceCommitError = e.message
|
|
3636
|
-
}
|
|
3637
|
-
const manifest = {
|
|
3638
|
-
workspace_id: platformOpts.workspaceId || null,
|
|
3639
|
-
scan_run_id: platformOpts.scanRunId || null,
|
|
3640
|
-
source_root: cwd,
|
|
3641
|
-
spec_root: platformOpts.specRoot || null,
|
|
3642
|
-
runtime_root: platformOpts.runtimeRoot || null,
|
|
3643
|
-
source_commit: sourceCommit,
|
|
3644
|
-
source_commit_error: sourceCommit === null ? (sourceCommitError || 'unknown') : undefined,
|
|
3645
|
-
generated_at: new Date().toISOString(),
|
|
3646
|
-
schema_version: 1,
|
|
3647
|
-
postcheck_result_path: null,
|
|
3648
|
-
workflow_runs_dir: platformOpts.runtimeRoot
|
|
3649
|
-
? join(platformOpts.runtimeRoot, 'scan-runs', platformOpts.scanRunId || 'unknown', 'workflow-runs')
|
|
3650
|
-
: null,
|
|
3651
|
-
platform_pointer_path: join(cwd, '.sillyspec-platform.json'),
|
|
3652
|
-
platform_pointer_status: POINTER_STATUS.ACTIVE,
|
|
3653
|
-
}
|
|
3654
|
-
const manifestPath = join(manifestDir, 'manifest.json')
|
|
3655
|
-
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n')
|
|
3656
|
-
console.log(`📄 manifest.json 已写入: ${manifestPath}`)
|
|
3657
|
-
stageData.scanMeta = stageData.scanMeta || {}; stageData.scanMeta.manifestWritten = true;
|
|
3658
|
-
if (!sourceCommit) {
|
|
3659
|
-
console.log(`⚠️ source_commit 无法获取(可能非 git 目录),已设为 null`)
|
|
3660
|
-
}
|
|
3661
|
-
// 清理平台参数临时文件
|
|
3662
|
-
const { unlinkSync } = await import('fs')
|
|
3663
|
-
const platformOptsFile = join(manifestDir, '.runtime', 'platform-scan.json')
|
|
3664
|
-
try { unlinkSync(platformOptsFile) } catch {}
|
|
3665
|
-
|
|
3666
|
-
// CLI 层 post-check(替代旧的简单检查)
|
|
3667
|
-
const { runScanPostCheck, printScanPostCheckResult, formatStructuredResult, writeStructuredResult } = await import('./scan-postcheck.js')
|
|
3668
|
-
const postResult = runScanPostCheck({
|
|
3669
|
-
cwd,
|
|
3670
|
-
specDir: platformOpts.specRoot,
|
|
3671
|
-
outputText,
|
|
3672
|
-
scanMeta: {
|
|
3673
|
-
projectListParsed: stageData.scanMeta?.projectListParsed ?? null,
|
|
3674
|
-
manifestWritten: stageData.scanMeta?.manifestWritten ?? null,
|
|
3675
|
-
},
|
|
3676
|
-
})
|
|
3677
|
-
printScanPostCheckResult(postResult)
|
|
3678
|
-
|
|
3679
|
-
// 生成结构化 JSON 并写入 runtime(供 SillyHub 消费)
|
|
3680
|
-
const structured = formatStructuredResult(postResult, {
|
|
3681
|
-
workspace_id: platformOpts.workspaceId,
|
|
3682
|
-
scan_run_id: platformOpts.scanRunId,
|
|
3683
|
-
source_root: cwd,
|
|
3684
|
-
spec_root: platformOpts.specRoot,
|
|
3685
|
-
runtime_root: platformOpts.runtimeRoot,
|
|
3686
|
-
})
|
|
3687
|
-
const postcheckJsonPath = writeStructuredResult(structured, platformOpts.specRoot, {
|
|
3688
|
-
runtimeRoot: platformOpts.runtimeRoot,
|
|
3689
|
-
scanRunId: platformOpts.scanRunId,
|
|
3690
|
-
})
|
|
3691
|
-
if (postcheckJsonPath) {
|
|
3692
|
-
console.log(`📄 postcheck-result.json 已写入: ${postcheckJsonPath}`)
|
|
3693
|
-
manifest.postcheck_result_path = postcheckJsonPath
|
|
3694
|
-
}
|
|
3695
|
-
|
|
3696
|
-
// 将 post-check 结果写入 manifest
|
|
3697
|
-
manifest.scan_post_check = {
|
|
3698
|
-
status: postResult.status,
|
|
3699
|
-
checks: postResult.checks,
|
|
3700
|
-
}
|
|
3701
|
-
// 更新 manifest
|
|
3702
|
-
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n')
|
|
3703
|
-
console.log(`📄 manifest.json 已更新(含 post-check 结果)`)
|
|
3704
|
-
|
|
3705
|
-
// 更新平台指针状态为 scan_completed
|
|
3706
|
-
const pointerPath = join(cwd, '.sillyspec-platform.json')
|
|
3707
|
-
try {
|
|
3708
|
-
const pointer = JSON.parse(_readFileSync(pointerPath, 'utf8'))
|
|
3709
|
-
pointer.status = POINTER_STATUS.SCAN_COMPLETED
|
|
3710
|
-
pointer.completedAt = new Date().toISOString()
|
|
3711
|
-
pointer.scanStatus = postResult.status
|
|
3712
|
-
writeAtomicSync(pointerPath, JSON.stringify(pointer, null, 2) + '\n')
|
|
3713
|
-
} catch (e) {
|
|
3714
|
-
// 不阻断 scan 主流程,但暴露失败——pointer 写失败会让平台看不到 scan_completed,
|
|
3715
|
-
// 与项目 fail-loud 原则一致:宁可可见地 warn,也不静默吞错。
|
|
3716
|
-
console.warn(`⚠️ 更新平台指针状态失败(scan_completed 可能未落盘): ${e.message}`)
|
|
3717
|
-
}
|
|
3718
|
-
|
|
3719
|
-
// failed_post_check 时强制阻止 clean success
|
|
3720
|
-
if (postResult.status === 'failed_post_check') {
|
|
3721
|
-
stageData.status = SCAN_STATUS.FAILED_POST_CHECK
|
|
3722
|
-
stageData.completedAt = new Date().toLocaleString('zh-CN',{hour12:false})
|
|
3723
|
-
await pm._write(cwd, progress, changeName)
|
|
3724
|
-
triggerSync(cwd, changeName, platformOpts)
|
|
3725
|
-
console.error(`\n❌ scan post-check 失败,状态设为 failed_post_check。不允许 clean success。`)
|
|
3726
|
-
console.error(` 请检查上方错误信息并修复后重新 scan。`)
|
|
3727
|
-
// 平台模式:exit(1) 让 daemon/SillyHub 感知非 0 退出码(manifest.json 已落盘,不会被撤销)
|
|
3728
|
-
if (platformOpts.specRoot || platformOpts.runtimeRoot) {
|
|
3729
|
-
console.error(' 平台模式:CLI 将以 exit code 1 退出,通知 SillyHub scan 失败。')
|
|
3730
|
-
process.exit(1)
|
|
3731
|
-
}
|
|
3732
|
-
// 接口与 plan contract (run.js:2551 附近 plan 失败分支) 对齐:
|
|
3733
|
-
// 返回 { stageCompleted:false, currentIdx, nextPendingIdx: currentIdx }
|
|
3734
|
-
// 让上层 runStage 走"完成但不推进"分支,--done 被拒
|
|
3735
|
-
return { stageCompleted: false, currentIdx, nextPendingIdx: currentIdx }
|
|
3736
|
-
} else if (postResult.status === 'completed_with_warnings') {
|
|
3737
|
-
// 警告不阻止完成,但记录
|
|
3738
|
-
stageData.status = 'completed'
|
|
3739
|
-
stageData.completedAt = new Date().toLocaleString('zh-CN',{hour12:false})
|
|
3740
|
-
await pm._write(cwd, progress, changeName)
|
|
3741
|
-
}
|
|
3742
|
-
} catch (e) {
|
|
3743
|
-
console.warn(`⚠️ manifest.json 写入失败: ${e.message}`)
|
|
3744
|
-
}
|
|
3745
|
-
}
|
|
3746
|
-
|
|
3747
|
-
// 非 platform 模式 scan 也做轻量 post-check + 结构化输出
|
|
3748
|
-
if (stageName === 'scan' && !platformOpts.specRoot && !platformOpts.runtimeRoot) {
|
|
3749
|
-
const { runScanPostCheck, printScanPostCheckResult, formatStructuredResult, writeStructuredResult } = await import('./scan-postcheck.js')
|
|
3750
|
-
const postResult = runScanPostCheck({ cwd, specDir: null, outputText })
|
|
3751
|
-
printScanPostCheckResult(postResult)
|
|
3752
|
-
// 结构化结果写入 .sillyspec/.runtime/
|
|
3753
|
-
const structured = formatStructuredResult(postResult, { source_root: cwd })
|
|
3754
|
-
const postcheckJsonPath = writeStructuredResult(structured, join(cwd, '.sillyspec'))
|
|
3755
|
-
if (postcheckJsonPath) {
|
|
3756
|
-
console.log(`📄 postcheck-result.json 已写入: ${postcheckJsonPath}`)
|
|
3757
|
-
}
|
|
3758
|
-
}
|
|
3759
|
-
|
|
3760
|
-
// 防御性守卫变量:确认所有步骤确实标记为 completed
|
|
3761
|
-
const actualCompleted = steps.filter(s => s.status === 'completed').length
|
|
3762
|
-
const actualTotal = steps.length
|
|
3763
|
-
|
|
3764
|
-
validateMetadata(cwd, stageName, specBase)
|
|
3765
|
-
|
|
3766
|
-
// 验证关键文件是否在正确的变更目录下(仅当所有步骤确实完成时才校验)
|
|
3767
|
-
if (actualCompleted === actualTotal && actualTotal > 0) {
|
|
3768
|
-
validateFileLocations(cwd, stageName, progress, changeName, specBase)
|
|
3769
|
-
}
|
|
3770
|
-
|
|
3771
|
-
// 辅助阶段完成后重置步骤
|
|
3772
|
-
const stageDef = stageRegistry[stageName]
|
|
3773
|
-
if (stageDef?.auxiliary) {
|
|
3774
|
-
const freshSteps = (stageDef.steps || []).map(s => ({
|
|
3775
|
-
name: s.name,
|
|
3776
|
-
status: 'pending',
|
|
3777
|
-
output: null,
|
|
3778
|
-
completedAt: null
|
|
3779
|
-
}))
|
|
3780
|
-
stageData.steps = freshSteps
|
|
3781
|
-
stageData.status = 'pending'
|
|
3782
|
-
stageData.completedAt = null
|
|
3783
|
-
if (progress.currentStage === stageName) progress.currentStage = ''
|
|
3784
|
-
await pm._write(cwd, progress, changeName)
|
|
3785
|
-
}
|
|
3786
|
-
|
|
3787
|
-
const total = steps.length
|
|
3788
|
-
console.log(`✅ ${stageName} 阶段已完成(${total}/${total} 步)`)
|
|
3789
|
-
|
|
3790
|
-
if (stageName === 'execute') {
|
|
3791
|
-
// execute run summary:展示真实可得的结构化信息
|
|
3792
|
-
try {
|
|
3793
|
-
const lastOutput = steps[steps.length - 1]?.output || ''
|
|
3794
|
-
const summary = formatExecuteSummary({
|
|
3795
|
-
changeName,
|
|
3796
|
-
stepsCompleted: total,
|
|
3797
|
-
stepsTotal: total,
|
|
3798
|
-
agentSummary: lastOutput,
|
|
3799
|
-
cwd,
|
|
3800
|
-
})
|
|
3801
|
-
console.log(`\n${summary}`)
|
|
3802
|
-
} catch (e) {
|
|
3803
|
-
// summary 失败不影响主流程
|
|
3804
|
-
console.log('\n👉 下一步:sillyspec run verify(验证通过后才能归档)')
|
|
3805
|
-
}
|
|
3806
|
-
} else if (stageName === 'archive') {
|
|
3807
|
-
console.log('\n👉 归档完成!现在可以提交了:git commit -m "..."')
|
|
3808
|
-
} else if (stageName === 'verify') {
|
|
3809
|
-
// verify 的"验证通过"提示延后到下方 validator 通过后才打印,
|
|
3810
|
-
// 避免校验失败(FAIL / 缺 verify-result.md)时仍声称"验证通过可以归档"。
|
|
3811
|
-
} else {
|
|
3812
|
-
console.log(`\n下一步由你决定:sillyspec run <stage>(brainstorm/plan/execute/verify/archive 等)`)
|
|
3813
|
-
}
|
|
3814
|
-
|
|
3815
|
-
// 阶段完成校验 — 防御性守卫:仅当所有步骤确实标记为 completed 时才跑 validator
|
|
3816
|
-
if (actualCompleted === actualTotal && actualTotal > 0) {
|
|
3817
|
-
const _gateEarlyReturn = await runStageCompletionGates({ stageName, cwd, changeName, platformOpts, specBase, progress, pm, stageData, steps, currentIdx })
|
|
3818
|
-
if (_gateEarlyReturn) return _gateEarlyReturn
|
|
3819
|
-
} else if (actualCompleted < actualTotal) {
|
|
3820
|
-
// 实际步骤未全部完成,跳过 validator(状态可能不同步)
|
|
3821
|
-
console.log(`\n⚠️ 阶段校验跳过:${actualTotal} 步中仅 ${actualCompleted} 步标记为已完成,可能存在状态不同步。如确认阶段已完成,请运行 --status 确认。`)
|
|
3822
|
-
}
|
|
3823
|
-
|
|
3824
|
-
// ── execute 阶段完成时条件性清理 worktree(不依赖 AI agent 的完成确认步骤)──
|
|
3825
|
-
if (stageName === 'execute' && changeName) {
|
|
3826
|
-
try {
|
|
3827
|
-
const { WorktreeManager } = await import('./worktree.js');
|
|
3828
|
-
const wm = new WorktreeManager({ cwd });
|
|
3829
|
-
const meta = wm.getMeta(changeName);
|
|
3830
|
-
if (!meta) {
|
|
3831
|
-
console.log('🔗 Worktree: n/a (no meta)');
|
|
3832
|
-
} else if (meta.mode === 'native-worktree') {
|
|
3833
|
-
console.log('🔗 Worktree: kept (外部隔离环境)');
|
|
3834
|
-
} else {
|
|
3835
|
-
// in-place 模式不再短路:cleanup 现在能安全处理 in-place(只清 meta,不碰主工作区)
|
|
3836
|
-
const check = wm.hasUnappliedChanges(changeName);
|
|
3837
|
-
if (check.hasChanges) {
|
|
3838
|
-
console.log(`🔗 Worktree: pending apply (${check.changedFiles.length} 个未应用变更)`);
|
|
3839
|
-
console.log(` 下一步: sillyspec worktree apply ${changeName}`);
|
|
3840
|
-
} else {
|
|
3841
|
-
const cleanResult = wm.cleanup(changeName);
|
|
3842
|
-
console.log(`🔗 Worktree: ${cleanResult.result}`);
|
|
3843
|
-
if (cleanResult.residual?.length > 0) {
|
|
3844
|
-
console.warn(` ⚠️ 清理残留: ${cleanResult.residual.join('; ')}`);
|
|
3845
|
-
console.warn(` 手动处理: sillyspec worktree cleanup ${changeName} --force`);
|
|
3846
|
-
} else if (cleanResult.details?.length > 0) {
|
|
3847
|
-
for (const d of cleanResult.details) {
|
|
3848
|
-
if (d.startsWith('⚠️')) console.log(` ${d}`);
|
|
3849
|
-
}
|
|
3850
|
-
}
|
|
3851
|
-
}
|
|
3852
|
-
}
|
|
3853
|
-
} catch (e) {
|
|
3854
|
-
console.warn(`🔗 Worktree: check failed — ${e.message}`);
|
|
3855
|
-
}
|
|
3856
|
-
}
|
|
3857
|
-
|
|
3858
|
-
return { stageCompleted: true, currentIdx, nextPendingIdx: -1 }
|
|
3859
|
-
}
|
|
3860
|
-
|
|
3861
|
-
progress.lastActive = new Date().toLocaleString('zh-CN',{hour12:false})
|
|
3862
|
-
await pm._write(cwd, progress, changeName)
|
|
3863
|
-
triggerSync(cwd, changeName, platformOpts)
|
|
3864
|
-
|
|
3865
|
-
// Append to user-inputs.md
|
|
3866
|
-
if (outputText) {
|
|
3867
|
-
const inputsPath = join(specBase, '.runtime', 'user-inputs.md')
|
|
3868
|
-
const entry = `\n## ${new Date().toLocaleString('zh-CN',{hour12:false})} | ${changeName || '?'} | ${stageName}: ${steps[currentIdx].name}\n${inputText ? "- 输入:" + inputText + "\n" : ""}- 输出:${outputText}\n`
|
|
3869
|
-
appendFileSync(inputsPath, entry)
|
|
3870
|
-
}
|
|
3871
|
-
|
|
3872
|
-
const defSteps = await getStageSteps(stageName, cwd, progress, platformOpts?.specRoot || null)
|
|
3873
|
-
console.log(`✅ Step ${currentIdx + 1}/${steps.length} 完成:${steps[currentIdx].name}\n`)
|
|
3874
|
-
|
|
3875
|
-
// Workflow post_check:scan 深度扫描完成后自动检查产物
|
|
3876
|
-
if (stageName === 'scan' && steps[currentIdx]?.name?.includes('深度扫描')) {
|
|
3877
|
-
try {
|
|
3878
|
-
const { loadWorkflow, runPostCheck, formatCheckReport, saveWorkflowRun } = await import('./workflow.js')
|
|
3879
|
-
const wf = loadWorkflow(cwd, 'scan-docs')
|
|
3880
|
-
if (wf) {
|
|
3881
|
-
// 确定当前项目(优先级链):
|
|
3882
|
-
// progress.project (dbProjectName,平台模式真实项目名,与 outputStep 占位符渲染对齐)
|
|
3883
|
-
// > change?.project (变更对象的项目字段,平台模式 change 创建时传入)
|
|
3884
|
-
// > steps[idx].project (perProject 展开标记,兼容旧模式)
|
|
3885
|
-
// > steps[idx].name 正则提取 [xxx] 后缀
|
|
3886
|
-
// > null(回退检查所有项目)
|
|
3887
|
-
// task-05 修复:日志显示项目名变 frontend 是 perProject 误展开 bug,
|
|
3888
|
-
// 用 progress.project(与 outputStep 占位符渲染路径一致)修正 myaaa/frontend 分裂。
|
|
3889
|
-
const currentProjectName = progress.project
|
|
3890
|
-
|| (typeof change !== 'undefined' && change ? change.project : null)
|
|
3891
|
-
|| steps[currentIdx].project
|
|
3892
|
-
|| (steps[currentIdx].name.match(/\[([^\]]+)\]\s*$/) || [])[1]
|
|
3893
|
-
|| null
|
|
3894
|
-
|
|
3895
|
-
// 确定要检查的项目列表
|
|
3896
|
-
let projectsToCheck = []
|
|
3897
|
-
if (currentProjectName) {
|
|
3898
|
-
// 按项目展开模式:只检查当前项目
|
|
3899
|
-
projectsToCheck = [currentProjectName]
|
|
3900
|
-
} else {
|
|
3901
|
-
// 兼容旧模式(未展开):检查所有项目
|
|
3902
|
-
const projectsDir = join(specBase, 'projects')
|
|
3903
|
-
const projectFiles = existsSync(projectsDir)
|
|
3904
|
-
? readdirSync(projectsDir).filter(f => f.endsWith('.yaml'))
|
|
3905
|
-
: []
|
|
3906
|
-
projectsToCheck = projectFiles.map(f => f.replace(/\.yaml$/, ''))
|
|
3907
|
-
}
|
|
3908
|
-
|
|
3909
|
-
let anyFailed = false
|
|
3910
|
-
for (const pName of projectsToCheck) {
|
|
3911
|
-
const result = runPostCheck(wf, cwd, pName, {}, specBase)
|
|
3912
|
-
const report = formatCheckReport(result)
|
|
3913
|
-
console.log(report)
|
|
3914
|
-
if (result.status === 'fail') {
|
|
3915
|
-
anyFailed = true
|
|
3916
|
-
// retry_prompts 由 _checkWorkflow 自动生成
|
|
3917
|
-
for (const rp of (result.retry_prompts || [])) {
|
|
3918
|
-
console.log(`\n🔄 重试提示(项目 ${pName}):\n`)
|
|
3919
|
-
console.log(rp.prompt)
|
|
3920
|
-
}
|
|
3921
|
-
}
|
|
3922
|
-
const saved = saveWorkflowRun(result, {
|
|
3923
|
-
cwd,
|
|
3924
|
-
source: 'run.js',
|
|
3925
|
-
stage: 'scan',
|
|
3926
|
-
step: steps[currentIdx]?.name,
|
|
3927
|
-
...(platformOpts.runtimeRoot ? { runtimeRoot: platformOpts.runtimeRoot } : {}),
|
|
3928
|
-
...(platformOpts.scanRunId ? { scanRunId: platformOpts.scanRunId } : {})
|
|
3929
|
-
})
|
|
3930
|
-
if (saved) console.log(`📁 结果已归档:${saved}`)
|
|
3931
|
-
}
|
|
3932
|
-
if (anyFailed) {
|
|
3933
|
-
console.log(`\n⚠️ 存在检查失败项,请按上面的重试提示修复后再继续。`)
|
|
3934
|
-
// task-07: 阻断推进(与 task-06 平台模式 scan-postcheck 失败分支 return 结构对齐)
|
|
3935
|
-
// scan 深度扫描产物校验未通过时,不允许 clean success / 进入下一 step,
|
|
3936
|
-
// 让上层走"完成但不推进"分支,--done 被拒。
|
|
3937
|
-
return { stageCompleted: false, currentIdx, nextPendingIdx: currentIdx }
|
|
3938
|
-
}
|
|
3939
|
-
}
|
|
3940
|
-
} catch (e) {
|
|
3941
|
-
console.warn(`⚠️ workflow 检查跳过:${e.message}`)
|
|
3942
|
-
}
|
|
3943
|
-
}
|
|
3944
|
-
|
|
3945
|
-
// Workflow post_check:archive extract-module-impact 完成后检查产物
|
|
3946
|
-
if (stageName === 'archive' && steps[currentIdx]?.name?.includes('extract-module-impact')) {
|
|
3947
|
-
try {
|
|
3948
|
-
const { loadWorkflow, runPostCheck, formatCheckReport, saveWorkflowRun } = await import('./workflow.js')
|
|
3949
|
-
const wf = loadWorkflow(cwd, 'archive-impact')
|
|
3950
|
-
if (wf && changeName) {
|
|
3951
|
-
const raw = JSON.stringify(wf)
|
|
3952
|
-
const resolved = JSON.parse(raw.replace(/<change-name>/g, changeName))
|
|
3953
|
-
const result = runPostCheck(resolved, cwd, progress.project || basename(cwd), {}, specBase)
|
|
3954
|
-
// 只报告 impact-analyzer 的结果(doc-syncer 是后续步骤)
|
|
3955
|
-
const impactResult = (result.roles || []).find(r => r.id === 'impact-analyzer')
|
|
3956
|
-
if (impactResult) {
|
|
3957
|
-
const icon = impactResult.status === 'pass' ? '✅' : '❌'
|
|
3958
|
-
console.log(`${icon} module-impact.md 检查${impactResult.status === 'pass' ? '通过' : '失败'}`)
|
|
3959
|
-
for (const f of (result.failures || []).filter(f => f.role_id === 'impact-analyzer')) {
|
|
3960
|
-
console.log(` └─ ${f}`)
|
|
3961
|
-
}
|
|
3962
|
-
}
|
|
3963
|
-
const saved = saveWorkflowRun(result, {
|
|
3964
|
-
cwd,
|
|
3965
|
-
source: 'run.js',
|
|
3966
|
-
stage: 'archive',
|
|
3967
|
-
step: steps[currentIdx]?.name,
|
|
3968
|
-
...(platformOpts.runtimeRoot ? { runtimeRoot: platformOpts.runtimeRoot } : {}),
|
|
3969
|
-
...(platformOpts.scanRunId ? { scanRunId: platformOpts.scanRunId } : {})
|
|
3970
|
-
})
|
|
3971
|
-
if (saved) console.log(`📁 结果已归档:${saved}`)
|
|
3972
|
-
}
|
|
3973
|
-
} catch (e) {
|
|
3974
|
-
console.warn(`⚠️ workflow 检查跳过:${e.message}`)
|
|
3975
|
-
}
|
|
3976
|
-
}
|
|
3977
|
-
|
|
3978
|
-
if (printNext) {
|
|
3979
|
-
await outputStep(stageName, nextPendingIdx, defSteps, cwd, changeName, progress.project || null, platformOpts)
|
|
3980
|
-
}
|
|
3981
|
-
return { stageCompleted: false, currentIdx, nextPendingIdx }
|
|
3982
|
-
}
|
|
3983
|
-
|
|
3984
|
-
// 测试专用导出:completeStep 是 step 完成处理核心(产物校验/gate 链/sync/auto 推进),
|
|
3985
|
-
// 行为保持重构需要 characterization 测试直接驱动它。有先例:worktree-guard.js 的 _queryDbFirstCellForTest。
|
|
3986
|
-
export { completeStep as _completeStepForTest }
|
|
3987
|
-
|
|
3988
|
-
async function skipStep(pm, progress, stageName, cwd, changeName, platformOpts = {}) {
|
|
3989
|
-
const stageData = progress.stages[stageName]
|
|
3990
|
-
if (!stageData || !stageData.steps) {
|
|
3991
|
-
console.error(`❌ 阶段 ${stageName} 未初始化`)
|
|
3992
|
-
process.exit(1)
|
|
3993
|
-
}
|
|
3994
|
-
|
|
3995
|
-
const steps = stageData.steps
|
|
3996
|
-
const currentIdx = steps.findIndex(s => s.status === 'pending' || s.status === 'in-progress')
|
|
3997
|
-
|
|
3998
|
-
if (currentIdx === -1) {
|
|
3999
|
-
const wsIdx = steps.findIndex(s => s.status === 'waiting')
|
|
4000
|
-
if (wsIdx !== -1) {
|
|
4001
|
-
console.error(`⏸️ Step ${wsIdx + 1} 正在等待用户输入,不能跳过。`)
|
|
4002
|
-
console.error(` 请先使用 --continue --answer "..." 继续,或用 --reset 重置。`)
|
|
4003
|
-
} else {
|
|
4004
|
-
console.error('没有待跳过的步骤')
|
|
4005
|
-
}
|
|
4006
|
-
process.exit(1)
|
|
4007
|
-
}
|
|
4008
|
-
|
|
4009
|
-
const defSteps = await getStageSteps(stageName, cwd, progress, platformOpts?.specRoot || null)
|
|
4010
|
-
const stepDef = defSteps ? defSteps[currentIdx] : null
|
|
4011
|
-
if (stepDef && !stepDef.optional) {
|
|
4012
|
-
console.error(`❌ 步骤 "${steps[currentIdx].name}" 不可跳过`)
|
|
4013
|
-
process.exit(1)
|
|
4014
|
-
}
|
|
4015
|
-
|
|
4016
|
-
steps[currentIdx].status = 'skipped'
|
|
4017
|
-
steps[currentIdx].skippedAt = new Date().toLocaleString('zh-CN',{hour12:false})
|
|
4018
|
-
progress.lastActive = new Date().toLocaleString('zh-CN',{hour12:false})
|
|
4019
|
-
await pm._write(cwd, progress, changeName)
|
|
4020
|
-
triggerSync(cwd, changeName, platformOpts)
|
|
4021
|
-
|
|
4022
|
-
console.log(`⏭️ Step ${currentIdx + 1}/${steps.length} 已跳过:${steps[currentIdx].name}`)
|
|
4023
|
-
|
|
4024
|
-
const nextPendingIdx = steps.findIndex(s => s.status === 'pending' || s.status === 'in-progress')
|
|
4025
|
-
if (nextPendingIdx !== -1 && defSteps) {
|
|
4026
|
-
console.log('')
|
|
4027
|
-
await outputStep(stageName, nextPendingIdx, defSteps, cwd, changeName, progress.project || null, platformOpts)
|
|
4028
|
-
} else {
|
|
4029
|
-
const wsIdx = steps.findIndex(s => s.status === 'waiting')
|
|
4030
|
-
if (wsIdx !== -1) {
|
|
4031
|
-
console.log(`\n⏸️ Step ${wsIdx + 1}/${steps.length} 正在等待:${steps[wsIdx].name}`)
|
|
4032
|
-
if (steps[wsIdx].waitReason) console.log(` 原因:${steps[wsIdx].waitReason}`)
|
|
4033
|
-
console.log(` 继续:sillyspec run ${stageName} --continue --answer "..."${changeName ? ` --change ${changeName}` : ''}`)
|
|
4034
|
-
}
|
|
4035
|
-
}
|
|
4036
|
-
}
|
|
4037
|
-
|
|
4038
|
-
function showStatus(progress, stageName) {
|
|
4039
|
-
const stageData = progress.stages[stageName]
|
|
4040
|
-
const stageDef = stageRegistry[stageName]
|
|
4041
|
-
|
|
4042
|
-
if (!stageData || !stageData.steps || stageData.steps.length === 0) {
|
|
4043
|
-
console.log(`阶段:${stageName}(${stageDef?.title || stageName})`)
|
|
4044
|
-
console.log(`进度:未初始化`)
|
|
4045
|
-
if (stageData?.status) {
|
|
4046
|
-
console.log(`状态:${stageData.status}`)
|
|
4047
|
-
if (stageData.staleReason) console.log(`⚠️ 失效原因:${stageData.staleReason}`)
|
|
4048
|
-
}
|
|
4049
|
-
return
|
|
4050
|
-
}
|
|
4051
|
-
|
|
4052
|
-
const steps = stageData.steps
|
|
4053
|
-
const completed = steps.filter(s => s.status === 'completed' || s.status === 'skipped').length
|
|
4054
|
-
const bar = '█'.repeat(completed) + '░'.repeat(steps.length - completed)
|
|
4055
|
-
|
|
4056
|
-
console.log(`阶段:${stageName}(${stageDef?.title || stageName})`)
|
|
4057
|
-
console.log(`进度:[${bar}] ${completed}/${steps.length}`)
|
|
4058
|
-
|
|
4059
|
-
// ── Revision v1 信息 ──
|
|
4060
|
-
if (stageData.status === 'revising') {
|
|
4061
|
-
console.log(`\n🔧 修订中 (revision ${stageData.revision || 1})`)
|
|
4062
|
-
if (stageData.reopenedFromStep) console.log(` 从步骤:${stageData.reopenedFromStep}`)
|
|
4063
|
-
if (stageData.reopenedAt) console.log(` 重开时间:${stageData.reopenedAt}`)
|
|
4064
|
-
}
|
|
4065
|
-
if (stageData.status === 'stale') {
|
|
4066
|
-
console.log(`\n⚠️ 已失效`)
|
|
4067
|
-
if (stageData.staleReason) console.log(` 原因:${stageData.staleReason}`)
|
|
4068
|
-
console.log(` 建议:sillyspec run ${stageName} --reopen --from-step 1`)
|
|
4069
|
-
}
|
|
4070
|
-
if (stageData.status === 'completed') {
|
|
4071
|
-
console.log(`\n✅ 已完成`)
|
|
4072
|
-
}
|
|
4073
|
-
|
|
4074
|
-
console.log('')
|
|
4075
|
-
|
|
4076
|
-
const firstPending = steps.findIndex(s => s.status === 'pending' || s.status === 'in-progress')
|
|
4077
|
-
|
|
4078
|
-
if (progress.batchProgress) {
|
|
4079
|
-
const bp = progress.batchProgress
|
|
4080
|
-
const bpTotal = bp.total || 0
|
|
4081
|
-
const bpCompleted = bp.completed || 0
|
|
4082
|
-
const bpFailed = bp.failed || 0
|
|
4083
|
-
const bpSkipped = bp.skipped || 0
|
|
4084
|
-
const bpBarLen = 20
|
|
4085
|
-
const bpFilled = Math.round((bpCompleted / Math.max(bpTotal, 1)) * bpBarLen)
|
|
4086
|
-
const bpBar = '█'.repeat(bpFilled) + '░'.repeat(bpBarLen - bpFilled)
|
|
4087
|
-
const bpParts = []
|
|
4088
|
-
if (bpFailed > 0) bpParts.push(`${bpFailed} 失败`)
|
|
4089
|
-
if (bpSkipped > 0) bpParts.push(`${bpSkipped} 跳过`)
|
|
4090
|
-
const bpSuffix = bpParts.length ? ` (${bpParts.join(', ')})` : ''
|
|
4091
|
-
console.log(`\n📊 批量进度: ${bpBar} ${bpCompleted}/${bpTotal}${bpSuffix}\n`)
|
|
4092
|
-
}
|
|
4093
|
-
|
|
4094
|
-
steps.forEach((step, i) => {
|
|
4095
|
-
const icon = step.status === 'completed' ? '✅' : step.status === 'skipped' ? '⏭️' : step.status === 'waiting' ? '⏸️' : '⬜'
|
|
4096
|
-
const isCurrent = (step.status === 'pending' || step.status === 'in-progress') && i === firstPending
|
|
4097
|
-
const isWaiting = step.status === 'waiting'
|
|
4098
|
-
console.log(`${icon} Step ${i + 1}: ${step.name}${isCurrent ? ' ← 当前' : ''}${isWaiting ? ' [WAITING]' : ''}`)
|
|
4099
|
-
if (isWaiting) {
|
|
4100
|
-
if (step.waitReason) console.log(` 原因:${step.waitReason}`)
|
|
4101
|
-
if (step.waitOptions) console.log(` 选项:${formatWaitOptions(step.waitOptions)}`)
|
|
4102
|
-
if (step.waitedAt) console.log(` 等待时间:${step.waitedAt}`)
|
|
4103
|
-
}
|
|
4104
|
-
})
|
|
4105
|
-
}
|
|
4106
|
-
|
|
4107
|
-
async function resetStage(pm, progress, stageName, cwd, changeName, platformOpts = {}) {
|
|
4108
|
-
// execute 阶段 reset 时清理自建 worktree,否则下次 run execute 会因 existingMeta 存在
|
|
4109
|
-
// 直接复用带脏状态的旧 worktree(启动逻辑:meta 存在即复用,不查健康状态)
|
|
4110
|
-
if (stageName === 'execute' && changeName) {
|
|
4111
|
-
try {
|
|
4112
|
-
const { WorktreeManager } = await import('./worktree.js')
|
|
4113
|
-
const wm = new WorktreeManager({ cwd })
|
|
4114
|
-
const meta = wm.getMeta(changeName)
|
|
4115
|
-
if (meta) {
|
|
4116
|
-
const cleanResult = wm.cleanup(changeName)
|
|
4117
|
-
if (cleanResult.residual?.length > 0) {
|
|
4118
|
-
console.warn(`⚠️ reset 清理 worktree 残留: ${cleanResult.residual.join('; ')}`)
|
|
4119
|
-
console.warn(` 手动处理: sillyspec worktree cleanup ${changeName} --force`)
|
|
4120
|
-
} else if (cleanResult.result === 'kept') {
|
|
4121
|
-
console.log(`🔗 旧 worktree 保留 (${cleanResult.mode}: 外部隔离环境)`)
|
|
4122
|
-
} else if (cleanResult.result !== 'skipped') {
|
|
4123
|
-
console.log(`🧹 已清理旧 worktree (${cleanResult.result}, mode: ${cleanResult.mode}),下次 execute 将重建干净环境`)
|
|
4124
|
-
}
|
|
4125
|
-
}
|
|
4126
|
-
} catch (e) {
|
|
4127
|
-
console.warn(`⚠️ reset 清理 worktree 失败(不阻断 reset): ${e.message}`)
|
|
4128
|
-
}
|
|
4129
|
-
}
|
|
4130
|
-
const defSteps = await getStageSteps(stageName, cwd, progress, platformOpts?.specRoot || null)
|
|
4131
|
-
progress.stages[stageName] = {
|
|
4132
|
-
status: 'in-progress',
|
|
4133
|
-
startedAt: new Date().toLocaleString('zh-CN',{hour12:false}),
|
|
4134
|
-
completedAt: null,
|
|
4135
|
-
steps: defSteps ? defSteps.map(s => ({ name: s.name, status: 'pending' })) : []
|
|
4136
|
-
}
|
|
4137
|
-
progress.lastActive = new Date().toLocaleString('zh-CN',{hour12:false})
|
|
4138
|
-
await pm._write(cwd, progress, changeName)
|
|
4139
|
-
triggerSync(cwd, changeName, platformOpts)
|
|
4140
|
-
console.log(`🔄 ${stageName} 阶段已重置`)
|
|
4141
|
-
}
|
|
4142
|
-
|
|
4143
|
-
/**
|
|
4144
|
-
* auto 模式:自动推进 brainstorm → plan → execute → verify
|
|
4145
|
-
*/
|
|
4146
|
-
async function runAutoMode(pm, progress, cwd, flags, changeName, platformOpts = {}) {
|
|
4147
|
-
const flowStages = ['brainstorm', 'plan', 'execute', 'verify', 'archive']
|
|
4148
|
-
const isDone = flags.includes('--done')
|
|
4149
|
-
const outputIdx = flags.indexOf('--output')
|
|
4150
|
-
const outputText = outputIdx !== -1 && flags[outputIdx + 1] ? flags[outputIdx + 1] : null
|
|
4151
|
-
const inputIdx = flags.indexOf('--input')
|
|
4152
|
-
const inputText = inputIdx !== -1 && flags[inputIdx + 1] ? flags[inputIdx + 1] : null
|
|
4153
|
-
const skipApproval = flags.includes('--skip-approval')
|
|
4154
|
-
const explicitMode = (() => {
|
|
4155
|
-
const m = flags.indexOf('--mode')
|
|
4156
|
-
return m !== -1 && flags[m + 1] ? flags[m + 1] : null
|
|
4157
|
-
})()
|
|
4158
|
-
const specBase = platformOpts?.specRoot || join(cwd, '.sillyspec')
|
|
4159
|
-
|
|
4160
|
-
// Helper: 在 auto 模式下获取步骤定义
|
|
4161
|
-
const getAutoSteps = async (stage) => {
|
|
4162
|
-
if (stage === 'brainstorm') {
|
|
4163
|
-
return brainstormAutoDef.steps
|
|
4164
|
-
}
|
|
4165
|
-
return getStageSteps(stage, cwd, progress, platformOpts?.specRoot || null)
|
|
4166
|
-
}
|
|
4167
|
-
|
|
4168
|
-
const nextInFlow = (stage) => {
|
|
4169
|
-
const i = flowStages.indexOf(stage)
|
|
4170
|
-
return i >= 0 && i < flowStages.length - 1 ? flowStages[i + 1] : null
|
|
4171
|
-
}
|
|
4172
|
-
const firstOpenStage = () => flowStages.find(s => progress.stages?.[s]?.status !== 'completed')
|
|
4173
|
-
const ensureAutoStage = async (stage) => {
|
|
4174
|
-
const stageChanged = progress.currentStage !== stage
|
|
4175
|
-
progress.currentStage = stage
|
|
4176
|
-
// Auto 模式下 brainstorm 使用 artifact-first 步骤
|
|
4177
|
-
if (stage === 'brainstorm') {
|
|
4178
|
-
const existingSteps = progress.stages?.brainstorm?.steps
|
|
4179
|
-
const isAutoModeSteps = existingSteps?.length === 4 && existingSteps?.[0]?.name === '状态检查与上下文加载'
|
|
4180
|
-
if (!isAutoModeSteps) {
|
|
4181
|
-
if (!progress.stages) progress.stages = {}
|
|
4182
|
-
progress.stages.brainstorm = {
|
|
4183
|
-
status: 'in-progress',
|
|
4184
|
-
startedAt: new Date().toLocaleString('zh-CN', { hour12: false }),
|
|
4185
|
-
completedAt: null,
|
|
4186
|
-
steps: brainstormAutoDef.steps.map(s => ({ name: s.name, status: 'pending' }))
|
|
4187
|
-
}
|
|
4188
|
-
await pm._write(cwd, progress, changeName)
|
|
4189
|
-
triggerSync(cwd, changeName, platformOpts)
|
|
4190
|
-
progress = await pm.read(cwd, changeName)
|
|
4191
|
-
return progress
|
|
4192
|
-
}
|
|
4193
|
-
}
|
|
4194
|
-
const changed = await ensureStageSteps(progress, stage, cwd)
|
|
4195
|
-
if (stageChanged || changed) {
|
|
4196
|
-
await pm._write(cwd, progress, changeName)
|
|
4197
|
-
triggerSync(cwd, changeName, platformOpts)
|
|
4198
|
-
}
|
|
4199
|
-
progress = await pm.read(cwd, changeName)
|
|
4200
|
-
return progress
|
|
4201
|
-
}
|
|
4202
|
-
|
|
4203
|
-
// ── Classify change on first entry ──
|
|
4204
|
-
if (!progress.stages?.brainstorm?.status && !progress.stages?.plan?.status) {
|
|
4205
|
-
const { classifyChange } = await import('./classify-change.js')
|
|
4206
|
-
const classification = classifyChange({ description: inputText || '', explicitMode })
|
|
4207
|
-
if (classification.mode === 'quick') {
|
|
4208
|
-
console.log(`📊 auto 模式分类:${classification.mode}(${classification.reason})`)
|
|
4209
|
-
console.log(` 此变更建议使用 quick 模式,运行:sillyspec run quick "${inputText || '需求'}"`)
|
|
4210
|
-
return
|
|
4211
|
-
}
|
|
4212
|
-
console.log(`📊 auto 模式分类:${classification.mode}(${classification.reason})`)
|
|
4213
|
-
}
|
|
4214
|
-
|
|
4215
|
-
let currentStage = progress.currentStage
|
|
4216
|
-
if (!currentStage || progress.stages?.[currentStage]?.status === 'completed') {
|
|
4217
|
-
currentStage = firstOpenStage()
|
|
4218
|
-
}
|
|
4219
|
-
if (!currentStage) {
|
|
4220
|
-
console.log('All auto flow stages are complete.')
|
|
4221
|
-
return
|
|
4222
|
-
}
|
|
4223
|
-
if (!flowStages.includes(currentStage)) {
|
|
4224
|
-
const openStage = firstOpenStage()
|
|
4225
|
-
if (!openStage) {
|
|
4226
|
-
console.log('All auto flow stages are complete.')
|
|
4227
|
-
return
|
|
4228
|
-
}
|
|
4229
|
-
console.log(`⚠️ 当前阶段 ${currentStage} 不在 auto 流程中,自动跳转到 ${openStage}`)
|
|
4230
|
-
currentStage = openStage
|
|
4231
|
-
}
|
|
4232
|
-
await ensureAutoStage(currentStage)
|
|
4233
|
-
|
|
4234
|
-
if (!isDone) {
|
|
4235
|
-
console.log('════════════════════════════════════════')
|
|
4236
|
-
console.log(' SillySpec Auto Mode')
|
|
4237
|
-
if (changeName) console.log(` Change: ${changeName}`)
|
|
4238
|
-
console.log('════════════════════════════════════════')
|
|
4239
|
-
console.log(` Flow: ${flowStages.join(' -> ')}`)
|
|
4240
|
-
console.log(` Current: ${currentStage}`)
|
|
4241
|
-
for (const stage of flowStages) {
|
|
4242
|
-
const stageData = progress.stages?.[stage]
|
|
4243
|
-
const total = stageData?.steps?.length || '?'
|
|
4244
|
-
const completed = stageData?.steps?.filter(step => step.status === 'completed' || step.status === 'skipped').length || 0
|
|
4245
|
-
const marker = stageData?.status === 'completed' ? 'done' : stage === currentStage ? 'active' : 'pending'
|
|
4246
|
-
console.log(` ${marker} ${stage} (${completed}/${total})`)
|
|
4247
|
-
}
|
|
4248
|
-
console.log('')
|
|
4249
|
-
|
|
4250
|
-
const defSteps = await getAutoSteps(currentStage)
|
|
4251
|
-
const pendingIdx = progress.stages[currentStage]?.steps?.findIndex(step => step.status === 'pending' || step.status === 'in-progress') ?? -1
|
|
4252
|
-
if (pendingIdx === -1) {
|
|
4253
|
-
const wsIdx = progress.stages[currentStage]?.steps?.findIndex(step => step.status === 'waiting') ?? -1
|
|
4254
|
-
if (wsIdx !== -1) {
|
|
4255
|
-
const ws = progress.stages[currentStage].steps[wsIdx]
|
|
4256
|
-
console.log(`⏸️ Step ${wsIdx + 1} 等待用户输入:${ws.name}`)
|
|
4257
|
-
if (ws.waitReason) console.log(` 原因:${ws.waitReason}`)
|
|
4258
|
-
console.log(` 继续:sillyspec run auto --continue --answer "..."`)
|
|
4259
|
-
return
|
|
4260
|
-
}
|
|
4261
|
-
const next = nextInFlow(currentStage)
|
|
4262
|
-
if (next) console.log(`${currentStage} is complete. Run: sillyspec run auto --done --output "${currentStage} complete"`)
|
|
4263
|
-
else console.log('All auto flow stages are complete.')
|
|
4264
|
-
return
|
|
4265
|
-
}
|
|
4266
|
-
// execute 阶段启动前检查审批
|
|
4267
|
-
if (currentStage === 'execute' && !skipApproval) {
|
|
4268
|
-
const approval = await checkApproval(cwd, changeName, platformOpts)
|
|
4269
|
-
if (approval) {
|
|
4270
|
-
if (approval.status === 'rejected') {
|
|
4271
|
-
console.error(`❌ 变更 ${changeName} 的执行已被拒绝:${approval.reason || '无原因'}`)
|
|
4272
|
-
process.exit(1)
|
|
4273
|
-
}
|
|
4274
|
-
if (approval.status === 'pending') {
|
|
4275
|
-
console.log(`⏳ 变更 ${changeName} 的执行审批待处理中...`)
|
|
4276
|
-
console.log(' 提示:使用 --skip-approval 跳过审批检查')
|
|
4277
|
-
}
|
|
4278
|
-
}
|
|
4279
|
-
}
|
|
4280
|
-
await outputStep(currentStage, pendingIdx, defSteps, cwd, changeName, progress.project || null, platformOpts)
|
|
4281
|
-
return
|
|
4282
|
-
}
|
|
4283
|
-
|
|
4284
|
-
if (!outputText) {
|
|
4285
|
-
console.error('auto --done requires --output')
|
|
4286
|
-
process.exit(1)
|
|
4287
|
-
}
|
|
4288
|
-
|
|
4289
|
-
const result = await completeStep(pm, progress, currentStage, cwd, outputText, inputText, { printNext: false, changeName, platformOpts })
|
|
4290
|
-
if (!result) return
|
|
4291
|
-
progress = await pm.read(cwd, changeName)
|
|
4292
|
-
|
|
4293
|
-
const nextPendingIdx = progress.stages[currentStage]?.steps?.findIndex(step => step.status === 'pending' || step.status === 'in-progress') ?? -1
|
|
4294
|
-
if (nextPendingIdx !== -1) {
|
|
4295
|
-
const defSteps = await getAutoSteps(currentStage)
|
|
4296
|
-
// execute 阶段启动前检查审批
|
|
4297
|
-
if (currentStage === 'execute' && !skipApproval) {
|
|
4298
|
-
const approval = await checkApproval(cwd, changeName, platformOpts)
|
|
4299
|
-
if (approval) {
|
|
4300
|
-
if (approval.status === 'rejected') {
|
|
4301
|
-
console.error(`❌ 变更 ${changeName} 的执行已被拒绝:${approval.reason || '无原因'}`)
|
|
4302
|
-
process.exit(1)
|
|
4303
|
-
}
|
|
4304
|
-
if (approval.status === 'pending') {
|
|
4305
|
-
console.log(`⏳ 变更 ${changeName} 的执行审批待处理中...`)
|
|
4306
|
-
console.log(' 提示:使用 --skip-approval 跳过审批检查')
|
|
4307
|
-
}
|
|
4308
|
-
}
|
|
4309
|
-
}
|
|
4310
|
-
await outputStep(currentStage, nextPendingIdx, defSteps, cwd, changeName, progress.project || null, platformOpts)
|
|
4311
|
-
return
|
|
4312
|
-
}
|
|
4313
|
-
|
|
4314
|
-
const next = nextInFlow(currentStage)
|
|
4315
|
-
if (!next) {
|
|
4316
|
-
console.log('\nAll auto flow stages are complete.')
|
|
4317
|
-
return
|
|
4318
|
-
}
|
|
4319
|
-
|
|
4320
|
-
// ── next-action.json 驱动:brainstorm → plan 推进判断 ──
|
|
4321
|
-
if (currentStage === 'brainstorm' && next === 'plan') {
|
|
4322
|
-
const changeDir = resolveChangeDir(cwd, progress, platformOpts?.specRoot || null)
|
|
4323
|
-
if (changeDir) {
|
|
4324
|
-
const nextActionFile = join(changeDir, 'brainstorm', 'next-action.json')
|
|
4325
|
-
try {
|
|
4326
|
-
const nextAction = JSON.parse(readFileSync(nextActionFile, 'utf8'))
|
|
4327
|
-
if (nextAction.has_blocking_questions === true) {
|
|
4328
|
-
console.log(`\n⏸️ brainstorm 有阻塞问题,无法自动进入 plan:`)
|
|
4329
|
-
for (const q of (nextAction.questions || [])) {
|
|
4330
|
-
console.log(` Q-${q.id}: ${q.question}`)
|
|
4331
|
-
if (q.options) console.log(` 选项:${q.options.join(' / ')}`)
|
|
4332
|
-
if (q.recommended) console.log(` 推荐:${q.recommended}`)
|
|
4333
|
-
}
|
|
4334
|
-
console.log(`\n 请回答阻塞问题后继续:sillyspec run auto --done --output "已回答"`)
|
|
4335
|
-
return
|
|
4336
|
-
}
|
|
4337
|
-
console.log(`\n✅ next-action.json: ${nextAction.status},自动进入 plan`)
|
|
4338
|
-
} catch (e) {
|
|
4339
|
-
// next-action.json 不存在或格式错误,继续推进(向后兼容)
|
|
4340
|
-
console.log(`\n⚠️ next-action.json 未找到,继续进入 plan`)
|
|
4341
|
-
}
|
|
4342
|
-
}
|
|
4343
|
-
}
|
|
4344
|
-
|
|
4345
|
-
progress.currentStage = next
|
|
4346
|
-
if (!progress.stages[next]) {
|
|
4347
|
-
progress.stages[next] = { status: 'pending', steps: [], startedAt: null, completedAt: null }
|
|
4348
|
-
}
|
|
4349
|
-
if (progress.stages[next].status === 'pending' || !progress.stages[next].status) {
|
|
4350
|
-
progress.stages[next].status = 'in-progress'
|
|
4351
|
-
progress.stages[next].startedAt = new Date().toLocaleString('zh-CN',{hour12:false})
|
|
4352
|
-
}
|
|
4353
|
-
progress.lastActive = new Date().toLocaleString('zh-CN',{hour12:false})
|
|
4354
|
-
await ensureStageSteps(progress, next, cwd)
|
|
4355
|
-
await pm._write(cwd, progress, changeName)
|
|
4356
|
-
triggerSync(cwd, changeName, platformOpts)
|
|
4357
|
-
progress = await pm.read(cwd, changeName)
|
|
4358
|
-
|
|
4359
|
-
console.log(`\n${currentStage} complete. Auto advanced to ${next}.`)
|
|
4360
|
-
const nextSteps = await getAutoSteps(next)
|
|
4361
|
-
const firstPending = progress.stages[next]?.steps?.findIndex(step => step.status === 'pending' || step.status === 'in-progress') ?? -1
|
|
4362
|
-
if (firstPending !== -1) {
|
|
4363
|
-
// execute 阶段启动前检查审批
|
|
4364
|
-
if (next === 'execute' && !skipApproval) {
|
|
4365
|
-
const approval = await checkApproval(cwd, changeName, platformOpts)
|
|
4366
|
-
if (approval) {
|
|
4367
|
-
if (approval.status === 'rejected') {
|
|
4368
|
-
console.error(`❌ 变更 ${changeName} 的执行已被拒绝:${approval.reason || '无原因'}`)
|
|
4369
|
-
process.exit(1)
|
|
4370
|
-
}
|
|
4371
|
-
if (approval.status === 'pending') {
|
|
4372
|
-
console.log(`⏳ 变更 ${changeName} 的执行审批待处理中...`)
|
|
4373
|
-
console.log(' 提示:使用 --skip-approval 跳过审批检查')
|
|
4374
|
-
}
|
|
4375
|
-
}
|
|
4376
|
-
}
|
|
4377
|
-
await outputStep(next, firstPending, nextSteps, cwd, changeName, progress.project || null, platformOpts)
|
|
4378
|
-
}
|
|
4379
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* sillyspec run 命令实现(W6 Step8 后退化为 barrel)。
|
|
3
|
+
*
|
|
4
|
+
* 实际逻辑在 src/run/*.js 叶子模块(shared / prompt / quick-audit / scan-profile /
|
|
5
|
+
* gates / complete-handlers / complete / stage / command)。run.js 仅作 barrel:
|
|
6
|
+
* 外部 import 契约通过 re-export 保留(run.js 是 index.js 懒加载入口,零入边叶子,
|
|
7
|
+
* 外部 import 零感知)。
|
|
8
|
+
*
|
|
9
|
+
* test 直接 import 的契约(9 个):runCommand / ensureStageSteps /
|
|
10
|
+
* _completeStepForTest / _outputStepForTest / parsePorcelainPath / auditQuickCompletion /
|
|
11
|
+
* applyRootPlaceholders / sanitizeProjectName / validateParsedProjects。
|
|
12
|
+
*/
|
|
13
|
+
// W6 Step8c: 主命令分发(runCommand)+ auto 模式 + ensureStageSteps 搬至 ./run/command.js
|
|
14
|
+
export { runCommand, ensureStageSteps } from './run/command.js'
|
|
15
|
+
// barrel re-export: parsePorcelainPath + auditQuickCompletion 被 test 直接 import(契约保留)
|
|
16
|
+
export { parsePorcelainPath, auditQuickCompletion } from './run/shared.js'
|
|
17
|
+
// barrel re-export: applyRootPlaceholders 被 test/prompt-placeholders.test.mjs 直接 import(契约保留)
|
|
18
|
+
export { applyRootPlaceholders } from './run/prompt.js'
|
|
19
|
+
// barrel re-export: sanitizeProjectName + validateParsedProjects 被 test 直接 import(随 handleScan 搬走,契约保留)
|
|
20
|
+
export { sanitizeProjectName, validateParsedProjects } from './run/complete-handlers.js'
|
|
21
|
+
// 测试专用导出:completeStep 是 step 完成处理核心、outputStep 是 prompt 渲染器,characterization 测试直接驱动
|
|
22
|
+
export { completeStep as _completeStepForTest } from './run/complete.js'
|
|
23
|
+
export { outputStep as _outputStepForTest } from './run/prompt.js'
|