sillyspec 3.26.10 → 3.26.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/.claude/skills/sillyspec-archive/SKILL.md +85 -85
  2. package/.claude/skills/sillyspec-brainstorm/SKILL.md +114 -114
  3. package/.claude/skills/sillyspec-commit/SKILL.md +109 -109
  4. package/.claude/skills/sillyspec-continue/SKILL.md +45 -45
  5. package/.claude/skills/sillyspec-doctor/SKILL.md +76 -76
  6. package/.claude/skills/sillyspec-execute/SKILL.md +200 -200
  7. package/.claude/skills/sillyspec-explore/SKILL.md +95 -95
  8. package/.claude/skills/sillyspec-knowledge/SKILL.md +269 -269
  9. package/.claude/skills/sillyspec-plan/SKILL.md +148 -148
  10. package/.claude/skills/sillyspec-quick/SKILL.md +115 -111
  11. package/.claude/skills/sillyspec-resume/SKILL.md +68 -68
  12. package/.claude/skills/sillyspec-scan/SKILL.md +79 -79
  13. package/.claude/skills/sillyspec-state/SKILL.md +64 -64
  14. package/.claude/skills/sillyspec-status/SKILL.md +54 -54
  15. package/.claude/skills/sillyspec-verify/SKILL.md +83 -83
  16. package/.claude/skills/sillyspec-workspace/SKILL.md +169 -169
  17. package/.gitattributes +2 -0
  18. package/.husky/pre-push +20 -20
  19. package/README.md +203 -201
  20. package/SKILL.md +91 -90
  21. package/package.json +39 -39
  22. package/packages/dashboard/dist/assets/index-Bq_Z2hne.js +7446 -7446
  23. package/packages/dashboard/dist/assets/index-O2W5RV4z.css +1 -1
  24. package/packages/dashboard/dist/index.html +16 -16
  25. package/packages/dashboard/package.json +25 -25
  26. package/packages/dashboard/server/executor.js +95 -86
  27. package/packages/dashboard/server/index.js +663 -601
  28. package/packages/dashboard/server/parser.js +526 -526
  29. package/src/change-risk-profile.js +254 -254
  30. package/src/classify-change.js +109 -109
  31. package/src/config-schema.js +313 -310
  32. package/src/contract-matrix.js +388 -379
  33. package/src/db.js +370 -350
  34. package/src/dispatch/backends/local-agent.js +155 -155
  35. package/src/dispatch/backends/sillyhub-mcp.js +245 -245
  36. package/src/dispatch/probe.js +246 -210
  37. package/src/dispatch/strategy.js +121 -121
  38. package/src/docs-check.js +188 -16
  39. package/src/docs-debt.js +6 -2
  40. package/src/docs-gate.js +1 -0
  41. package/src/doctor-diagnostics.js +898 -711
  42. package/src/endpoint-extractor.js +401 -401
  43. package/src/fs-atomic.js +71 -69
  44. package/src/git-helper.js +82 -79
  45. package/src/hooks/claude-pre-tool-use.cjs +125 -125
  46. package/src/hooks/worktree-guard.js +854 -848
  47. package/src/index.js +251 -20
  48. package/src/init.js +624 -623
  49. package/src/knowledge-match.js +130 -130
  50. package/src/local-detect.js +128 -120
  51. package/src/machine-interface.js +543 -543
  52. package/src/migrate.js +127 -127
  53. package/src/modules.js +7 -5
  54. package/src/progress/change-registry.js +297 -260
  55. package/src/progress/consistency-doctor.js +355 -355
  56. package/src/progress/shared.js +36 -36
  57. package/src/progress/stage-machine.js +589 -550
  58. package/src/progress/step-store.js +261 -261
  59. package/src/progress.js +1285 -1127
  60. package/src/quicklog.js +724 -693
  61. package/src/review-tier.js +77 -77
  62. package/src/run/command.js +1408 -1364
  63. package/src/run/complete-handlers.js +1450 -1399
  64. package/src/run/complete.js +1181 -920
  65. package/src/run/gates.js +878 -755
  66. package/src/run/prompt.js +765 -761
  67. package/src/run/quick-audit.js +155 -146
  68. package/src/run/shared.js +1348 -1215
  69. package/src/run/stage.js +527 -479
  70. package/src/scan-postcheck.js +371 -371
  71. package/src/setup.js +414 -416
  72. package/src/sillyhub-mcp/client.js +597 -529
  73. package/src/spec-dir-typo.js +3 -24
  74. package/src/spec-sync.js +358 -319
  75. package/src/stage-contract.js +871 -897
  76. package/src/stage-review.js +549 -549
  77. package/src/stages/archive.js +156 -155
  78. package/src/stages/brainstorm-auto.js +236 -236
  79. package/src/stages/brainstorm.js +597 -597
  80. package/src/stages/cmd-existence.js +145 -145
  81. package/src/stages/doctor.js +386 -371
  82. package/src/stages/execute.js +1014 -1003
  83. package/src/stages/explore.js +34 -34
  84. package/src/stages/index.js +29 -29
  85. package/src/stages/knowledge.js +498 -498
  86. package/src/stages/plan-postcheck.js +1323 -1302
  87. package/src/stages/plan.js +642 -623
  88. package/src/stages/quick.js +153 -151
  89. package/src/stages/scan.js +607 -607
  90. package/src/stages/status.js +64 -64
  91. package/src/stages/verify.js +264 -264
  92. package/src/sync.js +1325 -1135
  93. package/src/task-review.js +1204 -1019
  94. package/src/taskcard.js +178 -0
  95. package/src/verify-postcheck.js +1199 -1186
  96. package/src/workflow.js +600 -600
  97. package/src/worktree-apply.js +1237 -1136
  98. package/src/worktree-deps.js +453 -353
  99. package/src/worktree.js +1527 -1481
  100. package/templates/workflows/archive-impact.yaml +79 -79
  101. package/templates/workflows/scan-docs.yaml +132 -132
package/src/run/stage.js CHANGED
@@ -1,479 +1,527 @@
1
- /**
2
- * run/stage.js(W6 Step8b 从 run.js 抽出)。
3
- *
4
- * `run <stage>` 默认路径的执行主干(runStage)+ 其私有 helper:
5
- * - runStage:输出当前 step prompt(重 execute/scan/quick 启动期副作用——worktree 创建、
6
- * executeRunId 固定、scanProfile 裁剪、quick baseline 录入、plan design 契约校验、noAI 步骤代理)
7
- * - autoDetectChange:唯一变更目录时自动设置 currentChange(runStage 私有)
8
- * - executePlanPostcheck:noAI planPostcheck 步骤代理(runStage 私有)
9
- * - ensureDepsFreshness:execute 入口已存在 worktree 时 deps 自检重供给(runStage 私有)
10
- *
11
- * 安全锚:run.js 始终 barrel。runStage 由 run.js import 回来(无 test 直接 import,无需 re-export)。
12
- * checkApproval(runStage + runAutoMode 共用)已先下沉 shared.js(Step8a),避免 command/stage 环依赖。
13
- *
14
- * 路径修正(相对 src/run/):
15
- * - 动态 import './worktree.js' / './task-review.js' / './stages/plan.js' /
16
- * './stages/plan-postcheck.js' / './worktree-deps.js' → '../'(src/ 下退一层)
17
- * - 'child_process'(execSync)裸模块名不变
18
- */
19
- import { join, dirname } from 'node:path'
20
- import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'
21
- import { writeAtomicSync } from '../fs-atomic.js'
22
- import { resolveSpecDir, resolveChangeDir, resolveRuntimeRoot, resolveQuickSessionsDir, triggerSync, safeGit, parsePorcelainPath, formatWaitOptions, checkApproval, getStageSteps } from './shared.js'
23
- import { computeScanProfile, applyScanProfileSteps, executeScanPreflight, executeScanPostcheck } from './scan-profile.js'
24
- import { outputStep } from './prompt.js'
25
- import { allocateQuicklogEntry, deriveTitleFromLinkedChange, sanitizeDesc } from '../quicklog.js'
26
- import { createHash } from 'node:crypto'
27
- import { checkTransition } from '../stage-contract.js'
28
- import { AUXILIARY_STAGES } from '../constants.js'
29
- import { completeStageGates } from './gates.js'
30
-
31
- export async function runStage(pm, progress, stageName, cwd, changeName, skipApproval = false, platformOpts = {}, quickOpts = {}) {
32
- const specBase = platformOpts.specRoot || join(cwd, '.sillyspec')
33
- // 状态转换校验
34
- const prevStage = progress.currentStage || ''
35
- // task-07: 提取 prevStage 的 stageData,传给 checkTransition 检测 failed_post_check 门控
36
- const fromStageData = (progress.stages && prevStage && progress.stages[prevStage]) || undefined
37
- const transition = checkTransition(prevStage, stageName, fromStageData ? { fromStageData } : {})
38
- if (!transition.allowed) {
39
- console.error(`❌ 阶段转换不允许: ${prevStage || '(起始)'} → ${stageName}`)
40
- console.error(` 原因: ${transition.reason}`)
41
- console.error(` 提示: 使用 --skip-approval 绕过(需明确意图)`)
42
- if (!skipApproval) {
43
- process.exit(1)
44
- }
45
- }
46
-
47
- // execute 阶段启动前检查审批
48
- if (stageName === 'execute' && !skipApproval) {
49
- const approval = await checkApproval(cwd, changeName, platformOpts)
50
- if (approval) {
51
- if (approval.status === 'rejected') {
52
- console.error(`❌ 变更 ${changeName} 的执行已被拒绝:${approval.reason || '无原因'}`)
53
- process.exit(1)
54
- }
55
- if (approval.status === 'pending') {
56
- console.log(`⏳ 变更 ${changeName} 的执行审批待处理中...`)
57
- console.log(' 提示:使用 --skip-approval 跳过审批检查')
58
- }
59
- }
60
- }
61
-
62
- // execute 阶段:CLI 自动创建 worktree(不等 AI agent)
63
- if (stageName === 'execute' && changeName) {
64
- const effectiveChange = changeName
65
- const { WorktreeManager } = await import('../worktree.js')
66
- const wm = new WorktreeManager({ cwd })
67
- const existingMeta = wm.getMeta(effectiveChange)
68
- if (existingMeta) {
69
- console.log(`🔗 worktree 已存在: ${existingMeta.worktreePath} (${existingMeta.mode})`)
70
- } else {
71
- try {
72
- const result = wm.create(effectiveChange)
73
- console.log(`🔗 worktree 已创建: ${result.worktreePath} (分支: ${result.branch}, 模式: ${result.mode})`)
74
- } catch (e) {
75
- console.error(`❌ worktree 创建失败: ${e.message}`)
76
- console.error(` 修复建议:`)
77
- console.error(` 1. 运行 sillyspec worktree doctor --fix 检查并修复 worktree 状态`)
78
- console.error(` 2. 或手动清理残留:git worktree prune && git branch -D sillyspec/${effectiveChange}`)
79
- console.error(` 3. 必要时删除残留目录 .sillyspec/.runtime/worktrees/${effectiveChange}/ 后重试`)
80
- process.exit(1)
81
- }
82
- }
83
- // 入口 deps 自检(D-002):已存在 worktree(create short-circuit 不供给)时重供给
84
- if (existingMeta) {
85
- const execSpecBase = platformOpts?.specRoot || join(cwd, '.sillyspec')
86
- await ensureDepsFreshness(cwd, effectiveChange, execSpecBase, existingMeta)
87
- }
88
- }
89
-
90
- // ── execute 阶段启动时固定 executeRunId(绑定变更名,避免跨变更复用) ──
91
- let currentExecuteRunId = null
92
- if (stageName === 'execute') {
93
- const { generateExecuteRunId, isValidExecuteRunId } = await import('../task-review.js')
94
- const execSpecBase = platformOpts?.specRoot || join(cwd, '.sillyspec')
95
- const runtimeRoot = resolveRuntimeRoot(platformOpts, execSpecBase)
96
- const runIdFile = join(runtimeRoot, `current-execute-run-id-${changeName}`)
97
- mkdirSync(runtimeRoot, { recursive: true })
98
- // 优先读取已有的变更专属标记文件(agent 可写内容,格式校验防注入/穿越,非法视为缺失重生成)
99
- try {
100
- if (existsSync(runIdFile)) {
101
- const c = readFileSync(runIdFile, 'utf8').trim()
102
- if (c && !isValidExecuteRunId(c)) {
103
- console.warn(`⚠️ execute run marker 内容非法(期望 exec-YYYY-MM-DD-HHMMSS,实得 ${JSON.stringify(c.slice(0, 60))}),重新生成`)
104
- } else {
105
- currentExecuteRunId = c
106
- }
107
- }
108
- } catch {}
109
- if (!currentExecuteRunId) {
110
- currentExecuteRunId = generateExecuteRunId()
111
- // D-001#1 主写入点:mkdir execute-runs/<runId>/tasks 先于 marker(不变量:marker 在则目录在,
112
- // archive 完成度扫描/漂移兜底不再落到「有 marker 无目录」的空 run)。失败直接 throw——execute
113
- // 启动即失败优于事后 review 错配(调用方 runCommand 冒到 CLI 顶层 exit 1,给出修复指引)。
114
- try {
115
- mkdirSync(join(runtimeRoot, 'execute-runs', currentExecuteRunId, 'tasks'), { recursive: true })
116
- } catch (e) {
117
- throw new Error(`execute run 目录创建失败(${join(runtimeRoot, 'execute-runs', currentExecuteRunId)}): ${e.message};` +
118
- `请检查该路径是否为普通文件/只读,清理后重跑(sillyspec run execute --change ${changeName} --skip-approval)`)
119
- }
120
- writeFileSync(runIdFile, currentExecuteRunId + '\n')
121
- }
122
- }
123
-
124
- // 自动探测 currentChange
125
- if (autoDetectChange(progress, cwd)) {
126
- progress.lastActive = new Date().toLocaleString('zh-CN', { hour12: false })
127
- pm._write(cwd, progress, changeName)
128
- triggerSync(cwd, changeName, platformOpts)
129
- }
130
-
131
- const stageData = progress.stages[stageName]
132
- if (!stageData || !stageData.steps) {
133
- console.error(`❌ 阶段 ${stageName} 未初始化`)
134
- process.exit(1)
135
- }
136
-
137
- // 用户显式调用 sillyspec run <stage>:把它标记为当前阶段
138
- // (D-003@v1:auxiliary 阶段不写 currentStage,避免 scan/quick/explore/archive/status/doctor
139
- // 执行后污染主流程当前阶段;lastActive 心跳与 pm._write/triggerSync 照常)
140
- if (progress.currentStage !== stageName) {
141
- if (!AUXILIARY_STAGES.includes(stageName)) {
142
- progress.currentStage = stageName
143
- }
144
- progress.lastActive = new Date().toLocaleString('zh-CN',{hour12:false})
145
- pm._write(cwd, progress, changeName)
146
- triggerSync(cwd, changeName, platformOpts)
147
- }
148
-
149
- const steps = stageData.steps
150
- // ── 检查是否有 waiting step 需要先处理 ──
151
- const waitingIdx = steps.findIndex(s => s.status === 'waiting')
152
- if (waitingIdx !== -1) {
153
- const ws = steps[waitingIdx]
154
- console.error(`\n⏸️ Step ${waitingIdx + 1}/${steps.length} 正在等待用户输入:${ws.name}`)
155
- if (ws.waitReason) console.error(` 原因:${ws.waitReason}`)
156
- if (ws.waitOptions) console.error(` 选项:${formatWaitOptions(ws.waitOptions)}`)
157
- console.error(`\n 普通运行无法跳过等待中的步骤。请先处理:`)
158
- console.error(` sillyspec run ${stageName} --continue --answer "你的选择"${changeName ? ` --change ${changeName}` : ''}`)
159
- process.exit(1)
160
- }
161
-
162
- let currentIdx = steps.findIndex(s => s.status !== 'completed' && s.status !== 'skipped')
163
-
164
- // stale 步骤视为可执行(等同于 pending)
165
- if (currentIdx !== -1 && steps[currentIdx].status === 'stale') {
166
- steps[currentIdx].status = 'pending'
167
- pm._write(cwd, progress, changeName)
168
- triggerSync(cwd, changeName, platformOpts)
169
- }
170
-
171
- // ── scanProfile: 根据 project 规模动态裁剪步骤 ──
172
- let scanProfile = null
173
- if (stageName === 'scan' && steps.length > 0 && currentIdx === 0) {
174
- scanProfile = computeScanProfile(cwd, platformOpts)
175
- console.log(`\n📊 Scan Profile: ${scanProfile.mode} (原因: ${scanProfile.reason})`)
176
- if (scanProfile.mode !== 'deep') {
177
- applyScanProfileSteps(stageData, scanProfile, cwd, platformOpts)
178
- // 步骤被裁剪后 currentIdx 需要重新计算
179
- currentIdx = 0
180
- }
181
- // 保存 profile 供后续 postcheck 使用
182
- stageData.scanProfile = scanProfile
183
- pm._write(cwd, progress, changeName)
184
- } else if (stageName === 'scan' && stageData.scanProfile) {
185
- scanProfile = stageData.scanProfile
186
- }
187
- if (scanProfile) platformOpts.scanProfile = scanProfile
188
-
189
- if (stageName === 'scan') {
190
- try {
191
- const gitResult = safeGit(cwd, ['rev-parse', 'HEAD'])
192
- const scanGuard = {
193
- name_zh: '扫描守卫',
194
- sourceCommit: gitResult.value,
195
- sourceCommitError: gitResult.error,
196
- startedAt: new Date().toISOString(),
197
- forceRescan: quickOpts?.isForceRescan || false,
198
- }
199
- const guardFile = join(specBase, '.runtime', 'scan-guard.json')
200
- mkdirSync(dirname(guardFile), { recursive: true })
201
- writeFileSync(guardFile, JSON.stringify(scanGuard, null, 2) + '\n')
202
- if (scanGuard.forceRescan) {
203
- console.log('🛡️ scan 覆盖保护已记录: --force-rescan 已开启')
204
- } else {
205
- console.log('🛡️ scan 覆盖保护已记录: existing scan docs require current source_commit/updated_at')
206
- }
207
- } catch (e) {
208
- console.warn(`⚠️ scan 覆盖保护记录失败: ${e.message}`)
209
- }
210
- }
211
-
212
- if (currentIdx === -1) {
213
- // 所有步骤已 completed/skipped,但阶段未盖到 completed —— 这是上次最后一步完成后、
214
- // stage 升级事务未提交的崩溃中间态(正常完成会被上方 stageStatus==='completed' 守卫拦下)。
215
- // 旧逻辑无条件清空 steps 为 pending 会丢掉已完成的进度且不可恢复。改为走正式
216
- // completeStage:产物齐则补盖完成戳,不齐则给出 actionable 提示,永不静默清空步骤。
217
- console.log(`\nℹ️ ${stageName} 所有步骤已完成,但阶段未标记完成(上次可能中断)。尝试补盖完成戳…`)
218
- pm.completeStage(cwd, stageName, changeName)
219
- const after = pm.read(cwd, changeName)
220
- if (after?.stages?.[stageName]?.status === 'completed') {
221
- console.log(` ✅ 已补盖完成戳。下一步: sillyspec run <下一阶段>,或 sillyspec run ${stageName} --status 查看。`)
222
- process.exit(0)
223
- }
224
- // completeStage 已打印产物校验失败的明细;这里只补充恢复指引
225
- console.error(`\n ⚠️ 已保留步骤进度(未清空)。修复产物后重跑,或:`)
226
- console.error(` - 重新开始: sillyspec run ${stageName} --reset`)
227
- console.error(` - 强制补盖: sillyspec progress complete-stage ${stageName}${changeName ? ` --change ${changeName}` : ''} --force`)
228
- process.exit(1)
229
- }
230
-
231
- // quick 阶段:记录 baselineFiles + 分配 ql-ID(CLI 接管 QUICKLOG 写入)
232
- // 幂等判据是 session guard.json 文件(跨进程可靠),不是 progress.quickGuard
233
- // (D-003@v1:顶层 quickGuard 不跨进程持久化;agent 在 step 间用 `run quick` 取下一步
234
- // prompt 时每个新进程都进此块,按文件判幂等才不会重复分配 ql-ID / 重复写条目)。
235
- if (stageName === 'quick') {
236
- // quick-sessions 目录经 resolveQuickSessionsDir 单一解析(multi-agent-review Q4):
237
- // 平台模式 runtimeRoot 与 specBase/.runtime 不同时,写/读须对齐,否则 guard 写一处、收尾读另一处。
238
- const sessionGuardDir = join(resolveQuickSessionsDir(platformOpts, specBase), changeName)
239
- const guardFile = join(sessionGuardDir, 'guard.json')
240
- let existingGuard = null
241
- try {
242
- if (existsSync(guardFile)) existingGuard = JSON.parse(readFileSync(guardFile, 'utf8'))
243
- } catch {}
244
- if (existingGuard) {
245
- // 跨进程重入:复用已分配的 ql-ID,跳过 baseline 重捕与分配(幂等)
246
- progress.quickGuard = existingGuard
247
- } else {
248
- // baseline 采集用 safeGit(带 -c safe.directory,避免 linked worktree/容器异 uid/Windows 挂载点
249
- // 下裸 `git status` 抛错)。safeGit 已消除 safe.directory 类失败;若仍失败(真非 git 目录等),
250
- // 不硬阻断 quick 启动(平台模式/非 git 目录仍需渲染 step prompt),但 fail-visible:大声 warn +
251
- // baseline 置空。--done 时 auditQuickCompletion 的 safeGit 同样失败 → blocked,故不存在「静默
252
- // 完成」路径(multi-agent-review Q3)。
253
- const statusResult = safeGit(cwd, ['status', '--porcelain'], { trim: false })
254
- if (statusResult.error) {
255
- console.warn(`⚠️ quick baseline 采集失败(git status): ${statusResult.error}`)
256
- console.warn(` baseline 置空;--done 审计将因 git 不可用而阻断(无静默完成路径)。`)
257
- console.warn(` 排查:仓库 safe.directory 配置 / 非 git 目录。`)
258
- }
259
- try {
260
- const gitStatus = statusResult.value || ''
261
- // 记录全部预存脏文件(含 untracked + .sillyspec/ 路径)。quick 会话期间自身写入的元数据
262
- // (quicklog/.runtime/modules/_module-map 等)由 auditQuickCompletion 的 isQuickMetadata 精确豁免,
263
- // 不需要这里粗放过滤 .sillyspec/——旧过滤致预存 untracked .sillyspec/changes/ 不进 baseline,
264
- // 却在 audit 被当「危险(.sillyspec/)+新增」误判永久 blocked(ql-20260713-002-7628 修复)。
265
- const baselineFiles = gitStatus
266
- .split('\n').filter(Boolean)
267
- .map(line => parsePorcelainPath(line))
268
- .filter(Boolean)
269
- const allowedFiles = quickOpts?.quickFiles || []
270
- const allowNew = quickOpts?.isAllowNew || false
271
- const allowDelete = quickOpts?.isAllowDelete || false
272
- const forceBaseline = quickOpts?.isForceBaseline || false
273
- const linkedChanges = Array.isArray(quickOpts?.linkedChanges) ? quickOpts.linkedChanges : []
274
- // CLI 接管:分配 ql-ID + 写 QUICKLOG「进行中」条目 + 关联 tasks.md(持锁、当天唯一)
275
- const gitUser = safeGit(cwd, ['config', 'user.name']).value || 'unknown'
276
- // 标题回退:启动 quick 不带 --input 时,从关联变更的 proposal/design 提取语义标题,
277
- // 避免 QUICKLOG/tasks.md 落 (quick 任务) 占位(deriveTitleFromLinkedChange 读不到则空→sanitizeDesc 回退占位)。
278
- let quickDesc = quickOpts?.taskDescription || ''
279
- if (!quickDesc && linkedChanges.length > 0) {
280
- quickDesc = deriveTitleFromLinkedChange(specBase, linkedChanges[0])
281
- }
282
- const { qlId } = await allocateQuicklogEntry(specBase, gitUser, {
283
- description: quickDesc,
284
- linkedChanges,
285
- allowedFiles,
286
- })
287
- progress.quickGuard = {
288
- sessionId: changeName,
289
- // 锚定创建时的 specBase(坑 quick-cwd-drift-splits-specdir):sessionId 全局复用但
290
- // guard 按 specDir 落盘,记下归属让漂移可自证(detectQuickSessionDrift 靠目录定位即可,
291
- // 此字段供诊断/未来一致性校验)。
292
- specDir: specBase,
293
- name_zh: '快速任务守卫',
294
- baselineCommit: safeGit(cwd, ['rev-parse', 'HEAD']).value,
295
- baselineFiles,
296
- allowedFiles,
297
- // task-01: 录每个 allowedFile 内容 sha256,供 --done auditQuickCompletion 检测同文件并发
298
- // (allowedFile 在 baseline 且当前 hash ≠ 此值 = 我也改了 → 同文件并发 warn)。文件不存在/读失败跳过。
299
- allowedFilesHash: Object.fromEntries(allowedFiles.flatMap(f => {
300
- try { return [[f, createHash('sha256').update(readFileSync(join(cwd, f))).digest('hex')]] }
301
- catch { return [] }
302
- })),
303
- allowNew,
304
- allowDelete,
305
- forceBaseline,
306
- linkedChanges,
307
- quicklogId: qlId,
308
- startedAt: new Date().toISOString(),
309
- }
310
- // 写入 .runtime/quick-sessions/<sessionId>/guard.json 供 worktree-guard hook 读取
311
- // (D-002:按 session 存,多会话各自 guard 不互覆盖。runStage 作用域内 sessionId == changeName == quick-<uuid8>,见 §4.4/4.5)
312
- mkdirSync(sessionGuardDir, { recursive: true })
313
- writeAtomicSync(guardFile, JSON.stringify(progress.quickGuard, null, 2))
314
- const parts = [`${baselineFiles.length} 个已有脏文件`]
315
- if (allowedFiles.length > 0) parts.push(`${allowedFiles.length} 个 allowedFiles`)
316
- if (allowNew) parts.push('允许新增文件')
317
- if (allowDelete) parts.push('允许删除文件')
318
- console.log(`🛡️ quick 变更边界已记录: ${parts.join(', ')}`)
319
- console.log(`📝 QUICKLOG 条目已创建: ${qlId}`)
320
- // 回填 DB changes 行的 title + quicklog_id,让 quick-<hex> 可读、DB↔QUICKLOG 可对账。
321
- // title 用 quickDesc(任务描述或关联变更标题)经 sanitizeDesc 压一行限长,与 QUICKLOG 条目标题同源。
322
- try {
323
- pm.updateChangeMeta(cwd, changeName, { title: sanitizeDesc(quickDesc), quicklogId: qlId });
324
- } catch { /* 回填失败不阻断 quick 启动 */ }
325
- pm._write(cwd, progress, changeName)
326
- } catch (e) {
327
- console.warn(`⚠️ baseline 记录失败: ${e.message}`)
328
- }
329
- }
330
- }
331
-
332
- if (currentIdx > 0) {
333
- const completed = currentIdx
334
- const total = steps.length
335
- console.log(`⚠️ ${stageName} 已进行到第 ${currentIdx + 1}/${total} 步(前 ${completed} 步已完成)。`)
336
- console.log(` 继续执行将从中断处恢复,用 --reset 可重新开始。\n`)
337
- }
338
-
339
- // ── Brainstorm → Plan Contract:plan 启动前校验 design.md ──
340
- if (stageName === 'plan' && currentIdx === 0) {
341
- const changeDir = resolveChangeDir(cwd, progress, platformOpts?.specRoot || null)
342
- const designPath = changeDir ? join(changeDir, 'design.md') : null
343
- if (designPath && existsSync(designPath)) {
344
- const { validateDesignForPlan } = await import('../stages/plan.js')
345
- const designContent = readFileSync(designPath, 'utf8')
346
- const designValidation = validateDesignForPlan(designContent)
347
- if (!designValidation.ok) {
348
- console.error(`\n❌ Brainstorm → Plan Contract 校验失败:`)
349
- for (const err of designValidation.errors) console.error(` - ${err}`)
350
- console.error(`\n design.md 不满足 plan 契约,请先修复后重试。`)
351
- console.error(` 提示:sillyspec run brainstorm --reopen --from-step <步骤> 修订设计文档`)
352
- process.exit(1)
353
- }
354
- if (designValidation.warnings.length > 0) {
355
- console.log(`⚠️ Design contract 警告(不阻断):`)
356
- for (const w of designValidation.warnings) console.log(` - ${w}`)
357
- console.log()
358
- }
359
- }
360
- }
361
-
362
- const defSteps = await getStageSteps(stageName, cwd, progress, platformOpts?.specRoot || null)
363
- if (defSteps && defSteps[currentIdx]) {
364
- // noAI 步骤自动完成(CLI-only,不需要 Agent 参与)
365
- if (defSteps[currentIdx].noAI || stageData.steps[currentIdx]?.noAI) {
366
- const stepName = defSteps[currentIdx].name
367
- const cliAction = defSteps[currentIdx]._cliAction || stageData.steps[currentIdx]?._cliAction
368
- console.log(`⚙️ Step ${currentIdx + 1}/${stageData.steps.length}: ${stepName}(CLI 自动执行,无需 Agent)`)
369
- if (cliAction === 'scanPreflight') {
370
- await executeScanPreflight(cwd, platformOpts, scanProfile)
371
- } else if (cliAction === 'scanPostcheck') {
372
- await executeScanPostcheck(cwd, platformOpts, scanProfile)
373
- } else if (cliAction === 'planPostcheck') {
374
- await executePlanPostcheck(cwd, platformOpts, progress)
375
- }
376
- stageData.steps[currentIdx].status = 'completed'
377
- stageData.steps[currentIdx].completedAt = new Date().toLocaleString('zh-CN', { hour12: false })
378
- pm._write(cwd, progress, changeName)
379
- // 自动前进到下一步
380
- const nextIdx = stageData.steps.findIndex(s => s.status === 'pending' || s.status === 'in-progress')
381
- if (nextIdx !== -1 && defSteps[nextIdx]) {
382
- console.log('')
383
- await outputStep(stageName, nextIdx, defSteps, cwd, changeName, progress.project || null, platformOpts)
384
- } else {
385
- // 所有步骤完成
386
- stageData.status = 'completed'
387
- stageData.completedAt = new Date().toLocaleString('zh-CN', { hour12: false })
388
- // persist _write 移到 completeStageGates 成功之后(task-02 / review-2026-08-09 #2):gate 异常/失败 → rollback 回 in-progress 落盘,此处未到 _write,DB 不留假 completed。
389
- // 阶段完成收尾共享管线(noAI 末步核心修复 S1:plan postcheck independent-tier review verdict=fail /
390
- // 平台 scan manifest 此前被绕过)。gate 失败已 rollback 为 in-progress,early-return(不 fall through 到末尾 return)。
391
- const _stageGatesResult = await completeStageGates({ stageName, cwd, changeName, platformOpts, specBase, progress, pm, stageData, steps, currentIdx, outputText: null })
392
- // task-04 / A5:gate 失败(stageCompleted===false)设进程退出码 1(与 completeStep/continueStep 同语义)。
393
- if (_stageGatesResult?.stageCompleted === false) process.exitCode = 1
394
- if (_stageGatesResult) return _stageGatesResult
395
- // gate 全过:persist completed(task-02 移后;此处无 triggerSync,与同文件 noAI 末步语义一致)。
396
- pm._write(cwd, progress, changeName)
397
- console.log(`\n✅ ${stageName} 阶段全部完成。`)
398
- }
399
- return
400
- }
401
- await outputStep(stageName, currentIdx, defSteps, cwd, changeName, progress.project || null, platformOpts)
402
- }
403
- }
404
-
405
-
406
-
407
-
408
-
409
-
410
- /**
411
- * 自动探测并设置 currentChange(唯一变更目录时)
412
- * @returns {boolean} 是否设置了 currentChange
413
- */
414
- function autoDetectChange(progress, cwd, specDir = null) {
415
- if (progress.currentChange) return false
416
- const changesDir = join(specDir || resolveSpecDir(cwd), 'changes')
417
- if (!existsSync(changesDir)) return false
418
- const entries = readdirSync(changesDir, { withFileTypes: true })
419
- .filter(e => e.isDirectory() && e.name !== 'archive')
420
- if (entries.length === 1) {
421
- progress.currentChange = entries[0].name
422
- return true
423
- }
424
- return false
425
- }
426
-
427
-
428
- /**
429
- * Plan postcheck 的执行代理:委托给 plan-postcheck.js 模块
430
- */
431
- async function executePlanPostcheck(cwd, platformOpts, progress) {
432
- // resolveChangeDir 从 ./shared.js 导入(W6 Step1 抽出的纯函数,原 run.js 本地函数);历史上曾误从 ./modules.js
433
- // 导入(该模块未导出此函数,得到 undefined),导致 plan-postcheck.js:388 抛
434
- // "resolveChangeDir is not a function"。详见 docs/sillyspec/plan-postcheck-resolvechangedir-not-a-function.md
435
- const { executePlanPostcheck: runPostcheck } = await import('../stages/plan-postcheck.js')
436
- await runPostcheck({
437
- cwd,
438
- specRoot: platformOpts?.specRoot,
439
- resolveChangeDir,
440
- progress
441
- })
442
- }
443
-
444
-
445
-
446
- /**
447
- * execute 入口 deps 自检(D-002,change 2026-06-28-worktree-deps-provision)。
448
- * 已存在 worktree(create short-circuit 不供给)时,校验 deps 状态缺失/漂移 → 触发 provisionDeps 重供给并写回 meta。
449
- *
450
- * 判定改调共享 checkDepsFreshness(H1,change 2026-08-05-tooling-feedback-fixes task-04):
451
- * - status ∈ {missing, stale, main-drift, failed} → 触发重供给(main-drift 为新增主仓 lockfile 漂移触发)
452
- * - status === fresh → 跳过
453
- * 行为与原内联 noStatus/missing/stale 三判定等价(provisionDeps 调用 / meta 写回不变),新增 main-drift 触发。
454
- */
455
- async function ensureDepsFreshness(cwd, changeName, specBase, worktreeMeta) {
456
- if (!worktreeMeta || !worktreeMeta.worktreePath) return
457
- const { provisionDeps, checkDepsFreshness } = await import('../worktree-deps.js')
458
- const wtPath = worktreeMeta.worktreePath
459
- const fresh = checkDepsFreshness(worktreeMeta, wtPath, cwd)
460
- if (fresh.status === 'fresh') return
461
- const reason = fresh.detail || fresh.status
462
- console.log(`🔄 worktree deps 自检:${reason}(depsStatus=${worktreeMeta.depsStatus || 'unknown'}),重新供给...`)
463
- let deps = {}
464
- try {
465
- deps = provisionDeps(wtPath, cwd, { specBase }) || {}
466
- } catch (e) {
467
- deps = { depsStatus: 'failed', depsError: `provisionDeps crashed: ${e.message}` }
468
- }
469
- try {
470
- const { WorktreeManager } = await import('../worktree.js')
471
- const metaPath = join(new WorktreeManager({ cwd }).getWorktreePath(changeName), 'meta.json')
472
- const updated = { ...worktreeMeta, ...deps }
473
- writeAtomicSync(metaPath, JSON.stringify(updated, null, 2) + '\n')
474
- console.log(`✅ deps 重新供给完成:depsStatus=${deps.depsStatus}`)
475
- } catch (e) {
476
- console.warn(`⚠️ deps meta 写回失败:${e.message}`)
477
- }
478
- }
479
-
1
+ /**
2
+ * run/stage.js(W6 Step8b 从 run.js 抽出)。
3
+ *
4
+ * `run <stage>` 默认路径的执行主干(runStage)+ 其私有 helper:
5
+ * - runStage:输出当前 step prompt(重 execute/scan/quick 启动期副作用——worktree 创建、
6
+ * executeRunId 固定、scanProfile 裁剪、quick baseline 录入、plan design 契约校验、noAI 步骤代理)
7
+ * - autoDetectChange:唯一变更目录时自动设置 currentChange(runStage 私有)
8
+ * - executePlanPostcheck:noAI planPostcheck 步骤代理(runStage 私有)
9
+ * - ensureDepsFreshness:execute 入口已存在 worktree 时 deps 自检重供给(runStage 私有)
10
+ *
11
+ * 安全锚:run.js 始终 barrel。runStage 由 run.js import 回来(无 test 直接 import,无需 re-export)。
12
+ * checkApproval(runStage + runAutoMode 共用)已先下沉 shared.js(Step8a),避免 command/stage 环依赖。
13
+ *
14
+ * 路径修正(相对 src/run/):
15
+ * - 动态 import './worktree.js' / './task-review.js' / './stages/plan.js' /
16
+ * './stages/plan-postcheck.js' / './worktree-deps.js' → '../'(src/ 下退一层)
17
+ * - 'child_process'(execSync)裸模块名不变
18
+ */
19
+ import { join, dirname } from 'node:path'
20
+ import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'
21
+ import { writeAtomicSync } from '../fs-atomic.js'
22
+ import { resolveSpecDir, resolveChangeDir, resolveRuntimeRoot, resolveQuickSessionsDir, triggerSync, safeGit, parsePorcelainPath, formatWaitOptions, checkApproval, getStageSteps, warnApprovalUnknown } from './shared.js'
23
+ import { computeScanProfile, applyScanProfileSteps, executeScanPreflight, executeScanPostcheck } from './scan-profile.js'
24
+ import { outputStep } from './prompt.js'
25
+ import { allocateQuicklogEntry, deriveTitleFromLinkedChange, sanitizeDesc } from '../quicklog.js'
26
+ import { createHash } from 'node:crypto'
27
+ import { checkTransition } from '../stage-contract.js'
28
+ import { AUXILIARY_STAGES } from '../constants.js'
29
+ import { completeStageGates } from './gates.js'
30
+
31
+ export async function runStage(pm, progress, stageName, cwd, changeName, skipApproval = false, platformOpts = {}, quickOpts = {}) {
32
+ const specBase = platformOpts.specRoot || join(cwd, '.sillyspec')
33
+ // 状态转换校验
34
+ const prevStage = progress.currentStage || ''
35
+ // task-07: 提取 prevStage 的 stageData,传给 checkTransition 检测 failed_post_check 门控
36
+ const fromStageData = (progress.stages && prevStage && progress.stages[prevStage]) || undefined
37
+ const transition = checkTransition(prevStage, stageName, fromStageData ? { fromStageData } : {})
38
+ if (!transition.allowed) {
39
+ console.error(`❌ 阶段转换不允许: ${prevStage || '(起始)'} → ${stageName}`)
40
+ console.error(` 原因: ${transition.reason}`)
41
+ console.error(` 提示: 使用 --skip-approval 绕过(需明确意图)`)
42
+ if (!skipApproval) {
43
+ process.exit(1)
44
+ }
45
+ }
46
+
47
+ // execute 阶段启动前检查审批
48
+ if (stageName === 'execute' && !skipApproval) {
49
+ const approval = await checkApproval(cwd, changeName, platformOpts)
50
+ if (approval) {
51
+ if (approval.status === 'rejected') {
52
+ console.error(`❌ 变更 ${changeName} 的执行已被拒绝:${approval.reason || '无原因'}`)
53
+ process.exit(1)
54
+ }
55
+ if (approval.status === 'pending') {
56
+ console.log(`⏳ 变更 ${changeName} 的执行审批待处理中...`)
57
+ console.log(' 提示:使用 --skip-approval 跳过审批检查')
58
+ }
59
+ // HUB-07:unknown(连接了平台但 404/断网/非 JSON)此前静默落空(fail-open 无提示)——
60
+ // 醒目警告 + 留痕,放行但可审计
61
+ if (approval.status === 'unknown') {
62
+ warnApprovalUnknown(cwd, changeName, approval.reason)
63
+ }
64
+ }
65
+ }
66
+
67
+ // execute 阶段:CLI 自动创建 worktree(不等 AI agent)
68
+ if (stageName === 'execute' && changeName) {
69
+ const effectiveChange = changeName
70
+ const { WorktreeManager } = await import('../worktree.js')
71
+ const wm = new WorktreeManager({ cwd })
72
+ const existingMeta = wm.getMeta(effectiveChange)
73
+ if (existingMeta) {
74
+ console.log(`🔗 worktree 已存在: ${existingMeta.worktreePath} (${existingMeta.mode})`)
75
+ } else {
76
+ try {
77
+ const result = wm.create(effectiveChange)
78
+ console.log(`🔗 worktree 已创建: ${result.worktreePath} (分支: ${result.branch}, 模式: ${result.mode})`)
79
+ } catch (e) {
80
+ console.error(`❌ worktree 创建失败: ${e.message}`)
81
+ console.error(` 修复建议:`)
82
+ console.error(` 1. 运行 sillyspec worktree doctor --fix 检查并修复 worktree 状态`)
83
+ console.error(` 2. 或手动清理残留:git worktree prune && git branch -D sillyspec/${effectiveChange}`)
84
+ console.error(` 3. 必要时删除残留目录 .sillyspec/.runtime/worktrees/${effectiveChange}/ 后重试`)
85
+ process.exit(1)
86
+ }
87
+ }
88
+ // 入口 deps 自检(D-002):已存在 worktree(create short-circuit 不供给)时重供给
89
+ if (existingMeta) {
90
+ const execSpecBase = platformOpts?.specRoot || join(cwd, '.sillyspec')
91
+ await ensureDepsFreshness(cwd, effectiveChange, execSpecBase, existingMeta)
92
+ }
93
+ }
94
+
95
+ // ── execute 阶段启动时固定 executeRunId(绑定变更名,避免跨变更复用) ──
96
+ let currentExecuteRunId = null
97
+ if (stageName === 'execute') {
98
+ const { generateExecuteRunId, isValidExecuteRunId } = await import('../task-review.js')
99
+ const execSpecBase = platformOpts?.specRoot || join(cwd, '.sillyspec')
100
+ const runtimeRoot = resolveRuntimeRoot(platformOpts, execSpecBase)
101
+ const runIdFile = join(runtimeRoot, `current-execute-run-id-${changeName}`)
102
+ mkdirSync(runtimeRoot, { recursive: true })
103
+ // 优先读取已有的变更专属标记文件(agent 可写内容,格式校验防注入/穿越,非法视为缺失重生成)
104
+ try {
105
+ if (existsSync(runIdFile)) {
106
+ const c = readFileSync(runIdFile, 'utf8').trim()
107
+ if (c && !isValidExecuteRunId(c)) {
108
+ console.warn(`⚠️ execute run marker 内容非法(期望 exec-YYYY-MM-DD-HHMMSS,实得 ${JSON.stringify(c.slice(0, 60))}),重新生成`)
109
+ } else {
110
+ currentExecuteRunId = c
111
+ }
112
+ }
113
+ } catch {}
114
+ if (!currentExecuteRunId) {
115
+ currentExecuteRunId = generateExecuteRunId()
116
+ // D-001#1 主写入点:mkdir execute-runs/<runId>/tasks 先于 marker(不变量:marker 在则目录在,
117
+ // archive 完成度扫描/漂移兜底不再落到「有 marker 无目录」的空 run)。失败直接 throw——execute
118
+ // 启动即失败优于事后 review 错配(调用方 runCommand 冒到 CLI 顶层 exit 1,给出修复指引)。
119
+ try {
120
+ mkdirSync(join(runtimeRoot, 'execute-runs', currentExecuteRunId, 'tasks'), { recursive: true })
121
+ } catch (e) {
122
+ throw new Error(`execute run 目录创建失败(${join(runtimeRoot, 'execute-runs', currentExecuteRunId)}): ${e.message};` +
123
+ `请检查该路径是否为普通文件/只读,清理后重跑(sillyspec run execute --change ${changeName} --skip-approval)`)
124
+ }
125
+ writeFileSync(runIdFile, currentExecuteRunId + '\n')
126
+ // change 归属戳(坑 worktree-cleanup-marker-chain 根治):run 目录自带变更身份,
127
+ // marker 断裂(worktree cleanup / 归档清理 / 并行误删)后按戳归属,不再错配其他变更的 run
128
+ const { stampExecuteRunChange } = await import('../task-review.js')
129
+ stampExecuteRunChange(runtimeRoot, currentExecuteRunId, changeName)
130
+ }
131
+ }
132
+
133
+ // 自动探测 currentChange
134
+ if (autoDetectChange(progress, cwd)) {
135
+ progress.lastActive = new Date().toLocaleString('zh-CN', { hour12: false })
136
+ pm._write(cwd, progress, changeName)
137
+ triggerSync(cwd, changeName, platformOpts)
138
+ }
139
+
140
+ const stageData = progress.stages[stageName]
141
+ if (!stageData || !stageData.steps) {
142
+ console.error(`❌ 阶段 ${stageName} 未初始化`)
143
+ process.exit(1)
144
+ }
145
+
146
+ // 用户显式调用 sillyspec run <stage>:把它标记为当前阶段
147
+ // (D-003@v1:auxiliary 阶段不写 currentStage,避免 scan/quick/explore/archive/status/doctor
148
+ // 执行后污染主流程当前阶段;lastActive 心跳与 pm._write/triggerSync 照常)
149
+ if (progress.currentStage !== stageName) {
150
+ if (!AUXILIARY_STAGES.includes(stageName)) {
151
+ progress.currentStage = stageName
152
+ }
153
+ progress.lastActive = new Date().toLocaleString('zh-CN',{hour12:false})
154
+ pm._write(cwd, progress, changeName)
155
+ triggerSync(cwd, changeName, platformOpts)
156
+ }
157
+
158
+ const steps = stageData.steps
159
+ // ── 检查是否有 waiting step 需要先处理 ──
160
+ const waitingIdx = steps.findIndex(s => s.status === 'waiting')
161
+ if (waitingIdx !== -1) {
162
+ const ws = steps[waitingIdx]
163
+ console.error(`\n⏸️ Step ${waitingIdx + 1}/${steps.length} 正在等待用户输入:${ws.name}`)
164
+ if (ws.waitReason) console.error(` 原因:${ws.waitReason}`)
165
+ if (ws.waitOptions) console.error(` 选项:${formatWaitOptions(ws.waitOptions)}`)
166
+ console.error(`\n 普通运行无法跳过等待中的步骤。请先处理:`)
167
+ console.error(` sillyspec run ${stageName} --continue --answer "你的选择"${changeName ? ` --change ${changeName}` : ''}`)
168
+ process.exit(1)
169
+ }
170
+
171
+ let currentIdx = steps.findIndex(s => s.status !== 'completed' && s.status !== 'skipped')
172
+
173
+ // stale 步骤视为可执行(等同于 pending)
174
+ if (currentIdx !== -1 && steps[currentIdx].status === 'stale') {
175
+ steps[currentIdx].status = 'pending'
176
+ pm._write(cwd, progress, changeName)
177
+ triggerSync(cwd, changeName, platformOpts)
178
+ }
179
+
180
+ // ── scanProfile: 根据 project 规模动态裁剪步骤 ──
181
+ let scanProfile = null
182
+ if (stageName === 'scan' && steps.length > 0 && currentIdx === 0) {
183
+ scanProfile = computeScanProfile(cwd, platformOpts)
184
+ console.log(`\n📊 Scan Profile: ${scanProfile.mode} (原因: ${scanProfile.reason})`)
185
+ if (scanProfile.mode !== 'deep') {
186
+ applyScanProfileSteps(stageData, scanProfile, cwd, platformOpts)
187
+ // 步骤被裁剪后 currentIdx 需要重新计算
188
+ currentIdx = 0
189
+ }
190
+ // 保存 profile 供后续 postcheck 使用
191
+ stageData.scanProfile = scanProfile
192
+ pm._write(cwd, progress, changeName)
193
+ } else if (stageName === 'scan' && stageData.scanProfile) {
194
+ scanProfile = stageData.scanProfile
195
+ }
196
+ if (scanProfile) platformOpts.scanProfile = scanProfile
197
+
198
+ if (stageName === 'scan') {
199
+ try {
200
+ const gitResult = safeGit(cwd, ['rev-parse', 'HEAD'])
201
+ const scanGuard = {
202
+ name_zh: '扫描守卫',
203
+ sourceCommit: gitResult.value,
204
+ sourceCommitError: gitResult.error,
205
+ startedAt: new Date().toISOString(),
206
+ forceRescan: quickOpts?.isForceRescan || false,
207
+ }
208
+ const guardFile = join(specBase, '.runtime', 'scan-guard.json')
209
+ mkdirSync(dirname(guardFile), { recursive: true })
210
+ writeFileSync(guardFile, JSON.stringify(scanGuard, null, 2) + '\n')
211
+ if (scanGuard.forceRescan) {
212
+ console.log('🛡️ scan 覆盖保护已记录: --force-rescan 已开启')
213
+ } else {
214
+ console.log('🛡️ scan 覆盖保护已记录: existing scan docs require current source_commit/updated_at')
215
+ }
216
+ } catch (e) {
217
+ console.warn(`⚠️ scan 覆盖保护记录失败: ${e.message}`)
218
+ }
219
+ }
220
+
221
+ if (currentIdx === -1) {
222
+ // 所有步骤已 completed/skipped,但阶段未盖到 completed —— 这是上次最后一步完成后、
223
+ // stage 升级事务未提交的崩溃中间态(正常完成会被上方 stageStatus==='completed' 守卫拦下)。
224
+ // 旧逻辑无条件清空 steps 为 pending 会丢掉已完成的进度且不可恢复。改为走正式
225
+ // completeStage:产物齐则补盖完成戳,不齐则给出 actionable 提示,永不静默清空步骤。
226
+ console.log(`\nℹ️ ${stageName} 所有步骤已完成,但阶段未标记完成(上次可能中断)。尝试补盖完成戳…`)
227
+ pm.completeStage(cwd, stageName, changeName)
228
+ const after = pm.read(cwd, changeName)
229
+ if (after?.stages?.[stageName]?.status === 'completed') {
230
+ console.log(` ✅ 已补盖完成戳。下一步: sillyspec run <下一阶段>,或 sillyspec run ${stageName} --status 查看。`)
231
+ process.exit(0)
232
+ }
233
+ // completeStage 已打印产物校验失败的明细;这里只补充恢复指引
234
+ console.error(`\n ⚠️ 已保留步骤进度(未清空)。修复产物后重跑,或:`)
235
+ console.error(` - 重新开始: sillyspec run ${stageName} --reset`)
236
+ console.error(` - 强制补盖: sillyspec progress complete-stage ${stageName}${changeName ? ` --change ${changeName}` : ''} --force`)
237
+ process.exit(1)
238
+ }
239
+
240
+ // quick 阶段:记录 baselineFiles + 分配 ql-ID(CLI 接管 QUICKLOG 写入)
241
+ // 幂等判据是 session guard.json 文件(跨进程可靠),不是 progress.quickGuard
242
+ // (D-003@v1:顶层 quickGuard 不跨进程持久化;agent 在 step 间用 `run quick` 取下一步
243
+ // prompt 时每个新进程都进此块,按文件判幂等才不会重复分配 ql-ID / 重复写条目)。
244
+ if (stageName === 'quick') {
245
+ // quick-sessions 目录经 resolveQuickSessionsDir 单一解析(multi-agent-review Q4):
246
+ // 平台模式 runtimeRoot 与 specBase/.runtime 不同时,写/读须对齐,否则 guard 写一处、收尾读另一处。
247
+ const sessionGuardDir = join(resolveQuickSessionsDir(platformOpts, specBase), changeName)
248
+ const guardFile = join(sessionGuardDir, 'guard.json')
249
+ let existingGuard = null
250
+ try {
251
+ if (existsSync(guardFile)) existingGuard = JSON.parse(readFileSync(guardFile, 'utf8'))
252
+ } catch {}
253
+ if (existingGuard) {
254
+ // 跨进程重入:复用已分配的 ql-ID,跳过 baseline 重捕与分配(幂等)
255
+ progress.quickGuard = existingGuard
256
+ // 中途追加边界:会话中途发现要改启动声明外的文件,此前 resume 会把 --files 静默丢弃、
257
+ // 边界冻结在启动时刻,只能靠事后归属/审计行兜底。恢复时带 --files 即并入 allowedFiles
258
+ // (追加不替换、去重保序),同时点录 allowedFilesHash 供同文件并发检测(文件不存在跳过,
259
+ // 与启动同语义)。--done 审计直读 guard.json(complete-handlers §4.6),追加即被归属消费。
260
+ const resumeFiles = Array.isArray(quickOpts?.quickFiles) ? quickOpts.quickFiles.filter(Boolean) : []
261
+ if (resumeFiles.length > 0) {
262
+ const known = new Set(progress.quickGuard.allowedFiles || [])
263
+ const added = resumeFiles.filter(f => !known.has(f))
264
+ if (added.length > 0) {
265
+ progress.quickGuard.allowedFiles = [...(progress.quickGuard.allowedFiles || []), ...added]
266
+ progress.quickGuard.allowedFilesHash = { ...(progress.quickGuard.allowedFilesHash || {}) }
267
+ for (const f of added) {
268
+ try { progress.quickGuard.allowedFilesHash[f] = createHash('sha256').update(readFileSync(join(cwd, f))).digest('hex') }
269
+ catch { /* 预声明将新建的文件,与启动时 allowedFilesHash 同语义:不存在跳过 */ }
270
+ }
271
+ writeAtomicSync(guardFile, JSON.stringify(progress.quickGuard, null, 2))
272
+ console.log(`🛡️ quick 边界已追加: ${added.length} 个文件(累计 ${progress.quickGuard.allowedFiles.length} 个): ${added.join(', ')}`)
273
+ }
274
+ }
275
+ } else {
276
+ // baseline 采集用 safeGit(带 -c safe.directory,避免 linked worktree/容器异 uid/Windows 挂载点
277
+ // 下裸 `git status` 抛错)。safeGit 已消除 safe.directory 类失败;若仍失败(真非 git 目录等),
278
+ // 不硬阻断 quick 启动(平台模式/非 git 目录仍需渲染 step prompt),但 fail-visible:大声 warn +
279
+ // baseline 置空。--done 时 auditQuickCompletion 的 safeGit 同样失败 → blocked,故不存在「静默
280
+ // 完成」路径(multi-agent-review Q3)。
281
+ const statusResult = safeGit(cwd, ['status', '--porcelain'], { trim: false })
282
+ if (statusResult.error) {
283
+ console.warn(`⚠️ quick baseline 采集失败(git status): ${statusResult.error}`)
284
+ console.warn(` baseline 置空;--done 审计将因 git 不可用而阻断(无静默完成路径)。`)
285
+ console.warn(` 排查:仓库 safe.directory 配置 / 非 git 目录。`)
286
+ }
287
+ try {
288
+ const gitStatus = statusResult.value || ''
289
+ // 记录全部预存脏文件(含 untracked + .sillyspec/ 路径)。quick 会话期间自身写入的元数据
290
+ // (quicklog/.runtime/modules/_module-map 等)由 auditQuickCompletion 的 isQuickMetadata 精确豁免,
291
+ // 不需要这里粗放过滤 .sillyspec/——旧过滤致预存 untracked .sillyspec/changes/ 不进 baseline,
292
+ // 却在 audit 被当「危险(.sillyspec/)+新增」误判永久 blocked(ql-20260713-002-7628 修复)。
293
+ const baselineFiles = gitStatus
294
+ .split('\n').filter(Boolean)
295
+ .map(line => parsePorcelainPath(line))
296
+ .filter(Boolean)
297
+ const allowedFiles = quickOpts?.quickFiles || []
298
+ const allowNew = quickOpts?.isAllowNew || false
299
+ const allowDelete = quickOpts?.isAllowDelete || false
300
+ const forceBaseline = quickOpts?.isForceBaseline || false
301
+ const linkedChanges = Array.isArray(quickOpts?.linkedChanges) ? quickOpts.linkedChanges : []
302
+ // CLI 接管:分配 ql-ID + 写 QUICKLOG「进行中」条目 + 关联 tasks.md(持锁、当天唯一)
303
+ const gitUser = safeGit(cwd, ['config', 'user.name']).value || 'unknown'
304
+ // 标题回退:启动 quick 不带 --input 时,从关联变更的 proposal/design 提取语义标题,
305
+ // 避免 QUICKLOG/tasks.md 落 (quick 任务) 占位(deriveTitleFromLinkedChange 读不到则空→sanitizeDesc 回退占位)。
306
+ let quickDesc = quickOpts?.taskDescription || ''
307
+ if (!quickDesc && linkedChanges.length > 0) {
308
+ quickDesc = deriveTitleFromLinkedChange(specBase, linkedChanges[0])
309
+ }
310
+ const { qlId } = await allocateQuicklogEntry(specBase, gitUser, {
311
+ description: quickDesc,
312
+ linkedChanges,
313
+ allowedFiles,
314
+ })
315
+ progress.quickGuard = {
316
+ sessionId: changeName,
317
+ // 锚定创建时的 specBase(坑 quick-cwd-drift-splits-specdir):sessionId 全局复用但
318
+ // guard 按 specDir 落盘,记下归属让漂移可自证(detectQuickSessionDrift 靠目录定位即可,
319
+ // 此字段供诊断/未来一致性校验)。
320
+ specDir: specBase,
321
+ name_zh: '快速任务守卫',
322
+ baselineCommit: safeGit(cwd, ['rev-parse', 'HEAD']).value,
323
+ baselineFiles,
324
+ allowedFiles,
325
+ // task-01: 录每个 allowedFile 内容 sha256,供 --done auditQuickCompletion 检测同文件并发
326
+ // (allowedFile 在 baseline 且当前 hash ≠ 此值 = 我也改了 → 同文件并发 warn)。文件不存在/读失败跳过。
327
+ allowedFilesHash: Object.fromEntries(allowedFiles.flatMap(f => {
328
+ try { return [[f, createHash('sha256').update(readFileSync(join(cwd, f))).digest('hex')]] }
329
+ catch { return [] }
330
+ })),
331
+ allowNew,
332
+ allowDelete,
333
+ forceBaseline,
334
+ linkedChanges,
335
+ quicklogId: qlId,
336
+ startedAt: new Date().toISOString(),
337
+ }
338
+ // 写入 .runtime/quick-sessions/<sessionId>/guard.json 供 worktree-guard hook 读取
339
+ // (D-002:按 session 存,多会话各自 guard 不互覆盖。runStage 作用域内 sessionId == changeName == quick-<uuid8>,见 §4.4/4.5)
340
+ mkdirSync(sessionGuardDir, { recursive: true })
341
+ writeAtomicSync(guardFile, JSON.stringify(progress.quickGuard, null, 2))
342
+ const parts = [`${baselineFiles.length} 个已有脏文件`]
343
+ if (allowedFiles.length > 0) parts.push(`${allowedFiles.length} 个 allowedFiles`)
344
+ if (allowNew) parts.push('允许新增文件')
345
+ if (allowDelete) parts.push('允许删除文件')
346
+ console.log(`🛡️ quick 变更边界已记录: ${parts.join(', ')}`)
347
+ console.log(`📝 QUICKLOG 条目已创建: ${qlId}`)
348
+ // 缺 --input 且关联变更无可提取标题 → 条目落「(quick 任务)」占位标题,平台「快速修复」列表
349
+ // 默认隐藏进行中占位(task-06 口径),长会话全程不可见、语义标题要到最终 --done 才回填。
350
+ // 此刻会话刚起步零沉没成本,提示放弃重启带 --input 是最便宜的自愈点。
351
+ if (!quickDesc) {
352
+ console.warn('')
353
+ console.warn('⚠️ 本次 quick 未带 --input:QUICKLOG 条目将以占位标题「(quick 任务)」落盘')
354
+ console.warn(' 平台「快速修复」列表默认隐藏进行中的占位条目,语义标题要到最终 --done 才回填。')
355
+ console.warn(' 长任务建议放弃本会话、带一句话任务描述重启(旧会话 run quick --reset --change <sessionId> 重置):')
356
+ console.warn(' sillyspec run quick --input "<一句话任务描述>" [--linked-changes <a,b>] [--files <...>]')
357
+ }
358
+ // 回填 DB changes 行的 title + quicklog_id,让 quick-<hex> 可读、DB↔QUICKLOG 可对账。
359
+ // title 用 quickDesc(任务描述或关联变更标题)经 sanitizeDesc 压一行限长,与 QUICKLOG 条目标题同源。
360
+ try {
361
+ pm.updateChangeMeta(cwd, changeName, { title: sanitizeDesc(quickDesc), quicklogId: qlId });
362
+ } catch { /* 回填失败不阻断 quick 启动 */ }
363
+ pm._write(cwd, progress, changeName)
364
+ // ql-20260819-009:起步即推「进行中」占位条目到平台。runStage 前段三处 triggerSync
365
+ // (autoDetectChange / currentStage 切换 / stale 复位)全在本块之前执行——「进行中」骨架
366
+ // 此前要等第一次 --done 才上平台,平台快速修复列表存在起步盲窗。此刻骨架已分配 + 进度已
367
+ // 落盘,补一次 triggerSync 立即推 QUICKLOG(quick-<hex8> 会话自动降级 syncSpecTreeOnly
368
+ // 只推 spec 树;未连接平台静默跳过、8s 熔断 best-effort,均不阻断 quick 启动)。平台派发
369
+ // (claim)模式起步同样走 runStage 本块,一处插入全覆盖;guard 已存在的跨进程重入走上方
370
+ // existingGuard 分支跳过本块,条目已在平台,不重复推。
371
+ triggerSync(cwd, changeName, platformOpts)
372
+ } catch (e) {
373
+ console.warn(`⚠️ baseline 记录失败: ${e.message}`)
374
+ }
375
+ }
376
+ }
377
+
378
+ if (currentIdx > 0) {
379
+ const completed = currentIdx
380
+ const total = steps.length
381
+ console.log(`⚠️ ${stageName} 已进行到第 ${currentIdx + 1}/${total} 步(前 ${completed} 步已完成)。`)
382
+ console.log(` 继续执行将从中断处恢复,用 --reset 可重新开始。\n`)
383
+ }
384
+
385
+ // ── Brainstorm → Plan Contract:plan 启动前校验 design.md ──
386
+ if (stageName === 'plan' && currentIdx === 0) {
387
+ const changeDir = resolveChangeDir(cwd, progress, platformOpts?.specRoot || null)
388
+ const designPath = changeDir ? join(changeDir, 'design.md') : null
389
+ if (designPath && existsSync(designPath)) {
390
+ const { validateDesignForPlan } = await import('../stages/plan.js')
391
+ const designContent = readFileSync(designPath, 'utf8')
392
+ const designValidation = validateDesignForPlan(designContent)
393
+ if (!designValidation.ok) {
394
+ console.error(`\n❌ Brainstorm → Plan Contract 校验失败:`)
395
+ for (const err of designValidation.errors) console.error(` - ${err}`)
396
+ console.error(`\n design.md 不满足 plan 契约,请先修复后重试。`)
397
+ console.error(` 提示:sillyspec run brainstorm --reopen --from-step <步骤> 修订设计文档`)
398
+ process.exit(1)
399
+ }
400
+ if (designValidation.warnings.length > 0) {
401
+ console.log(`⚠️ Design contract 警告(不阻断):`)
402
+ for (const w of designValidation.warnings) console.log(` - ${w}`)
403
+ console.log()
404
+ }
405
+ }
406
+ }
407
+
408
+ const defSteps = await getStageSteps(stageName, cwd, progress, platformOpts?.specRoot || null)
409
+ if (defSteps && defSteps[currentIdx]) {
410
+ // noAI 步骤自动完成(CLI-only,不需要 Agent 参与)
411
+ if (defSteps[currentIdx].noAI || stageData.steps[currentIdx]?.noAI) {
412
+ const stepName = defSteps[currentIdx].name
413
+ const cliAction = defSteps[currentIdx]._cliAction || stageData.steps[currentIdx]?._cliAction
414
+ console.log(`⚙️ Step ${currentIdx + 1}/${stageData.steps.length}: ${stepName}(CLI 自动执行,无需 Agent)`)
415
+ if (cliAction === 'scanPreflight') {
416
+ await executeScanPreflight(cwd, platformOpts, scanProfile)
417
+ } else if (cliAction === 'scanPostcheck') {
418
+ await executeScanPostcheck(cwd, platformOpts, scanProfile)
419
+ } else if (cliAction === 'planPostcheck') {
420
+ await executePlanPostcheck(cwd, platformOpts, progress)
421
+ } else {
422
+ throw new Error(`noAI 步骤 ${stepName} 的未知 _cliAction: ${cliAction}——请在 stage.js 注册对应分支`)
423
+ }
424
+ stageData.steps[currentIdx].status = 'completed'
425
+ stageData.steps[currentIdx].completedAt = new Date().toLocaleString('zh-CN', { hour12: false })
426
+ pm._write(cwd, progress, changeName)
427
+ // 自动前进到下一步
428
+ const nextIdx = stageData.steps.findIndex(s => s.status === 'pending' || s.status === 'in-progress')
429
+ if (nextIdx !== -1 && defSteps[nextIdx]) {
430
+ console.log('')
431
+ await outputStep(stageName, nextIdx, defSteps, cwd, changeName, progress.project || null, platformOpts)
432
+ } else {
433
+ // 所有步骤完成
434
+ stageData.status = 'completed'
435
+ stageData.completedAt = new Date().toLocaleString('zh-CN', { hour12: false })
436
+ // persist _write 移到 completeStageGates 成功之后(task-02 / review-2026-08-09 #2):gate 异常/失败 → rollback 回 in-progress 落盘,此处未到 _write,DB 不留假 completed。
437
+ // 阶段完成收尾共享管线(noAI 末步核心修复 S1:plan postcheck independent-tier review verdict=fail /
438
+ // 平台 scan manifest 此前被绕过)。gate 失败已 rollback 为 in-progress,early-return(不 fall through 到末尾 return)。
439
+ const _stageGatesResult = await completeStageGates({ stageName, cwd, changeName, platformOpts, specBase, progress, pm, stageData, steps, currentIdx, outputText: null })
440
+ // task-04 / A5:gate 失败(stageCompleted===false)设进程退出码 1(与 completeStep/continueStep 同语义)。
441
+ if (_stageGatesResult?.stageCompleted === false) process.exitCode = 1
442
+ if (_stageGatesResult) return _stageGatesResult
443
+ // gate 全过:persist completed(task-02 移后;此处无 triggerSync,与同文件 noAI 末步语义一致)。
444
+ pm._write(cwd, progress, changeName)
445
+ console.log(`\n✅ ${stageName} 阶段全部完成。`)
446
+ }
447
+ return
448
+ }
449
+ await outputStep(stageName, currentIdx, defSteps, cwd, changeName, progress.project || null, platformOpts)
450
+ }
451
+ }
452
+
453
+
454
+
455
+
456
+
457
+
458
+ /**
459
+ * 自动探测并设置 currentChange(唯一变更目录时)
460
+ * @returns {boolean} 是否设置了 currentChange
461
+ */
462
+ function autoDetectChange(progress, cwd, specDir = null) {
463
+ if (progress.currentChange) return false
464
+ const changesDir = join(specDir || resolveSpecDir(cwd), 'changes')
465
+ if (!existsSync(changesDir)) return false
466
+ const entries = readdirSync(changesDir, { withFileTypes: true })
467
+ .filter(e => e.isDirectory() && e.name !== 'archive')
468
+ if (entries.length === 1) {
469
+ progress.currentChange = entries[0].name
470
+ return true
471
+ }
472
+ return false
473
+ }
474
+
475
+
476
+ /**
477
+ * Plan postcheck 的执行代理:委托给 plan-postcheck.js 模块
478
+ */
479
+ async function executePlanPostcheck(cwd, platformOpts, progress) {
480
+ // resolveChangeDir 从 ./shared.js 导入(W6 Step1 抽出的纯函数,原 run.js 本地函数);历史上曾误从 ./modules.js
481
+ // 导入(该模块未导出此函数,得到 undefined),导致 plan-postcheck.js:388 抛
482
+ // "resolveChangeDir is not a function"。详见 docs/sillyspec/plan-postcheck-resolvechangedir-not-a-function.md
483
+ const { executePlanPostcheck: runPostcheck } = await import('../stages/plan-postcheck.js')
484
+ await runPostcheck({
485
+ cwd,
486
+ specRoot: platformOpts?.specRoot,
487
+ resolveChangeDir,
488
+ progress
489
+ })
490
+ }
491
+
492
+
493
+
494
+ /**
495
+ * execute 入口 deps 自检(D-002,change 2026-06-28-worktree-deps-provision)。
496
+ * 已存在 worktree(create short-circuit 不供给)时,校验 deps 状态缺失/漂移 → 触发 provisionDeps 重供给并写回 meta。
497
+ *
498
+ * 判定改调共享 checkDepsFreshness(H1,change 2026-08-05-tooling-feedback-fixes task-04):
499
+ * - status ∈ {missing, stale, main-drift, failed} → 触发重供给(main-drift 为新增主仓 lockfile 漂移触发)
500
+ * - status === fresh → 跳过
501
+ * 行为与原内联 noStatus/missing/stale 三判定等价(provisionDeps 调用 / meta 写回不变),新增 main-drift 触发。
502
+ */
503
+ async function ensureDepsFreshness(cwd, changeName, specBase, worktreeMeta) {
504
+ if (!worktreeMeta || !worktreeMeta.worktreePath) return
505
+ const { provisionDeps, checkDepsFreshness } = await import('../worktree-deps.js')
506
+ const wtPath = worktreeMeta.worktreePath
507
+ const fresh = checkDepsFreshness(worktreeMeta, wtPath, cwd)
508
+ if (fresh.status === 'fresh') return
509
+ const reason = fresh.detail || fresh.status
510
+ console.log(`🔄 worktree deps 自检:${reason}(depsStatus=${worktreeMeta.depsStatus || 'unknown'}),重新供给...`)
511
+ let deps = {}
512
+ try {
513
+ deps = provisionDeps(wtPath, cwd, { specBase }) || {}
514
+ } catch (e) {
515
+ deps = { depsStatus: 'failed', depsError: `provisionDeps crashed: ${e.message}` }
516
+ }
517
+ try {
518
+ const { WorktreeManager } = await import('../worktree.js')
519
+ const metaPath = join(new WorktreeManager({ cwd }).getWorktreePath(changeName), 'meta.json')
520
+ const updated = { ...worktreeMeta, ...deps }
521
+ writeAtomicSync(metaPath, JSON.stringify(updated, null, 2) + '\n')
522
+ console.log(`✅ deps 重新供给完成:depsStatus=${deps.depsStatus}`)
523
+ } catch (e) {
524
+ console.warn(`⚠️ deps meta 写回失败:${e.message}`)
525
+ }
526
+ }
527
+