sillyspec 3.25.9 → 3.25.11

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 (71) hide show
  1. package/.claude/skills/sillyspec-auto/SKILL.md +8 -13
  2. package/.claude/skills/sillyspec-brainstorm/SKILL.md +8 -6
  3. package/.claude/skills/sillyspec-continue/SKILL.md +3 -3
  4. package/.claude/skills/sillyspec-doctor/SKILL.md +1 -1
  5. package/.claude/skills/sillyspec-execute/SKILL.md +10 -4
  6. package/.claude/skills/sillyspec-plan/SKILL.md +1 -1
  7. package/.claude/skills/sillyspec-verify/SKILL.md +1 -1
  8. package/README.md +9 -0
  9. package/package.json +2 -2
  10. package/src/change-list.js +11 -8
  11. package/src/change-risk-profile.js +22 -0
  12. package/src/db.js +160 -159
  13. package/src/dispatch/backends/local-agent.js +152 -0
  14. package/src/dispatch/backends/sillyhub-mcp.js +242 -0
  15. package/src/dispatch/probe.js +208 -0
  16. package/src/dispatch/strategy.js +121 -0
  17. package/src/doctor-diagnostics.js +19 -25
  18. package/src/fs-atomic.js +69 -69
  19. package/src/git-helper.js +66 -0
  20. package/src/hooks/worktree-guard.js +64 -55
  21. package/src/index.js +176 -34
  22. package/src/init.js +7 -14
  23. package/src/machine-interface.js +54 -19
  24. package/src/migrate.js +14 -4
  25. package/src/modules.js +16 -5
  26. package/src/progress/change-registry.js +72 -70
  27. package/src/progress/consistency-doctor.js +71 -14
  28. package/src/progress/shared.js +6 -3
  29. package/src/progress/stage-machine.js +61 -65
  30. package/src/progress/step-store.js +62 -67
  31. package/src/progress.js +155 -233
  32. package/src/quicklog.js +13 -5
  33. package/src/run/command.js +65 -37
  34. package/src/run/complete-handlers.js +1005 -889
  35. package/src/run/complete.js +115 -208
  36. package/src/run/concurrent-detect.js +132 -0
  37. package/src/run/gates.js +289 -17
  38. package/src/run/prompt.js +22 -68
  39. package/src/run/quick-audit.js +10 -9
  40. package/src/run/shared.js +648 -600
  41. package/src/run/stage.js +43 -24
  42. package/src/scan-postcheck.js +9 -31
  43. package/src/sillyhub-mcp/client.js +389 -0
  44. package/src/spec-dir-typo.js +71 -0
  45. package/src/stage-contract-engine.js +22 -1
  46. package/src/stage-contract-spec.js +11 -4
  47. package/src/stage-contract.js +26 -3
  48. package/src/stage-review.js +13 -9
  49. package/src/stages/archive.js +4 -6
  50. package/src/stages/brainstorm-auto.js +26 -20
  51. package/src/stages/brainstorm.js +29 -5
  52. package/src/stages/cmd-existence.js +145 -0
  53. package/src/stages/doctor.js +9 -6
  54. package/src/stages/execute.js +61 -20
  55. package/src/stages/plan-postcheck.js +152 -0
  56. package/src/stages/plan.js +16 -31
  57. package/src/stages/quick.js +1 -1
  58. package/src/stages/scan.js +9 -3
  59. package/src/stages/status.js +0 -1
  60. package/src/stages/verify.js +12 -7
  61. package/src/sync.js +4 -4
  62. package/src/task-review.js +30 -1
  63. package/src/verify-postcheck.js +933 -862
  64. package/src/version.js +19 -0
  65. package/src/worktree-apply.js +85 -68
  66. package/src/worktree-deps.js +89 -5
  67. package/src/worktree.js +213 -111
  68. package/templates/prompts/taskcard-rules.md +27 -0
  69. package/templates/prompts/verify-probes.md +3 -3
  70. package/.claude/skills/sillyspec-propose/SKILL.md +0 -87
  71. package/src/stages/propose.js +0 -181
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * run/complete.js(W6 Step7b 从 run.js 抽出)。
3
3
  *
4
- * step 完成处理核心:completeStep(调度主干——WAIT/scanProfile/requiresWait 门控 + 各 handler 调用 +
4
+ * step 完成处理核心:completeStep(调度主干——WAIT/requiresWait 门控 + 各 handler 调用 +
5
5
  * completion path 收尾 + single-step path)+ skipStep/waitStep/continueStep(W6 Step7b-2 续搬)。
6
6
  * 本 commit(7b-1)先搬 completeStep + 2 独占 helper(validateMetadata/validateFileLocations)。
7
7
  *
@@ -15,106 +15,19 @@
15
15
  * - node: join(path) + existsSync/readdirSync/readFileSync/mkdirSync/writeFileSync/appendFileSync/statSync(fs)
16
16
  */
17
17
  import { join } from 'node:path'
18
- import { existsSync, readdirSync, readFileSync, mkdirSync, writeFileSync, appendFileSync, statSync } from 'node:fs'
19
- import { triggerSync, WAIT_MARKER_RE, getStageSteps, formatWaitOptions } from './shared.js'
18
+ import { existsSync, readFileSync, mkdirSync, writeFileSync, appendFileSync } from 'node:fs'
19
+ import { writeAtomicSync } from '../fs-atomic.js'
20
+ import { withFileLock } from '../quicklog.js'
21
+ import { triggerSync, WAIT_MARKER_RE, getStageSteps, formatWaitOptions, resolveRuntimeRoot } from './shared.js'
20
22
  import { outputStep } from './prompt.js'
21
- import { enforceDepsGate, enforceReviewJsonGate, runStageCompletionGates } from './gates.js'
22
- import { handleArchiveConfirmStep, handlePlanGeneratePlanStep, handleScanProjectListStep, handleWorkflowPostCheck, handleQuickStageCompletion, handleExecuteWaveArtifact, handleExecuteWorktreeCleanup, handleScanStageCompleted } from './complete-handlers.js'
23
- import { stageRegistry } from '../stages/index.js'
23
+ import { enforceDepsGate, enforceReviewJsonGate, completeStageGates, readDesignScale } from './gates.js'
24
+ import { handleArchiveConfirmStep, handlePlanGeneratePlanStep, handleScanProjectListStep, handleWorkflowPostCheck, handleQuickStageCompletion, handleExecuteWaveArtifact } from './complete-handlers.js'
24
25
  import { formatExecuteSummary } from '../worktree-apply.js'
26
+ import { isEndToEndTaskText } from '../change-risk-profile.js'
25
27
 
26
- function validateMetadata(cwd, stageName, specBase) {
27
- const changesDir = join(specBase, 'changes')
28
- if (!existsSync(changesDir)) return
29
-
30
- const cutoff = Date.now() - 10 * 60 * 1000
31
- const missing = []
32
-
33
- function walk(dir) {
34
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
35
- const full = join(dir, entry.name)
36
- try {
37
- if (entry.isDirectory()) { walk(full); continue }
38
- if (!/\.(md|yaml|yml)$/.test(entry.name)) continue
39
- const mtime = statSync(full).mtimeMs
40
- if (mtime < cutoff) continue
41
- const content = readFileSync(full, 'utf-8')
42
- if (!content.includes('author:') && !content.includes('author:')) missing.push(full)
43
- if (!content.includes('created_at:') && !content.includes('created_at:')) missing.push(full)
44
- } catch (e) { /* skip unreadable files */ }
45
- }
46
- }
47
-
48
- walk(changesDir)
49
- const unique = [...new Set(missing)]
50
- if (unique.length > 0) {
51
- console.log(`\n⚠️ 以下文件缺少 author 或 created_at 元数据:`)
52
- unique.forEach(f => console.log(` - ${f.replace(cwd + '/', '')}`))
53
- console.log('请在文件头部添加 author(git 用户名)和 created_at(精确到秒)')
54
- }
55
- }
56
-
57
- /**
58
- * 读取 design.md frontmatter 的 scale 字段(brainstorm 末步写入 'small'|'large')。
59
- * completeStep 的下一步提示据此分叉:small→quick,large/读不到→plan(fail-safe 走重流程)。
60
- * 只解析首个 YAML frontmatter 块,避免误读正文里的 "scale:"。
61
- */
62
- function readDesignScale(specBase, changeName) {
63
- if (!changeName) return null
64
- const designPath = join(specBase, 'changes', changeName, 'design.md')
65
- if (!existsSync(designPath)) return null
66
- const text = readFileSync(designPath, 'utf8')
67
- const fm = text.match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*\r?\n/)
68
- if (!fm) return null
69
- const m = fm[1].match(/^scale:[ \t]*['"]?(\w+)/m)
70
- return m ? m[1] : null
71
- }
72
-
73
- /**
74
- * 验证关键文件是否存在于正确的变更目录下
75
- * 防止 AI 将文件写到错误的路径
76
- */
77
- function validateFileLocations(cwd, stageName, progress, changeName, specBase) {
78
- const effectiveChange = changeName || progress.currentChange
79
- if (!effectiveChange) return
80
-
81
- const changeDir = join(specBase, 'changes', effectiveChange)
82
- if (!existsSync(changeDir)) return
83
-
84
- // 每个阶段完成后预期存在的文件
85
- const expectedFiles = {
86
- brainstorm: ['design.md', 'proposal.md', 'requirements.md', 'tasks.md'],
87
- plan: ['plan.md'],
88
- archive: ['module-impact.md'],
89
- }
90
-
91
- const expected = expectedFiles[stageName]
92
- if (!expected) return
93
-
94
- const missing = []
95
- for (const file of expected) {
96
- if (!existsSync(join(changeDir, file))) {
97
- missing.push(file)
98
- }
99
- }
100
-
101
- if (missing.length > 0) {
102
- console.log(`\n⚠️ 文件位置验证:以下文件未在变更目录中找到`)
103
- console.log(` 变更目录:${changeDir.replace(cwd + '/', '')}/`)
104
- for (const f of missing) {
105
- // 检查是否写到了错误的位置
106
- const wrongPath = join(specBase, 'changes', 'change', effectiveChange, f)
107
- if (existsSync(wrongPath)) {
108
- console.log(` ❌ ${f} — 不存在,但发现了错误路径:${wrongPath.replace(cwd + '/', '')}`)
109
- console.log(` 提示:应该写入 ${changeDir.replace(cwd + '/', '')}/${f}`)
110
- } else {
111
- console.log(` ⬜ ${f} — 未找到(该阶段可能未产出此文件)`)
112
- }
113
- }
114
- } else {
115
- console.log(`\n✅ 文件位置验证:所有 ${expected.length} 个预期文件均在变更目录中`)
116
- }
117
- }
28
+ // validateMetadata / readDesignScale / validateFileLocations 已迁至 ./gates.js(completeStageGates
29
+ // 共享收尾管线,消除 noAI 末步 / continueStep 完成分支绕过 gate 的 S1/S2/S3 不对称)。
30
+ // completeStep 阶段完成分支(task-02 接入 completeStageGates)+ brainstorm 下一步提示改 import 自 gates.js。
118
31
 
119
32
  /**
120
33
  * 坑1:用 --done --answer 解掉「已 waiting 的步骤」。
@@ -147,10 +60,9 @@ export function resolveWaitingStepWithAnswer(steps, doneAnswer, nowStr) {
147
60
  }
148
61
 
149
62
  export async function completeStep(pm, progress, stageName, cwd, outputText, inputText = null, options = {}) {
150
- const { printNext = true, confirm = false, changeName, platformOpts = {}, nonInteractive = false, confirmMode = null, isForceBaseline = false, isAllowNew = false } = options
63
+ const { printNext = true, confirm = false, changeName, platformOpts = {}, nonInteractive = false, isForceBaseline = false, isAllowNew = false } = options
151
64
  const specBase = platformOpts.specRoot || join(cwd, '.sillyspec')
152
65
  const stageData = progress.stages[stageName]
153
- const scanProfile = stageData?.scanProfile || null
154
66
 
155
67
  // ── WAIT MARKER 硬校验 ──
156
68
  // 如果 output 包含等待标记,拒绝 --done 推进
@@ -163,12 +75,6 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
163
75
  process.exit(1)
164
76
  }
165
77
  }
166
-
167
- // scanProfile 非 deep 模式:截断 outputText 减少 token 传递
168
- let effectiveOutput = outputText
169
- if (scanProfile && scanProfile.mode !== 'deep' && outputText && outputText.length > 1000) {
170
- effectiveOutput = outputText.slice(0, 1000) + '\n\n…[输出已截断,完整内容见 artifact]'
171
- }
172
78
  if (!stageData || !stageData.steps) {
173
79
  console.error(`❌ 阶段 ${stageName} 未初始化`)
174
80
  process.exit(1)
@@ -181,13 +87,29 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
181
87
  // requiresWait 步骤时会跳过它、把 --answer 静默丢弃,步骤永久卡 WAITING、末步报「等待用户输入」。
182
88
  // 修复:带 doneAnswer 且存在 waiting 步骤时,把首个 waiting 拉回 pending + 补 waitAnswer,主流程
183
89
  // requiresWait 门控见 waitAnswer 已置→不阻断→正常 completed。仅 --answer 触发,普通 --done 零变化。
184
- const _resolvedWaitIdx = resolveWaitingStepWithAnswer(steps, options && options.doneAnswer, new Date().toLocaleString('zh-CN', { hour12: false }))
90
+ const _doneAnswer = options && options.doneAnswer
91
+ // ── 多 waiting 歧义守卫(对齐 continueStep 742-751 的同名保护)──
92
+ // resolveWaitingStepWithAnswer 只 findIndex 解「第一个」waiting;阶段内 ≥2 个 waiting 时
93
+ // --done --answer 会静默把 answer 填给第一个,可能答错对象。与 --continue 对齐:多 waiting 时报错列出。
94
+ if (_doneAnswer) {
95
+ const waitingIdxs = steps.map((s, i) => (s && s.status === 'waiting') ? i : -1).filter(i => i !== -1)
96
+ if (waitingIdxs.length > 1) {
97
+ console.error(`❌ 检测到 ${waitingIdxs.length} 个等待中的步骤,--done --answer 无法确定解哪一个:`)
98
+ for (const i of waitingIdxs) {
99
+ console.error(` Step ${i + 1}: ${steps[i].name}${steps[i].waitReason ? `(${steps[i].waitReason})` : ''}`)
100
+ }
101
+ console.error(` 出路:用 --continue 逐个指定恢复(每次解一个 waiting,降到 1 个后即可 --done --answer):`)
102
+ console.error(` sillyspec run ${stageName} --continue --from-step <序号|名称> --answer "..."${changeName ? ` --change ${changeName}` : ''}`)
103
+ process.exit(1)
104
+ }
105
+ }
106
+ const _resolvedWaitIdx = resolveWaitingStepWithAnswer(steps, _doneAnswer, new Date().toLocaleString('zh-CN', { hour12: false }))
185
107
  if (_resolvedWaitIdx !== -1) {
186
108
  currentIdx = _resolvedWaitIdx
187
109
  console.log(`⚠️ Step "${steps[_resolvedWaitIdx].name}" 此前处于 waiting,--done --answer 已补回答并拉回待完成。`)
188
110
  }
189
111
  if (currentIdx === -1) {
190
- console.error('没有待完成的步骤')
112
+ console.error(`没有待完成的步骤(阶段 ${stageName} 已无 pending/in-progress 步骤)。当前阶段状态:${stageData?.status ?? '未知'}。用 \`sillyspec run ${stageName} --status\` 查看进度,或 \`sillyspec progress show\` 看全局下一步。`)
191
113
  process.exit(1)
192
114
  }
193
115
 
@@ -311,14 +233,14 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
311
233
  if (hasWaiting) {
312
234
  // 有等待中的步骤,阶段未完成
313
235
  progress.lastActive = new Date().toLocaleString('zh-CN',{hour12:false})
314
- await pm._write(cwd, progress, changeName)
236
+ pm._write(cwd, progress, changeName)
315
237
  const wsIdx = steps.findIndex(s => s.status === 'waiting')
316
238
  console.log(`\n⏸️ 阶段暂停:Step ${wsIdx + 1} 等待用户输入`)
317
239
  if (steps[wsIdx].waitReason) console.log(` 原因:${steps[wsIdx].waitReason}`)
318
240
  return { stageCompleted: false, currentIdx, nextPendingIdx: -1 }
319
241
  }
320
242
  // quick 收尾(W6 Step6b 抽至 complete-handlers.js handleQuickStageCompletion)
321
- await handleQuickStageCompletion({ stageName, steps, currentIdx, cwd, progress, changeName, specBase, outputText, confirm, isForceBaseline, isAllowNew, platformOpts })
243
+ await handleQuickStageCompletion({ stageName, steps, currentIdx, cwd, progress, changeName, specBase, outputText, confirm, isForceBaseline, isAllowNew, platformOpts, pm })
322
244
 
323
245
  // ── reopen --done 回填(坑 brainstorm-reopen-step-state-desync)──
324
246
  // nextPendingIdx === -1 且无 waiting,说明要进阶段完成分支。此时若存在 stale 步骤
@@ -333,15 +255,15 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
333
255
  st.status = 'completed'
334
256
  st.completedAt = st.completedAt || nowStr
335
257
  }
336
- await pm._write(cwd, progress, changeName)
258
+ pm._write(cwd, progress, changeName)
337
259
  console.log(` ⚠️ 同步回填 ${staleSteps.length} 个 stale 步骤为 completed(reopen --from-step N 后 --done,方案未变)`)
338
260
  }
339
261
 
340
262
  stageData.status = 'completed'
341
263
  stageData.completedAt = new Date().toLocaleString('zh-CN',{hour12:false})
342
264
  progress.lastActive = new Date().toLocaleString('zh-CN',{hour12:false})
343
- await pm._write(cwd, progress, changeName)
344
- triggerSync(cwd, changeName, platformOpts)
265
+ // persist(_write + triggerSync)移到 completeStageGates 成功之后(task-01 / review-2026-08-09 #2):
266
+ // gate 任一段异常/失败 → rollbackCompletionAndReturn 回 in-progress 并落盘;此处未到 _write,DB 不留假 completed。
345
267
 
346
268
  // Append to user-inputs.md
347
269
  if (outputText) {
@@ -350,36 +272,15 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
350
272
  appendFileSync(inputsPath, entry)
351
273
  }
352
274
 
353
- // scan 平台 manifest + post-check(W6 Step6d 抽至 complete-handlers.js handleScanStageCompleted)
354
- const _scanResult = await handleScanStageCompleted({ stageName, currentIdx, cwd, progress, pm, stageData, changeName, outputText, platformOpts })
355
- if (_scanResult) return _scanResult
356
-
357
- // 防御性守卫变量:确认所有步骤确实标记为 completed
358
- const actualCompleted = steps.filter(s => s.status === 'completed').length
359
- const actualTotal = steps.length
360
-
361
- validateMetadata(cwd, stageName, specBase)
275
+ // 阶段完成收尾共享管线(handleScan manifest + validateMetadata/FileLocations + auxiliary 重置 +
276
+ // runStageCompletionGates + execute worktree cleanup),消除 S1/S2/S3 三处收尾不对称(task-01 抽出)。
277
+ // gate 失败已 rollback,early-return 跳过下方"阶段已完成/下一步"提示(合理收紧:回滚不该打完成提示)。
278
+ const _stageGatesResult = await completeStageGates({ stageName, cwd, changeName, platformOpts, specBase, progress, pm, stageData, steps, currentIdx, outputText })
279
+ if (_stageGatesResult) return _stageGatesResult
362
280
 
363
- // 验证关键文件是否在正确的变更目录下(仅当所有步骤确实完成时才校验)
364
- if (actualCompleted === actualTotal && actualTotal > 0) {
365
- validateFileLocations(cwd, stageName, progress, changeName, specBase)
366
- }
367
-
368
- // 辅助阶段完成后重置步骤
369
- const stageDef = stageRegistry[stageName]
370
- if (stageDef?.auxiliary) {
371
- const freshSteps = (stageDef.steps || []).map(s => ({
372
- name: s.name,
373
- status: 'pending',
374
- output: null,
375
- completedAt: null
376
- }))
377
- stageData.steps = freshSteps
378
- stageData.status = 'pending'
379
- stageData.completedAt = null
380
- if (progress.currentStage === stageName) progress.currentStage = ''
381
- await pm._write(cwd, progress, changeName)
382
- }
281
+ // gate 全过:persist completed(task-01 移后)。此处到 _write 之间若崩,DB 仍 in-progress(内存已 completed 但未落盘),下次进 CLI 读 DB 即 in-progress,不产生"假 completed"。
282
+ pm._write(cwd, progress, changeName)
283
+ triggerSync(cwd, changeName, platformOpts)
383
284
 
384
285
  const total = steps.length
385
286
  console.log(`✅ ${stageName} 阶段已完成(${total}/${total} 步)`)
@@ -434,23 +335,11 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
434
335
  }
435
336
  }
436
337
 
437
- // 阶段完成校验 — 防御性守卫:仅当所有步骤确实标记为 completed 时才跑 validator
438
- if (actualCompleted === actualTotal && actualTotal > 0) {
439
- const _gateEarlyReturn = await runStageCompletionGates({ stageName, cwd, changeName, platformOpts, specBase, progress, pm, stageData, steps, currentIdx })
440
- if (_gateEarlyReturn) return _gateEarlyReturn
441
- } else if (actualCompleted < actualTotal) {
442
- // 实际步骤未全部完成,跳过 validator(状态可能不同步)
443
- console.log(`\n⚠️ 阶段校验跳过:${actualTotal} 步中仅 ${actualCompleted} 步标记为已完成,可能存在状态不同步。如确认阶段已完成,请运行 --status 确认。`)
444
- }
445
-
446
- // execute worktree cleanup(W6 Step6c 抽至 complete-handlers.js handleExecuteWorktreeCleanup)
447
- await handleExecuteWorktreeCleanup({ stageName, changeName, cwd })
448
-
449
338
  return { stageCompleted: true, currentIdx, nextPendingIdx: -1 }
450
339
  }
451
340
 
452
341
  progress.lastActive = new Date().toLocaleString('zh-CN',{hour12:false})
453
- await pm._write(cwd, progress, changeName)
342
+ pm._write(cwd, progress, changeName)
454
343
  triggerSync(cwd, changeName, platformOpts)
455
344
 
456
345
  // Append to user-inputs.md
@@ -469,6 +358,13 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
469
358
 
470
359
  if (printNext) {
471
360
  await outputStep(stageName, nextPendingIdx, defSteps, cwd, changeName, progress.project || null, platformOpts)
361
+ // task-08:底部锚定行——outputStep 渲染的长 prompt 易被 tail 截断,末尾再打一行推进位置,
362
+ // 让 agent 只看末几行也能知道「推进到第几步」。仅单步推进分支(阶段完成分支已有 ✅ 阶段已完成)。
363
+ // defSteps 越界防御:平台模式 buildPlanSteps 长度漂移时 nextPendingIdx 可能越界
364
+ // (参照 prompt.js:174-179 越界降级先例),越界时静默跳过,不崩。
365
+ if (defSteps && defSteps[nextPendingIdx]) {
366
+ console.log(`\n🚀 advanced to step ${nextPendingIdx + 1}/${steps.length}: ${defSteps[nextPendingIdx].name}`)
367
+ }
472
368
  }
473
369
  return { stageCompleted: false, currentIdx, nextPendingIdx }
474
370
  }
@@ -482,6 +378,31 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
482
378
  * @returns {{autoChecked:boolean, checkedCount:number, skippedCount:number, planTotal:number, planChecked:number}}
483
379
  * planTotal/planChecked 在调用方于本函数之后用 readPlanCheckboxStatus 重读(勾选已落盘)。
484
380
  */
381
+ /**
382
+ * 读 task 卡片文本(plan.md task 行外的详细描述/allowed_paths),用于端到端 task 判定。
383
+ * 卡片缺失返回空串(仅靠 plan.md task 行文本判)。
384
+ */
385
+ function readTaskCardText(changeDir, taskNum) {
386
+ try { return readFileSync(join(changeDir, 'tasks', `task-${taskNum}.md`), 'utf8') } catch { return '' }
387
+ }
388
+
389
+ /**
390
+ * 判定 task 是否该被 autoCheckPlanFromReviews 自动勾选。
391
+ * 端到端/deployment-critical task 要求 review spec+quality 双 pass(cannot_verify 不算,防批量完成
392
+ * 放行未真验的端到端 task);普通 task 非 fail 即可(保主 agent 直接实现模式体验,其 cannot_verify
393
+ * 草稿仍可批量收尾)。坑 execute-batch-complete-endtoend-checkbox。
394
+ * @param {{ok?:boolean, review?:{specVerdict?:string, qualityVerdict?:string}}} r readReview 结果
395
+ * @param {boolean} endToEnd 是否端到端/deployment-critical task
396
+ * @returns {boolean}
397
+ */
398
+ export function shouldAutoCheckTask(r, endToEnd) {
399
+ if (!r?.ok) return false
400
+ const spec = r.review?.specVerdict
401
+ const quality = r.review?.qualityVerdict
402
+ if (spec === 'fail' || quality === 'fail') return false
403
+ if (endToEnd) return spec === 'pass' && quality === 'pass'
404
+ return true
405
+ }
485
406
  async function autoCheckPlanFromReviews({ stageName, changeName, cwd, platformOpts }) {
486
407
  if (stageName !== 'execute' || !changeName) {
487
408
  return { autoChecked: false, checkedCount: 0, skippedCount: 0 }
@@ -490,31 +411,40 @@ async function autoCheckPlanFromReviews({ stageName, changeName, cwd, platformOp
490
411
  const specBaseLc = platformOpts?.specRoot || join(cwd, '.sillyspec')
491
412
  const changeDir = join(specBaseLc, 'changes', changeName)
492
413
  const planPath = join(changeDir, 'plan.md')
493
- const runtimeRoot = platformOpts?.runtimeRoot || join(specBaseLc, '.runtime')
414
+ const runtimeRoot = resolveRuntimeRoot(platformOpts, specBaseLc)
494
415
  const runIdFile = join(runtimeRoot, `current-execute-run-id-${changeName}`)
495
416
  if (!existsSync(planPath) || !existsSync(runIdFile)) {
496
417
  return { autoChecked: false, checkedCount: 0, skippedCount: 0 }
497
418
  }
498
419
  const executeRunId = readFileSync(runIdFile, 'utf8').trim()
499
- const planContent = readFileSync(planPath, 'utf8')
500
420
  const { readReview } = await import('../task-review.js')
501
- let checkedCount = 0
502
- let skippedCount = 0
503
- const updated = planContent.replace(/^(\s*[-*]\s*\[)\s(\]\s*task-\d+)/gim, (match, p1, p2) => {
504
- const taskNum = match.match(/task-(\d+)/)[1].padStart(2, '0')
505
- const reviewPath = join(runtimeRoot, 'execute-runs', executeRunId, 'tasks', `task-${taskNum}`, 'review.json')
506
- const r = readReview(reviewPath)
507
- if (r.ok && r.review?.specVerdict !== 'fail' && r.review?.qualityVerdict !== 'fail') {
508
- checkedCount++
509
- return `${p1}x${p2}` // 勾选
421
+ // plan.md 是 agent 与 CLI 都会写的共享文件(agent 勾 checkbox、此处 autoCheck 也勾选)。
422
+ // 读-改-写必须整体持锁(withFileLock 串行化多进程),否则并发 execute --done / 手动勾选互相覆盖
423
+ // (后到者覆盖先到者);写入用 writeAtomicSync(tmp+rename 原子),防 Windows 整文件覆盖被读半截
424
+ // (fs-atomic.js 头注明的 reader-writer 竞态坑)。锁文件放 changeDir,与 QUICKLOG 同机制。
425
+ const lockPath = join(changeDir, '.plan.md.lock')
426
+ return await withFileLock(lockPath, async () => {
427
+ const planContent = readFileSync(planPath, 'utf8')
428
+ let checkedCount = 0
429
+ let skippedCount = 0
430
+ const updated = planContent.replace(/^(\s*[-*]\s*\[)\s(\]\s*task-\d+)/gim, (match, p1, p2) => {
431
+ const taskNum = match.match(/task-(\d+)/)[1].padStart(2, '0')
432
+ const reviewPath = join(runtimeRoot, 'execute-runs', executeRunId, 'tasks', `task-${taskNum}`, 'review.json')
433
+ const r = readReview(reviewPath)
434
+ // 端到端 task:必须 pass(cannot_verify 不算);普通 task:非 fail 即可(坑 execute-batch-complete-endtoend-checkbox)
435
+ const endToEnd = isEndToEndTaskText(match + ' ' + readTaskCardText(changeDir, taskNum))
436
+ if (shouldAutoCheckTask(r, endToEnd)) {
437
+ checkedCount++
438
+ return `${p1}x${p2}` // 勾选
439
+ }
440
+ skippedCount++
441
+ return match // 不勾
442
+ })
443
+ if (checkedCount > 0) {
444
+ writeAtomicSync(planPath, updated)
510
445
  }
511
- skippedCount++
512
- return match // 不勾
446
+ return { autoChecked: checkedCount > 0, checkedCount, skippedCount }
513
447
  })
514
- if (checkedCount > 0) {
515
- writeFileSync(planPath, updated)
516
- }
517
- return { autoChecked: checkedCount > 0, checkedCount, skippedCount }
518
448
  } catch {
519
449
  return { autoChecked: false, checkedCount: 0, skippedCount: 0 }
520
450
  }
@@ -592,7 +522,7 @@ export async function waitStep(pm, progress, stageName, cwd, outputText, waitRea
592
522
  // 查找下一个 pending 或 in-progress 的步骤
593
523
  const currentIdx = stageData.steps.findIndex(s => s.status === 'pending' || s.status === 'in-progress')
594
524
  if (currentIdx === -1) {
595
- console.error('没有可以等待的步骤')
525
+ console.error(`没有可以等待的步骤(阶段 ${stageName} 已无 pending/in-progress 步骤)。当前阶段状态:${stageData?.status ?? '未知'}。用 \`sillyspec run ${stageName} --status\` 查看进度,或 \`sillyspec progress show\` 看全局下一步。`)
596
526
  process.exit(1)
597
527
  }
598
528
 
@@ -651,7 +581,7 @@ export async function waitStep(pm, progress, stageName, cwd, outputText, waitRea
651
581
  }
652
582
 
653
583
  progress.lastActive = now
654
- await pm._write(cwd, progress, changeName)
584
+ pm._write(cwd, progress, changeName)
655
585
  triggerSync(cwd, changeName, platformOpts)
656
586
 
657
587
  console.log(`⏸️ Step ${currentIdx + 1}/${stageData.steps.length} 已暂停等待:${stageData.steps[currentIdx].name}`)
@@ -678,7 +608,7 @@ export async function continueStep(pm, progress, stageName, cwd, answer, options
678
608
  // 查找 waiting 的步骤
679
609
  const waitingSteps = stageData.steps.map((s, i) => ({ ...s, idx: i })).filter(s => s.status === 'waiting')
680
610
  if (waitingSteps.length === 0) {
681
- console.error('没有处于等待状态的步骤')
611
+ console.error(`没有处于等待状态的步骤(阶段 ${stageName} 当前无 waiting 步骤,--continue 无目标)。当前阶段状态:${stageData?.status ?? '未知'}。用 \`sillyspec run ${stageName} --status\` 查看进度,或 \`sillyspec progress show\` 看全局下一步。`)
682
612
  process.exit(1)
683
613
  }
684
614
 
@@ -754,7 +684,7 @@ export async function continueStep(pm, progress, stageName, cwd, answer, options
754
684
  }
755
685
 
756
686
  progress.lastActive = now
757
- await pm._write(cwd, progress, changeName)
687
+ pm._write(cwd, progress, changeName)
758
688
  triggerSync(cwd, changeName, platformOpts)
759
689
 
760
690
  console.log(`✅ Step ${currentIdx + 1}/${stageData.steps.length} 已继续:${currentStep.name}`)
@@ -793,37 +723,14 @@ export async function continueStep(pm, progress, stageName, cwd, answer, options
793
723
  if (nextPendingIdx === -1 && nextWaitingIdx === -1) {
794
724
  stageData.status = 'completed'
795
725
  stageData.completedAt = now
796
- await pm._write(cwd, progress, changeName)
726
+ // persist _write 移到 completeStageGates 成功之后(task-03 / review-2026-08-09 #2):gate 异常/失败 → rollback 回 in-progress 落盘,此处未到 _write,DB 不留假 completed。
727
+ // 阶段完成收尾共享管线(含 execute worktree cleanup),消除 continueStep 完成分支绕过 gate 的 S2(task-01 抽出)。
728
+ // gate 失败已 rollback,early-return 跳过下方"阶段已完成/下一步"提示(与 completeStep 同语义)。
729
+ const _stageGatesResult = await completeStageGates({ stageName, cwd, changeName, platformOpts, specBase, progress, pm, stageData, steps: stageData.steps, currentIdx, outputText: null })
730
+ if (_stageGatesResult) return _stageGatesResult
731
+ // gate 全过:persist completed(task-03 移后;此处无 triggerSync)。
732
+ pm._write(cwd, progress, changeName)
797
733
  console.log(`\n✅ ${stageName} 阶段已完成(${stageData.steps.length}/${stageData.steps.length} 步)`)
798
- // ── execute 阶段完成时条件性清理 worktree ──
799
- if (stageName === 'execute' && changeName) {
800
- try {
801
- const { WorktreeManager } = await import('../worktree.js');
802
- const wm = new WorktreeManager({ cwd });
803
- const meta = wm.getMeta(changeName);
804
- if (!meta) {
805
- console.log('🔗 Worktree: n/a (no meta)');
806
- } else if (meta.mode === 'native-worktree') {
807
- console.log('🔗 Worktree: kept (外部隔离环境)');
808
- } else {
809
- // in-place 模式不再短路:cleanup 现在能安全处理 in-place(只清 meta,不碰主工作区)
810
- const check = wm.hasUnappliedChanges(changeName);
811
- if (check.hasChanges) {
812
- console.log(`🔗 Worktree: pending apply (${check.changedFiles.length} 个未应用变更)`);
813
- console.log(` 下一步: sillyspec worktree apply ${changeName}`);
814
- } else {
815
- const cleanResult = wm.cleanup(changeName);
816
- console.log(`🔗 Worktree: ${cleanResult.result}`);
817
- if (cleanResult.residual?.length > 0) {
818
- console.warn(` ⚠️ 清理残留: ${cleanResult.residual.join('; ')}`);
819
- console.warn(` 手动处理: sillyspec worktree cleanup ${changeName} --force`);
820
- }
821
- }
822
- }
823
- } catch (e) {
824
- console.warn(`🔗 Worktree: check failed — ${e.message}`);
825
- }
826
- }
827
734
  // 阶段完成后明确下一步(agent 常卡:stageData completed 但不知要 run <下一阶段> 推进 currentStage)
828
735
  const nextStageHint = { brainstorm: 'plan', plan: 'execute', execute: 'verify', verify: 'archive' }[stageName]
829
736
  if (nextStageHint) {
@@ -884,7 +791,7 @@ export async function skipStep(pm, progress, stageName, cwd, changeName, platfor
884
791
  console.error(`⏸️ Step ${wsIdx + 1} 正在等待用户输入,不能跳过。`)
885
792
  console.error(` 请先使用 --continue --answer "..." 继续,或用 --reset 重置。`)
886
793
  } else {
887
- console.error('没有待跳过的步骤')
794
+ console.error(`没有待跳过的步骤(阶段 ${stageName} 已无 pending/in-progress 步骤)。当前阶段状态:${stageData?.status ?? '未知'}。用 \`sillyspec run ${stageName} --status\` 查看进度,或 \`sillyspec progress show\` 看全局下一步。`)
888
795
  }
889
796
  process.exit(1)
890
797
  }
@@ -899,7 +806,7 @@ export async function skipStep(pm, progress, stageName, cwd, changeName, platfor
899
806
  steps[currentIdx].status = 'skipped'
900
807
  steps[currentIdx].skippedAt = new Date().toLocaleString('zh-CN',{hour12:false})
901
808
  progress.lastActive = new Date().toLocaleString('zh-CN',{hour12:false})
902
- await pm._write(cwd, progress, changeName)
809
+ pm._write(cwd, progress, changeName)
903
810
  triggerSync(cwd, changeName, platformOpts)
904
811
 
905
812
  console.log(`⏭️ Step ${currentIdx + 1}/${steps.length} 已跳过:${steps[currentIdx].name}`)
@@ -0,0 +1,132 @@
1
+ /**
2
+ * concurrent-detect — 多 agent 并发写预检的纯函数检测核心(design §7)。
3
+ *
4
+ * 单次 git status --porcelain 扫描,复用 isQuickMetadata 的「关联 vs 他者」分类口径,
5
+ * 产出两类并发信号:
6
+ * - foreignFiles: 脏文件里非 metadata、不在 ownFiles 的真实业务文件(他者未提交改动)
7
+ * - otherActiveChanges: 脏文件落在 .sillyspec/changes/<他者变更>/ 下去重成的变更名集合
8
+ *
9
+ * 非阻塞 advisory:检测结果仅在调用点本地消费(→ formatConcurrentWarning → console.warn),
10
+ * 不改 audit status / gate 通过性 / isQuickMetadata 语义(design §2/§9)。
11
+ *
12
+ * 纯函数无副作用:不写盘、不 console(console 留给调用点 task-02/03)。
13
+ */
14
+ import { safeGit, parsePorcelainPath, isQuickMetadata } from './shared.js'
15
+
16
+ /**
17
+ * 内联解析 .sillyspec/changes/<dir>/ 路径取 <dir>。
18
+ * 与 shared.js isQuickMetadata 内部 regex(^\.sillyspec\/changes\/([^/]+)(\/|$))同源——
19
+ * D-008 deferred:暂不抽 shared.js 公共 helper,改其一改其二。
20
+ * @param {string} file 已归一化(正斜杠)的 git 路径
21
+ * @returns {string|null} 变更目录名,不在 changes/ 下返回 null
22
+ */
23
+ function extractChangeDir(file) {
24
+ const m = file.match(/^\.sillyspec\/changes\/([^/]+)(\/|$)/)
25
+ return m ? m[1] : null
26
+ }
27
+
28
+ /** git 路径反斜杠归一(跨平台 Windows 兼容)。 */
29
+ function normalizeGitPath(p) {
30
+ return String(p).replace(/\\/g, '/')
31
+ }
32
+
33
+ /**
34
+ * 检测工作树里的并发他者改动(非阻塞 advisory 用)。
35
+ *
36
+ * @param {string} cwd 主仓库根
37
+ * @param {{ changeName: string, linkedChanges?: string[], ownFiles?: string[], specDir?: string }} opts
38
+ * - changeName: 当前变更名(排除自身变更目录)
39
+ * - linkedChanges: 关联变更(透传给 isQuickMetadata 的关联归类,同时从 otherActiveChanges 排除)
40
+ * - ownFiles: 本 --done 负责的文件(从 foreignFiles 排除,避免把自己当他者)
41
+ * - specDir: 规范目录(保留参数,当前实现用 .sillyspec/ 前缀 regex 定位,暂未使用)
42
+ * @returns {{ hasForeign: boolean, foreignFiles: string[], otherActiveChanges: string[], gitError: string|null }}
43
+ * - foreignFiles: 脏文件里非 metadata、不在 ownFiles 的真实业务文件
44
+ * - otherActiveChanges: 脏文件落在 .sillyspec/changes/<他者变更>/ 下,去重成的变更名集合
45
+ * - gitError: git status 读失败时填错误串,hasForeign=false(FR-04 fail-open,不抛异常)
46
+ */
47
+ export function detectConcurrentChanges(cwd, { changeName, linkedChanges = [], ownFiles = [], specDir } = {}) {
48
+ // safeGit 必传 trim:false(D-004):porcelain 首行前导空格是状态码一部分,trim 会削掉致
49
+ // parsePorcelainPath 丢首字符(坑见 shared.js auditQuickCompletion :448 注释)。
50
+ const statusResult = safeGit(cwd, ['status', '--porcelain'], { trim: false })
51
+
52
+ // FR-04 fail-open:git status 读不到不崩、不阻断、不误报(advisory 漏报可接受,最坏漏报而非误阻)。
53
+ if (statusResult.error) {
54
+ return { hasForeign: false, foreignFiles: [], otherActiveChanges: [], gitError: statusResult.error }
55
+ }
56
+
57
+ const gitStatus = statusResult.value || ''
58
+ // 不整段 trim:同 auditQuickCompletion :455 注释,会削首行前导空格致首文件路径丢首字符。
59
+ const entries = gitStatus.split('\n').filter(Boolean)
60
+
61
+ // 本变更 + 关联变更目录集合:rule1 据此把「自己/关联」从他者变更中排除。
62
+ const ownChangeSet = new Set(
63
+ (Array.isArray(linkedChanges) ? linkedChanges : [])
64
+ .map(normalizeGitPath)
65
+ .filter(Boolean)
66
+ )
67
+ if (changeName) ownChangeSet.add(normalizeGitPath(changeName))
68
+
69
+ // ownFiles 归一化集合:rule3 据此把本会话产出从他者业务文件中排除。
70
+ const ownFileSet = new Set(
71
+ (Array.isArray(ownFiles) ? ownFiles : [])
72
+ .map(normalizeGitPath)
73
+ .filter(Boolean)
74
+ )
75
+
76
+ const foreignFiles = []
77
+ const otherActiveSet = new Set()
78
+
79
+ for (const line of entries) {
80
+ // parsePorcelainPath:去引号 / 处理 rename / 归一化反斜杠(跨平台)。
81
+ const file = parsePorcelainPath(line)
82
+ if (!file) continue
83
+
84
+ // rule1:落 .sillyspec/changes/<dir>/ 且 <dir> 非本变更非关联 → otherActiveChanges 去重。
85
+ const dir = extractChangeDir(file)
86
+ if (dir && !ownChangeSet.has(normalizeGitPath(dir))) {
87
+ otherActiveSet.add(dir)
88
+ continue
89
+ }
90
+
91
+ // rule2:isQuickMetadata 为 true(quick 元数据 / 关联变更目录 / quicklog/.runtime/modules 等)→ 跳过。
92
+ // 注意:当前变更自己的 changes/<changeName>/ 也走这里——changeName 通常不在 linkedChanges,
93
+ // isQuickMetadata 视为元数据放行,与本变更自身目录不该当他者的语义一致。
94
+ if (isQuickMetadata(file, linkedChanges)) continue
95
+
96
+ // rule3:真实业务文件 → 不在 ownFiles 则归入他者 foreignFiles。
97
+ if (!ownFileSet.has(file)) {
98
+ foreignFiles.push(file)
99
+ }
100
+ }
101
+
102
+ const otherActiveChanges = [...otherActiveSet]
103
+ const hasForeign = foreignFiles.length > 0 || otherActiveChanges.length > 0
104
+ return { hasForeign, foreignFiles, otherActiveChanges, gitError: null }
105
+ }
106
+
107
+ /**
108
+ * 把检测结果格式化为多行 ⚠️ 警告串。
109
+ * @param {{ hasForeign: boolean, foreignFiles?: string[], otherActiveChanges?: string[], gitError?: string|null }|null} detected
110
+ * @returns {string|null} 无他者并发(hasForeign 为 false / null 输入)返回 null,调用点据此跳过 console.warn
111
+ */
112
+ export function formatConcurrentWarning(detected) {
113
+ if (!detected || !detected.hasForeign) return null
114
+ const foreignFiles = Array.isArray(detected.foreignFiles) ? detected.foreignFiles : []
115
+ const otherActiveChanges = Array.isArray(detected.otherActiveChanges) ? detected.otherActiveChanges : []
116
+
117
+ const lines = ['⚠️ 检测到工作树存在并发他者改动(非阻断 advisory,请人工确认):']
118
+
119
+ if (foreignFiles.length > 0) {
120
+ lines.push('他者业务文件(git-dirty,可能非本变更产出):')
121
+ for (const f of foreignFiles) lines.push(` - ${f}`)
122
+ }
123
+
124
+ if (otherActiveChanges.length > 0) {
125
+ // D-005:文案用「脏变更目录」+ git-dirty 标注,勿用「活跃」防与 DB active 状态混淆。
126
+ lines.push('他者脏变更目录(git-dirty,非 DB active):')
127
+ for (const c of otherActiveChanges) lines.push(` - ${c}`)
128
+ }
129
+
130
+ lines.push('提交请用显式 pathspec 隔离本变更文件,勿 git add . 扫入他者工作。')
131
+ return lines.join('\n')
132
+ }