sillyspec 3.26.4 → 3.26.6

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 (66) hide show
  1. package/.claude/skills/sillyspec-brainstorm/SKILL.md +1 -0
  2. package/.claude/skills/sillyspec-execute/SKILL.md +1 -0
  3. package/.claude/skills/sillyspec-plan/SKILL.md +1 -0
  4. package/.claude/skills/sillyspec-quick/SKILL.md +5 -3
  5. package/package.json +1 -1
  6. package/src/db.js +4 -2
  7. package/src/fs-atomic.js +2 -2
  8. package/src/git-helper.js +21 -8
  9. package/src/index.js +3 -1
  10. package/src/progress/stage-machine.js +10 -4
  11. package/src/quicklog.js +2 -0
  12. package/src/review-tier.js +26 -14
  13. package/src/run/command.js +50 -7
  14. package/src/run/complete-handlers.js +95 -1
  15. package/src/run/complete.js +81 -6
  16. package/src/run/gates.js +1 -1
  17. package/src/run/prompt.js +34 -20
  18. package/src/run/quick-audit.js +5 -6
  19. package/src/run/shared.js +45 -11
  20. package/src/run/stage.js +10 -0
  21. package/src/run.js +5 -5
  22. package/src/stage-contract-spec.js +11 -0
  23. package/src/stage-contract.js +11 -1
  24. package/src/stage-review.js +9 -1
  25. package/src/stages/archive.js +12 -26
  26. package/src/stages/brainstorm-auto.js +1 -0
  27. package/src/stages/brainstorm.js +1 -0
  28. package/src/stages/execute.js +45 -0
  29. package/src/stages/plan.js +17 -1
  30. package/src/stages/quick.js +2 -0
  31. package/src/stages/scan.js +2 -2
  32. package/src/stages/verify.js +1 -0
  33. package/src/worktree-apply.js +3 -3
  34. package/src/worktree.js +83 -8
  35. package/packages/dashboard/index.html +0 -15
  36. package/packages/dashboard/package-lock.json +0 -2384
  37. package/packages/dashboard/public/favicon.jpg +0 -0
  38. package/packages/dashboard/public/logo.jpg +0 -0
  39. package/packages/dashboard/public/prototype-dashboard.html +0 -836
  40. package/packages/dashboard/public/prototype-overview.html +0 -256
  41. package/packages/dashboard/src/App.vue +0 -558
  42. package/packages/dashboard/src/components/ActionBar.vue +0 -93
  43. package/packages/dashboard/src/components/CommandPalette.vue +0 -96
  44. package/packages/dashboard/src/components/DetailPanel.vue +0 -137
  45. package/packages/dashboard/src/components/DocPreview.vue +0 -257
  46. package/packages/dashboard/src/components/DocTree.vue +0 -114
  47. package/packages/dashboard/src/components/HResizeHandle.vue +0 -48
  48. package/packages/dashboard/src/components/LogStream.vue +0 -65
  49. package/packages/dashboard/src/components/PipelineStage.vue +0 -95
  50. package/packages/dashboard/src/components/PipelineView.vue +0 -156
  51. package/packages/dashboard/src/components/ProjectCard.vue +0 -187
  52. package/packages/dashboard/src/components/ProjectList.vue +0 -210
  53. package/packages/dashboard/src/components/ProjectOverview.vue +0 -152
  54. package/packages/dashboard/src/components/StageBadge.vue +0 -67
  55. package/packages/dashboard/src/components/StepCard.vue +0 -94
  56. package/packages/dashboard/src/components/VResizeHandle.vue +0 -61
  57. package/packages/dashboard/src/components/detail/DocsDetail.vue +0 -48
  58. package/packages/dashboard/src/components/detail/GitDetail.vue +0 -61
  59. package/packages/dashboard/src/components/detail/TechDetail.vue +0 -43
  60. package/packages/dashboard/src/composables/useDashboard.js +0 -213
  61. package/packages/dashboard/src/composables/useKeyboard.js +0 -119
  62. package/packages/dashboard/src/composables/useLayout.js +0 -131
  63. package/packages/dashboard/src/composables/useWebSocket.js +0 -129
  64. package/packages/dashboard/src/main.js +0 -8
  65. package/packages/dashboard/src/style.css +0 -132
  66. package/packages/dashboard/vite.config.js +0 -18
@@ -5,25 +5,30 @@
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
  *
8
- * 安全锚:run.js 始终 barrel。completeStep 由 run.js import 回来;_completeStepForTest 被 9 个
9
- * run-complete-step-* characterization 测试直接 import,run.js barrel re-export(export { completeStep as
10
- * _completeStepForTest } 沿用 imported-binding re-export)契约保留。
8
+ * 安全锚:run.js 始终 barrel。completeStep 由 run/command.js 在 --done 路径调用,无测试专用导出
9
+ * (曾以 `_completeStepForTest` 别名 re-export 供 characterization 测试直驱动,2026-08-13 这些测试
10
+ * 全部迁到 CLI 子进程 `sillyspec run <stage> --done`,导出已移除——见 run.js barrel 注释)。
11
11
  *
12
12
  * 依赖(completeStep 仅用已抽叶子 + 随搬 helper,零 run.js 闭包,零动态 import):
13
13
  * - shared.js: triggerSync/WAIT_MARKER_RE/getStageSteps;prompt.js: outputStep;gates.js: enforceDepsGate/runStageCompletionGates
14
14
  * - complete-handlers.js: 8 handler;stages/index.js: stageRegistry;worktree-apply.js: formatExecuteSummary
15
+ * - scan-profile.js: executeScanPreflight/executeScanPostcheck/computeScanProfile(noAI 硬门)
16
+ * - stages/plan-postcheck.js: executePlanPostcheck(noAI 硬门)
15
17
  * - node: join(path) + existsSync/readdirSync/readFileSync/mkdirSync/writeFileSync/appendFileSync/statSync(fs)
16
18
  */
17
19
  import { join } from 'node:path'
18
20
  import { existsSync, readFileSync, mkdirSync, writeFileSync, appendFileSync } from 'node:fs'
19
21
  import { writeAtomicSync } from '../fs-atomic.js'
20
22
  import { withFileLock } from '../quicklog.js'
21
- import { triggerSync, WAIT_MARKER_RE, getStageSteps, formatWaitOptions, resolveRuntimeRoot, getOrCreateMultiRepoContext } from './shared.js'
23
+ import { triggerSync, WAIT_MARKER_RE, getStageSteps, formatWaitOptions, resolveRuntimeRoot, getOrCreateMultiRepoContext, resolveChangeDir } from './shared.js'
24
+ import { executeScanPreflight, executeScanPostcheck, computeScanProfile } from './scan-profile.js'
25
+ import { executePlanPostcheck as runPlanPostcheckLib } from '../stages/plan-postcheck.js'
22
26
  import { outputStep } from './prompt.js'
23
27
  import { enforceDepsGate, enforceReviewJsonGate, completeStageGates, readDesignScale } from './gates.js'
24
28
  import { handleArchiveConfirmStep, handlePlanGeneratePlanStep, handleScanProjectListStep, handleWorkflowPostCheck, handleQuickStageCompletion, handleExecuteWaveArtifact } from './complete-handlers.js'
25
29
  import { formatExecuteSummary } from '../worktree-apply.js'
26
30
  import { isEndToEndTaskText } from '../change-risk-profile.js'
31
+ import { deriveTitleFromLinkedChange } from '../quicklog.js'
27
32
 
28
33
  // validateMetadata / readDesignScale / validateFileLocations 已迁至 ./gates.js(completeStageGates
29
34
  // 共享收尾管线,消除 noAI 末步 / continueStep 完成分支绕过 gate 的 S1/S2/S3 不对称)。
@@ -59,8 +64,34 @@ export function resolveWaitingStepWithAnswer(steps, doneAnswer, nowStr) {
59
64
  return waitIdx
60
65
  }
61
66
 
67
+ /**
68
+ * wait 选项单选校验(坑 wait-choice-enforcement):定义了 waitOptions 的 requiresWait/
69
+ * repeatableWait 步骤,--answer 必须命中预设选项之一——防止 agent 一句话代答绕过人工
70
+ * 选择(方案选择类 wait 的 answer 本就该是选项本身)。开放回答型步骤(澄清追问,answer
71
+ * 为自由文本)在 step 定义显式声明 waitFreeAnswer: true 豁免。
72
+ * 校验失败打印选项清单 + exit 1(fail-loud,与 requiresWait 门同风格)。
73
+ *
74
+ * waitOptions 优先取 stepDef(运行时构建的步骤定义,与 brainstorm/plan 静态定义同源),
75
+ * 回退 DB step entry(--continue 路径 waiting 步骤的 waitOptions 已落盘)。
76
+ */
77
+ function enforceWaitChoice(stepDef, stepEntry, answer, stageName, changeName) {
78
+ if (answer == null) return
79
+ const opts = Array.isArray(stepDef?.waitOptions) ? stepDef.waitOptions
80
+ : (Array.isArray(stepEntry?.waitOptions) ? stepEntry.waitOptions : null)
81
+ if (!opts || opts.length === 0) return
82
+ if (stepDef?.waitFreeAnswer === true) return
83
+ if (opts.includes(answer)) return
84
+ console.error(`❌ Step "${stepEntry?.name || '当前步骤'}" 是封闭单选 wait,--answer 必须命中预设选项之一(防 AI 代答绕过人工选择)。`)
85
+ console.error(` 你的 --answer: ${JSON.stringify(answer)}`)
86
+ console.error(` 可选值(复制其一): ${opts.join(' / ')}`)
87
+ console.error(` 自由文本回答的场景,该步骤应声明 waitFreeAnswer(当前未声明)。`)
88
+ if (changeName) console.error(` 重试:sillyspec run ${stageName} --done --answer "<选项>" --change ${changeName}`)
89
+ else console.error(` 重试:sillyspec run ${stageName} --done --answer "<选项>"`)
90
+ process.exit(1)
91
+ }
92
+
62
93
  export async function completeStep(pm, progress, stageName, cwd, outputText, inputText = null, options = {}) {
63
- const { printNext = true, confirm = false, changeName, platformOpts = {}, nonInteractive = false, isForceBaseline = false, isAllowNew = false } = options
94
+ const { printNext = true, confirm = false, changeName, platformOpts = {}, nonInteractive = false, isForceBaseline = false, isAllowNew = false, isAllowDelete = false } = options
64
95
  const specBase = platformOpts.specRoot || join(cwd, '.sillyspec')
65
96
  const stageData = progress.stages[stageName]
66
97
 
@@ -117,6 +148,10 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
117
148
  const defStepsForCurrent = await getStageSteps(stageName, cwd, progress, platformOpts?.specRoot || null)
118
149
  const currentStepDef = defStepsForCurrent?.[currentIdx] || {}
119
150
  const currentStep = steps[currentIdx]
151
+ // --done --answer 解 waiting 的路径同样过单选校验(与下方 requiresWait doneAnswer 分支同规则)
152
+ if (_resolvedWaitIdx !== -1 && _doneAnswer) {
153
+ enforceWaitChoice(currentStepDef, currentStep, _doneAnswer, stageName, changeName)
154
+ }
120
155
  if (currentStepDef.requiresWait === true && !currentStep.waitAnswer) {
121
156
  // 检查 --done 是否带了 --answer:如果是,自动补全 waitAnswer 状态,一步完成
122
157
  const doneAnswer = typeof options !== 'undefined' && options.doneAnswer ? options.doneAnswer : null
@@ -133,6 +168,7 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
133
168
  currentStep.waitReason = currentStepDef.waitReason || '等待用户输入'
134
169
  console.log(`⚠️ Step "${currentStep.name}" 的确认(${currentStepDef.waitReason})已在前置步骤完成,自动跳过。`)
135
170
  } else if (doneAnswer) {
171
+ enforceWaitChoice(currentStepDef, currentStep, doneAnswer, stageName, changeName)
136
172
  currentStep.status = 'waiting'
137
173
  currentStep.waitAnswer = doneAnswer
138
174
  currentStep.waitReason = currentStepDef.waitReason || '等待用户输入'
@@ -156,6 +192,27 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
156
192
  // 提前到每次 --done 校验(而非等 Task Review Gate 在整阶段收尾才暴露,迫使用户事后批量补)。
157
193
  await enforceReviewJsonGate(stageName, cwd, changeName, steps[currentIdx], steps, currentIdx, specBase, platformOpts)
158
194
 
195
+ // ── noAI 步骤硬门(坑 noai-done-bypass):noAI 步骤的确定性校验不可被 --done 绕过 ──
196
+ // 正常路径 agent 跑 `run <stage>` 推进到 noAI step 时,runStage 自动执行 _cliAction
197
+ // (stage.js noAI 分支,不写 step output);若 agent 对 noAI step 直接 --done,此前
198
+ // completeStep 无 noAI 检测直接标 completed,executePlanPostcheck 等 CLI 确定性校验被
199
+ // 静默跳过(实证:multi-agent-platform 2026-08-13-spec-sync-visibility tasks/ 从未
200
+ // 生成但 plan 阶段 completed——step4 output 为 agent 口吻 178 字符而非 CLI 自动路径的
201
+ // 空 output)。修复:--done 落到 noAI step 时同样执行 _cliAction,校验 throw →
202
+ // completeStep 不推进(step 保持 pending)。分支对齐 stage.js 的 noAI 自动执行。
203
+ const _isNoAIStep = currentStepDef.noAI === true || currentStep?.noAI === true
204
+ if (_isNoAIStep) {
205
+ const _cliAction = currentStepDef._cliAction || currentStep?._cliAction
206
+ console.log(`⚙️ Step ${currentIdx + 1}/${steps.length}: ${steps[currentIdx].name}(noAI,--done 路径执行 CLI 校验)`)
207
+ if (_cliAction === 'scanPreflight') {
208
+ await executeScanPreflight(cwd, platformOpts, stageData.scanProfile || computeScanProfile(cwd, platformOpts))
209
+ } else if (_cliAction === 'scanPostcheck') {
210
+ await executeScanPostcheck(cwd, platformOpts, stageData.scanProfile || computeScanProfile(cwd, platformOpts))
211
+ } else if (_cliAction === 'planPostcheck') {
212
+ await runPlanPostcheckLib({ cwd, specRoot: platformOpts?.specRoot, resolveChangeDir, progress })
213
+ }
214
+ }
215
+
159
216
  steps[currentIdx].status = 'completed'
160
217
  steps[currentIdx].completedAt = new Date().toLocaleString('zh-CN',{hour12:false})
161
218
  if (outputText) {
@@ -248,7 +305,7 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
248
305
  return { stageCompleted: false, currentIdx, nextPendingIdx: -1 }
249
306
  }
250
307
  // quick 收尾(W6 Step6b 抽至 complete-handlers.js handleQuickStageCompletion)
251
- await handleQuickStageCompletion({ stageName, steps, currentIdx, cwd, progress, changeName, specBase, outputText, confirm, isForceBaseline, isAllowNew, platformOpts, pm })
308
+ await handleQuickStageCompletion({ stageName, steps, currentIdx, cwd, progress, changeName, specBase, outputText, confirm, isForceBaseline, isAllowNew, isAllowDelete, platformOpts, pm })
252
309
 
253
310
  // ── reopen --done 回填(坑 brainstorm-reopen-step-state-desync)──
254
311
  // nextPendingIdx === -1 且无 waiting,说明要进阶段完成分支。此时若存在 stale 步骤
@@ -299,6 +356,16 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
299
356
  const _stageGatesResult = await completeStageGates({ stageName, cwd, changeName, platformOpts, specBase, progress, pm, stageData, steps, currentIdx, outputText, ctx: _completeCtx })
300
357
  if (_stageGatesResult) return _stageGatesResult
301
358
 
359
+ // 完整流程 change title 刷新:proposal/design 落盘后(brainstorm/plan 完成),从 proposal 首个 #
360
+ // 标题刷新 changes.title(人类可读中文标题,用户可随时刷新)。quick-<hex> 无 proposal 目录 →
361
+ // deriveTitleFromLinkedChange 返回 '' 不刷新(quick 走 handleQuickStageCompletion 的 extractTitleFromResult)。
362
+ if (changeName && !/^quick-[0-9a-f]{8}$/.test(changeName)) {
363
+ try {
364
+ const refinedTitle = deriveTitleFromLinkedChange(specBase, changeName)
365
+ if (refinedTitle) pm.updateChangeMeta(cwd, changeName, { title: refinedTitle })
366
+ } catch { /* title 刷新失败不阻断完成 */ }
367
+ }
368
+
302
369
  // gate 全过:persist completed(task-01 移后)。此处到 _write 之间若崩,DB 仍 in-progress(内存已 completed 但未落盘),下次进 CLI 读 DB 即 in-progress,不产生"假 completed"。
303
370
  pm._write(cwd, progress, changeName)
304
371
  triggerSync(cwd, changeName, platformOpts)
@@ -677,6 +744,7 @@ export async function continueStep(pm, progress, stageName, cwd, answer, options
677
744
  const prevOutput = currentStep.output || ''
678
745
  const waitRound = (currentStep.waitRound || 0) + 1
679
746
  currentStep.waitRound = waitRound
747
+ enforceWaitChoice(currentStepDef, currentStep, answer, stageName, changeName)
680
748
  currentStep.waitAnswer = answer
681
749
  currentStep.waitAnswers = Array.isArray(currentStep.waitAnswers) ? currentStep.waitAnswers : []
682
750
  currentStep.waitAnswers.push({
@@ -764,6 +832,13 @@ export async function continueStep(pm, progress, stageName, cwd, answer, options
764
832
  }
765
833
  const _stageGatesResult = await completeStageGates({ stageName, cwd, changeName, platformOpts, specBase, progress, pm, stageData, steps: stageData.steps, currentIdx, outputText: null, ctx: _contCtx })
766
834
  if (_stageGatesResult) return _stageGatesResult
835
+ // 完整流程 change title 刷新(同 completeStep 完成分支,wait 解除后阶段完成也刷新)。
836
+ if (changeName && !/^quick-[0-9a-f]{8}$/.test(changeName)) {
837
+ try {
838
+ const refinedTitle = deriveTitleFromLinkedChange(specBase, changeName)
839
+ if (refinedTitle) pm.updateChangeMeta(cwd, changeName, { title: refinedTitle })
840
+ } catch { /* title 刷新失败不阻断完成 */ }
841
+ }
767
842
  // gate 全过:persist completed(task-03 移后;此处无 triggerSync)。
768
843
  pm._write(cwd, progress, changeName)
769
844
  console.log(`\n✅ ${stageName} 阶段已完成(${stageData.steps.length}/${stageData.steps.length} 步)`)
package/src/run/gates.js CHANGED
@@ -309,7 +309,7 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
309
309
  : 'acceptance'
310
310
  const searchDirs = [effectiveSpecBase, reviewChangeDir, cwd].filter(Boolean)
311
311
  const reviewResult = validateStageReview({ stage: stageName, reviewType, runtimeRoot, reviewRunId, searchDirs })
312
- printStageReviewResult(reviewResult, { stage: stageName })
312
+ printStageReviewResult(reviewResult, { stage: stageName, reviewRunId, runtimeRoot })
313
313
  if (!reviewResult.ok) {
314
314
  return await rollbackCompletionAndReturn(pm, progress, stageData, steps, currentIdx, cwd, changeName, platformOpts)
315
315
  }
package/src/run/prompt.js CHANGED
@@ -7,9 +7,10 @@
7
7
  * loadModuleContextIndex/buildModuleContextInjection/parseModuleMapSimple
8
8
  * (_module-map.yaml 模块上下文匹配注入,仅 outputStep 用)。
9
9
  *
10
- * 安全锚:run.js 始终 barrel。outputStep + applyRootPlaceholders 由 run.js import 回来;
11
- * _outputStepForTest(output-step-render 测试)+ applyRootPlaceholders(prompt-placeholders
12
- * 测试)被 test 直接 import,run.js barrel re-export 契约保留。
10
+ * 安全锚:run.js 始终 barrel。applyRootPlaceholders 由 run.js import 回来;
11
+ * outputStep(output-step-render / archive-task-completion-injection 测试)+ applyRootPlaceholders
12
+ * (prompt-placeholders 测试)被 test 直接 import 本模块(outputStep 不经 barrel,2026-08-13 起 test
13
+ * 直 import 源模块,`_outputStepForTest` 别名已移除)。
13
14
  *
14
15
  * 路径修正(相对 src/run/):
15
16
  * - stageRegistry 从 '../stages/index.js';resolvePromptIncludes/safeGit/WAIT_MARKER_RE 从 './shared.js'
@@ -33,18 +34,18 @@ import { REVIEW_SCHEMA_VERSION } from '../task-review.js'
33
34
  * @param {string} projectName - 项目名
34
35
  * @returns {object|null} 解析后的模块索引,null 表示无索引
35
36
  */
36
- function loadModuleContextIndex(specBase, projectName) {
37
+ export function loadModuleContextIndex(specBase, projectName) {
37
38
  try {
38
39
  const mapPath = join(specBase, 'docs', projectName, 'modules', '_module-map.yaml')
39
40
  if (!existsSync(mapPath)) return null
40
41
  const content = readFileSync(mapPath, 'utf8')
41
- // schema_version 校验(advisory):modules.js 写 schema_version: 2;缺失或非 2 → warn,
42
- // 提示 v1/v2 格式混用风险(旧格式字段解析会静默错位)。正常生成的文件不触发。
42
+ // schema_version 校验:仅缺失 schema_version(真 malformed,parseModuleMapSimple 必错位)时 warn;
43
+ // schema_version=1(旧格式)静默——读端 buildModuleContextInjection 已 v1/v2 双兼容
44
+ // (data.paths || data.core_files),v1 解析正常。原 v1 warn 是过激噪声(每步渲染 prompt 刷屏)。
45
+ // v1→v2 根因在 scan prompt 模板仍写 schema_version:1,升级 scan.js 是单独改动(见 troubleshooting)。
43
46
  const sv = content.match(/^schema_version:\s*(\d+)/m)
44
47
  if (!sv) {
45
48
  console.warn(`⚠️ _module-map.yaml 缺少 schema_version 声明(期望 2),模块解析可能错位:${mapPath}(跑 \`sillyspec modules rebuild\` 升级到 schema_version: 2 可消除此警告)`)
46
- } else if (sv[1] !== '2') {
47
- console.warn(`⚠️ _module-map.yaml schema_version=${sv[1]}(期望 2),可能为旧格式,模块解析可能错位:${mapPath}(跑 \`sillyspec modules rebuild\` 升级到 schema_version: 2 可消除此警告)`)
48
49
  }
49
50
  return parseModuleMapSimple(content)
50
51
  } catch {
@@ -116,6 +117,19 @@ function buildModuleContextInjection(taskDescription, moduleIndex, specBase, pro
116
117
 
117
118
  // parseModuleMapSimple 复用 modules.js 的 canonical 实现(合并历史 copy-paste 副本,2026-08-07;
118
119
  // 无循环依赖:modules.js 仅 import fs/path/db.js,prompt.js → modules.js 单向)。
120
+ /**
121
+ * 提示词路径根的单一解析:specRoot(平台) > specDriftAnchor(worktree 漂移锚定主仓) > cwd/.sillyspec(本地)。
122
+ * 治 execute review.json 提示路径写 worktree 副本、--done 校验读主仓的分裂(坑 execute-prompt-spec-drift):
123
+ * CLI 自动锚定主仓 spec(command.js detectWorktreeSpecDrift → platformOpts.specDriftAnchor)只修正了
124
+ * resolveRuntimeRoot 的 runtime 落点,outputStep 拼 {SPEC_ROOT}/.runtime/execute-runs/… 若仍用 cwd
125
+ * (worktree)→ 提示的 review.json 路径落到 worktree 副本 .sillyspec,而 gate/checkbox 从主仓
126
+ * resolveRuntimeRoot 读 → agent 落盘错位,marker 漂移改用他 run / task 未勾。specDriftAnchor 非空即主仓
127
+ * specBase,优先于 cwd。平台模式 specRoot 优先语义不变。
128
+ */
129
+ export function resolvePromptSpecBase(platformOpts, cwd) {
130
+ return platformOpts?.specRoot || platformOpts?.specDriftAnchor || join(cwd, '.sillyspec')
131
+ }
132
+
119
133
  /**
120
134
  * 输出当前步骤的 prompt
121
135
  */
@@ -236,7 +250,7 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
236
250
  // 替换 <quicklog-id> 占位符(quick 阶段:从 session guard.json 读 CLI 分配的 ql-ID,
237
251
  // 供 agent 在模块文档变更索引等处引用)
238
252
  if (promptText.includes('<quicklog-id>')) {
239
- const specBaseQl = platformOpts?.specRoot || join(cwd, '.sillyspec')
253
+ const specBaseQl = resolvePromptSpecBase(platformOpts, cwd)
240
254
  let qlIdVal = ''
241
255
  try {
242
256
  const sessionGuardFile = join(specBaseQl, '.runtime', 'quick-sessions', changeName, 'guard.json')
@@ -248,7 +262,7 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
248
262
  }
249
263
  // 替换 <linked-changes> 占位符(quick 阶段:从 .runtime/quick-sessions/<sessionId>/guard.json 读关联变更)
250
264
  if (promptText.includes('<linked-changes>')) {
251
- const specBaseLc = platformOpts?.specRoot || join(cwd, '.sillyspec')
265
+ const specBaseLc = resolvePromptSpecBase(platformOpts, cwd)
252
266
  let linkedChanges = []
253
267
  try {
254
268
  // D-002:guard 按 session 存。changeName == quick-<uuid8>(见 runStage 参数解析)。回退读旧单文件(兼容 task-03 前)
@@ -266,7 +280,7 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
266
280
  if (platformOpts?.specRoot || platformOpts?.runtimeRoot) {
267
281
  const projectName = dbProjectName || basename(cwd)
268
282
  // platformOpts.specRoot 现在指向 specDir 本身(可能是 cwd/.sillyspec 或外部路径)
269
- const specSillyspec = platformOpts.specRoot || join(cwd, '.sillyspec')
283
+ const specSillyspec = resolvePromptSpecBase(platformOpts, cwd)
270
284
  const docsRoot = join(specSillyspec, 'docs', projectName)
271
285
  const projectsRoot = join(specSillyspec, 'projects')
272
286
  const changesRoot = join(specSillyspec, 'changes')
@@ -328,7 +342,7 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
328
342
  // 常规模式(无平台 specRoot):占位符替换为 cwd/.sillyspec 下对应路径
329
343
  // 让用 {SPEC_ROOT}/{DOCS_ROOT} 等占位符的 prompt(如 quick/scan)在常规模式也写到正确位置
330
344
  const projectName = dbProjectName || basename(cwd)
331
- const specSillyspec = join(cwd, '.sillyspec')
345
+ const specSillyspec = resolvePromptSpecBase(platformOpts, cwd)
332
346
  promptText = applyRootPlaceholders(promptText, {
333
347
  specRoot: specSillyspec,
334
348
  docsRoot: join(specSillyspec, 'docs', projectName),
@@ -356,7 +370,7 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
356
370
 
357
371
  // scanProfile 分支也要替换占位符(非 platform 模式也会走到这里)
358
372
  const _pName = dbProjectName || basename(cwd)
359
- const _specSS = platformOpts?.specRoot || join(cwd, '.sillyspec')
373
+ const _specSS = resolvePromptSpecBase(platformOpts, cwd)
360
374
  const _docsRoot = join(_specSS, 'docs', _pName)
361
375
  promptText = applyRootPlaceholders(promptText, {
362
376
  specRoot: _specSS,
@@ -368,7 +382,7 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
368
382
  } else {
369
383
  // 非 platform 模式也要替换占位符
370
384
  const projectName = dbProjectName || basename(cwd)
371
- const specSillyspec = join(cwd, '.sillyspec')
385
+ const specSillyspec = resolvePromptSpecBase(platformOpts, cwd)
372
386
  const docsRoot = join(specSillyspec, 'docs', projectName)
373
387
  const projectsRoot = join(specSillyspec, 'projects')
374
388
  const workflowsRoot = join(specSillyspec, 'workflows')
@@ -380,7 +394,7 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
380
394
  if (stageName === 'execute' && promptText.includes('{KNOWLEDGE_HIT_REPORT}')) {
381
395
  try {
382
396
  const { matchKnowledge } = await import('../knowledge-match.js')
383
- const effectiveSpecBase = platformOpts?.specRoot || join(cwd, '.sillyspec')
397
+ const effectiveSpecBase = resolvePromptSpecBase(platformOpts, cwd)
384
398
  const knowledgeDir = join(effectiveSpecBase, 'knowledge')
385
399
  // taskContext: changeName + plan.md task names for better matching
386
400
  let taskContext = changeName || ''
@@ -409,7 +423,7 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
409
423
  // Execute: 注入 currentExecuteRunId(从变更专属标记文件读取)
410
424
  if (stageName === 'execute' && promptText.includes('{EXECUTE_RUN_ID}')) {
411
425
  let runId = ''
412
- const execSpecBase = platformOpts?.specRoot || join(cwd, '.sillyspec')
426
+ const execSpecBase = resolvePromptSpecBase(platformOpts, cwd)
413
427
  const runtimeRoot = resolveRuntimeRoot(platformOpts, execSpecBase)
414
428
  const runIdFile = join(runtimeRoot, `current-execute-run-id-${changeName}`)
415
429
  try {
@@ -433,7 +447,7 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
433
447
  try {
434
448
  const { classifyReviewTier } = await import('../review-tier.js')
435
449
  const { generateStageReviewRunId, renderReviewJsonContract, stageReviewMarkerPath } = await import('../stage-review.js')
436
- const tierSpecBase = platformOpts?.specRoot || join(cwd, '.sillyspec')
450
+ const tierSpecBase = resolvePromptSpecBase(platformOpts, cwd)
437
451
  const tierChangeDir = changeName ? join(tierSpecBase, 'changes', changeName) : null
438
452
  const designPath = tierChangeDir ? join(tierChangeDir, 'design.md') : null
439
453
  let planLevel = null
@@ -491,7 +505,7 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
491
505
  if (stageName === 'archive' && promptText.includes('{TASK_COMPLETION_REPORT}')) {
492
506
  try {
493
507
  const { summarizeTaskCompletion } = await import('../task-review.js')
494
- const tcrSpecBase = platformOpts?.specRoot || join(cwd, '.sillyspec')
508
+ const tcrSpecBase = resolvePromptSpecBase(platformOpts, cwd)
495
509
  const tcrRuntimeRoot = resolveRuntimeRoot(platformOpts, tcrSpecBase)
496
510
  const tcrChangeDir = changeName ? join(tcrSpecBase, 'changes', changeName) : null
497
511
  const summary = tcrChangeDir
@@ -505,7 +519,7 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
505
519
 
506
520
  // 注入模块上下文(brainstorm/plan/execute 阶段,基于 Module Context Index)
507
521
  if (['brainstorm', 'plan', 'execute'].includes(stageName) && projectName) {
508
- const effectiveSpecBase = platformOpts?.specRoot || join(cwd, '.sillyspec')
522
+ const effectiveSpecBase = resolvePromptSpecBase(platformOpts, cwd)
509
523
  const moduleIndex = loadModuleContextIndex(effectiveSpecBase, projectName)
510
524
  if (moduleIndex && Object.keys(moduleIndex).length > 0) {
511
525
  // 尝试从 step prompt / changeName 匹配模块
@@ -563,7 +577,7 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
563
577
  if (platformOpts?.specRoot || platformOpts?.runtimeRoot || changeName) {
564
578
  if (stepIndex !== 0) console.log(`\n### ⚠️ 路径与平台规则(每步提醒,通用铁律见首步)`)
565
579
  if (platformOpts?.specRoot || platformOpts?.runtimeRoot) {
566
- const specSillyspec = platformOpts.specRoot || join(cwd, '.sillyspec')
580
+ const specSillyspec = resolvePromptSpecBase(platformOpts, cwd)
567
581
  console.log(`- **平台模式:所有文件只能写入 \`${specSillyspec}/\` 下的对应子目录,严禁写入源码目录。**`)
568
582
  console.log('- **平台模式:Write 工具失败时,不允许用 cat > / tee / heredoc 等方式绕过。先 Read 再 Write,仍失败则记录并停止。**')
569
583
  console.log('- **平台模式:local.yaml 中的 commands 必须在 package.json scripts 中真实存在,不存在的标记 unavailable。**')
@@ -24,17 +24,16 @@ export function printQuickAuditReview(review) {
24
24
  console.error(`\n quick 已停止:请恢复/拆分这些变更,或重新运行 quick 并显式声明范围。`)
25
25
  // 解锁咒语须在 BLOCKED 分支也给出 —— 原只在 confirm/review 分支打印,
26
26
  // 普通 --done 走这里只看到「恢复/拆分」反方向指引,正确解法(带 flag 重跑)根本不出现。
27
- // 但删除是例外:deletedFiles.length>0 是裸判定(auditQuickCompletion 判定行不受任何 flag
28
- // 门控),--force-baseline / --allow-new 都救不回。此时甩 flag 建议会误导调用者照加后继续
29
- // 被拦——改提示走完整流程 execute(有意设计:删除是高危操作,须 review 把关)。
27
+ // 删除:默认 fail-closed(高危操作),但 --allow-delete 显式 opt-in 可解锁(对称 --allow-new)。
30
28
  if (review.deletedFiles && review.deletedFiles.length > 0) {
31
29
  console.error(`\n ⛔ 本次含删除文件 ${review.deletedFiles.length} 个:${review.deletedFiles.join(', ')}`)
32
- console.error(` quick 不支持删除——--force-baseline / --allow-new 均无法解锁(有意设计,高危操作须走 review 把关)。`)
33
- console.error(` 请改走完整流程:sillyspec run execute(在 plan 的 task allowed_paths 内删除),或先 git restore 撤回删除后再 --done。`)
30
+ console.error(` 删除是破坏性操作默认 fail-closed,确认删除这些文件请带 --allow-delete 显式解锁:`)
31
+ console.error(` sillyspec run quick --done --allow-delete --change <id> --output "..."`)
32
+ console.error(` (--force-baseline / --allow-new 不能解锁删除;不确认请 git restore 撤回删除后再 --done,或走完整流程 execute 由 review 把关)`)
34
33
  } else {
35
34
  console.error(` 如确认接受这些变更,重新运行 --done 时带上对应 flag 即可解锁:`)
36
35
  console.error(` sillyspec run quick --done --force-baseline --allow-new --change <id> --output "..."`)
37
- console.error(` (--force-baseline 覆盖受保护/危险文件如 src/run.js;--allow-new 允许新增文件)`)
36
+ console.error(` (--force-baseline 覆盖受保护/危险文件如 src/run.js;--allow-new 允许新增文件;--allow-delete 允许删除文件)`)
38
37
  }
39
38
  } else if (review.status === 'warning') {
40
39
  console.warn(`\n⚠️ quick 变更边界审计 — WARNING:`)
package/src/run/shared.js CHANGED
@@ -17,6 +17,7 @@ import { fileURLToPath } from 'node:url'
17
17
  // safeGit 收口至 src/git-helper.js:import 建本地词法绑定(本模块内部 L128/130/431 调用)+
18
18
  // re-export 供 run/ 层现有调用方继续从 shared.js 引用(pure re-export 不建本地绑定,会致内部 ReferenceError)。
19
19
  import { safeGit } from '../git-helper.js'
20
+ import { createHash } from 'node:crypto'
20
21
  export { safeGit }
21
22
 
22
23
  const __dirname = dirname(fileURLToPath(import.meta.url))
@@ -494,7 +495,7 @@ export function isQuickMetadata(p, linkedChanges = []) {
494
495
  * @returns {{ status: 'safe'|'warning'|'blocked', reasons: string[], changedFiles: string[], newFiles: string[], deletedFiles: string[], baselineHit: string[], stagedTotal: number }}
495
496
  */
496
497
  export async function auditQuickCompletion(cwd, guard, options = {}) {
497
- const { baselineFiles, allowedFiles = [], allowNew = false, forceBaseline = false } = guard
498
+ const { baselineFiles, allowedFiles = [], allowNew = false, forceBaseline = false, allowDelete = false } = guard
498
499
  const { isConfirm } = options
499
500
  // stagedTotal:当前所有非 quick 元数据的未提交条目(含前序 baseline 残留)。
500
501
  // 与 changedFiles(扣 baseline 后的本轮新增)区分,供审计文案同时展示「本轮新增 vs 累计暂存」,
@@ -506,7 +507,10 @@ export async function auditQuickCompletion(cwd, guard, options = {}) {
506
507
  // 外层 catch 吞成 warning(multi-agent-review Q3)。safeGit 返回 {value,error} 不抛;若仍有 error
507
508
  // 说明真读不到 git 状态,保守阻断(审计无锚点不能放行),不静默降级。
508
509
  // trim:false 必传:porcelain 首行前导空格是状态码一部分,trim 会削掉致 parsePorcelainPath 丢首字符。
509
- const statusResult = safeGit(cwd, ['status', '--porcelain'], { trim: false })
510
+ // timeout 15000 + retryOnTimeout:机器忙时 git 子进程启动慢,默认 5s 偏紧易瞬时超时(ETIMEDOUT),
511
+ // 审计锚点失败即 blocked 中断 quick。加大到 15s 并对 ETIMEDOUT 重试一次(重试 30s),消化绝大多数
512
+ // 瞬时抖动,免去用户手工重跑(实测反馈:审计 git 偶发 ETIMEDOUT,重试即过)。
513
+ const statusResult = safeGit(cwd, ['status', '--porcelain'], { trim: false, timeout: 15000, retryOnTimeout: true })
510
514
  if (statusResult.error) {
511
515
  result.reasons.push(`审计失败(git status): ${statusResult.error}`)
512
516
  result.status = 'blocked'
@@ -584,9 +588,11 @@ export async function auditQuickCompletion(cwd, guard, options = {}) {
584
588
  }
585
589
  }
586
590
 
587
- // 检查 deleted files
588
- for (const f of result.deletedFiles) {
589
- result.reasons.push(`删除文件: ${f}`)
591
+ // 检查 deleted files(--allow-delete 显式放行:删除是破坏性操作,默认 fail-closed,flag 即知情 opt-in)
592
+ if (!allowDelete) {
593
+ for (const f of result.deletedFiles) {
594
+ result.reasons.push(`删除文件: ${f}`)
595
+ }
590
596
  }
591
597
 
592
598
  // 检查 baseline hit(除非 force-baseline)
@@ -614,9 +620,10 @@ export async function auditQuickCompletion(cwd, guard, options = {}) {
614
620
  }
615
621
  }
616
622
 
617
- // 判定结果(force-baseline 降级 baselineHit → 非 blocked;allow-new 降级新增文件 → 非 warning。
618
- // reasons 文案本就受这两个 flag 控制,但原判定直接看数组长度,致 flag 对 status 失效。)
619
- if ((!forceBaseline && result.baselineHit.length > 0) || result.deletedFiles.length > 0 || result.reasons.some(r => r.startsWith('危险') || r.startsWith('删除'))) {
623
+ // 判定结果(force-baseline 降级 baselineHit → 非 blocked;allow-new 降级新增文件 → 非 warning;
624
+ // allow-delete 降级删除文件 → 非 blocked。reasons 文案本就受 flag 控制,但原判定直接看数组长度,
625
+ // 致 flag 对 status 失效。)
626
+ if ((!forceBaseline && result.baselineHit.length > 0) || (!allowDelete && result.deletedFiles.length > 0) || result.reasons.some(r => r.startsWith('危险') || r.startsWith('删除'))) {
620
627
  result.status = 'blocked'
621
628
  } else if ((!allowNew && result.newFiles.length > 0) || (allowedFiles.length > 0 && result.reasons.some(r => r.startsWith('超出')))) {
622
629
  result.status = 'warning'
@@ -637,6 +644,28 @@ export async function auditQuickCompletion(cwd, guard, options = {}) {
637
644
  }
638
645
  } catch {}
639
646
 
647
+ // task-02: 同文件并发检测——allowedFile 在 baseline(他者改过)且当前 hash ≠ step1 hash(我也改了)
648
+ // → commit 整文件 pathspec 会夹带他者 hunk,warn 给分离指引(advisory,不阻断,D-002)
649
+ if (allowedFiles.length > 0 && guard.allowedFilesHash) {
650
+ const sameFileHits = []
651
+ for (const f of allowedFiles) {
652
+ if (isBaselineFile(f) && guard.allowedFilesHash[f] !== undefined) {
653
+ try {
654
+ const cur = createHash('sha256').update(readFileSync(join(cwd, f))).digest('hex')
655
+ if (cur !== guard.allowedFilesHash[f]) sameFileHits.push(f)
656
+ } catch {} // 文件读失败(删除等)跳过
657
+ }
658
+ }
659
+ if (sameFileHits.length > 0) {
660
+ result.reasons.push(`同文件并发: ${sameFileHits.length} 个 allowedFile 含他者+你的改动(${sameFileHits.join(', ')})`)
661
+ console.warn(`\n⚠️ 同文件并发(${sameFileHits.length} 个 allowedFile 含他者改动+你的改动,commit 整文件会夹带他者 hunk):`)
662
+ for (const f of sameFileHits) {
663
+ console.warn(` - ${f}`)
664
+ console.warn(` 分离:git add -p ${f}(交互选你的 hunk)或 git diff ${f} > mine.patch(编辑留你的)+ git apply --cached mine.patch`)
665
+ }
666
+ }
667
+ }
668
+
640
669
  // --confirm 模式:展示 diff 并等待确认
641
670
  if (isConfirm && (result.status === 'warning' || result.status === 'blocked')) {
642
671
  console.log(`\n📋 quick 变更概览:`)
@@ -658,12 +687,17 @@ export async function auditQuickCompletion(cwd, guard, options = {}) {
658
687
  }
659
688
  }
660
689
  if (result.deletedFiles.length > 0) {
661
- console.log(`\n ⛔ 本次含删除文件 ${result.deletedFiles.length} 个——quick 不支持删除,--force-baseline / --allow-new 均无法解锁(有意设计,高危操作须走 review 把关)。`)
662
- console.log(` 请改走完整流程:sillyspec run execute(在 plan 的 task allowed_paths 内删除),或先 git restore 撤回删除后再 --done。`)
690
+ if (guard.allowDelete) {
691
+ console.log(`\n ℹ️ 已确认删除文件 ${result.deletedFiles.length} 个(--allow-delete 显式解锁):${result.deletedFiles.join(', ')}`)
692
+ } else {
693
+ console.log(`\n ⛔ 本次含删除文件 ${result.deletedFiles.length} 个——删除是破坏性操作默认 fail-closed,确认删除请带 --allow-delete 显式解锁:`)
694
+ console.log(` sillyspec run quick --done --allow-delete --change <id> --output "..."`)
695
+ console.log(` (--force-baseline / --allow-new 不能解锁删除;不确认请 git restore 撤回删除后再 --done,或走完整流程 execute 由 review 把关)`)
696
+ }
663
697
  } else {
664
698
  console.log(`\n 如确认接受这些变更,重新运行 --done 时带上对应 flag 即可解锁:`)
665
699
  console.log(` sillyspec run quick --done --force-baseline --allow-new --change <id> --output "..."`)
666
- console.log(` (--force-baseline 覆盖受保护/危险文件如 src/run.js;--allow-new 允许新增文件)`)
700
+ console.log(` (--force-baseline 覆盖受保护/危险文件如 src/run.js;--allow-new 允许新增文件;--allow-delete 允许删除文件)`)
667
701
  console.log(` 或在首个 sillyspec run quick 启动(step 1)时就声明这些 flag,持久化进 guard。`)
668
702
  }
669
703
  }
package/src/run/stage.js CHANGED
@@ -23,6 +23,7 @@ import { resolveSpecDir, resolveChangeDir, resolveRuntimeRoot, resolveQuickSessi
23
23
  import { computeScanProfile, applyScanProfileSteps, executeScanPreflight, executeScanPostcheck } from './scan-profile.js'
24
24
  import { outputStep } from './prompt.js'
25
25
  import { allocateQuicklogEntry, deriveTitleFromLinkedChange, sanitizeDesc } from '../quicklog.js'
26
+ import { createHash } from 'node:crypto'
26
27
  import { checkTransition } from '../stage-contract.js'
27
28
  import { completeStageGates } from './gates.js'
28
29
 
@@ -248,6 +249,7 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
248
249
  .filter(Boolean)
249
250
  const allowedFiles = quickOpts?.quickFiles || []
250
251
  const allowNew = quickOpts?.isAllowNew || false
252
+ const allowDelete = quickOpts?.isAllowDelete || false
251
253
  const forceBaseline = quickOpts?.isForceBaseline || false
252
254
  const linkedChanges = Array.isArray(quickOpts?.linkedChanges) ? quickOpts.linkedChanges : []
253
255
  // CLI 接管:分配 ql-ID + 写 QUICKLOG「进行中」条目 + 关联 tasks.md(持锁、当天唯一)
@@ -273,7 +275,14 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
273
275
  baselineCommit: safeGit(cwd, ['rev-parse', 'HEAD']).value,
274
276
  baselineFiles,
275
277
  allowedFiles,
278
+ // task-01: 录每个 allowedFile 内容 sha256,供 --done auditQuickCompletion 检测同文件并发
279
+ // (allowedFile 在 baseline 且当前 hash ≠ 此值 = 我也改了 → 同文件并发 warn)。文件不存在/读失败跳过。
280
+ allowedFilesHash: Object.fromEntries(allowedFiles.flatMap(f => {
281
+ try { return [[f, createHash('sha256').update(readFileSync(join(cwd, f))).digest('hex')]] }
282
+ catch { return [] }
283
+ })),
276
284
  allowNew,
285
+ allowDelete,
277
286
  forceBaseline,
278
287
  linkedChanges,
279
288
  quicklogId: qlId,
@@ -286,6 +295,7 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
286
295
  const parts = [`${baselineFiles.length} 个已有脏文件`]
287
296
  if (allowedFiles.length > 0) parts.push(`${allowedFiles.length} 个 allowedFiles`)
288
297
  if (allowNew) parts.push('允许新增文件')
298
+ if (allowDelete) parts.push('允许删除文件')
289
299
  console.log(`🛡️ quick 变更边界已记录: ${parts.join(', ')}`)
290
300
  console.log(`📝 QUICKLOG 条目已创建: ${qlId}`)
291
301
  // 回填 DB changes 行的 title + quicklog_id,让 quick-<hex> 可读、DB↔QUICKLOG 可对账。
package/src/run.js CHANGED
@@ -6,9 +6,12 @@
6
6
  * 外部 import 契约通过 re-export 保留(run.js 是 index.js 懒加载入口,零入边叶子,
7
7
  * 外部 import 零感知)。
8
8
  *
9
- * test 直接 import 的契约(9 个):runCommand / ensureStageSteps /
10
- * _completeStepForTest / _outputStepForTest / parsePorcelainPath / auditQuickCompletion /
9
+ * test 直接 import 的契约(7 个):runCommand / ensureStageSteps /
10
+ * parsePorcelainPath / auditQuickCompletion /
11
11
  * applyRootPlaceholders / sanitizeProjectName / validateParsedProjects。
12
+ * (outputStep 由 test/output-step-render + archive-task-completion-injection 直接自
13
+ * ./run/prompt.js import,无需 barrel 转出;completeStep/outputStep 的 `_xxxForTest` 测试别名
14
+ * 2026-08-13 全部移除——completion 行为走 CLI 子进程,纯渲染器 outputStep 直 import 源模块。)
12
15
  */
13
16
  // W6 Step8c: 主命令分发(runCommand)+ auto 模式 + ensureStageSteps 搬至 ./run/command.js
14
17
  export { runCommand, ensureStageSteps } from './run/command.js'
@@ -18,6 +21,3 @@ export { parsePorcelainPath, auditQuickCompletion, isQuickMetadata } from './run
18
21
  export { applyRootPlaceholders } from './run/prompt.js'
19
22
  // barrel re-export: sanitizeProjectName + validateParsedProjects 被 test 直接 import(随 handleScan 搬走,契约保留)
20
23
  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'
@@ -262,6 +262,17 @@ const PLAN_RULES = [
262
262
  spec: '产出 `plan.md`(任务拆解与规划,缺失即阻断)',
263
263
  failMessage: 'plan.md 缺失: ${path}',
264
264
  },
265
+ // module-impact 首版(D-001@v2):large 变更(scale≠small)在 plan review_plan 步生成 module-impact.md
266
+ // 首版(活文档全程演化——execute/verify 更新、archive 终审)。small 豁免(走 quick,module-impact 无用)。
267
+ // condition 生效依赖 validatePlanOutputs 把 design.md scale 传入 ctx(stage-contract.js D-009)。
268
+ {
269
+ id: 'plan.module-impact.exists', stage: 'plan', source: 'validatePlanOutputs', severity: 'error', kind: 'file-exists',
270
+ target: { root: 'change', path: 'module-impact.md' },
271
+ data: {},
272
+ condition: { ctxField: 'scale', ne: 'small' },
273
+ spec: 'large 变更产出 `module-impact.md` 首版(plan review_plan 步生成;scale=small 豁免)',
274
+ failMessage: 'module-impact.md 缺失: ${path}(large 变更应在 plan 审查计划步生成模块影响分析首版)',
275
+ },
265
276
  // entry-point-wiring(custom):design.md 命中入口实例化/启动路径模式时,被提到的入口文件
266
277
  // (cli.ts/main.ts/server.(js|ts)/index.(js|ts))必须出现在某 task allowed_paths 或 plan.md
267
278
  // 文件变更清单中,否则需 design.md 紧邻写明豁免。判定算法(多源 allowed_paths 收集 + 逐文件对账 +
@@ -328,8 +328,18 @@ function validatePlanOutputs(cwd, changeName, context = {}) {
328
328
  const changeDir = resolveChangeDir(cwd, changeName, specRoot)
329
329
  const planFile = join(changeDir, 'plan.md')
330
330
 
331
+ // 读 design.md frontmatter scale(若有)传入引擎 ctx,供 plan.module-impact.exists 的 condition
332
+ // (scale≠small)判定——large 要求 module-impact 首版,small 豁免(走 quick)。fail-safe:无 scale→null→
333
+ // condition(ne 'small')成立→保守要求 module-impact(同 brainstorm scale 读取模式,line 264-272)。
334
+ const designPathForScale = join(changeDir, 'design.md')
335
+ let scale = null
336
+ if (existsSync(designPathForScale)) {
337
+ const fm = readFileSync(designPathForScale, 'utf8').match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*\r?\n/)
338
+ const sm = fm && fm[1].match(/^scale:[ \t]*['"]?(\w+)/m)
339
+ if (sm) scale = sm[1]
340
+ }
331
341
  // plan.md 存在性由引擎消费 manifest。entryPoint/id-trace/decisions 为 custom kind(下方保留)。
332
- const engineResult = evaluateRules('plan', { changeDir })
342
+ const engineResult = evaluateRules('plan', { changeDir, scale })
333
343
  const errors = [...engineResult.errors]
334
344
  const warnings = [...engineResult.warnings]
335
345
 
@@ -414,7 +414,7 @@ export function validateStageReview(opts) {
414
414
  * @param {object} context - { stage, reviewRunId } 用于错误提示路径
415
415
  */
416
416
  export function printStageReviewResult(result, context = {}) {
417
- const { stage } = context
417
+ const { stage, reviewRunId, runtimeRoot } = context
418
418
  if (result.ok && result.warnings.length === 0) {
419
419
  console.log(`\n✅ Stage Review Gate — ${stage} 阶段独立审查通过`)
420
420
  return
@@ -425,6 +425,14 @@ export function printStageReviewResult(result, context = {}) {
425
425
  for (const err of result.errors) {
426
426
  console.error(` - ${err}`)
427
427
  }
428
+ // echo 完整 review.json 路径:runId + marker 已由 CLI 自动生成写入(prompt.js review step
429
+ // 渲染 / gate 触发),agent 直接派独立子代理把 review.json 写到此路径,勿手算 runId、勿手写
430
+ // marker(历史坑:agent 撞 gate 后自己算 runId 猜错 review- 前缀,填 exec-/裸 ts 被 CLI 忽略)。
431
+ if (reviewRunId) {
432
+ const reviewDir = runtimeRoot ? `${runtimeRoot}/stage-reviews/${stage}-${reviewRunId}` : `.sillyspec/.runtime/stage-reviews/${stage}-${reviewRunId}`
433
+ console.error(`\n 📁 review.json 直接写此路径(runId/marker CLI 已生成写入,勿手算 runId、勿手写 marker):`)
434
+ console.error(` ${reviewDir}/review.json`)
435
+ }
428
436
  console.error(`\n 提示:tier=independent 要求独立审查子代理产出 review.json,补全后重新 --done`)
429
437
  console.error(` 可用 sillyspec register-stage-review --change <名> --stage ${stage} [--from <已有review.json>] 一步生成 run 目录 + review.json 骨架(docHash 自动算)+ 写 marker + 自检,省掉手动建目录/写 marker`)
430
438
  }