sillyspec 3.20.2 → 3.20.3

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 (208) hide show
  1. package/.claude/skills/sillyspec-archive/SKILL.md +1 -5
  2. package/.claude/skills/sillyspec-auto/SKILL.md +2 -8
  3. package/.claude/skills/sillyspec-brainstorm/SKILL.md +1 -28
  4. package/.claude/skills/sillyspec-commit/SKILL.md +3 -4
  5. package/.claude/skills/sillyspec-continue/SKILL.md +4 -5
  6. package/.claude/skills/sillyspec-doctor/SKILL.md +3 -12
  7. package/.claude/skills/sillyspec-execute/SKILL.md +2 -15
  8. package/.claude/skills/sillyspec-explore/SKILL.md +1 -14
  9. package/.claude/skills/sillyspec-plan/SKILL.md +36 -5
  10. package/.claude/skills/sillyspec-propose/SKILL.md +0 -4
  11. package/.claude/skills/sillyspec-quick/SKILL.md +1 -5
  12. package/.claude/skills/sillyspec-resume/SKILL.md +66 -23
  13. package/.claude/skills/sillyspec-scan/SKILL.md +1 -5
  14. package/.claude/skills/sillyspec-state/SKILL.md +8 -8
  15. package/.claude/skills/sillyspec-status/SKILL.md +1 -5
  16. package/.claude/skills/sillyspec-verify/SKILL.md +1 -5
  17. package/.claude/skills/sillyspec-workspace/SKILL.md +3 -11
  18. package/.sillyspec/changes/archive/2026-04-08-derive-state/design.md +97 -0
  19. package/.sillyspec/changes/archive/2026-04-08-derive-state/plan.md +51 -0
  20. package/.sillyspec/changes/archive/2026-04-08-derive-state/proposal.md +29 -0
  21. package/.sillyspec/changes/archive/2026-04-08-derive-state/requirements.md +34 -0
  22. package/.sillyspec/changes/archive/2026-04-08-derive-state/tasks.md +13 -0
  23. package/.sillyspec/changes/archive/2026-04-08-derive-state/verify-result.md +43 -0
  24. package/.sillyspec/changes/auto-mode/design.md +50 -0
  25. package/.sillyspec/changes/auto-mode/proposal.md +19 -0
  26. package/.sillyspec/changes/auto-mode/requirements.md +21 -0
  27. package/.sillyspec/changes/auto-mode/tasks.md +7 -0
  28. package/.sillyspec/changes/brainstorm-archive/2026-04-05-dashboard-design.md +206 -0
  29. package/.sillyspec/changes/brainstorm-archive/2026-04-05-unified-docs-design.md +199 -0
  30. package/.sillyspec/changes/dashboard/design.md +219 -0
  31. package/.sillyspec/changes/dashboard/design.md.braindraft +206 -0
  32. package/.sillyspec/changes/run-command-design/design.md +1230 -0
  33. package/.sillyspec/changes/unified-docs-design/design.md +199 -0
  34. package/.sillyspec/docs/sillyspec/scan/.gitkeep +0 -0
  35. package/.sillyspec/knowledge/INDEX.md +8 -0
  36. package/.sillyspec/knowledge/uncategorized.md +3 -0
  37. package/.sillyspec/plans/2026-04-05-dashboard.md +737 -0
  38. package/.sillyspec/projects/sillyspec.yaml +3 -0
  39. package/README.md +11 -13
  40. package/SKILL.md +40 -44
  41. package/dist/steps/brainstorm/01-load-context.md +30 -0
  42. package/dist/steps/brainstorm/02-reuse-check.md +6 -0
  43. package/dist/steps/brainstorm/03-prototype-analysis.md +11 -0
  44. package/dist/steps/brainstorm/04-module-split.md +23 -0
  45. package/dist/steps/brainstorm/05-dialog-explore.md +8 -0
  46. package/dist/steps/brainstorm/06-propose-approaches.md +3 -0
  47. package/dist/steps/brainstorm/07-present-design.md +3 -0
  48. package/dist/steps/brainstorm/08-write-design.md +21 -0
  49. package/dist/steps/brainstorm/09-self-review.md +15 -0
  50. package/dist/steps/brainstorm/10-user-confirm.md +3 -0
  51. package/dist/steps/brainstorm/11-output-spec.md +7 -0
  52. package/dist/steps/brainstorm/manifest.yaml +26 -0
  53. package/dist/steps/execute/01-load-context.md +41 -0
  54. package/dist/steps/execute/02-scan-conventions.md +47 -0
  55. package/dist/steps/execute/03-skill-mcp.md +19 -0
  56. package/dist/steps/execute/04-assign-task.md +22 -0
  57. package/dist/steps/execute/04b-prompt-template.md +54 -0
  58. package/dist/steps/execute/05-write-test.md +7 -0
  59. package/dist/steps/execute/06-write-code.md +8 -0
  60. package/dist/steps/execute/07-run-test.md +26 -0
  61. package/dist/steps/execute/08-fix-issues.md +28 -0
  62. package/dist/steps/execute/09-next-task.md +33 -0
  63. package/dist/steps/execute/manifest.yaml +28 -0
  64. package/dist/steps/plan/01-load-context.md +22 -0
  65. package/dist/steps/plan/02-anchor-confirm.md +1 -0
  66. package/dist/steps/plan/03-expand-tasks.md +33 -0
  67. package/dist/steps/plan/04-mark-order.md +15 -0
  68. package/dist/steps/plan/05-e2e-planning.md +17 -0
  69. package/dist/steps/plan/06-self-check.md +16 -0
  70. package/dist/steps/plan/07-save.md +1 -0
  71. package/dist/steps/plan/manifest.yaml +18 -0
  72. package/dist/steps/scan/01-env-detect.md +51 -0
  73. package/dist/steps/scan/02-tech-stack.md +16 -0
  74. package/dist/steps/scan/03-conventions.md +16 -0
  75. package/dist/steps/scan/04-structure.md +19 -0
  76. package/dist/steps/scan/05-quality.md +18 -0
  77. package/dist/steps/scan/06-complete.md +49 -0
  78. package/dist/steps/scan/manifest.yaml +16 -0
  79. package/dist/steps/verify/01-load-specs.md +28 -0
  80. package/dist/steps/verify/02-check-tasks.md +1 -0
  81. package/dist/steps/verify/03-check-design.md +6 -0
  82. package/dist/steps/verify/04-run-tests.md +7 -0
  83. package/dist/steps/verify/05-e2e-tests.md +27 -0
  84. package/dist/steps/verify/05b-e2e-fix.md +33 -0
  85. package/dist/steps/verify/06-code-quality.md +25 -0
  86. package/dist/steps/verify/07-lint-check.md +27 -0
  87. package/dist/steps/verify/08-output-report.md +14 -0
  88. package/dist/steps/verify/manifest.yaml +22 -0
  89. package/package.json +1 -7
  90. package/packages/dashboard/dist/assets/{index-Bq_Z2hne.js → index-D1EVTLmc.js} +1264 -1264
  91. package/packages/dashboard/dist/assets/index-DGe8CqeP.css +1 -0
  92. package/packages/dashboard/dist/index.html +2 -2
  93. package/packages/dashboard/package-lock.json +6 -6
  94. package/packages/dashboard/server/executor.js +1 -1
  95. package/packages/dashboard/server/index.js +18 -98
  96. package/packages/dashboard/server/parser.js +71 -139
  97. package/packages/dashboard/server/watcher.js +6 -14
  98. package/packages/dashboard/src/App.vue +185 -418
  99. package/packages/dashboard/src/components/ActionBar.vue +1 -10
  100. package/packages/dashboard/src/components/CommandPalette.vue +1 -5
  101. package/packages/dashboard/src/components/DocPreview.vue +8 -105
  102. package/packages/dashboard/src/components/DocTree.vue +19 -75
  103. package/packages/dashboard/src/components/PipelineStage.vue +2 -22
  104. package/packages/dashboard/src/components/PipelineView.vue +5 -32
  105. package/packages/dashboard/src/components/ProjectOverview.vue +139 -113
  106. package/packages/dashboard/src/components/StageBadge.vue +3 -17
  107. package/packages/dashboard/src/components/StepCard.vue +2 -7
  108. package/packages/dashboard/src/composables/useDashboard.js +0 -28
  109. package/src/derive.js +147 -0
  110. package/src/index.js +40 -688
  111. package/src/init.js +63 -119
  112. package/src/migrate.js +7 -7
  113. package/src/progress.js +248 -1474
  114. package/src/run.js +302 -3008
  115. package/src/setup.js +64 -2
  116. package/src/stages/archive.js +17 -123
  117. package/src/stages/brainstorm.js +48 -454
  118. package/src/stages/doctor.js +46 -99
  119. package/src/stages/execute.js +59 -420
  120. package/src/stages/index.js +18 -12
  121. package/src/stages/plan.js +189 -492
  122. package/src/stages/propose.js +11 -70
  123. package/src/stages/quick.js +13 -52
  124. package/src/stages/scan.js +68 -485
  125. package/src/stages/status.js +1 -1
  126. package/src/stages/verify.js +16 -203
  127. package/src/step.js +543 -0
  128. package/.claude/skills/sillyspec-knowledge/SKILL.md +0 -270
  129. package/.husky/pre-push +0 -13
  130. package/CLAUDE.md +0 -18
  131. package/docs/brainstorm-plan-contract.md +0 -64
  132. package/docs/plan-execute-contract.md +0 -123
  133. package/docs/platform-scan-protocol.md +0 -298
  134. package/docs/revision-mode.md +0 -115
  135. package/docs/sillyspec/file-lifecycle/known-implementation-gaps.md +0 -99
  136. package/docs/sillyspec/file-lifecycle/platform-workflows-sync.md +0 -218
  137. package/docs/sillyspec/file-lifecycle/stage-artifacts.md +0 -167
  138. package/docs/sillyspec/file-lifecycle/storage-and-state.md +0 -148
  139. package/docs/sillyspec/file-lifecycle/worktree-and-guard.md +0 -193
  140. package/docs/sillyspec/file-lifecycle.md +0 -125
  141. package/docs/workflow-contract-regression.md +0 -106
  142. package/docs/worktree-isolation.md +0 -252
  143. package/packages/dashboard/dist/assets/index-O2W5RV4z.css +0 -1
  144. package/packages/dashboard/dist/prototype-dashboard.html +0 -836
  145. package/packages/dashboard/dist/prototype-overview.html +0 -256
  146. package/packages/dashboard/public/prototype-dashboard.html +0 -836
  147. package/packages/dashboard/public/prototype-overview.html +0 -256
  148. package/packages/dashboard/src/components/HResizeHandle.vue +0 -48
  149. package/packages/dashboard/src/components/ProjectCard.vue +0 -187
  150. package/packages/dashboard/src/components/VResizeHandle.vue +0 -61
  151. package/packages/dashboard/src/composables/useLayout.js +0 -131
  152. package/src/brainstorm-postcheck.js +0 -158
  153. package/src/change-list.js +0 -52
  154. package/src/change-risk-profile.js +0 -352
  155. package/src/classify-change.js +0 -73
  156. package/src/constants.js +0 -70
  157. package/src/contract-matrix.js +0 -278
  158. package/src/db.js +0 -201
  159. package/src/endpoint-extractor.js +0 -315
  160. package/src/hooks/claude-pre-tool-use.cjs +0 -125
  161. package/src/hooks/worktree-guard.js +0 -761
  162. package/src/knowledge-match.js +0 -130
  163. package/src/modules.js +0 -482
  164. package/src/scan-postcheck.js +0 -383
  165. package/src/stage-contract.js +0 -700
  166. package/src/stages/brainstorm-auto.js +0 -229
  167. package/src/stages/explore.js +0 -34
  168. package/src/stages/knowledge.js +0 -498
  169. package/src/stages/plan-postcheck.js +0 -513
  170. package/src/sync.js +0 -497
  171. package/src/task-review.js +0 -346
  172. package/src/workflow.js +0 -785
  173. package/src/worktree-apply.js +0 -549
  174. package/src/worktree.js +0 -932
  175. package/templates/workflows/archive-impact.yaml +0 -79
  176. package/templates/workflows/scan-docs.yaml +0 -132
  177. package/test/brainstorm-plan-contract.test.mjs +0 -273
  178. package/test/check-syntax.mjs +0 -26
  179. package/test/cli-top-level-aliases.test.mjs +0 -174
  180. package/test/contract-artifacts.test.mjs +0 -323
  181. package/test/decision-supersede.test.mjs +0 -277
  182. package/test/knowledge-match.test.mjs +0 -231
  183. package/test/plan-execute-contract.test.mjs +0 -330
  184. package/test/plan-optimization.test.mjs +0 -572
  185. package/test/platform-artifacts.test.mjs +0 -190
  186. package/test/platform-failure-samples.test.mjs +0 -199
  187. package/test/platform-recovery-chain.test.mjs +0 -179
  188. package/test/platform-recovery.test.mjs +0 -167
  189. package/test/platform-scan-p0.test.mjs +0 -175
  190. package/test/revision-v1.test.mjs +0 -1145
  191. package/test/run-sanitize-project-name.test.mjs +0 -51
  192. package/test/run-scan-postcheck-fail.test.mjs +0 -64
  193. package/test/run-scan-project-parse.test.mjs +0 -200
  194. package/test/run-tests.mjs +0 -48
  195. package/test/scan-docs-yaml-placeholders.test.mjs +0 -84
  196. package/test/scan-knowledge.test.mjs +0 -175
  197. package/test/scan-paths.test.mjs +0 -68
  198. package/test/scan-postcheck-project-priority.test.mjs +0 -85
  199. package/test/scan-postcheck.test.mjs +0 -197
  200. package/test/scan-workflow-anyfailed-block.test.mjs +0 -52
  201. package/test/spec-dir.test.mjs +0 -206
  202. package/test/stage-contract-failed-post-check.test.mjs +0 -102
  203. package/test/stage-contract.test.mjs +0 -299
  204. package/test/stage-definitions.test.mjs +0 -39
  205. package/test/wait-gates.test.mjs +0 -501
  206. package/test/workflow-spec-base.test.mjs +0 -142
  207. package/test/worktree-guard.test.mjs +0 -136
  208. package/test/worktree-native-overlay.test.mjs +0 -188
package/src/run.js CHANGED
@@ -2,569 +2,65 @@
2
2
  * sillyspec run 命令实现
3
3
  *
4
4
  * CLI 成为流程引擎,AI 变成步骤执行器。
5
- * 支持多变更并行:每个变更状态存储在 sillyspec.db 中。
6
5
  */
7
- import { basename, join, resolve, dirname } from 'path'
8
- import { existsSync, readdirSync, mkdirSync, writeFileSync, appendFileSync, readFileSync, rmSync, statSync } from 'fs'
9
- import { createRequire } from 'module'
10
- const require = createRequire(import.meta.url)
6
+ import { basename, join } from 'path'
7
+ import { existsSync, readdirSync, mkdirSync, writeFileSync, appendFileSync, readFileSync, statSync } from 'fs'
11
8
  import { ProgressManager } from './progress.js'
12
- import { SCAN_STATUS, POINTER_STATUS, isPointerCorrupted } from './constants.js'
13
-
14
- /**
15
- * 清洗项目名:只保留 ASCII 字母/数字/横线/下划线/点,过滤中文和特殊字符。
16
- * - 必须含至少一个字母(拒绝纯数字 "0"/"7"/"07",避免 scan-projects.json 脏数据)
17
- * - 长度必须 ≥ 2(拒绝单字符 "a"/"0")
18
- * @param {string} name - 原始项目名候选
19
- * @returns {string | null} 合法项目名或 null(拒绝)
20
- */
21
- export function sanitizeProjectName(name) {
22
- if (!name) return null
23
- const clean = String(name).replace(/[^a-zA-Z0-9_\-.]/g, '').trim()
24
- if (!clean) return null
25
- if (!/[a-zA-Z]/.test(clean)) return null // 纯数字/符号拒绝("0"/"7"/"07")
26
- if (clean.length < 2) return null // 单字符拒绝("a"/"0")
27
- return clean
28
- }
29
-
30
- /**
31
- * 校验从 step 2 解析出的项目列表。
32
- * 不通过则不落盘 projects/*.yaml、不展开 perProject 步骤。
33
- *
34
- * @param {Array<{id: string, path?: string}>} projects - 项目列表(含可选 path)
35
- * @param {string} sourceRoot - 源码根目录,用于 path 安全校验
36
- */
37
- export function validateParsedProjects(projects, sourceRoot) {
38
- const errors = []
39
- if (!projects || projects.length === 0) {
40
- return { ok: false, errors: ['项目列表为空'] }
41
- }
42
- if (projects.length > 10) {
43
- return { ok: false, errors: [`项目数量 ${projects.length} 超过上限 10,疑似误解析`] }
44
- }
45
- const safeRoot = resolve(sourceRoot)
46
- const seen = new Set()
47
- for (const proj of projects) {
48
- const id = proj.id || proj
49
- if (seen.has(id)) {
50
- errors.push(`重复项目名: ${id}`)
51
- }
52
- seen.add(id)
53
- // slug 合法性:只允许 a-z 0-9 _ - .,长度 2-64
54
- if (!/^[a-zA-Z][\w\-.]{1,63}$/.test(id)) {
55
- errors.push(`项目名 "${id}" 不合法(需 slug 格式:字母开头,只含 a-zA-Z0-9_-., 长度 2-64)`)
56
- }
57
- // path 安全校验(如果提供了 path)
58
- if (proj.path) {
59
- if (proj.path.includes('..')) {
60
- errors.push(`项目 "${id}" 的 path 包含 .. ,拒绝越界`)
61
- } else {
62
- const absPath = resolve(safeRoot, proj.path)
63
- if (!absPath.startsWith(safeRoot + '/') && absPath !== safeRoot) {
64
- errors.push(`项目 "${id}" 的 path "${proj.path}" 解析后超出 source_root`)
65
- }
66
- if (!existsSync(absPath)) {
67
- errors.push(`项目 "${id}" 的 path "${proj.path}" 不存在`)
68
- }
69
- }
70
- }
71
- }
72
- if (errors.length > 0) return { ok: false, errors }
73
- return { ok: true, errors: [] }
74
- }
75
-
76
- /**
77
- * 在容器/Docker 环境下,git 可能因目录所有权不匹配报 dubious ownership。
78
- * 使用 -c safe.directory= 临时参数,不污染全局 git config。
79
- * @param {string} cwd - 仓库根目录
80
- * @param {string[]} args - git 子命令及参数,如 ['rev-parse', 'HEAD']
81
- * @returns {{ value: string, error: string|null }}
82
- */
83
- function safeGit(cwd, args) {
84
- const { execSync } = require('child_process')
85
- const fullArgs = ['-c', `safe.directory=${cwd}`, '-C', cwd, ...args]
86
- try {
87
- const value = execSync(['git', ...fullArgs].join(' '), { encoding: 'utf8', timeout: 5000 }).trim()
88
- return { value, error: null }
89
- } catch (e) {
90
- return { value: null, error: e.message.split('\n')[0] }
91
- }
92
- }
93
-
94
- // ── Wait State Constants ──
95
- // 正则匹配:只识别独立一行的标记,避免误伤文档正文引用
96
- const WAIT_MARKER_RE = /^\s*\[(WAIT_FOR_USER|NEEDS_CONFIRM|NEEDS_DECISION)\]\s*$/m
97
- const WAIT_MARKERS = ['[WAIT_FOR_USER]', '[NEEDS_CONFIRM]', '[NEEDS_DECISION]']
98
-
99
- /**
100
- * 格式化 waitOptions 为人类可读字符串
101
- */
102
- function formatWaitOptions(raw) {
103
- if (!raw) return null
104
- try {
105
- const parsed = JSON.parse(raw)
106
- if (Array.isArray(parsed)) return parsed.join(', ')
107
- return raw
108
- } catch {
109
- return raw
110
- }
111
- }
112
-
113
- /**
114
- * 格式化 repeatableWait 步骤的历史用户回答,注入到重新输出的 step prompt 前。
115
- * @param {object} step - progress 中的 step 对象(含 waitAnswers 数组)
116
- * @returns {string|null} 格式化的历史文本,或 null(无历史)
117
- */
118
- function formatWaitHistory(step) {
119
- const answers = Array.isArray(step.waitAnswers) ? step.waitAnswers : []
120
- if (answers.length === 0) return null
121
- let text = `本步骤历史用户回答(共 ${answers.length} 轮):\n`
122
- for (const item of answers) {
123
- text += `\n${item.round}. ${item.answer}`
124
- if (item.question) {
125
- text += `\n 对应问题/摘要:${item.question}`
126
- }
127
- }
128
- const maxRounds = step.maxWaitRounds || null
129
- if (maxRounds && answers.length >= maxRounds) {
130
- text += `\n\n已达到 maxWaitRounds=${maxRounds}。请基于以上回答总结需求;除非仍有阻塞问题,否则完成本步骤并进入方案讨论。`
131
- } else {
132
- text += `\n\n请判断信息是否足够:如果足够,完成本步骤;如果仍缺关键约束,再提出一个问题并 --wait。`
133
- }
134
- return text
135
- }
136
-
137
- /**
138
- * 解析规范目录路径
139
- * 向上查找含 .sillyspec 的祖先目录,类似 git 找 .git 的逻辑。
140
- * @param {string} cwd - 项目根目录(或子目录)
141
- * @param {object} [opts]
142
- * @param {string} [opts.specDir] - 用户指定的 specDir(通过 --spec-dir 或 --spec-root)
143
- * @returns {string} 规范目录的绝对路径
144
- */
145
- function resolveSpecDir(cwd, opts = {}) {
146
- if (opts.specDir) return resolve(opts.specDir)
147
- let dir = resolve(cwd)
148
- while (true) {
149
- const candidate = join(dir, '.sillyspec')
150
- if (existsSync(candidate)) return candidate
151
- const parent = dirname(dir)
152
- if (parent === dir) break
153
- dir = parent
154
- }
155
- return join(resolve(cwd), '.sillyspec')
156
- }
157
- import { stageRegistry, auxiliaryStages } from './stages/index.js'
158
- import { checkTransition, runValidators } from './stage-contract.js'
9
+ import { stageRegistry, getNextStage, auxiliaryStages } from './stages/index.js'
159
10
  import { buildExecuteSteps } from './stages/execute.js'
160
11
  import { buildPlanSteps } from './stages/plan.js'
161
- import { formatExecuteSummary } from './worktree-apply.js'
162
- import { classifyChange } from './classify-change.js'
163
- import { detectRiskProfile } from './change-risk-profile.js'
164
- import { definition as brainstormAutoDef } from './stages/brainstorm-auto.js'
165
-
166
- /**
167
- * 从 _module-map.yaml 读取模块上下文索引
168
- * 用于 brainstorm/plan/execute 阶段按任务命中模块精准注入上下文
169
- *
170
- * @param {string} specBase - 规范目录(.sillyspec 或 specRoot)
171
- * @param {string} projectName - 项目名
172
- * @returns {object|null} 解析后的模块索引,null 表示无索引
173
- */
174
- function loadModuleContextIndex(specBase, projectName) {
175
- try {
176
- const { existsSync, readFileSync } = require('fs')
177
- const { join } = require('path')
178
- const mapPath = join(specBase, 'docs', projectName, 'modules', '_module-map.yaml')
179
- if (!existsSync(mapPath)) return null
180
- const content = readFileSync(mapPath, 'utf8')
181
- return parseModuleMapSimple(content)
182
- } catch {
183
- return null
184
- }
185
- }
186
-
187
- /**
188
- * 根据 AI 输出的任务描述,匹配相关模块并生成上下文注入文本
189
- * 匹配策略:模块 id / role / doc 路径中的关键词
190
- *
191
- * @param {string} taskDescription - 任务描述(来自 plan.md / step prompt / outputText)
192
- * @param {object} moduleIndex - loadModuleContextIndex 返回值
193
- * @param {string} specBase - 规范目录
194
- * @param {string} projectName - 项目名
195
- * @returns {string} 上下文注入文本,空字符串表示无匹配模块
196
- */
197
- function buildModuleContextInjection(taskDescription, moduleIndex, specBase, projectName) {
198
- if (!moduleIndex || !taskDescription) return ''
199
- const { existsSync } = require('fs')
200
- const { join } = require('path')
201
-
202
- const taskLower = taskDescription.toLowerCase()
203
- const matched = []
204
-
205
- for (const [moduleId, data] of Object.entries(moduleIndex)) {
206
- let score = 0
207
- let matchReasons = []
208
- // 模块 id 匹配
209
- if (taskLower.includes(moduleId.toLowerCase())) { score += 3; matchReasons.push(`id:${moduleId}`) }
210
- // role 描述匹配
211
- if (data.role && taskLower.includes(data.role.toLowerCase())) { score += 2; matchReasons.push('role') }
212
- // core_files 路径匹配
213
- const coreFiles = data.paths || data.core_files || []
214
- for (const p of coreFiles) {
215
- if (taskLower.includes(p.toLowerCase())) { score += 1; matchReasons.push(`file:${p}`); break }
216
- }
217
- if (score > 0) matched.push({ moduleId, data, score, matchReasons })
218
- }
219
-
220
- if (matched.length === 0) return ''
221
-
222
- matched.sort((a, b) => b.score - a.score)
223
-
224
- let injection = '\n### 📦 模块上下文(按相关性排序,来自 Module Context Index)\n\n'
225
- injection += `> 以下模块上下文由 scan 阶段生成的 _module-map.yaml 自动匹配。\n`
226
- injection += `> Matched modules: ${matched.map(m => m.moduleId).join(', ')}\n`
227
- injection += `> Reasons: ${matched.map(m => m.matchReasons.join(', ')).join('; ')}\n\n`
228
-
229
- for (const { moduleId, data } of matched) {
230
- injection += `#### ${moduleId}\n`
231
- if (data.role) injection += `- **职责**: ${String(data.role).slice(0, 100)}\n`
232
- const riskLevel = data.risk_level || 'medium'
233
- injection += `- **风险等级**: ${riskLevel}\n`
234
- const coreFiles = data.paths || data.core_files || []
235
- if (coreFiles.length > 0) injection += `- **核心文件**: ${coreFiles.join(', ')}\n`
236
- if (data.doc) {
237
- const docPath = join(specBase, 'docs', projectName, data.doc)
238
- const exists = existsSync(docPath)
239
- injection += `- **模块文档**: ${data.doc}${exists ? ' ✅' : ' ⚠️ 不存在'}\n`
240
- }
241
- const deps = data.depends_on || []
242
- if (deps.length > 0) injection += `- **依赖**: ${deps.join(', ')}\n`
243
- const usedBy = data.used_by || []
244
- if (usedBy.length > 0) injection += `- **被引用**: ${usedBy.join(', ')}\n`
245
- injection += '\n'
246
- }
247
-
248
- return injection
249
- }
250
-
251
- // 复用 modules.js 的简单 YAML 解析(避免循环依赖)
252
- function parseModuleMapSimple(content) {
253
- const modules = {}
254
- let currentModule = null
255
- let currentKey = null
256
- let currentArray = null
257
-
258
- for (const line of content.split('\n')) {
259
- const moduleMatch = line.match(/^ ([a-zA-Z0-9_-]+):$/)
260
- if (moduleMatch) {
261
- if (currentArray && currentModule && currentKey) {
262
- modules[currentModule][currentKey] = currentArray
263
- }
264
- currentModule = moduleMatch[1]
265
- modules[currentModule] = {}
266
- currentKey = null
267
- currentArray = null
268
- continue
269
- }
270
- if (!currentModule) continue
271
-
272
- const arrayFieldMatch = line.match(/^ (depends_on|used_by|paths|tags|aliases|entrypoints|main_symbols|review_reasons|core_files|test_files|related_docs|verify_commands):$/)
273
- if (arrayFieldMatch) {
274
- if (currentArray && currentKey) modules[currentModule][currentKey] = currentArray
275
- currentKey = arrayFieldMatch[1]
276
- currentArray = []
277
- continue
278
- }
279
-
280
- const inlineArrayMatch = line.match(/^ (depends_on|used_by|paths|tags|aliases|entrypoints|main_symbols|review_reasons|core_files|test_files|related_docs|verify_commands): \[(.*)\]$/)
281
- if (inlineArrayMatch) {
282
- if (currentArray && currentKey) modules[currentModule][currentKey] = currentArray
283
- const vals = inlineArrayMatch[2].split(',').map(v => v.trim()).filter(Boolean)
284
- modules[currentModule][inlineArrayMatch[1]] = vals
285
- currentKey = null
286
- currentArray = null
287
- continue
288
- }
289
-
290
- const scalarMatch = line.match(/^ (status|doc|needs_review|role|risk_level): (.+)$/)
291
- if (scalarMatch) {
292
- if (currentArray && currentKey) { modules[currentModule][currentKey] = currentArray; currentArray = null; currentKey = null }
293
- modules[currentModule][scalarMatch[1]] = scalarMatch[2]
294
- continue
295
- }
296
-
297
- const itemMatch = line.match(/^ - (.+)$/)
298
- if (itemMatch && currentArray !== null) {
299
- currentArray.push(itemMatch[1].trim())
300
- continue
301
- }
302
- }
303
- if (currentArray && currentModule && currentKey) {
304
- modules[currentModule][currentKey] = currentArray
305
- }
306
- return modules
307
- }
308
-
309
- /**
310
- * 同步触发辅助函数:_write 后 best-effort 同步到平台
311
- */
312
- /**
313
- * quick 完成审计:对比 baseline 与实际变更
314
- */
315
- async function auditQuickCompletion(cwd, guard, options = {}) {
316
- const { baselineFiles, allowedFiles = [], allowNew = false, forceBaseline = false } = guard
317
- const { isConfirm } = options
318
- const result = { status: 'safe', reasons: [], changedFiles: [], newFiles: [], deletedFiles: [], baselineHit: [] }
319
-
320
- try {
321
- const { execSync } = await import('child_process')
322
- const gitStatus = execSync('git status --porcelain', { cwd, encoding: 'utf8', timeout: 10000 })
323
- const currentEntries = gitStatus.trim().split('\n').filter(Boolean)
324
-
325
- const normalizeGitPath = (p) => p.replace(/\\/g, '/')
326
- const isQuickMetadata = (p) => {
327
- const file = normalizeGitPath(p)
328
- return file.startsWith('.sillyspec/quicklog/')
329
- || file.startsWith('.sillyspec/.runtime/')
330
- || file === '.sillyspec/knowledge/uncategorized.md'
331
- || (/^\.sillyspec\/docs\/[^/]+\/modules\/[^/]+\.md$/.test(file))
332
- || (/^\.sillyspec\/docs\/[^/]+\/modules\/_module-map\.yaml$/.test(file))
333
- }
334
- const DANGEROUS_PATTERNS = [
335
- 'package.json',
336
- 'package-lock.json',
337
- 'yarn.lock',
338
- 'pnpm-lock.yaml',
339
- '.eslintrc',
340
- 'tsconfig.json',
341
- 'src/db.js',
342
- 'src/progress.js',
343
- 'src/run.js',
344
- 'src/stage-contract.js',
345
- 'src/worktree.js',
346
- 'src/worktree-apply.js',
347
- 'src/hooks/',
348
- ]
349
-
350
- for (const entry of currentEntries) {
351
- const status = entry.slice(0, 2).trim()
352
- const file = normalizeGitPath(entry.slice(3).trim())
353
- if (!file || file.startsWith('??. ')) continue
354
-
355
- result.changedFiles.push(file)
356
- if (status === 'D' || status === ' D') result.deletedFiles.push(file)
357
- if (status === '??') result.newFiles.push(file)
358
-
359
- // 检查是否命中 baseline protected files
360
- if (baselineFiles.includes(file)) {
361
- result.baselineHit.push(file)
362
- }
363
-
364
- // 检查危险文件(除非 force-baseline)
365
- if (file.startsWith('.sillyspec/') && !isQuickMetadata(file) && !forceBaseline) {
366
- result.reasons.push(`危险文件变更: ${file}`)
367
- }
368
-
369
- if (DANGEROUS_PATTERNS.some(p => file === p || file.startsWith(p)) && !forceBaseline) {
370
- result.reasons.push(`危险文件变更: ${file}`)
371
- }
372
- }
373
-
374
- // 检查 deleted files
375
- for (const f of result.deletedFiles) {
376
- result.reasons.push(`删除文件: ${f}`)
377
- }
378
-
379
- // 检查 baseline hit(除非 force-baseline)
380
- if (!forceBaseline) {
381
- for (const f of result.baselineHit) {
382
- result.reasons.push(`覆盖 baseline 文件: ${f}`)
383
- }
384
- }
385
-
386
- // 检查 new files(除非 allow-new)
387
- if (!allowNew) {
388
- for (const f of result.newFiles) {
389
- if (!isQuickMetadata(f)) {
390
- result.reasons.push(`新增文件(需 --allow-new): ${f}`)
391
- }
392
- }
393
- }
394
-
395
- // 检查 allowedFiles 范围
396
- if (allowedFiles.length > 0) {
397
- for (const f of result.changedFiles) {
398
- if (!allowedFiles.includes(f) && !isQuickMetadata(f)) {
399
- result.reasons.push(`超出 allowedFiles: ${f}`)
400
- }
401
- }
402
- }
403
-
404
- // 判定结果
405
- if (result.baselineHit.length > 0 || result.deletedFiles.length > 0 || result.reasons.some(r => r.startsWith('危险') || r.startsWith('删除'))) {
406
- result.status = 'blocked'
407
- } else if (result.newFiles.length > 0 || (allowedFiles.length > 0 && result.reasons.some(r => r.startsWith('超出')))) {
408
- result.status = 'warning'
409
- }
410
-
411
- // quicklog 存在性检查
412
- try {
413
- const quicklogDir = join(cwd, '.sillyspec', 'quicklog')
414
- if (existsSync(quicklogDir)) {
415
- const qlFiles = readdirSync(quicklogDir).filter(f => f.endsWith('.md'))
416
- if (qlFiles.length === 0) {
417
- result.reasons.push('quicklog 目录为空(无任务记录)')
418
- if (result.status === 'safe') result.status = 'warning'
419
- }
420
- } else {
421
- result.reasons.push('quicklog 目录不存在(agent 未创建任务记录)')
422
- if (result.status === 'safe') result.status = 'warning'
423
- }
424
- } catch {}
425
-
426
- // --confirm 模式:展示 diff 并等待确认
427
- if (isConfirm && (result.status === 'warning' || result.status === 'blocked')) {
428
- console.log(`\n📋 quick 变更概览:`)
429
- console.log(` 新增: ${result.newFiles.length}, 修改: ${result.changedFiles.length - result.newFiles.length - result.deletedFiles.length}, 删除: ${result.deletedFiles.length}`)
430
- if (result.changedFiles.length > 0) {
431
- console.log(`\n 变更文件:`)
432
- for (const f of result.changedFiles) {
433
- const isBaseline = baselineFiles.includes(f)
434
- const isDangerous = DANGEROUS_PATTERNS.some(p => f.includes(p))
435
- const marker = isBaseline ? '🔴' : isDangerous ? '⚠️' : ' '
436
- console.log(` ${marker} ${f}`)
437
- }
438
- }
439
- console.log(`\n 状态: ${result.status.toUpperCase()}`)
440
- if (result.reasons.length > 0) {
441
- console.log(` 原因:`)
442
- for (const r of result.reasons) {
443
- console.log(` - ${r}`)
444
- }
445
- }
446
- console.log(`\n 如确认接受这些变更,重新运行:sillyspec run quick --done --confirm --output "..."`)
447
- console.log(` 或使用 --force-baseline 允许覆盖 baseline 文件,--allow-new 允许新增文件`)
448
- }
449
- } catch (e) {
450
- result.reasons.push(`审计失败: ${e.message}`)
451
- result.status = 'warning'
452
- }
453
-
454
- return result
455
- }
456
-
457
- function printQuickAuditReview(review) {
458
- if (review.status === 'blocked') {
459
- console.error(`\n🚫 quick 变更边界审计 — BLOCKED:`)
460
- for (const r of review.reasons) {
461
- console.error(` - ${r}`)
462
- }
463
- console.error(`\n quick 已停止:请恢复/拆分这些变更,或重新运行 quick 并显式声明范围。`)
464
- } else if (review.status === 'warning') {
465
- console.warn(`\n⚠️ quick 变更边界审计 — WARNING:`)
466
- for (const r of review.reasons) {
467
- console.warn(` - ${r}`)
468
- }
469
- } else {
470
- console.log(`\n✅ quick 变更边界审计 — SAFE (变更 ${review.changedFiles.length} 个文件)`)
471
- }
472
- }
473
-
474
- async function triggerSync(cwd, changeName, platformOpts = {}) {
475
- // 平台模式(SillyHub)走自己的回传链路,不走 CLI 内置 sync
476
- if (platformOpts?.specRoot || platformOpts?.runtimeRoot) return
477
- try {
478
- if (changeName && !existsSync(join(cwd, '.sillyspec', 'changes', changeName))) return
479
- const syncMod = await import('./sync.js')
480
- await syncMod.sync(changeName, cwd)
481
- } catch (e) {
482
- // sync.js 不存在或同步失败,静默跳过
483
- console.warn('⚠️ 同步失败:', e.message)
484
- }
485
- }
486
-
487
- /**
488
- * 审批检查辅助函数:execute 阶段启动前检查
489
- * @returns {{ status: string, reason?: string } | null}
490
- */
491
- async function checkApproval(cwd, changeName, platformOpts = {}) {
492
- // 平台模式不需要 CLI 内置审批检查
493
- if (platformOpts?.specRoot || platformOpts?.runtimeRoot) return null
494
- try {
495
- const syncMod = await import('./sync.js')
496
- return await syncMod.checkApproval(changeName, cwd)
497
- } catch (e) {
498
- return null
499
- }
500
- }
501
-
502
- /**
503
- * 统一查找变更目录(与 progress.js 的变更检测逻辑一致)
504
- */
505
- function resolveChangeDir(cwd, progress, specDir = null) {
506
- const changesDir = join(specDir || resolveSpecDir(cwd), 'changes')
507
- if (!existsSync(changesDir)) return null
508
-
509
- // 1. 优先用 currentChange
510
- if (progress.currentChange) {
511
- const target = join(changesDir, progress.currentChange)
512
- if (existsSync(target)) return target
513
- }
514
-
515
- // 2. fallback:唯一非 archive 目录
516
- const entries = readdirSync(changesDir, { withFileTypes: true })
517
- .filter(e => e.isDirectory() && e.name !== 'archive')
518
- if (entries.length === 1) return join(changesDir, entries[0].name)
519
-
520
- return null
521
- }
522
-
523
- /**
524
- * 自动探测并设置 currentChange(唯一变更目录时)
525
- * @returns {boolean} 是否设置了 currentChange
526
- */
527
- function autoDetectChange(progress, cwd, specDir = null) {
528
- if (progress.currentChange) return false
529
- const changesDir = join(specDir || resolveSpecDir(cwd), 'changes')
530
- if (!existsSync(changesDir)) return false
531
- const entries = readdirSync(changesDir, { withFileTypes: true })
532
- .filter(e => e.isDirectory() && e.name !== 'archive')
533
- if (entries.length === 1) {
534
- progress.currentChange = entries[0].name
535
- return true
536
- }
537
- return false
538
- }
539
-
540
- /**
541
- * 从 progress 或变更目录推导变更名
542
- */
543
- function resolveChangeName(cwd, progress, specDir = null) {
544
- if (progress.currentChange) return progress.currentChange
545
- const changesDir = join(specDir || resolveSpecDir(cwd), 'changes')
546
- if (!existsSync(changesDir)) return null
547
- const entries = readdirSync(changesDir, { withFileTypes: true })
548
- .filter(e => e.isDirectory() && e.name !== 'archive')
549
- if (entries.length === 1) return entries[0].name
550
- return null
551
- }
552
12
 
553
13
  /**
554
14
  * 获取阶段的步骤定义(execute 需要动态构建)
555
15
  */
556
- async function getStageSteps(stageName, cwd, progress, specDir = null) {
16
+ async function getStageSteps(stageName, cwd, progress) {
557
17
  if (stageName === 'execute') {
558
- const changeDir = resolveChangeDir(cwd, progress, specDir)
18
+ const changesDir = join(cwd, '.sillyspec', 'changes')
559
19
  let planFile = null
560
- if (changeDir) {
561
- const p = join(changeDir, 'plan.md')
562
- if (existsSync(p)) planFile = p
20
+ // 优先用 currentChange 指定的变更名
21
+ if (progress.currentChange) {
22
+ const target = join(changesDir, progress.currentChange, 'plan.md')
23
+ if (existsSync(target)) planFile = target
24
+ }
25
+ // fallback:扫描 changes/ 非 archive 目录下的 plan.md
26
+ if (!planFile && existsSync(changesDir)) {
27
+ const candidates = []
28
+ for (const entry of readdirSync(changesDir, { withFileTypes: true })) {
29
+ if (!entry.isDirectory() || entry.name === 'archive') continue
30
+ const p = join(changesDir, entry.name, 'plan.md')
31
+ if (existsSync(p)) candidates.push({ name: entry.name, path: p })
32
+ }
33
+ if (candidates.length === 1) {
34
+ planFile = candidates[0].path
35
+ } else if (candidates.length > 1) {
36
+ console.log('⚠️ 检测到多个变更,请选择:')
37
+ candidates.forEach((c, i) => console.log(` ${i + 1}. ${c.name}`))
38
+ const readline = await import('readline')
39
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
40
+ const answer = await new Promise(resolve => {
41
+ rl.question(`\n请输入编号(默认 1):`, input => {
42
+ rl.close()
43
+ const num = parseInt(input) || 1
44
+ resolve(num >= 1 && num <= candidates.length ? num - 1 : 0)
45
+ })
46
+ })
47
+ planFile = candidates[answer].path
48
+ console.log(`✅ 已选择:${candidates[answer].name}\n`)
49
+ }
563
50
  }
564
51
  return buildExecuteSteps(planFile)
565
52
  }
566
53
  if (stageName === 'plan') {
567
- const changeDir = resolveChangeDir(cwd, progress, specDir)
54
+ const changesDir = join(cwd, '.sillyspec', 'changes')
55
+ let changeDir = null
56
+ if (progress.currentChange) {
57
+ const target = join(changesDir, progress.currentChange)
58
+ if (existsSync(target)) changeDir = target
59
+ }
60
+ if (!changeDir && existsSync(changesDir)) {
61
+ const entries = readdirSync(changesDir, { withFileTypes: true }).filter(e => e.isDirectory() && e.name !== 'archive')
62
+ if (entries.length === 1) changeDir = join(changesDir, entries[0].name)
63
+ }
568
64
  return buildPlanSteps(changeDir)
569
65
  }
570
66
  const def = stageRegistry[stageName]
@@ -572,12 +68,12 @@ async function getStageSteps(stageName, cwd, progress, specDir = null) {
572
68
  }
573
69
 
574
70
  /**
575
- * 确保阶段的 steps 已初始化到 progress
71
+ * 确保阶段的 steps 已初始化到 progress.json
576
72
  */
577
- async function ensureStageSteps(progress, stageName, cwd, specDir = null) {
73
+ async function ensureStageSteps(progress, stageName, cwd) {
578
74
  if (!progress.stages) progress.stages = {}
579
75
 
580
- const steps = await getStageSteps(stageName, cwd, progress, specDir)
76
+ const steps = await getStageSteps(stageName, cwd, progress)
581
77
  if (!steps) return false
582
78
 
583
79
  if (!progress.stages[stageName] || !progress.stages[stageName].steps || progress.stages[stageName].steps.length === 0) {
@@ -592,10 +88,11 @@ async function ensureStageSteps(progress, stageName, cwd, specDir = null) {
592
88
 
593
89
  // 检查步骤数量是否匹配(execute 动态步骤可能变化)
594
90
  if (progress.stages[stageName].steps.length !== steps.length) {
91
+ // 保留已完成的状态,重新构建步骤列表
595
92
  const oldSteps = progress.stages[stageName].steps
596
- progress.stages[stageName].steps = steps.map(s => {
597
- const old = oldSteps.find(step => step.name === s.name)
598
- if (old) return old
93
+ progress.stages[stageName].steps = steps.map((s, i) => {
94
+ const old = oldSteps[i]
95
+ if (old && old.name === s.name) return old
599
96
  return { name: s.name, status: 'pending' }
600
97
  })
601
98
  return true
@@ -607,22 +104,10 @@ async function ensureStageSteps(progress, stageName, cwd, specDir = null) {
607
104
  /**
608
105
  * 输出当前步骤的 prompt
609
106
  */
610
- async function outputStep(stageName, stepIndex, steps, cwd, changeName, dbProjectName, platformOpts = {}, prevStepAnswer = null) {
107
+ function outputStep(stageName, stepIndex, steps, cwd) {
611
108
  const step = steps[stepIndex]
612
109
  const total = steps.length
613
- const projectName = dbProjectName || basename(cwd)
614
-
615
- // ── Revision context injection ──
616
- const revisionCtx = platformOpts?._revision
617
- if (revisionCtx) {
618
- console.log(`### 🔄 Revision Context`)
619
- console.log(`本阶段处于修订模式(revision ${revisionCtx.revision}),不是首次执行。`)
620
- console.log(`- 修订起始步骤:${revisionCtx.fromStep}`)
621
- console.log(`- 当前步骤之前已完成的步骤仍然有效,不需要重做。`)
622
- console.log(`- 当前步骤及之后的步骤需要重新生成或调整已有产物。`)
623
- console.log(`- 已有产物文件(design.md、plan.md 等)被保留,审视并更新它们,而不是从零创建。`)
624
- console.log(`- 不要绕过 CLI 进度追踪。\n`)
625
- }
110
+ const projectName = basename(cwd)
626
111
 
627
112
  const personas = {
628
113
  brainstorm: `### 🎯 你的角色:资深架构师
@@ -634,9 +119,7 @@ async function outputStep(stageName, stepIndex, steps, cwd, changeName, dbProjec
634
119
  verify: `### 🔍 你的角色:QA 专家
635
120
  你是一位吹毛求疵的 QA 专家。假设所有代码都有 bug,用最坏情况测试。关注边界、异常、并发。有问题直说,用证据说话,不写"看起来没问题"。`,
636
121
  quick: `### 💻 你的角色:全栈老兵
637
- 你是一位实战经验丰富的全栈工程师。不纠结架构和流程,理解需求就直接干。不确定的地方先问清楚再动手,先读后写,改完就收。问题排查思路开阔,前端报错不一定是前端问题——可能是后端数据、浏览器兼容、甚至设备硬件。解决方案实用接地气,用户描述有误敢于直接指出。`,
638
- explore: `### 🧭 你的角色:技术探索伙伴
639
- 你帮助用户澄清问题、调查代码库、比较方案和暴露风险。探索阶段不写实现代码,不安装依赖,不把讨论强行推进成开发。`
122
+ 你是一位实战经验丰富的全栈工程师。不纠结架构和流程,理解需求就直接干。不确定的地方先问清楚再动手,先读后写,改完就收。问题排查思路开阔,前端报错不一定是前端问题——可能是后端数据、浏览器兼容、甚至设备硬件。解决方案实用接地气,用户描述有误敢于直接指出。`
640
123
  }
641
124
 
642
125
  console.log(`---`)
@@ -644,495 +127,29 @@ async function outputStep(stageName, stepIndex, steps, cwd, changeName, dbProjec
644
127
  console.log(`step: ${stepIndex + 1}/${total}`)
645
128
  console.log(`stepName: ${step.name}`)
646
129
  console.log(`project: ${projectName}`)
647
- if (changeName) {
648
- console.log(`change: ${changeName}`)
649
- const isPlatform = platformOpts?.specRoot || platformOpts?.runtimeRoot
650
- const changeDirBase = isPlatform ? platformOpts.specRoot : '.sillyspec'
651
- const changeDir = join(changeDirBase, 'changes', changeName)
652
- console.log(`changeDir: ${changeDir}`)
653
- }
654
130
  console.log(`---\n`)
655
131
  if (personas[stageName]) {
656
132
  console.log(personas[stageName])
657
133
  console.log('')
658
134
  }
659
- // 注入全局护栏(如 _globalGuardrails)
660
- const stageDef = stageRegistry[stageName]
661
- const guardrails = stageDef && stageDef._globalGuardrails ? stageDef._globalGuardrails : ''
662
-
663
135
  console.log(`## Step ${stepIndex + 1}/${total}: ${step.name}\n`)
664
- if (guardrails) {
665
- console.log(guardrails.trim())
666
- console.log('')
667
- }
668
- let promptText = step.prompt
669
- // 替换 prompt 中的占位符
670
- if (projectName && promptText.includes('<project>')) {
671
- promptText = promptText.replace(/<project>/g, projectName)
672
- }
673
- // 替换 <git-user> 占位符
674
- if (promptText.includes('<git-user>')) {
675
- const { execSync } = await import('child_process')
676
- try {
677
- const gitUser = safeGit(cwd, ['config', 'user.name']).value || 'unknown'
678
- promptText = promptText.replace(/<git-user>/g, gitUser)
679
- } catch {
680
- promptText = promptText.replace(/<git-user>/g, 'unknown')
681
- }
682
- }
683
- // 替换时间戳占位符
684
- const now = new Date()
685
- const nowDatetime = now.getFullYear() + '-' + String(now.getMonth()+1).padStart(2,'0') + '-' + String(now.getDate()).padStart(2,'0') + ' ' + String(now.getHours()).padStart(2,'0') + ':' + String(now.getMinutes()).padStart(2,'0') + ':' + String(now.getSeconds()).padStart(2,'0')
686
- const nowTimestamp = now.getFullYear() + String(now.getMonth()+1).padStart(2,'0') + String(now.getDate()).padStart(2,'0') + '-' + String(now.getHours()).padStart(2,'0') + String(now.getMinutes()).padStart(2,'0') + String(now.getSeconds()).padStart(2,'0')
687
- const nowDate = now.getFullYear() + '-' + String(now.getMonth()+1).padStart(2,'0') + '-' + String(now.getDate()).padStart(2,'0')
688
- promptText = promptText.replace(/<now-datetime>/g, nowDatetime)
689
- promptText = promptText.replace(/<now-timestamp>/g, nowTimestamp)
690
- promptText = promptText.replace(/<now-date>/g, nowDate)
691
- // 替换 <change-name> 占位符
692
- if (changeName && promptText.includes('<change-name>')) {
693
- promptText = promptText.replace(/<change-name>/g, changeName)
694
- }
695
- // 平台模式:注入路径覆盖指令
696
- if (platformOpts?.specRoot || platformOpts?.runtimeRoot) {
697
- const projectName = dbProjectName || basename(cwd)
698
- // platformOpts.specRoot 现在指向 specDir 本身(可能是 cwd/.sillyspec 或外部路径)
699
- const specSillyspec = platformOpts.specRoot || join(cwd, '.sillyspec')
700
- const docsRoot = join(specSillyspec, 'docs', projectName)
701
- const projectsRoot = join(specSillyspec, 'projects')
702
- const changesRoot = join(specSillyspec, 'changes')
703
- const workflowsRoot = join(specSillyspec, 'workflows')
704
- const knowledgeRoot = join(specSillyspec, 'knowledge')
705
-
706
- promptText = promptText.replace(/\{DOCS_ROOT\}/g, docsRoot)
707
- promptText = promptText.replace(/\{PROJECTS_ROOT\}/g, projectsRoot)
708
- promptText = promptText.replace(/\{WORKFLOWS_ROOT\}/g, workflowsRoot)
709
- promptText = promptText.replace(/\{KNOWLEDGE_ROOT\}/g, knowledgeRoot)
710
- promptText = promptText.replace(/\{SPEC_ROOT\}/g, specSillyspec)
711
-
712
- const platformDirectives = []
713
- platformDirectives.push(
714
- `## ⚠️ 平台模式 — 写入路径约束(必须严格遵守)\n` +
715
- `\n` +
716
- `规范目录(specDir): \`${specSillyspec}\`\n` +
717
- `- 文档根目录: \`${docsRoot}/\`\n` +
718
- `- 项目注册表: \`${projectsRoot}/\`\n` +
719
- `- 变更目录: \`${changesRoot}/\`\n` +
720
- `- 工作流目录: \`${workflowsRoot}/\`\n` +
721
- `- 术语目录: \`${knowledgeRoot}/\`\n` +
722
- `\n` +
723
- `### ⛔ 写入规则\n` +
724
- `1. **所有文档、配置、产物只能写入上述路径**。严禁写入源码目录或相对路径 \`.sillyspec/\`。\n` +
725
- `2. **不允许**从 cwd 推导文档路径,必须使用上面列出的绝对路径。\n` +
726
- `3. **源码扫描范围**必须排除:.sillyspec/、.claude/、.git/、node_modules/、dist/、build/、__pycache__/\n` +
727
- `4. **local.yaml 校验**:commands 中引用的命令必须在 package.json 的 scripts 中存在,不存在的标记为 unavailable,不能写 "配置良好"\n` +
728
- `\n` +
729
- `### ⛔ Write 工具规则\n` +
730
- `1. 如果 Write 返回 \"File has not been read yet\",正确动作是:先 Read 目标文件 → 再 Write 覆盖。\n` +
731
- `2. **不允许**用 cat >、tee、heredoc 等 Bash 方式绕过 Write 工具。\n` +
732
- `3. 如果 Write 和 Read 均失败,记录失败并停止当前 step。\n` +
733
- `\n` +
734
- `### 📍 Workflow YAML 占位符映射(task-05)\n` +
735
- `读取 \`{WORKFLOWS_ROOT}/scan-docs.yaml\` 时,yaml 内的占位符按以下映射替换为绝对路径:\n` +
736
- `- \`{SPEC_ROOT}\` → \`${specSillyspec}\`(规范目录根)\n` +
737
- `- \`<project>\` → 当前项目名(见下方 step 提示,等于 \`${projectName}\`)\n` +
738
- `- 例:\`{SPEC_ROOT}/docs/<project>/scan/ARCHITECTURE.md\` → \`${docsRoot}/scan/ARCHITECTURE.md\`\n` +
739
- `\n` +
740
- `创建目录: \`mkdir -p ${docsRoot}/{scan,modules,flows} ${projectsRoot} ${changesRoot}\`\n`
741
- )
742
- if (platformOpts.runtimeRoot) {
743
- const scanRunId = platformOpts.scanRunId || 'scan-' + new Date().toISOString().slice(0, 19).replace(/[T:]/g, '-')
744
- platformDirectives.push(
745
- `运行时产物写入: \`${platformOpts.runtimeRoot}/scan-runs/${scanRunId}/\`\n`
746
- )
747
- }
748
- if (platformOpts.workspaceId) {
749
- platformDirectives.push(`workspace_id: ${platformOpts.workspaceId}`)
750
- }
751
- promptText = platformDirectives.join('\n') + '\n\n' + promptText
752
- }
753
-
754
- // 注入 scanProfile 硬约束指令
755
- if (stageName === 'scan' && platformOpts?.scanProfile) {
756
- const sp = platformOpts.scanProfile
757
- const profileDirectives = []
758
- profileDirectives.push(`## 📊 Scan Profile: ${sp.mode} (${sp.reason})`)
759
- if (sp.maxAgentCalls === 0) {
760
- profileDirectives.push(`**⛔ 严禁使用子代理(Agent/Task 工具)。** 必须在本 turn 内完成所有工作。`)
761
- } else if (sp.maxAgentCalls > 0) {
762
- profileDirectives.push(`**子代理上限:${sp.maxAgentCalls} 个。** 不要超出。`)
763
- }
764
- if (sp.maxDocs < 99) {
765
- profileDirectives.push(`**文档上限:${sp.maxDocs} 份。** 只生成核心文档,不要额外生成 flows/glossary/module-card。`)
766
- }
767
- profileDirectives.push(`--output 只需要列出文件名,不要写长篇总结。`)
768
- promptText = profileDirectives.join('\n') + '\n\n' + promptText
769
-
770
- // scanProfile 分支也要替换占位符(非 platform 模式也会走到这里)
771
- const _pName = dbProjectName || basename(cwd)
772
- const _specSS = platformOpts?.specRoot || join(cwd, '.sillyspec')
773
- const _docsRoot = join(_specSS, 'docs', _pName)
774
- promptText = promptText.replace(/\{DOCS_ROOT\}/g, _docsRoot)
775
- promptText = promptText.replace(/\{PROJECTS_ROOT\}/g, join(_specSS, 'projects'))
776
- promptText = promptText.replace(/\{WORKFLOWS_ROOT\}/g, join(_specSS, 'workflows'))
777
- promptText = promptText.replace(/\{KNOWLEDGE_ROOT\}/g, join(_specSS, 'knowledge'))
778
- promptText = promptText.replace(/\{SPEC_ROOT\}/g, _specSS)
779
- } else {
780
- // 非 platform 模式也要替换占位符
781
- const projectName = dbProjectName || basename(cwd)
782
- const specSillyspec = join(cwd, '.sillyspec')
783
- const docsRoot = join(specSillyspec, 'docs', projectName)
784
- const projectsRoot = join(specSillyspec, 'projects')
785
- const workflowsRoot = join(specSillyspec, 'workflows')
786
- const knowledgeRoot = join(specSillyspec, 'knowledge')
787
- promptText = promptText.replace(/\{DOCS_ROOT\}/g, docsRoot)
788
- promptText = promptText.replace(/\{PROJECTS_ROOT\}/g, projectsRoot)
789
- promptText = promptText.replace(/\{WORKFLOWS_ROOT\}/g, workflowsRoot)
790
- promptText = promptText.replace(/\{KNOWLEDGE_ROOT\}/g, knowledgeRoot)
791
- promptText = promptText.replace(/\{SPEC_ROOT\}/g, specSillyspec)
792
- }
793
-
794
- // Knowledge hit report: execute 阶段注入匹配结果
795
- if (stageName === 'execute' && promptText.includes('{KNOWLEDGE_HIT_REPORT}')) {
796
- try {
797
- const { matchKnowledge } = await import('./knowledge-match.js')
798
- const effectiveSpecBase = platformOpts?.specRoot || join(cwd, '.sillyspec')
799
- const knowledgeDir = join(effectiveSpecBase, 'knowledge')
800
- // taskContext: changeName + plan.md task names for better matching
801
- let taskContext = changeName || ''
802
- if (changeName) {
803
- const planPath = join(effectiveSpecBase, 'changes', changeName, 'plan.md')
804
- try {
805
- const planContent = readFileSync(planPath, 'utf8')
806
- // match both "- [ ] task-01: title" and "## task-01: title" formats
807
- const taskLines = [...planContent.matchAll(/(?:^\- \[[ x]\] |^## )task-\d+[^:]*:?\s*(.+)/gm)]
808
- if (taskLines.length > 0) {
809
- taskContext += ' ' + taskLines.map(t => t[1]).join(' ')
810
- }
811
- } catch {}
812
- }
813
- const knowledgeResult = matchKnowledge(knowledgeDir, taskContext)
814
- promptText = promptText.replace(/\{KNOWLEDGE_HIT_REPORT\}/g, knowledgeResult.report)
815
- // 写入 runtime JSON
816
- const runtimeDir = join(effectiveSpecBase, '.runtime')
817
- mkdirSync(runtimeDir, { recursive: true })
818
- writeFileSync(join(runtimeDir, 'knowledge-hit-report.json'), JSON.stringify(knowledgeResult.json, null, 2) + '\n')
819
- } catch (e) {
820
- promptText = promptText.replace(/\{KNOWLEDGE_HIT_REPORT\}/g, 'Status: no matches (error: ' + e.message + ')')
821
- }
822
- }
823
-
824
- // Execute: 注入 currentExecuteRunId(从变更专属标记文件读取)
825
- if (stageName === 'execute' && promptText.includes('{EXECUTE_RUN_ID}')) {
826
- let runId = ''
827
- const execSpecBase = platformOpts?.specRoot || join(cwd, '.sillyspec')
828
- const runIdFile = join(execSpecBase, '.runtime', `current-execute-run-id-${effectiveChange}`)
829
- try {
830
- if (existsSync(runIdFile)) {
831
- runId = readFileSync(runIdFile, 'utf8').trim()
832
- }
833
- } catch {}
834
- if (!runId) {
835
- const { generateExecuteRunId } = await import('./task-review.js')
836
- runId = generateExecuteRunId()
837
- }
838
- promptText = promptText.replace(/\{EXECUTE_RUN_ID\}/g, runId)
839
- }
840
-
841
- // 注入模块上下文(brainstorm/plan/execute 阶段,基于 Module Context Index)
842
- if (['brainstorm', 'plan', 'execute'].includes(stageName) && projectName) {
843
- const effectiveSpecBase = platformOpts?.specRoot || join(cwd, '.sillyspec')
844
- const moduleIndex = loadModuleContextIndex(effectiveSpecBase, projectName)
845
- if (moduleIndex && Object.keys(moduleIndex).length > 0) {
846
- // 尝试从 step prompt / changeName 匹配模块
847
- const taskDesc = step.prompt || changeName || ''
848
- const injection = buildModuleContextInjection(taskDesc, moduleIndex, effectiveSpecBase, projectName)
849
- if (injection) {
850
- promptText = injection + '\n' + promptText
851
- }
852
- }
853
- }
854
-
855
- // 平台模式 prompt 自检:确保没有裸相对输出路径
856
- // 只匹配正向写入指令中的裸路径,避免误杀「禁止写入 .sillyspec/」等安全说明
857
- if ((platformOpts?.specRoot || platformOpts?.runtimeRoot) && stageName === 'scan') {
858
- const writeCtx = /(?<!不要|禁止|严禁)(?:save[\s.]+to|write|create|mkdir|git add|写入|保存到|写入到)[^a-zA-Z]*\.sillyspec\/[a-z]/i
859
- if (writeCtx.test(promptText)) {
860
- console.error(`❌ [sillyspec] BUG: 平台模式 scan prompt 包含写入指令指向裸相对路径 .sillyspec/`)
861
- console.error(` 这会导致 agent 写入源码目录而非 spec-root,属于源码污染 bug。`)
862
- console.error(` 请将路径改为对应的 {DOCS_ROOT}/{PROJECTS_ROOT}/{WORKFLOWS_ROOT}/{KNOWLEDGE_ROOT}/{SPEC_ROOT} 占位符。`)
863
- process.exit(1)
864
- }
865
- }
866
-
867
- if (prevStepAnswer) {
868
- console.log(`\n### 📩 上一步用户回答`)
869
- console.log(prevStepAnswer)
870
- }
871
-
872
- console.log(promptText)
136
+ console.log(step.prompt)
873
137
  console.log(`\n### ⚠️ 铁律`)
874
138
  console.log('- **文档是核心资产,代码是文档的产物。** 没有文档就没有代码——文档是 AI 的记忆,是团队协作的基础,是后续维护的唯一依据。任何代码产出必须先有对应的设计/规范文档支撑。')
875
139
  console.log('- 只做本步骤描述的操作,不得自行扩展或跳过')
876
- console.log('- 不要回头修改已完成的步骤')
140
+ console.log('- 不要回头修改已完成的步骤')
877
141
  console.log('- 不要编造不存在的 CLI 子命令')
878
142
  console.log('- 完成后立即执行 --done 命令,不得跳过')
879
- console.log('- 不要用 mv/rename 重命名变更目录,必须用 `sillyspec change-rename <旧名> <新名>`')
880
143
  console.log('- 文档类型文件(.md/.yaml/.json 等)头部必须包含 author(git 用户名)和 created_at(精确到秒)')
881
144
  console.log('- 执行构建/测试前必须先读 local.yaml,优先使用其中配置的命令、路径和环境变量;未配置时才使用默认值')
882
- // 平台模式额外铁律
883
- if (platformOpts?.specRoot || platformOpts?.runtimeRoot) {
884
- const specSillyspec = platformOpts.specRoot || join(cwd, '.sillyspec')
885
- console.log(`- **平台模式:所有文件只能写入 \`${specSillyspec}/\` 下的对应子目录,严禁写入源码目录。**`)
886
- console.log('- **平台模式:Write 工具失败时,不允许用 cat > / tee / heredoc 等方式绕过。先 Read 再 Write,仍失败则记录并停止。**')
887
- console.log('- **平台模式:local.yaml 中的 commands 必须在 package.json scripts 中真实存在,不存在的标记 unavailable。**')
888
- }
889
- // 路径安全规则:防止 AI 拼错变更目录
890
- if (changeName) {
891
- const isPlatform = platformOpts?.specRoot || platformOpts?.runtimeRoot
892
- const changeDirBase = isPlatform ? platformOpts.specRoot : '.sillyspec'
893
- const changeDir = join(changeDirBase, 'changes', changeName)
894
- console.log(`- **文件路径规则:所有变更文件必须写入 \`${changeDir}/\` 目录下。不要自己拼接路径,直接使用 changeDir 值。示例:\`${changeDir}/proposal.md\`**`)
895
- }
896
- const changeFlag = changeName ? ` --change ${changeName}` : ''
897
- // 检测当前 step prompt 是否包含 WAIT 指令(即可能需要等待用户)
898
- const stepPrompt = promptText || ''
899
- const requiresWait = step.requiresWait === true
900
- const conditionalWait = step.conditionalWait === true
901
- const mayNeedWait = WAIT_MARKER_RE.test(stepPrompt) || requiresWait || conditionalWait
902
-
903
145
  console.log(`\n### 完成后执行`)
904
- if (requiresWait) {
905
- console.log(`本步骤必须等待用户输入,不能直接 --done:`)
906
- console.log(`sillyspec run ${stageName} --wait --reason "${step.waitReason || '等待用户输入'}" --options "${(step.waitOptions || ['确认']).join(',')}"${changeFlag} --output "你的问题/方案摘要"`)
907
- console.log(``)
908
- console.log(`用户回答后执行:`)
909
- console.log(`sillyspec run ${stageName} --continue --answer "用户回答"${changeFlag}`)
910
- console.log(``)
911
- console.log(`收到回答并完成本步骤总结后,再执行:`)
912
- } else if (mayNeedWait) {
913
- console.log(`如果需要用户决策(选择方案/确认设计等):`)
914
- console.log(`sillyspec run ${stageName} --wait --reason "${step.waitReason || '等待原因'}" --options "${(step.waitOptions || ['选项1', '选项2']).join(',')}"${changeFlag} --output "你的摘要"`)
915
- console.log(``)
916
- console.log(`如果不需要用户决策,正常完成:`)
917
- }
918
- console.log(`sillyspec run ${stageName} --done${changeFlag} --input "用户原始需求/反馈" --output "你的摘要"`)
919
- }
920
-
921
- /**
922
- * 根据 project 规模计算 scan profile
923
- * quick: fileCount≤30 && sourceBytes≤80KB && projectCount≤3 → 3 步,0 子代理,5 份文档
924
- * standard: fileCount≤200 && sourceBytes≤800KB → 压缩步骤,最多 1 子代理
925
- * deep: 大项目或 --deep → 完整流程
926
- */
927
- function computeScanProfile(cwd, platformOpts) {
928
- // --deep 标志强制 deep
929
- const flags = process.argv.slice(2)
930
- if (flags.includes('--deep')) {
931
- return { mode: 'deep', reason: '用户指定 --deep', maxAgentCalls: 4, maxDocs: 99 }
932
- }
933
-
934
- const specDir = platformOpts?.specRoot || join(cwd, '.sillyspec')
935
- const projectsDir = join(specDir, 'projects')
936
- let projectCount = 1
937
- try {
938
- if (existsSync(projectsDir)) {
939
- projectCount = readdirSync(projectsDir).filter(f => f.endsWith('.yaml')).length
940
- }
941
- } catch {}
942
-
943
- // 快速估算源码规模
944
- let fileCount = 0
945
- let sourceBytes = 0
946
- try {
947
- const { execSync } = require('child_process')
948
- const findCmd = `find "${cwd}" -type f \\( -name '*.js' -o -name '*.ts' -o -name '*.tsx' -o -name '*.py' -o -name '*.java' -o -name '*.go' -o -name '*.rs' -o -name '*.rb' -o -name '*.php' -o -name '*.c' -o -name '*.cpp' -o -name '*.h' -o -name '*.jsx' -o -name '*.vue' -o -name '*.svelte' \\) -not -path '*/node_modules/*' -not -path '*/.git/*' -not -path '*/dist/*' -not -path '*/build/*' -not -path '*/__pycache__/*' -not -path '*/.sillyspec/*' -not -path '*/.claude/*' 2>/dev/null`
949
- const files = execSync(findCmd, { encoding: 'utf8', timeout: 10000 }).trim().split('\n').filter(Boolean)
950
- fileCount = files.length
951
- for (const f of files) {
952
- try { sourceBytes += statSync(f).size } catch {}
953
- }
954
- } catch {
955
- // find 失败时假设中等规模
956
- return { mode: 'standard', reason: '无法估算项目规模', maxAgentCalls: 1, maxDocs: 8 }
957
- }
958
-
959
- if (fileCount <= 30 && sourceBytes <= 80_000 && projectCount <= 3) {
960
- return { mode: 'quick', reason: `${fileCount} 源文件, ${Math.round(sourceBytes / 1024)}KB`, maxAgentCalls: 0, maxDocs: 5, _fileCount: fileCount, _sourceBytes: sourceBytes, _projectCount: projectCount }
961
- }
962
- if (fileCount <= 200 && sourceBytes <= 800_000) {
963
- return { mode: 'standard', reason: `${fileCount} 源文件, ${Math.round(sourceBytes / 1024)}KB`, maxAgentCalls: 1, maxDocs: 8, _fileCount: fileCount, _sourceBytes: sourceBytes, _projectCount: projectCount }
964
- }
965
- return { mode: 'deep', reason: `${fileCount} 源文件, ${Math.round(sourceBytes / 1024)}KB`, maxAgentCalls: 4, maxDocs: 99, _fileCount: fileCount, _sourceBytes: sourceBytes, _projectCount: projectCount }
966
- }
967
-
968
- /**
969
- * 根据 scanProfile 裁剪步骤
970
- * quick: 3 步 — CLI preflight / AI generate / CLI postcheck
971
- * standard: 跳过续扫检测(4), 跳过可选步骤(9)
972
- */
973
- function applyScanProfileSteps(stageData, profile, cwd, platformOpts) {
974
- const steps = stageData.steps
975
- const mode = profile.mode
976
-
977
- if (mode === 'quick') {
978
- const specBase = platformOpts?.specRoot || join(cwd, '.sillyspec')
979
- const projectName = basename(cwd)
980
- const docsRoot = join(specBase, 'docs', projectName)
981
-
982
- // Step 1: CLI preflight(不调 AI,自动完成)
983
- const step1 = {
984
- name: '项目概览(自动探测)',
985
- status: 'pending',
986
- noAI: true,
987
- _cliAction: 'scanPreflight',
988
- prompt: '',
989
- outputHint: 'preflight 结果',
990
- optional: false
991
- }
992
- // Step 2: AI 生成核心文档(唯一 AI roundtrip)
993
- const step2 = {
994
- name: '生成核心文档',
995
- status: 'pending',
996
- prompt: `## Quick Scan — 核心文档生成
997
-
998
- 项目规模较小(quick profile),请一次性生成所有核心文档。
999
-
1000
- ### 操作
1001
- 1. 读取项目结构和关键文件(package.json / pyproject.toml / README / 入口文件)
1002
- 2. 生成以下 4 份文档并写入 \`{DOCS_ROOT}/scan/\`:
1003
- - **PROJECT.md** — 项目简介、技术栈、模块划分
1004
- - **ARCHITECTURE.md** — 架构概览、模块关系、技术决策
1005
- - **CONVENTIONS.md** — 代码风格、框架隐形规则
1006
- - **STRUCTURE.md** — 目录树 + 模块说明
1007
- 3. 如发现子项目,注册到 \`{PROJECTS_ROOT}/\` 下
1008
-
1009
- 每份文档头必须包含 frontmatter:\`author\` 和 \`created_at\`。
1010
-
1011
- ### ⛔ 硬约束
1012
- - **严禁使用子代理(Agent/Task 工具)。** 所有文档在一个 turn 内完成。
1013
- - 不要搜索 .sillyspec/ .claude/ .git/ node_modules/ dist/ build/
1014
- - --output 只需要列出生成的文件名,不要写长篇总结
1015
-
1016
- ### 输出
1017
- 生成的文件列表`,
1018
- outputHint: '文件列表',
1019
- optional: false
1020
- }
1021
- // Step 3: CLI postcheck(不调 AI,自动完成)
1022
- const selfCheck = steps.find(s => s.name === '自检和提交') || {
1023
- name: '自检和提交', status: 'pending', prompt: '', outputHint: '结果', optional: false
1024
- }
1025
- const step3 = { ...selfCheck, status: 'pending', noAI: true, _cliAction: 'scanPostcheck', prompt: '' }
1026
- stageData.steps = [step1, step2, step3]
1027
- return
1028
- }
1029
-
1030
- if (mode === 'standard') {
1031
- // 跳过 Step 4(断点续扫检测),跳过 Step 9(flows+glossary,可选)
1032
- const skipNames = ['断点续扫检测', '生成业务流程和术语表(可选)']
1033
- for (const step of stageData.steps) {
1034
- if (skipNames.includes(step.name) && step.status === 'pending') {
1035
- step.status = 'skipped'
1036
- step.skippedAt = new Date().toLocaleString('zh-CN', { hour12: false })
1037
- }
1038
- }
1039
- }
1040
- }
1041
-
1042
- /**
1043
- * CLI-only: quick scan preflight
1044
- * 收集项目快照,打印 summary,不调 AI
1045
- */
1046
- async function executeScanPreflight(cwd, platformOpts, scanProfile) {
1047
- const specBase = platformOpts?.specRoot || join(cwd, '.sillyspec')
1048
- const projectName = basename(cwd)
1049
- console.log(` 📁 项目: ${projectName}`)
1050
- console.log(` 📊 Profile: ${scanProfile.mode} (${scanProfile.reason})`)
1051
- // 快速列出顶层结构
1052
- try {
1053
- const { execSync } = await import('child_process')
1054
- const dirs = execSync(`ls -d */ 2>/dev/null | grep -v node_modules | grep -v '.git' | grep -v '.sillyspec' | grep -v '.claude' | head -20`, { cwd, encoding: 'utf8' }).trim()
1055
- if (dirs) {
1056
- console.log(` 📂 目录: ${dirs.split('\n').map(d => d.replace(/\/$/, '')).join(', ')}`)
1057
- }
1058
- } catch {}
1059
- console.log(` ✅ Preflight 完成,准备生成核心文档\n`)
1060
- }
1061
-
1062
- /**
1063
- * CLI-only: quick scan postcheck
1064
- * 执行文件存在性 + manifest 检查,不调 AI
1065
- */
1066
- async function executeScanPostcheck(cwd, platformOpts, scanProfile) {
1067
- const { runScanPostCheck, printScanPostCheckResult } = await import('./scan-postcheck.js')
1068
- const specDir = platformOpts?.specRoot || null
1069
- const result = runScanPostCheck({
1070
- cwd,
1071
- specDir,
1072
- scanMeta: {
1073
- projectListParsed: true,
1074
- manifestWritten: undefined,
1075
- },
1076
- })
1077
- printScanPostCheckResult(result)
1078
- // 写 manifest(如果还没写)
1079
- if (platformOpts?.specRoot) {
1080
- try {
1081
- const { mkdirSync, writeFileSync } = await import('fs')
1082
- const { join: pJoin, basename: pBasename } = await import('path')
1083
- const { execSync } = await import('child_process')
1084
- const manifestDir = platformOpts.specRoot
1085
- let sourceCommit = null
1086
- let sourceCommitError = null
1087
- try {
1088
- const gitResult = safeGit(cwd, ['rev-parse', 'HEAD'])
1089
- sourceCommit = gitResult.value
1090
- sourceCommitError = gitResult.error
1091
- } catch (e) {
1092
- sourceCommitError = e.message
1093
- }
1094
- mkdirSync(manifestDir, { recursive: true })
1095
- const manifest = {
1096
- scan_profile: {
1097
- mode: scanProfile.mode,
1098
- file_count: scanProfile._fileCount || 0,
1099
- source_bytes: scanProfile._sourceBytes || 0,
1100
- project_count: scanProfile._projectCount || 0,
1101
- reason: scanProfile.reason,
1102
- },
1103
- workspace_id: platformOpts.workspaceId || null,
1104
- scan_run_id: platformOpts.scanRunId || null,
1105
- source_commit: sourceCommit,
1106
- source_commit_error: sourceCommit === null ? (sourceCommitError || 'unknown') : undefined,
1107
- generated_at: new Date().toISOString(),
1108
- schema_version: 2,
1109
- }
1110
- const manifestPath = pJoin(manifestDir, 'manifest.json')
1111
- writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n')
1112
- console.log(` 📄 manifest.json 已写入: ${manifestPath}`)
1113
- } catch (e) {
1114
- console.warn(` ⚠️ manifest 写入失败: ${e.message}`)
1115
- }
1116
- }
1117
- }
1118
-
1119
- /**
1120
- * Plan postcheck 的执行代理:委托给 plan-postcheck.js 模块
1121
- */
1122
- async function executePlanPostcheck(cwd, platformOpts) {
1123
- const { executePlanPostcheck: runPostcheck } = await import('./stages/plan-postcheck.js')
1124
- const { resolveChangeDir } = await import('./modules.js')
1125
- await runPostcheck({
1126
- cwd,
1127
- specRoot: platformOpts?.specRoot,
1128
- resolveChangeDir
1129
- })
146
+ console.log(`sillyspec run ${stageName} --done --input "用户原始需求/反馈" --output "你的摘要"`)
1130
147
  }
1131
148
 
1132
149
  /**
1133
150
  * sillyspec run <stage> 主命令
1134
151
  */
1135
- export async function runCommand(args, cwd, specDir = null) {
152
+ export async function runCommand(args, cwd) {
1136
153
  // 解析参数
1137
154
  const stageName = args[0]
1138
155
  const flags = args.slice(1)
@@ -1149,147 +166,10 @@ export async function runCommand(args, cwd, specDir = null) {
1149
166
  process.exit(1)
1150
167
  }
1151
168
 
1152
- // ── cwd 纠正:向上查找真实项目根 ──
1153
- // 防止多 project 工作区中 cwd 停在子目录(如 backend/)时
1154
- // 状态写入子目录下误建的 .sillyspec,导致状态分裂
1155
- if (!specDir) {
1156
- const resolvedRoot = resolveSpecDir(cwd)
1157
- if (resolvedRoot && resolvedRoot !== join(cwd, '.sillyspec')) {
1158
- const realRoot = dirname(resolvedRoot)
1159
- if (realRoot !== cwd) {
1160
- cwd = realRoot
1161
- }
1162
- }
1163
- }
1164
-
1165
- // 平台模式参数(供 SillyHub 等平台调用)
1166
- // --spec-dir 是统一参数名,--spec-root 保留为向后兼容别名
1167
- const getFlagValue = (name) => {
1168
- const idx = flags.indexOf(name)
1169
- return idx !== -1 && flags[idx + 1] ? flags[idx + 1] : null
1170
- }
1171
169
  const isDone = flags.includes('--done')
1172
170
  const isSkip = flags.includes('--skip')
1173
171
  const isStatus = flags.includes('--status')
1174
172
  const isReset = flags.includes('--reset')
1175
- const isReopen = flags.includes('--reopen')
1176
- const fromStepValue = getFlagValue('--from-step')
1177
- const isConfirm = flags.includes('--confirm')
1178
- const isSkipApproval = flags.includes('--skip-approval')
1179
- const isWait = flags.includes('--wait')
1180
- const isContinue = flags.includes('--continue')
1181
- const isNonInteractive = flags.includes('--non-interactive')
1182
- const isInteractive = flags.includes('--interactive')
1183
- const waitReason = getFlagValue('--reason')
1184
- const waitOptions = getFlagValue('--options')
1185
- const continueAnswer = getFlagValue('--answer')
1186
- const confirmMode = getFlagValue('--confirm-mode')
1187
- const resolvedSpecDir = specDir || getFlagValue('--spec-dir') || getFlagValue('--spec-root')
1188
- const platformOpts = {
1189
- specRoot: resolvedSpecDir ? resolve(resolvedSpecDir) : null,
1190
- runtimeRoot: getFlagValue('--runtime-root') ? resolve(getFlagValue('--runtime-root')) : null,
1191
- workspaceId: getFlagValue('--workspace-id'),
1192
- scanRunId: getFlagValue('--scan-run-id'),
1193
- }
1194
-
1195
- // 跨 --done 生命周期:从 metadata 文件恢复 platformOpts
1196
- // 首次 scan 时写入,所有后续调用(包括 run、--done、--skip)都读取
1197
- // 优先在 specDir 下查找,否则回退到 cwd/.sillyspec/.runtime/
1198
- let specRoot = platformOpts.specRoot || resolveSpecDir(cwd)
1199
- // 平台参数恢复策略:
1200
- // 1. 优先检查 cwd/.sillyspec-platform.json(轻量指针文件,不污染 .sillyspec 结构)
1201
- // 2. 然后检查 specRoot/.runtime/platform-scan.json(首次 scan 写入)
1202
- const platformPointer = join(cwd, '.sillyspec-platform.json')
1203
- const platformScanFile = join(specRoot, '.runtime', 'platform-scan.json')
1204
- let platformOptsFile = existsSync(platformPointer) ? platformPointer : platformScanFile
1205
- let platformFileExists = existsSync(platformOptsFile)
1206
- // 如果命令行没传 spec-root,尝试从持久化文件恢复
1207
- if (!platformOpts.specRoot && !platformOpts.runtimeRoot) {
1208
- if (platformFileExists) {
1209
- try {
1210
- const { readFileSync } = await import('fs')
1211
- const saved = JSON.parse(readFileSync(platformOptsFile, 'utf8'))
1212
- if (saved.specRoot) platformOpts.specRoot = saved.specRoot
1213
- if (saved.runtimeRoot) platformOpts.runtimeRoot = saved.runtimeRoot
1214
- if (saved.workspaceId) platformOpts.workspaceId = saved.workspaceId
1215
- if (saved.scanRunId) platformOpts.scanRunId = saved.scanRunId
1216
- // 平台模式 fail-fast:文件存在但缺少 specRoot
1217
- if (!platformOpts.specRoot && !platformOpts.runtimeRoot) {
1218
- console.error(`❌ 平台模式参数文件存在但缺少 specRoot/runtimeRoot: ${platformOptsFile}`)
1219
- console.error(' 可能原因:platform-scan.json 损坏或写入不完整')
1220
- console.error(' 解决:重新运行首次 scan 并传入 --spec-root')
1221
- process.exit(1)
1222
- }
1223
- // 恢复成功:更新 specRoot(初始值可能是 cwd/.sillyspec,恢复后应为真实 specDir)
1224
- specRoot = platformOpts.specRoot || specRoot
1225
- } catch (e) {
1226
- console.error(`❌ 平台模式参数文件读取失败: ${platformOptsFile}`)
1227
- console.error(` 错误: ${e.message}`)
1228
- console.error(' 可能原因:文件损坏')
1229
- console.error(' 解决:删除该文件并重新运行首次 scan 传入 --spec-root')
1230
- process.exit(1)
1231
- }
1232
- }
1233
- }
1234
- // 持久化 platformOpts
1235
- // 在 specRoot/.runtime/ 写主文件,同时在 cwd/.sillyspec/.runtime/ 写恢复指针
1236
- if (platformOpts.specRoot || platformOpts.runtimeRoot) {
1237
- try {
1238
- const { mkdirSync, writeFileSync } = await import('fs')
1239
- mkdirSync(join(specRoot, '.runtime'), { recursive: true })
1240
- writeFileSync(platformOptsFile, JSON.stringify({
1241
- specRoot: platformOpts.specRoot,
1242
- runtimeRoot: platformOpts.runtimeRoot,
1243
- workspaceId: platformOpts.workspaceId,
1244
- scanRunId: platformOpts.scanRunId,
1245
- savedAt: new Date().toISOString(),
1246
- }, null, 2) + '\n')
1247
- // 恢复指针:在 cwd 下写 .sillyspec-platform.json(不在 .sillyspec 内,不污染源码结构)
1248
- // 供后续 --done(不带 --spec-root)找到 specDir
1249
- writeFileSync(join(cwd, '.sillyspec-platform.json'), JSON.stringify({
1250
- specRoot: platformOpts.specRoot,
1251
- runtimeRoot: platformOpts.runtimeRoot,
1252
- workspaceId: platformOpts.workspaceId,
1253
- scanRunId: platformOpts.scanRunId,
1254
- savedAt: new Date().toISOString(),
1255
- }, null, 2) + '\n')
1256
- } catch {
1257
- // 静默失败,不影响主流程
1258
- }
1259
- }
1260
-
1261
- // 统一规范基路径:平台模式用 specRoot,本地模式用 cwd/.sillyspec
1262
- // runCommand 后续所有 .sillyspec/ 操作必须用 specBase
1263
- const specBase = platformOpts.specRoot || join(cwd, '.sillyspec')
1264
-
1265
- // 平台模式:清理旧版本残留的 cwd/.sillyspec/(防止源码污染)。
1266
- // ⚠️ 同 init.js:必须保护真实资产(changes/、projects/、sillyspec.db)。
1267
- if (platformOpts.specRoot) {
1268
- const legacyDir = join(cwd, '.sillyspec')
1269
- if (existsSync(legacyDir)) {
1270
- let hasChanges = false;
1271
- try {
1272
- const cd = join(legacyDir, 'changes');
1273
- if (existsSync(cd)) hasChanges = readdirSync(cd).length > 0;
1274
- } catch {}
1275
- let hasProjects = false;
1276
- try {
1277
- const pd = join(legacyDir, 'projects');
1278
- if (existsSync(pd)) hasProjects = readdirSync(pd).length > 0;
1279
- } catch {}
1280
- const hasDb = existsSync(join(legacyDir, 'sillyspec.db'));
1281
-
1282
- if (hasChanges || hasProjects || hasDb) {
1283
- console.error('❌ [sillyspec] 拒绝删除源码目录的 .sillyspec/:检测到真实资产。仅清理运行时残留。');
1284
- for (const residue of ['.runtime', 'local.yaml', 'codebase']) {
1285
- const p = join(legacyDir, residue);
1286
- if (existsSync(p)) { try { rmSync(p, { recursive: true, force: true }) } catch {} }
1287
- }
1288
- } else {
1289
- try { rmSync(legacyDir, { recursive: true, force: true }) } catch {}
1290
- }
1291
- }
1292
- }
1293
173
 
1294
174
  // 解析 --output
1295
175
  let outputText = null
@@ -1312,176 +192,44 @@ export async function runCommand(args, cwd, specDir = null) {
1312
192
  changeName = flags[changeIdx + 1]
1313
193
  }
1314
194
 
1315
- // 解析 --files a.js,b.js(quick 专用:显式声明 allowedFiles)
1316
- let quickFiles = []
1317
- const filesIdx = flags.indexOf('--files')
1318
- if (filesIdx !== -1 && flags[filesIdx + 1]) {
1319
- quickFiles = flags[filesIdx + 1].split(',').map(f => f.trim()).filter(Boolean)
1320
- }
1321
-
1322
- const isAllowNew = flags.includes('--allow-new')
1323
- const isForceBaseline = flags.includes('--force-baseline')
1324
- const isForceRescan = flags.includes('--force-rescan')
1325
-
1326
- // 未知参数 fail-fast
1327
- const knownFlags = new Set([
1328
- '--done', '--skip', '--status', '--reset', '--confirm', '--skip-approval',
1329
- '--wait', '--continue', '--non-interactive', '--interactive',
1330
- '--reason', '--options', '--answer', '--confirm-mode',
1331
- '--output', '--input', '--change',
1332
- '--spec-dir', '--spec-root', '--runtime-root', '--workspace-id', '--scan-run-id',
1333
- '--files', '--allow-new', '--force-baseline', '--force-rescan',
1334
- '--json', '--dir', '--help',
1335
- '--reopen', '--from-step', '--mode',
1336
- ])
1337
- for (let i = 0; i < flags.length; i++) {
1338
- const f = flags[i]
1339
- if (f.startsWith('--')) {
1340
- if (!knownFlags.has(f)) {
1341
- console.error(`❌ 未知参数: ${f}`)
1342
- console.error(`已知参数: ${[...knownFlags].sort().join(', ')}`)
1343
- process.exit(1)
1344
- }
1345
- // 跳过 value 参数
1346
- i++
1347
- }
1348
- }
1349
-
1350
195
  const isAuxiliary = auxiliaryStages.includes(stageName)
1351
- // scan 元数据追踪(存储在 stageData.scanMeta 中,completeStep 通过 progress 访问)
1352
196
 
1353
- const pm = new ProgressManager({ specDir: specRoot })
1354
- let progress = await pm.read(cwd, changeName)
197
+ const pm = new ProgressManager()
198
+ let progress = pm.read(cwd)
1355
199
 
1356
200
  if (!progress) {
1357
- // 如果指定了变更名或有变更目录,自动初始化变更的 progress
1358
- const autoChange = changeName || resolveChangeNameAuto(cwd, specRoot)
1359
- if (autoChange) {
1360
- progress = await pm.initChange(cwd, autoChange)
1361
- } else if (isAuxiliary) {
1362
- // 辅助阶段(scan/explore/quick/doctor/status)自动使用默认变更名
1363
- const autoName = changeName || resolveChangeNameAuto(cwd, specRoot) || 'default'
1364
- changeName = autoName
1365
- progress = await pm.initChange(cwd, autoName)
1366
- // initChange 可能因 project 表为空返回 null
1367
- if (!progress) {
1368
- progress = { currentStage: stageName, stages: {}, lastActive: new Date().toLocaleString('zh-CN', { hour12: false }), project: '' }
1369
- }
1370
- } else {
1371
- // brainstorm 作为流程入口,自动生成变更名并初始化
1372
- if (stageName === 'brainstorm') {
1373
- const date = new Date().toISOString().slice(0, 10)
1374
- const autoName = `${date}-new-change`
1375
- console.log(`🔄 自动创建变更:${autoName}`)
1376
- console.log(` 提示:可以用 --change <名称> 指定自定义变更名`)
1377
- console.log(` 或事后重命名:sillyspec change-rename ${autoName} <新名称>`)
1378
- progress = await pm.initChange(cwd, autoName)
1379
- changeName = autoName
1380
- } else {
1381
- console.error('❌ 未找到进度数据,请先运行 sillyspec init 或指定 --change <变更名>')
1382
- process.exit(1)
1383
- }
201
+ // 辅助命令可以在没有 progress.json 时工作(比如 scan)
202
+ if (!isAuxiliary) {
203
+ console.error('❌ 未找到 progress.json,请先运行 sillyspec init')
204
+ process.exit(1)
1384
205
  }
206
+ progress = pm.init(cwd)
1385
207
  }
1386
208
 
1387
- // 确保 progress 有 currentChange
1388
- const effectiveChange = changeName || progress.currentChange || resolveChangeName(cwd, progress, specRoot)
1389
-
1390
209
  // -- auto 模式:自动推进所有流程阶段
1391
210
  if (stageName === 'auto') {
1392
- return await runAutoMode(pm, progress, cwd, flags, effectiveChange, platformOpts)
211
+ return await runAutoMode(pm, progress, cwd, flags)
1393
212
  }
1394
213
 
1395
- // --change 只作为变更名标识,不再拦截流程
1396
- // 注册变更到全局活跃列表(如果尚未注册)
1397
- if (effectiveChange) {
1398
- await pm.registerChange(cwd, effectiveChange)
214
+ // --change 设置当前变更名
215
+ if (changeName) {
216
+ progress.currentChange = changeName
217
+ progress.lastActive = new Date().toLocaleString('zh-CN', { hour12: false })
218
+ pm._write(cwd, progress)
219
+ console.log(`✅ 当前变更设置为:${changeName}`)
220
+ return
1399
221
  }
1400
222
 
1401
223
  // --reset
1402
224
  if (isReset) {
1403
- return await resetStage(pm, progress, stageName, cwd, effectiveChange, platformOpts)
1404
- }
1405
-
1406
- // ── 规则 1:completed 阶段直接 run,拒绝(但 --status 放行)──
1407
- const stageStatus = progress.stages[stageName]?.status
1408
- if (stageStatus === 'completed' && !isReopen && !isStatus) {
1409
- console.error(`\n❌ ${stageName} 阶段已完成。`)
1410
- console.error(` 使用 --reopen 进行修订,或 --reset 从头开始。`)
1411
- console.error(` 修订示例: sillyspec run ${stageName} --reopen --from-step <步骤序号或名称>`)
1412
- process.exit(1)
1413
- }
1414
-
1415
- // ── 规则 5:stale 阶段直接 run,拒绝(但 --status / --reset 放行)──
1416
- if (stageStatus === 'stale' && !isReopen && !isStatus && !isReset) {
1417
- const staleReason = progress.stages[stageName]?.staleReason || '上游阶段已修订'
1418
- console.error(`\n⚠️ ${stageName} 阶段已失效(stale)。`)
1419
- console.error(` 原因:${staleReason}`)
1420
- console.error(` 使用 --reopen --from-step <步骤> 进行修订,或 --reset 从头开始。`)
1421
- process.exit(1)
1422
- }
1423
-
1424
- // ── --reopen 处理 ──
1425
- if (isReopen) {
1426
- // stale/revising 阶段可能 steps 为空,或者 execute 阶段的 steps 需要从最新 plan.md 刷新
1427
- const stageDataPre = progress.stages[stageName]
1428
- const needsInit = !stageDataPre || !stageDataPre.steps || stageDataPre.steps.length === 0
1429
- // execute 阶段在 reopen 时需要从最新 plan.md 重新解析 steps(plan 可能已变更)
1430
- if (needsInit || stageName === 'execute') {
1431
- const freshSteps = await getStageSteps(stageName, cwd, progress, specRoot)
1432
- if (freshSteps && freshSteps.length > 0) {
1433
- if (!progress.stages[stageName]) progress.stages[stageName] = { status: 'stale', steps: [] }
1434
- progress.stages[stageName].steps = freshSteps.map(s => ({ name: s.name, status: 'pending' }))
1435
- await pm._write(cwd, progress, effectiveChange)
1436
- progress = await pm.read(cwd, effectiveChange) || progress
1437
- }
1438
- }
1439
-
1440
- const result = await pm.reopenStage(cwd, stageName, {
1441
- fromStep: fromStepValue,
1442
- changeName: effectiveChange,
1443
- })
1444
- if (!result.ok) {
1445
- console.error(`\n❌ ${result.error}`)
1446
- if (stageStatus === 'completed') {
1447
- console.error(`\n 提示:sillyspec run ${stageName} --reopen --from-step <步骤序号或名称>`)
1448
- }
1449
- process.exit(1)
1450
- }
1451
- console.log(`\n🔧 ${stageName} 阶段已重新打开(revision ${result.revision})`)
1452
- console.log(` 从步骤「${result.fromStep}」开始修订`)
1453
- console.log(` 该步骤及之后的产出需要重新生成。`)
1454
- if (stageName === 'execute') console.log(` ⚡ execute 步骤已从最新 plan.md 重新解析。`)
1455
- console.log('')
1456
-
1457
- // 重新读取 progress
1458
- progress = await pm.read(cwd, effectiveChange) || progress
1459
-
1460
- // 注入 revision context 到 platformOpts,供 outputStep 使用
1461
- const stageData = progress.stages[stageName]
1462
- if (stageData && stageData.revision > 0) {
1463
- platformOpts._revision = {
1464
- revision: stageData.revision,
1465
- fromStep: stageData.reopenedFromStep,
1466
- }
1467
- }
1468
- } else {
1469
- // 非 reopen 的正常执行:如果阶段处于 revising 状态,也注入 revision context
1470
- const revStageData = progress.stages[stageName]
1471
- if (revStageData && revStageData.status === 'revising' && revStageData.revision > 0 && !platformOpts._revision) {
1472
- platformOpts._revision = {
1473
- revision: revStageData.revision,
1474
- fromStep: revStageData.reopenedFromStep,
1475
- }
1476
- }
225
+ return await resetStage(pm, progress, stageName, cwd)
1477
226
  }
1478
227
 
1479
228
  // 确保步骤已初始化
1480
- const changed = await ensureStageSteps(progress, stageName, cwd, specRoot)
1481
- if (changed && effectiveChange) {
1482
- await pm._write(cwd, progress, effectiveChange)
1483
- triggerSync(cwd, effectiveChange, platformOpts)
1484
- progress = await pm.read(cwd, effectiveChange) || progress
229
+ const changed = await ensureStageSteps(progress, stageName, cwd)
230
+ if (changed) {
231
+ pm._write(cwd, progress)
232
+ progress = pm.read(cwd)
1485
233
  }
1486
234
 
1487
235
  // --status
@@ -1489,1504 +237,205 @@ export async function runCommand(args, cwd, specDir = null) {
1489
237
  return showStatus(progress, stageName)
1490
238
  }
1491
239
 
1492
- // --skip
1493
- if (isSkip) {
1494
- return await skipStep(pm, progress, stageName, cwd, effectiveChange)
1495
- }
1496
-
1497
- // --wait: 将 step 设为 waiting(独立于 --done)
1498
- if (isWait) {
1499
- return await waitStep(pm, progress, stageName, cwd, outputText, waitReason, waitOptions, { changeName: effectiveChange, nonInteractive: isNonInteractive && !isInteractive, platformOpts })
1500
- }
1501
-
1502
- // --continue: 从 waiting 恢复
1503
- if (isContinue) {
1504
- return await continueStep(pm, progress, stageName, cwd, continueAnswer, { changeName: effectiveChange, nonInteractive: isNonInteractive && !isInteractive, platformOpts })
1505
- }
1506
-
1507
- // --done
1508
- if (isDone) {
1509
- const doneAnswer = getFlagValue('--answer')
1510
- return await completeStep(pm, progress, stageName, cwd, outputText, inputText, { confirm: isConfirm, changeName: effectiveChange, nonInteractive: isNonInteractive && !isInteractive, platformOpts, confirmMode, doneAnswer })
1511
- }
1512
-
1513
- // 默认:输出当前步骤
1514
- return await runStage(pm, progress, stageName, cwd, effectiveChange, isSkipApproval, platformOpts, { quickFiles, isAllowNew, isForceBaseline, isForceRescan })
1515
- }
1516
-
1517
- /**
1518
- * 自动推导变更名(不依赖 progress)
1519
- */
1520
- function resolveChangeNameAuto(cwd, specDir = null) {
1521
- const changesDir = join(specDir || resolveSpecDir(cwd), 'changes')
1522
- if (!existsSync(changesDir)) return null
1523
- const entries = readdirSync(changesDir, { withFileTypes: true })
1524
- .filter(e => e.isDirectory() && e.name !== 'archive')
1525
- if (entries.length === 1) return entries[0].name
1526
- return null
1527
- }
1528
-
1529
- async function runStage(pm, progress, stageName, cwd, changeName, skipApproval = false, platformOpts = {}, quickOpts = {}) {
1530
- const specBase = platformOpts.specRoot || join(cwd, '.sillyspec')
1531
- // 状态转换校验
1532
- const prevStage = progress.currentStage || ''
1533
- // task-07: 提取 prevStage 的 stageData,传给 checkTransition 检测 failed_post_check 门控
1534
- const fromStageData = (progress.stages && prevStage && progress.stages[prevStage]) || undefined
1535
- const transition = checkTransition(prevStage, stageName, fromStageData ? { fromStageData } : {})
1536
- if (!transition.allowed) {
1537
- console.error(`❌ 阶段转换不允许: ${prevStage || '(起始)'} → ${stageName}`)
1538
- console.error(` 原因: ${transition.reason}`)
1539
- console.error(` 提示: 使用 --skip-approval 绕过(需明确意图)`)
1540
- if (!skipApproval) {
1541
- process.exit(1)
1542
- }
1543
- }
1544
-
1545
- // execute 阶段启动前检查审批
1546
- if (stageName === 'execute' && !skipApproval) {
1547
- const approval = await checkApproval(cwd, changeName, platformOpts)
1548
- if (approval) {
1549
- if (approval.status === 'rejected') {
1550
- console.error(`❌ 变更 ${changeName} 的执行已被拒绝:${approval.reason || '无原因'}`)
1551
- process.exit(1)
1552
- }
1553
- if (approval.status === 'pending') {
1554
- console.log(`⏳ 变更 ${changeName} 的执行审批待处理中...`)
1555
- console.log(' 提示:使用 --skip-approval 跳过审批检查')
1556
- }
1557
- }
1558
- }
1559
-
1560
- // execute 阶段:CLI 自动创建 worktree(不等 AI agent)
1561
- if (stageName === 'execute' && changeName) {
1562
- const effectiveChange = changeName
1563
- const { WorktreeManager } = await import('./worktree.js')
1564
- const wm = new WorktreeManager({ cwd })
1565
- const existingMeta = wm.getMeta(effectiveChange)
1566
- if (existingMeta) {
1567
- console.log(`🔗 worktree 已存在: ${existingMeta.worktreePath} (${existingMeta.mode})`)
1568
- } else {
1569
- try {
1570
- const result = wm.create(effectiveChange)
1571
- console.log(`🔗 worktree 已创建: ${result.worktreePath} (分支: ${result.branch}, 模式: ${result.mode})`)
1572
- } catch (e) {
1573
- console.error(`❌ worktree 创建失败: ${e.message}`)
1574
- console.error(` 继续执行前请解决上述问题,或使用 --no-worktree 跳过。`)
1575
- process.exit(1)
1576
- }
1577
- }
1578
- }
1579
-
1580
- // ── execute 阶段启动时固定 executeRunId(绑定变更名,避免跨变更复用) ──
1581
- let currentExecuteRunId = null
1582
- if (stageName === 'execute') {
1583
- const { generateExecuteRunId } = await import('./task-review.js')
1584
- const execSpecBase = platformOpts?.specRoot || join(cwd, '.sillyspec')
1585
- const runtimeRoot = join(execSpecBase, '.runtime')
1586
- const runIdFile = join(runtimeRoot, `current-execute-run-id-${changeName}`)
1587
- mkdirSync(runtimeRoot, { recursive: true })
1588
- // 优先读取已有的变更专属标记文件
1589
- try {
1590
- if (existsSync(runIdFile)) {
1591
- currentExecuteRunId = readFileSync(runIdFile, 'utf8').trim()
1592
- }
1593
- } catch {}
1594
- if (!currentExecuteRunId) {
1595
- currentExecuteRunId = generateExecuteRunId()
1596
- writeFileSync(runIdFile, currentExecuteRunId + '\n')
1597
- }
1598
- }
1599
-
1600
- // 自动探测 currentChange
1601
- if (autoDetectChange(progress, cwd)) {
1602
- progress.lastActive = new Date().toLocaleString('zh-CN', { hour12: false })
1603
- await pm._write(cwd, progress, changeName)
1604
- triggerSync(cwd, changeName, platformOpts)
1605
- }
1606
-
1607
- const stageData = progress.stages[stageName]
1608
- if (!stageData || !stageData.steps) {
1609
- console.error(`❌ 阶段 ${stageName} 未初始化`)
1610
- process.exit(1)
1611
- }
1612
-
1613
- // 用户显式调用 sillyspec run <stage>:把它标记为当前阶段
1614
- if (progress.currentStage !== stageName) {
1615
- progress.currentStage = stageName
1616
- progress.lastActive = new Date().toLocaleString('zh-CN',{hour12:false})
1617
- await pm._write(cwd, progress, changeName)
1618
- triggerSync(cwd, changeName, platformOpts)
1619
- }
1620
-
1621
- const steps = stageData.steps
1622
- // ── 检查是否有 waiting step 需要先处理 ──
1623
- const waitingIdx = steps.findIndex(s => s.status === 'waiting')
1624
- if (waitingIdx !== -1) {
1625
- const ws = steps[waitingIdx]
1626
- console.error(`\n⏸️ Step ${waitingIdx + 1}/${steps.length} 正在等待用户输入:${ws.name}`)
1627
- if (ws.waitReason) console.error(` 原因:${ws.waitReason}`)
1628
- if (ws.waitOptions) console.error(` 选项:${formatWaitOptions(ws.waitOptions)}`)
1629
- console.error(`\n 普通运行无法跳过等待中的步骤。请先处理:`)
1630
- console.error(` sillyspec run ${stageName} --continue --answer "你的选择"${changeName ? ` --change ${changeName}` : ''}`)
1631
- process.exit(1)
1632
- }
1633
-
1634
- let currentIdx = steps.findIndex(s => s.status !== 'completed' && s.status !== 'skipped')
1635
-
1636
- // stale 步骤视为可执行(等同于 pending)
1637
- if (currentIdx !== -1 && steps[currentIdx].status === 'stale') {
1638
- steps[currentIdx].status = 'pending'
1639
- await pm._write(cwd, progress, changeName)
1640
- triggerSync(cwd, changeName, platformOpts)
1641
- }
1642
-
1643
- // ── scanProfile: 根据 project 规模动态裁剪步骤 ──
1644
- let scanProfile = null
1645
- if (stageName === 'scan' && steps.length > 0 && currentIdx === 0) {
1646
- scanProfile = computeScanProfile(cwd, platformOpts)
1647
- console.log(`\n📊 Scan Profile: ${scanProfile.mode} (原因: ${scanProfile.reason})`)
1648
- if (scanProfile.mode !== 'deep') {
1649
- applyScanProfileSteps(stageData, scanProfile, cwd, platformOpts)
1650
- // 步骤被裁剪后 currentIdx 需要重新计算
1651
- currentIdx = 0
1652
- }
1653
- // 保存 profile 供后续 postcheck 使用
1654
- stageData.scanProfile = scanProfile
1655
- await pm._write(cwd, progress, changeName)
1656
- } else if (stageName === 'scan' && stageData.scanProfile) {
1657
- scanProfile = stageData.scanProfile
1658
- }
1659
- if (scanProfile) platformOpts.scanProfile = scanProfile
1660
-
1661
- if (stageName === 'scan') {
1662
- try {
1663
- const gitResult = safeGit(cwd, ['rev-parse', 'HEAD'])
1664
- const scanGuard = {
1665
- sourceCommit: gitResult.value,
1666
- sourceCommitError: gitResult.error,
1667
- startedAt: new Date().toISOString(),
1668
- forceRescan: quickOpts?.isForceRescan || false,
1669
- }
1670
- const guardFile = join(specBase, '.runtime', 'scan-guard.json')
1671
- mkdirSync(dirname(guardFile), { recursive: true })
1672
- writeFileSync(guardFile, JSON.stringify(scanGuard, null, 2) + '\n')
1673
- if (scanGuard.forceRescan) {
1674
- console.log('🛡️ scan 覆盖保护已记录: --force-rescan 已开启')
1675
- } else {
1676
- console.log('🛡️ scan 覆盖保护已记录: existing scan docs require current source_commit/updated_at')
1677
- }
1678
- } catch (e) {
1679
- console.warn(`⚠️ scan 覆盖保护记录失败: ${e.message}`)
1680
- }
1681
- }
1682
-
1683
- if (currentIdx === -1) {
1684
- // 已完成 → 自动重置,重新开始
1685
- const freshSteps = await getStageSteps(stageName, cwd, progress)
1686
- stageData.steps = freshSteps
1687
- ? freshSteps.map(s => ({ name: s.name, status: 'pending' }))
1688
- : []
1689
- stageData.status = 'in-progress'
1690
- stageData.startedAt = new Date().toLocaleString('zh-CN', { hour12: false })
1691
- stageData.completedAt = null
1692
- await pm._write(cwd, progress, changeName)
1693
- triggerSync(cwd, changeName, platformOpts)
1694
- currentIdx = 0
1695
- console.log(`🔄 ${stageName} 阶段已自动重置,重新开始。\n`)
1696
- }
1697
-
1698
- // quick 阶段:记录 baselineFiles
1699
- if (stageName === 'quick' && !progress.quickGuard) {
1700
- try {
1701
- const { execSync } = await import('child_process')
1702
- const gitStatus = execSync('git status --porcelain', { cwd, encoding: 'utf8', timeout: 10000 })
1703
- const baselineFiles = gitStatus
1704
- .trim().split('\n').filter(Boolean)
1705
- .map(line => line.slice(3).trim())
1706
- .filter(f => !f.startsWith('.sillyspec/'))
1707
- const allowedFiles = quickOpts?.quickFiles || []
1708
- const allowNew = quickOpts?.isAllowNew || false
1709
- const forceBaseline = quickOpts?.isForceBaseline || false
1710
- progress.quickGuard = {
1711
- baselineCommit: safeGit(cwd, ['rev-parse', 'HEAD']).value,
1712
- baselineFiles,
1713
- allowedFiles,
1714
- allowNew,
1715
- forceBaseline,
1716
- startedAt: new Date().toISOString(),
1717
- }
1718
- // 写入 quick-guard.json 供 worktree-guard hook 读取
1719
- const guardFile = join(specBase, '.runtime', 'quick-guard.json')
1720
- writeFileSync(guardFile, JSON.stringify(progress.quickGuard, null, 2))
1721
- const parts = [`${baselineFiles.length} 个已有脏文件`]
1722
- if (allowedFiles.length > 0) parts.push(`${allowedFiles.length} 个 allowedFiles`)
1723
- if (allowNew) parts.push('允许新增文件')
1724
- console.log(`🛡️ quick 变更边界已记录: ${parts.join(', ')}`)
1725
- await pm._write(cwd, progress, changeName)
1726
- } catch (e) {
1727
- console.warn(`⚠️ baseline 记录失败: ${e.message}`)
1728
- }
1729
- }
1730
-
1731
- if (currentIdx > 0) {
1732
- const completed = currentIdx
1733
- const total = steps.length
1734
- console.log(`⚠️ ${stageName} 已进行到第 ${currentIdx + 1}/${total} 步(前 ${completed} 步已完成)。`)
1735
- console.log(` 继续执行将从中断处恢复,用 --reset 可重新开始。\n`)
1736
- }
1737
-
1738
- // ── Brainstorm → Plan Contract:plan 启动前校验 design.md ──
1739
- if (stageName === 'plan' && currentIdx === 0) {
1740
- const changeDir = resolveChangeDir(cwd, progress, platformOpts?.specRoot || null)
1741
- const designPath = changeDir ? join(changeDir, 'design.md') : null
1742
- if (designPath && existsSync(designPath)) {
1743
- const { validateDesignForPlan } = await import('./stages/plan.js')
1744
- const designContent = readFileSync(designPath, 'utf8')
1745
- const designValidation = validateDesignForPlan(designContent)
1746
- if (!designValidation.ok) {
1747
- console.error(`\n❌ Brainstorm → Plan Contract 校验失败:`)
1748
- for (const err of designValidation.errors) console.error(` - ${err}`)
1749
- console.error(`\n design.md 不满足 plan 契约,请先修复后重试。`)
1750
- console.error(` 提示:sillyspec run brainstorm --reopen --from-step <步骤> 修订设计文档`)
1751
- process.exit(1)
1752
- }
1753
- if (designValidation.warnings.length > 0) {
1754
- console.log(`⚠️ Design contract 警告(不阻断):`)
1755
- for (const w of designValidation.warnings) console.log(` - ${w}`)
1756
- console.log()
1757
- }
1758
- }
1759
- }
1760
-
1761
- const defSteps = await getStageSteps(stageName, cwd, progress)
1762
- if (defSteps && defSteps[currentIdx]) {
1763
- // noAI 步骤自动完成(CLI-only,不需要 Agent 参与)
1764
- if (defSteps[currentIdx].noAI || stageData.steps[currentIdx]?.noAI) {
1765
- const stepName = defSteps[currentIdx].name
1766
- const cliAction = defSteps[currentIdx]._cliAction || stageData.steps[currentIdx]?._cliAction
1767
- console.log(`⚙️ Step ${currentIdx + 1}/${stageData.steps.length}: ${stepName}(CLI 自动执行,无需 Agent)`)
1768
- if (cliAction === 'scanPreflight') {
1769
- await executeScanPreflight(cwd, platformOpts, scanProfile)
1770
- } else if (cliAction === 'scanPostcheck') {
1771
- await executeScanPostcheck(cwd, platformOpts, scanProfile)
1772
- } else if (cliAction === 'planPostcheck') {
1773
- await executePlanPostcheck(cwd, platformOpts)
1774
- }
1775
- stageData.steps[currentIdx].status = 'completed'
1776
- stageData.steps[currentIdx].completedAt = new Date().toLocaleString('zh-CN', { hour12: false })
1777
- await pm._write(cwd, progress, changeName)
1778
- // 自动前进到下一步
1779
- const nextIdx = stageData.steps.findIndex(s => s.status === 'pending' || s.status === 'in-progress')
1780
- if (nextIdx !== -1 && defSteps[nextIdx]) {
1781
- console.log('')
1782
- await outputStep(stageName, nextIdx, defSteps, cwd, changeName, progress.project || null, platformOpts)
1783
- } else {
1784
- // 所有步骤完成
1785
- stageData.status = 'completed'
1786
- stageData.completedAt = new Date().toLocaleString('zh-CN', { hour12: false })
1787
- await pm._write(cwd, progress, changeName)
1788
- console.log(`\n✅ ${stageName} 阶段全部完成。`)
1789
- }
1790
- return
1791
- }
1792
- await outputStep(stageName, currentIdx, defSteps, cwd, changeName, progress.project || null, platformOpts)
1793
- }
1794
- }
1795
-
1796
- function validateMetadata(cwd, stageName, specBase) {
1797
- const changesDir = join(specBase, 'changes')
1798
- if (!existsSync(changesDir)) return
1799
-
1800
- const cutoff = Date.now() - 10 * 60 * 1000
1801
- const missing = []
1802
-
1803
- function walk(dir) {
1804
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
1805
- const full = join(dir, entry.name)
1806
- try {
1807
- if (entry.isDirectory()) { walk(full); continue }
1808
- if (!/\.(md|yaml|yml)$/.test(entry.name)) continue
1809
- const mtime = statSync(full).mtimeMs
1810
- if (mtime < cutoff) continue
1811
- const content = readFileSync(full, 'utf-8')
1812
- if (!content.includes('author:') && !content.includes('author:')) missing.push(full)
1813
- if (!content.includes('created_at:') && !content.includes('created_at:')) missing.push(full)
1814
- } catch (e) { /* skip unreadable files */ }
1815
- }
1816
- }
1817
-
1818
- walk(changesDir)
1819
- const unique = [...new Set(missing)]
1820
- if (unique.length > 0) {
1821
- console.log(`\n⚠️ 以下文件缺少 author 或 created_at 元数据:`)
1822
- unique.forEach(f => console.log(` - ${f.replace(cwd + '/', '')}`))
1823
- console.log('请在文件头部添加 author(git 用户名)和 created_at(精确到秒)')
1824
- }
1825
- }
1826
-
1827
- /**
1828
- * 验证关键文件是否存在于正确的变更目录下
1829
- * 防止 AI 将文件写到错误的路径
1830
- */
1831
- function validateFileLocations(cwd, stageName, progress, changeName, specBase) {
1832
- const effectiveChange = changeName || progress.currentChange
1833
- if (!effectiveChange) return
1834
-
1835
- const changeDir = join(specBase, 'changes', effectiveChange)
1836
- if (!existsSync(changeDir)) return
1837
-
1838
- // 每个阶段完成后预期存在的文件
1839
- const expectedFiles = {
1840
- brainstorm: ['design.md', 'proposal.md', 'requirements.md', 'tasks.md'],
1841
- plan: ['plan.md'],
1842
- archive: ['module-impact.md'],
1843
- }
1844
-
1845
- const expected = expectedFiles[stageName]
1846
- if (!expected) return
1847
-
1848
- const missing = []
1849
- for (const file of expected) {
1850
- if (!existsSync(join(changeDir, file))) {
1851
- missing.push(file)
1852
- }
1853
- }
1854
-
1855
- if (missing.length > 0) {
1856
- console.log(`\n⚠️ 文件位置验证:以下文件未在变更目录中找到`)
1857
- console.log(` 变更目录:${changeDir.replace(cwd + '/', '')}/`)
1858
- for (const f of missing) {
1859
- // 检查是否写到了错误的位置
1860
- const wrongPath = join(specBase, 'changes', 'change', effectiveChange, f)
1861
- if (existsSync(wrongPath)) {
1862
- console.log(` ❌ ${f} — 不存在,但发现了错误路径:${wrongPath.replace(cwd + '/', '')}`)
1863
- console.log(` 提示:应该写入 ${changeDir.replace(cwd + '/', '')}/${f}`)
1864
- } else {
1865
- console.log(` ⬜ ${f} — 未找到(该阶段可能未产出此文件)`)
1866
- }
1867
- }
1868
- } else {
1869
- console.log(`\n✅ 文件位置验证:所有 ${expected.length} 个预期文件均在变更目录中`)
1870
- }
1871
- }
1872
-
1873
- async function archiveChangeDirectory(pm, cwd, progress, specBase) {
1874
- const { renameSync } = await import('fs')
1875
- const archiveChangeName = progress.currentChange
1876
- if (!archiveChangeName) {
1877
- console.error('❌ 归档失败:未找到当前变更名(currentChange)')
1878
- process.exit(1)
1879
- }
1880
- const changesDir = join(specBase, 'changes')
1881
- const archiveDir = join(changesDir, 'archive')
1882
- const srcDir = join(changesDir, archiveChangeName)
1883
- const date = new Date().toISOString().slice(0, 10)
1884
- const destDir = join(archiveDir, `${date}-${archiveChangeName}`)
1885
-
1886
- if (!existsSync(srcDir)) {
1887
- console.error(`❌ 归档失败:源目录不存在 ${srcDir}`)
1888
- process.exit(1)
1889
- }
1890
- if (existsSync(destDir)) {
1891
- console.error(`❌ 归档失败:目标目录已存在 ${destDir}`)
1892
- process.exit(1)
1893
- }
1894
- mkdirSync(archiveDir, { recursive: true })
1895
- renameSync(srcDir, destDir)
1896
-
1897
- if (!existsSync(destDir) || existsSync(srcDir)) {
1898
- console.error('❌ 归档校验失败:移动操作异常')
1899
- process.exit(1)
1900
- }
1901
-
1902
- await pm.unregisterChange(cwd, archiveChangeName)
1903
- console.log(`📦 已归档:${archiveChangeName} → archive/${date}-${archiveChangeName}/`)
1904
- }
1905
-
1906
- // ── Wait Step ──
1907
-
1908
- async function waitStep(pm, progress, stageName, cwd, outputText, waitReason, waitOptions, options = {}) {
1909
- const { changeName, nonInteractive = false, platformOpts = {} } = options
1910
- const specBase = platformOpts.specRoot || join(cwd, '.sillyspec')
1911
- const stageData = progress.stages[stageName]
1912
-
1913
- if (!stageData || !stageData.steps) {
1914
- console.error(`❌ 阶段 ${stageName} 未初始化`)
1915
- process.exit(1)
1916
- }
1917
-
1918
- // 查找下一个 pending 或 in-progress 的步骤
1919
- const currentIdx = stageData.steps.findIndex(s => s.status === 'pending' || s.status === 'in-progress')
1920
- if (currentIdx === -1) {
1921
- console.error('没有可以等待的步骤')
1922
- process.exit(1)
1923
- }
1924
-
1925
- // 前置检查:不允许已有 waiting 步骤时再 --wait
1926
- const existingWaitingIdx = stageData.steps.findIndex(s => s.status === 'waiting')
1927
- if (existingWaitingIdx !== -1) {
1928
- const ws = stageData.steps[existingWaitingIdx]
1929
- console.error(`❌ 已有步骤处于等待状态:Step ${existingWaitingIdx + 1} "${ws.name}"`)
1930
- console.error(` 请先 --continue 或 --reset 该步骤,再开始新的 --wait`)
1931
- process.exit(1)
1932
- }
1933
-
1934
- // maxWaitRounds 硬上限:达到后拒绝继续 --wait
1935
- const currentStep = stageData.steps[currentIdx]
1936
- const defSteps = await getStageSteps(stageName, cwd, progress, platformOpts?.specRoot || null)
1937
- const stepDef = defSteps?.[currentIdx] || {}
1938
- const maxWaitRounds = currentStep.maxWaitRounds ?? stepDef.maxWaitRounds
1939
- const currentWaitRound = currentStep.waitRound || 0
1940
- if (maxWaitRounds && currentWaitRound >= maxWaitRounds) {
1941
- console.error(`❌ Step "${currentStep.name}" 已达到最大等待轮次(maxWaitRounds=${maxWaitRounds})`)
1942
- console.error(` 请基于已有回答完成本步骤:`)
1943
- console.error(` sillyspec run ${stageName} --done${changeName ? ` --change ${changeName}` : ''} --output "需求理解摘要"`)
1944
- process.exit(1)
1945
- }
1946
-
1947
- // 非交互模式下拒绝等待
1948
- if (nonInteractive) {
1949
- console.error(`❌ Human decision required in non-interactive mode.`)
1950
- console.error(` Reason: ${waitReason || '(unknown)'}`)
1951
- if (waitOptions) console.error(` Options: ${formatWaitOptions(waitOptions)}`)
1952
- console.error(` Fix: rerun with --interactive or provide decision via sillyspec run ${stageName} --continue --answer "..."`)
1953
- process.exit(2)
1954
- }
1955
-
1956
- const now = new Date().toLocaleString('zh-CN', { hour12: false })
1957
- stageData.steps[currentIdx].status = 'waiting'
1958
- stageData.steps[currentIdx].waitedAt = now
1959
- if (outputText) {
1960
- const MAX_OUTPUT = 200
1961
- stageData.steps[currentIdx].output = outputText.length > MAX_OUTPUT
1962
- ? outputText.slice(0, MAX_OUTPUT) + '…' : outputText
1963
- }
1964
- if (waitReason) stageData.steps[currentIdx].waitReason = waitReason
1965
- if (waitOptions) {
1966
- // 统一存为 JSON 数组
1967
- try {
1968
- const parsed = JSON.parse(waitOptions)
1969
- if (Array.isArray(parsed)) {
1970
- stageData.steps[currentIdx].waitOptions = JSON.stringify(parsed)
1971
- } else {
1972
- stageData.steps[currentIdx].waitOptions = JSON.stringify(waitOptions.split(',').map(o => o.trim()))
1973
- }
1974
- } catch {
1975
- stageData.steps[currentIdx].waitOptions = JSON.stringify(waitOptions.split(',').map(o => o.trim()))
1976
- }
1977
- }
1978
-
1979
- progress.lastActive = now
1980
- await pm._write(cwd, progress, changeName)
1981
- triggerSync(cwd, changeName, platformOpts)
240
+ // --skip
241
+ if (isSkip) {
242
+ return await skipStep(pm, progress, stageName, cwd)
243
+ }
1982
244
 
1983
- console.log(`⏸️ Step ${currentIdx + 1}/${stageData.steps.length} 已暂停等待:${stageData.steps[currentIdx].name}`)
1984
- if (waitReason) console.log(` 原因:${waitReason}`)
1985
- if (waitOptions) console.log(` 选项:${formatWaitOptions(waitOptions)}`)
1986
- console.log(` 继续时执行:sillyspec run ${stageName} --continue --answer "你的选择"${changeName ? ` --change ${changeName}` : ''}`)
1987
- }
245
+ // --done
246
+ if (isDone) {
247
+ return await completeStep(pm, progress, stageName, cwd, outputText, inputText)
248
+ }
1988
249
 
1989
- // ── Continue Step ──
250
+ // 默认:输出当前步骤
251
+ return await runStage(pm, progress, stageName, cwd)
252
+ }
1990
253
 
1991
- async function continueStep(pm, progress, stageName, cwd, answer, options = {}) {
1992
- const { changeName, platformOpts = {} } = options
1993
- const specBase = platformOpts.specRoot || join(cwd, '.sillyspec')
254
+ async function runStage(pm, progress, stageName, cwd) {
1994
255
  const stageData = progress.stages[stageName]
1995
-
1996
256
  if (!stageData || !stageData.steps) {
1997
257
  console.error(`❌ 阶段 ${stageName} 未初始化`)
1998
258
  process.exit(1)
1999
259
  }
2000
260
 
2001
- if (!answer) {
2002
- console.error(' --continue 需要 --answer 参数')
2003
- process.exit(1)
2004
- }
261
+ const steps = stageData.steps
262
+ let currentIdx = steps.findIndex(s => s.status !== 'completed' && s.status !== 'skipped')
2005
263
 
2006
- // 查找 waiting 的步骤
2007
- const waitingSteps = stageData.steps.map((s, i) => ({ ...s, idx: i })).filter(s => s.status === 'waiting')
2008
- if (waitingSteps.length === 0) {
2009
- console.error('没有处于等待状态的步骤')
2010
- process.exit(1)
2011
- }
2012
- if (waitingSteps.length > 1) {
2013
- console.error(`❌ 检测到 ${waitingSteps.length} 个等待中的步骤,无法确定恢复目标:`)
2014
- for (const ws of waitingSteps) {
2015
- console.error(` Step ${ws.idx + 1}: ${ws.name}${ws.waitReason ? `(${ws.waitReason})` : ''}`)
2016
- }
2017
- console.error(` 请使用 --reset 重置,或手动修复 DB`)
2018
- process.exit(1)
2019
- }
2020
- const currentIdx = waitingSteps[0].idx
2021
- const defSteps = await getStageSteps(stageName, cwd, progress, platformOpts?.specRoot || null)
2022
- const currentStepDef = defSteps?.[currentIdx] || {}
2023
- const currentStep = stageData.steps[currentIdx]
2024
- const isRepeatableWait = currentStepDef.repeatableWait === true || currentStep.repeatableWait === true
2025
- const requiresWait = currentStepDef.requiresWait === true || currentStep.requiresWait === true
2026
- const shouldReturnToCurrentStep = isRepeatableWait || requiresWait
2027
-
2028
- const now = new Date().toLocaleString('zh-CN', { hour12: false })
2029
- const prevOutput = currentStep.output || ''
2030
- const waitRound = (currentStep.waitRound || 0) + 1
2031
- currentStep.waitRound = waitRound
2032
- currentStep.waitAnswer = answer
2033
- currentStep.waitAnswers = Array.isArray(currentStep.waitAnswers) ? currentStep.waitAnswers : []
2034
- currentStep.waitAnswers.push({
2035
- round: waitRound,
2036
- answer,
2037
- question: prevOutput || null,
2038
- answeredAt: now,
2039
- })
2040
- currentStep.maxWaitRounds = currentStepDef.maxWaitRounds ?? currentStep.maxWaitRounds
2041
-
2042
- // 合并 waiting 信息到 output
2043
- const waitInfo = currentStep.waitReason || ''
2044
- if (waitInfo) {
2045
- currentStep.output = prevOutput
2046
- ? `${prevOutput} | 用户回答#${waitRound}:${answer}`
2047
- : `用户回答#${waitRound}:${answer}`
264
+ if (currentIdx === -1) {
265
+ // 阶段已完成
266
+ console.log(`✅ ${stageName} 阶段已完成。`)
267
+ console.log(` 继续执行将重新开始,可用 --reset 显式重置。\n`)
268
+ // 自动重置,允许重复执行
269
+ steps.forEach(s => { s.status = 'pending'; s.completedAt = null; s.output = null; s.startedAt = null })
270
+ stageData.status = 'in_progress'
271
+ stageData.completedAt = null
272
+ pm._write(cwd, progress)
273
+ currentIdx = 0
274
+ } else if (currentIdx > 0) {
275
+ // 有进行中的步骤,提示用户
276
+ const completed = currentIdx
277
+ const total = steps.length
278
+ console.log(`⚠️ ${stageName} 已进行到第 ${currentIdx + 1}/${total} 步(前 ${completed} 步已完成)。`)
279
+ console.log(` 继续执行将从中断处恢复,用 --reset 可重新开始。\n`)
2048
280
  }
2049
281
 
2050
- // 清除等待状态
2051
- delete currentStep.waitReason
2052
- delete currentStep.waitOptions
2053
- delete currentStep.waitedAt
2054
-
2055
- if (shouldReturnToCurrentStep) {
2056
- currentStep.status = 'pending'
2057
- currentStep.completedAt = null
2058
- } else {
2059
- currentStep.status = 'completed'
2060
- currentStep.completedAt = now
282
+ const stageDef = stageRegistry[stageName]
283
+ const defSteps = await getStageSteps(stageName, cwd, progress)
284
+ if (defSteps && defSteps[currentIdx]) {
285
+ outputStep(stageName, currentIdx, defSteps, cwd)
2061
286
  }
287
+ }
2062
288
 
2063
- progress.lastActive = now
2064
- await pm._write(cwd, progress, changeName)
2065
- triggerSync(cwd, changeName, platformOpts)
2066
-
2067
- console.log(`✅ Step ${currentIdx + 1}/${stageData.steps.length} 已继续:${currentStep.name}`)
2068
- console.log(` 回答:${answer}`)
289
+ function validateMetadata(cwd, stageName) {
290
+ const changesDir = join(cwd, '.sillyspec', 'changes')
291
+ if (!existsSync(changesDir)) return
2069
292
 
2070
- // Append to user-inputs.md
2071
- const inputsPath = join(specBase, '.runtime', 'user-inputs.md')
2072
- const entry = `\n## ${now} | ${changeName || '?'} | ${stageName}: ${currentStep.name} [CONTINUED]\n- 回答:${answer}\n`
2073
- appendFileSync(inputsPath, entry)
2074
-
2075
- // shouldReturnToCurrentStep: 回到当前步骤继续执行(repeatable=多轮探索,requiresWait=确认后执行动作)
2076
- if (shouldReturnToCurrentStep) {
2077
- console.log(`\n🔁 Step ${currentIdx + 1}/${stageData.steps.length} 已收到用户输入,回到当前步骤继续执行。`)
2078
- if (isRepeatableWait) {
2079
- console.log(` 已收集回答轮次:${waitRound}${currentStep.maxWaitRounds ? `/${currentStep.maxWaitRounds}` : ''}`)
2080
- }
2081
- if (defSteps && defSteps[currentIdx]) {
2082
- console.log('')
2083
- await outputStep(
2084
- stageName,
2085
- currentIdx,
2086
- defSteps,
2087
- cwd,
2088
- changeName,
2089
- progress.project || null,
2090
- platformOpts,
2091
- formatWaitHistory(currentStep)
2092
- )
2093
- }
2094
- return { stageCompleted: false, currentIdx, nextPendingIdx: currentIdx }
2095
- }
293
+ // 找最近 10 分钟内修改的 md/yaml 文件
294
+ const cutoff = Date.now() - 10 * 60 * 1000
295
+ const missing = []
2096
296
 
2097
- // 检查阶段是否全部完成
2098
- const nextPendingIdx = stageData.steps.findIndex(s => s.status === 'pending')
2099
- const nextWaitingIdx = stageData.steps.findIndex(s => s.status === 'waiting')
2100
- if (nextPendingIdx === -1 && nextWaitingIdx === -1) {
2101
- stageData.status = 'completed'
2102
- stageData.completedAt = now
2103
- await pm._write(cwd, progress, changeName)
2104
- console.log(`\n✅ ${stageName} 阶段已完成(${stageData.steps.length}/${stageData.steps.length} 步)`)
2105
- // ── execute 阶段完成时条件性清理 worktree ──
2106
- if (stageName === 'execute' && changeName) {
297
+ function walk(dir) {
298
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
299
+ const full = join(dir, entry.name)
2107
300
  try {
2108
- const { WorktreeManager } = await import('./worktree.js');
2109
- const wm = new WorktreeManager({ cwd });
2110
- const meta = wm.getMeta(changeName);
2111
- if (!meta) {
2112
- console.log('🔗 Worktree: n/a (no meta)');
2113
- } else if (meta.mode === 'native-worktree') {
2114
- console.log('🔗 Worktree: kept (外部隔离环境)');
2115
- } else if (meta.mode === 'in-place-fallback') {
2116
- console.log('🔗 Worktree: n/a (in-place 模式)');
2117
- } else {
2118
- const check = wm.hasUnappliedChanges(changeName);
2119
- if (check.hasChanges) {
2120
- console.log(`🔗 Worktree: pending apply (${check.changedFiles.length} 个未应用变更)`);
2121
- console.log(` 下一步: sillyspec worktree apply ${changeName}`);
2122
- } else {
2123
- const cleanResult = wm.cleanup(changeName);
2124
- console.log(`🔗 Worktree: ${cleanResult.result}`);
2125
- }
2126
- }
2127
- } catch (e) {
2128
- console.warn(`🔗 Worktree: check failed — ${e.message}`);
2129
- }
301
+ if (entry.isDirectory()) { walk(full); continue }
302
+ if (!/\.(md|yaml|yml)$/.test(entry.name)) continue
303
+ const mtime = statSync(full).mtimeMs
304
+ if (mtime < cutoff) continue
305
+ const content = readFileSync(full, 'utf-8')
306
+ if (!content.includes('author:') && !content.includes('author:')) missing.push(full)
307
+ if (!content.includes('created_at:') && !content.includes('created_at:')) missing.push(full)
308
+ } catch (e) { /* skip unreadable files */ }
2130
309
  }
2131
- return { stageCompleted: true, currentIdx, nextPendingIdx: -1 }
2132
310
  }
2133
311
 
2134
- // 输出下一步
2135
- if (nextPendingIdx !== -1 && defSteps) {
2136
- console.log('')
2137
- await outputStep(stageName, nextPendingIdx, defSteps, cwd, changeName, progress.project || null, platformOpts, answer)
2138
- } else if (nextWaitingIdx !== -1 && defSteps) {
2139
- // 下一个步骤也在等待状态
2140
- const ws = stageData.steps[nextWaitingIdx]
2141
- console.log(`\n⏸️ Step ${nextWaitingIdx + 1}/${stageData.steps.length} 仍在等待:${ws.name}`)
2142
- if (ws.waitReason) console.log(` 原因:${ws.waitReason}`)
2143
- console.log(` 继续:sillyspec run ${stageName} --continue --answer "..."${changeName ? ` --change ${changeName}` : ''}`)
312
+ walk(changesDir)
313
+ const unique = [...new Set(missing)]
314
+ if (unique.length > 0) {
315
+ console.log(`\n⚠️ 以下文件缺少 author created_at 元数据:`)
316
+ unique.forEach(f => console.log(` - ${f.replace(cwd + '/', '')}`))
317
+ console.log('请在文件头部添加 author(git 用户名)和 created_at(精确到秒)')
2144
318
  }
2145
-
2146
- return { stageCompleted: false, currentIdx, nextPendingIdx: nextPendingIdx }
2147
319
  }
2148
320
 
2149
- async function completeStep(pm, progress, stageName, cwd, outputText, inputText = null, options = {}) {
2150
- const { printNext = true, confirm = false, changeName, platformOpts = {}, nonInteractive = false, confirmMode = null } = options
2151
- const specBase = platformOpts.specRoot || join(cwd, '.sillyspec')
321
+ async function completeStep(pm, progress, stageName, cwd, outputText, inputText = null) {
2152
322
  const stageData = progress.stages[stageName]
2153
- const scanProfile = stageData?.scanProfile || null
2154
-
2155
- // ── WAIT MARKER 硬校验 ──
2156
- // 如果 output 包含等待标记,拒绝 --done 推进
2157
- if (outputText) {
2158
- const match = WAIT_MARKER_RE.exec(outputText)
2159
- if (match) {
2160
- console.error(`❌ Refused: step output contains ${match[1]} — human input required.`)
2161
- console.error(` 使用 --wait 替代 --done,例如:`)
2162
- console.error(` sillyspec run ${stageName} --wait --reason "等待用户决策" --output "你的摘要"${changeName ? ` --change ${changeName}` : ''}`)
2163
- process.exit(1)
2164
- }
2165
- }
2166
-
2167
- // scanProfile 非 deep 模式:截断 outputText 减少 token 传递
2168
- let effectiveOutput = outputText
2169
- if (scanProfile && scanProfile.mode !== 'deep' && outputText && outputText.length > 1000) {
2170
- effectiveOutput = outputText.slice(0, 1000) + '\n\n…[输出已截断,完整内容见 artifact]'
2171
- }
2172
323
  if (!stageData || !stageData.steps) {
2173
324
  console.error(`❌ 阶段 ${stageName} 未初始化`)
2174
325
  process.exit(1)
2175
326
  }
2176
327
 
2177
328
  const steps = stageData.steps
2178
- const currentIdx = steps.findIndex(s => s.status === 'pending' || s.status === 'in-progress')
329
+ let currentIdx = steps.findIndex(s => s.status === 'pending')
330
+
2179
331
  if (currentIdx === -1) {
2180
332
  console.error('没有待完成的步骤')
2181
333
  process.exit(1)
2182
334
  }
2183
335
 
2184
- // ── requiresWait 硬门控 ──
2185
- const defStepsForCurrent = await getStageSteps(stageName, cwd, progress, platformOpts?.specRoot || null)
2186
- const currentStepDef = defStepsForCurrent?.[currentIdx] || {}
2187
- const currentStep = steps[currentIdx]
2188
- if (currentStepDef.requiresWait === true && !currentStep.waitAnswer) {
2189
- // 检查 --done 是否带了 --answer:如果是,自动补全 waitAnswer 状态,一步完成
2190
- const doneAnswer = typeof options !== 'undefined' && options.doneAnswer ? options.doneAnswer : null
2191
- if (doneAnswer) {
2192
- currentStep.status = 'waiting'
2193
- currentStep.waitAnswer = doneAnswer
2194
- currentStep.waitReason = currentStepDef.waitReason || '等待用户输入'
2195
- console.log(`⚠️ Step "${currentStep.name}" 需要 wait,但 --done 带了 --answer,自动补全 wait 状态。`)
2196
- } else {
2197
- console.error(`❌ Step "${currentStep.name}" 必须先等待用户输入,不能直接 --done。`)
2198
- console.error(` 原因:${currentStepDef.waitReason || '该步骤需要人工确认/回答'}`)
2199
- if (currentStepDef.waitOptions) {
2200
- console.error(` 选项:${currentStepDef.waitOptions.join(', ')}`)
336
+ // plan 阶段 Step 4(展开任务)完成后,动态追加 task 蓝图步骤
337
+ // 先处理动态插入,再标记完成,避免 currentIdx 失效
338
+ if (stageName === 'plan' && currentIdx === 3 && progress.currentChange) {
339
+ const planFile = join(cwd, '.sillyspec', 'changes', progress.currentChange, 'plan.md')
340
+ if (existsSync(planFile)) {
341
+ const planContent = readFileSync(planFile, 'utf8')
342
+ const { buildPlanSteps } = await import('./stages/plan.js')
343
+ const fullSteps = buildPlanSteps(join(cwd, '.sillyspec', 'changes', progress.currentChange), planContent)
344
+ const taskSteps = fullSteps.slice(5, -2)
345
+ if (taskSteps.length > 0) {
346
+ const suffixSteps = fullSteps.slice(-2)
347
+ for (const ts of taskSteps) {
348
+ steps.push({ name: ts.name, status: 'pending', prompt: ts.prompt, outputHint: ts.outputHint, optional: false })
349
+ }
350
+ for (const ss of suffixSteps) {
351
+ steps.push({ name: ss.name, status: 'pending', prompt: ss.prompt, outputHint: ss.outputHint, optional: false })
352
+ }
2201
353
  }
2202
- console.error(` 请先执行:`)
2203
- console.error(` sillyspec run ${stageName} --wait --reason "${currentStepDef.waitReason || '等待用户输入'}" --options "${(currentStepDef.waitOptions || ['确认']).join(',')}"${changeName ? ` --change ${changeName}` : ''} --output "你的问题/方案摘要"`)
2204
- console.error(` 或使用 --done --answer "用户回答" 一步完成 wait + done`)
2205
- process.exit(1)
2206
354
  }
2207
355
  }
2208
356
 
357
+ // 标记完成(在动态插入之后,currentIdx 仍然指向正确的 pending 步骤)
2209
358
  steps[currentIdx].status = 'completed'
2210
359
  steps[currentIdx].completedAt = new Date().toLocaleString('zh-CN',{hour12:false})
2211
360
  if (outputText) {
2212
361
  const MAX_OUTPUT = 200
2213
362
  if (outputText.length > MAX_OUTPUT) {
2214
363
  steps[currentIdx].output = outputText.slice(0, MAX_OUTPUT) + '…'
2215
- steps[currentIdx].output_truncated = true
2216
- steps[currentIdx].output_original_length = outputText.length
2217
- // 平台模式:artifact 写入 runtime-root,否则写 .sillyspec/.runtime/artifacts
2218
- const artifactBase = platformOpts?.runtimeRoot
2219
- ? join(platformOpts.runtimeRoot, 'scan-runs', platformOpts.scanRunId || 'unknown')
2220
- : join(specBase, '.runtime', 'artifacts')
2221
- mkdirSync(artifactBase, { recursive: true })
364
+ // Save full output to artifacts/
365
+ const artifactsDir = join(cwd, '.sillyspec', '.runtime', 'artifacts')
366
+ mkdirSync(artifactsDir, { recursive: true })
2222
367
  const ts = new Date().toISOString().slice(0,19).replace(/[-T:]/g, '')
2223
- writeFileSync(join(artifactBase, `${changeName || 'unknown'}-${stageName}-step${currentIdx + 1}-${ts}.txt`), outputText)
368
+ writeFileSync(join(artifactsDir, `${stageName}-step${currentIdx + 1}-${ts}.txt`), outputText)
2224
369
  } else {
2225
370
  steps[currentIdx].output = outputText
2226
371
  }
2227
372
  }
2228
373
 
2229
- if (stageName === 'archive' && steps[currentIdx]?.name === '确认归档') {
2230
- if (!confirm) {
2231
- steps[currentIdx].status = 'pending'
2232
- steps[currentIdx].completedAt = null
2233
- if (outputText) steps[currentIdx].output = null
2234
- await pm._write(cwd, progress, changeName)
2235
- console.log('⚠️ 请添加 --confirm 确认归档,例如:sillyspec run archive --done --confirm --output "确认归档"')
2236
- return { stageCompleted: false, currentIdx, nextPendingIdx: currentIdx }
2237
- }
2238
- await archiveChangeDirectory(pm, cwd, progress, specBase)
2239
- }
2240
-
2241
- // archive "确认归档" 步骤完成后,校验归档完整性
2242
- if (stageName === 'archive' && steps[currentIdx]?.name === '确认归档' && confirm) {
2243
- const projectName = progress.project || basename(cwd)
2244
- const contractResult = runValidators('archive', cwd, changeName, { projectName, specRoot: platformOpts?.specRoot })
2245
- if (contractResult.errors.length > 0) {
2246
- console.error(`\n❌ 归档校验失败:`)
2247
- for (const err of contractResult.errors) {
2248
- console.error(` - ${err}`)
2249
- }
2250
- }
2251
- if (contractResult.warnings.length > 0) {
2252
- console.warn(`\n⚠️ 归档校验警告:`)
2253
- for (const w of contractResult.warnings) {
2254
- console.warn(` - ${w}`)
2255
- }
2256
- }
2257
- }
2258
-
2259
- // plan 阶段 "generate_plan" 完成后,动态插入任务蓝图 + postcheck 步骤
2260
- // 使用稳定 id 匹配,不依赖中文标题
2261
- if (stageName === 'plan') {
2262
- const currentStepDef = defStepsForCurrent?.[currentIdx]
2263
- const currentStepEntry = steps[currentIdx]
2264
- const stepId = currentStepDef?.id || currentStepEntry?.id || currentStepEntry?._stepId
2265
- if (stepId === 'generate_plan') {
2266
- const changeDir = resolveChangeDir(cwd, progress)
2267
- if (changeDir) {
2268
- const planFile = join(changeDir, 'plan.md')
2269
- if (existsSync(planFile)) {
2270
- const planContent = readFileSync(planFile, 'utf8')
2271
- const { buildPlanSteps, fixedPrefix, fixedSuffix } = await import('./stages/plan.js')
2272
- const fullSteps = buildPlanSteps(changeDir, planContent)
2273
- const prefixLen = fixedPrefix.length
2274
- const suffixLen = fixedSuffix.length
2275
- // 新结构:[...fixedPrefix, coordinatorStep?, postcheckStep?]
2276
- // fixedSuffix 为空,所以 coordinator + postcheck 都在 prefix 之后
2277
- const coordinatorSteps = fullSteps.slice(prefixLen, suffixLen > 0 ? -suffixLen : undefined)
2278
- if (coordinatorSteps.length > 0) {
2279
- for (let i = 0; i < coordinatorSteps.length; i++) {
2280
- const stepDef = coordinatorSteps[i]
2281
- const stepEntry = {
2282
- id: stepDef.id,
2283
- name: stepDef.name,
2284
- status: 'pending',
2285
- prompt: stepDef.prompt || '',
2286
- outputHint: stepDef.outputHint,
2287
- optional: stepDef.optional
2288
- }
2289
- // 传递 noAI / _cliAction 属性
2290
- if (stepDef.noAI) stepEntry.noAI = true
2291
- if (stepDef._cliAction) stepEntry._cliAction = stepDef._cliAction
2292
- steps.splice(currentIdx + 1 + i, 0, stepEntry)
2293
- }
2294
- console.log(` 📝 已动态插入 ${coordinatorSteps.length} 个步骤(${coordinatorSteps.map(s => s.name).join(', ')})`)
2295
- }
2296
- }
2297
- }
2298
- }
2299
- }
2300
-
2301
- // scan 阶段 step 2 "构建扫描项目列表" 完成后,按项目展开 perProject 步骤
2302
- if (stageName === 'scan' && steps[currentIdx]?.name === '构建扫描项目列表') {
2303
- // 解析项目列表:只接受结构化输出(YAML block 或 BEGIN_PROJECT_LIST 标记)
2304
- // 不再从自由文本猜测项目名——自由文本列表的误解析会导致垃圾项目落盘
2305
- let parsedProjects = [] // Array<{id, path?}>
2306
- let parsedFromStructuredOutput = false
2307
- if (outputText) {
2308
- // 格式 A: YAML block — 匹配 scan_projects: 下所有 - id: xxx 条目(含多行属性)
2309
- const yamlBlock = outputText.match(/scan_projects:\s*\n([\s\S]+?)(?=$|\n[^\s])/)
2310
- if (yamlBlock) {
2311
- const entries = [...yamlBlock[1].matchAll(/-\s+id:\s*(\S+)(?:[\s\S]*?)(?=\n\s+-\s+id:|$)/g)]
2312
- for (const m of entries) {
2313
- const id = sanitizeProjectName(m[1])
2314
- if (!id) continue
2315
- // 提取可选 path 字段
2316
- const pathMatch = m[0].match(/path:\s*(\S+)/)
2317
- const entry = pathMatch ? { id, path: pathMatch[1].trim() } : { id }
2318
- parsedProjects.push(entry)
2319
- }
2320
- parsedFromStructuredOutput = parsedProjects.length > 0
2321
- }
2322
- // 格式 B: BEGIN_PROJECT_LIST ... END_PROJECT_LIST 标记块
2323
- if (!parsedFromStructuredOutput) {
2324
- const blockMatch = outputText.match(/BEGIN_PROJECT_LIST\s*\n([\s\S]*?)\n*END_PROJECT_LIST/)
2325
- if (blockMatch) {
2326
- const raw = [...blockMatch[1].matchAll(/^-\s+(\S+)/gm)].map(m => m[1])
2327
- parsedProjects = raw.map(s => sanitizeProjectName(s)).filter(Boolean).map(id => ({ id }))
2328
- parsedFromStructuredOutput = parsedProjects.length > 0
2329
- }
2330
- }
2331
- }
374
+ // 检查是否还有下一步
375
+ const nextPendingIdx = steps.findIndex(s => s.status === 'pending')
2332
376
 
2333
- const projectNames = parsedProjects.map(p => p.id)
377
+ if (nextPendingIdx === -1) {
378
+ // 全部完成
379
+ stageData.status = 'completed'
380
+ stageData.completedAt = new Date().toLocaleString('zh-CN',{hour12:false})
2334
381
 
2335
- if (parsedFromStructuredOutput) {
2336
- stageData.scanMeta = stageData.scanMeta || {}
2337
- stageData.scanMeta.projectListParsed = true
2338
- } else {
2339
- // 结构化输出未解析到 回退读取已有 projects/*.yaml
2340
- // 读取时也校验:path 不存在的 yaml 视为垃圾,直接跳过
2341
- console.warn('⚠️ step 2 未输出结构化项目列表,回退扫描已注册项目')
2342
- stageData.scanMeta = stageData.scanMeta || {}
2343
- stageData.scanMeta.projectListParsed = false
2344
- const projectsDir = join(specBase, 'projects')
2345
- if (existsSync(projectsDir)) {
2346
- const yamlFiles = readdirSync(projectsDir).filter(f => f.endsWith('.yaml'))
2347
- const fallbackProjects = []
2348
- const fallbackSkipped = []
2349
- for (const yf of yamlFiles) {
2350
- const pName = yf.replace(/\.yaml$/, '')
2351
- const yamlContent = readFileSync(join(projectsDir, yf), 'utf8')
2352
- const pathMatch = yamlContent.match(/^path:\s*(.+)/m)
2353
- const pPath = pathMatch ? pathMatch[1].trim() : pName
2354
- // 校验 path 是否存在且在 source_root 内
2355
- const absPath = resolve(cwd, pPath)
2356
- if (pPath.includes('..') || (!absPath.startsWith(resolve(cwd)) && absPath !== resolve(cwd))) {
2357
- fallbackSkipped.push(`${pName} (path 越界: ${pPath})`)
2358
- continue
2359
- }
2360
- if (!existsSync(absPath)) {
2361
- fallbackSkipped.push(`${pName} (path 不存在: ${pPath})`)
2362
- continue
2363
- }
2364
- fallbackProjects.push({ id: pName, path: pPath })
2365
- }
2366
- if (fallbackSkipped.length > 0) {
2367
- console.warn(`⚠️ 跳过 ${fallbackSkipped.length} 个垃圾/过期项目配置:${fallbackSkipped.join(', ')}`)
2368
- console.warn(' 建议清理 projects/ 下的无效 yaml 文件')
2369
- }
2370
- parsedProjects = fallbackProjects
2371
- projectNames.length = 0
2372
- projectNames.push(...fallbackProjects.map(p => p.id))
382
+ const next = getNextStage(stageName)
383
+ if (next) {
384
+ progress.currentStage = next
385
+ if (!progress.stages[next]) progress.stages[next] = { status: 'pending', steps: [], startedAt: null, completedAt: null }
386
+ if (progress.stages[next].status === 'pending' || !progress.stages[next].status) {
387
+ progress.stages[next].status = 'in-progress'
388
+ progress.stages[next].startedAt = new Date().toLocaleString('zh-CN',{hour12:false})
2373
389
  }
2374
- if (parsedProjects.length === 0) {
2375
- // 无结构化输出 + 无合法已有项目 → step 2 失败
2376
- console.error('❌ step 2 未输出结构化项目列表,且 projects/ 下无合法项目配置')
2377
- console.error(' 请在 --output 中输出 scan_projects YAML block 或 BEGIN_PROJECT_LIST 标记块')
2378
- steps[currentIdx].validationError = '未输出结构化项目列表且无合法 fallback'
2379
- // 不展开 perProject 步骤,直接跳到下一步
2380
- }
2381
- }
2382
-
2383
- // 校验解析出的项目列表(原子守卫:不通过就不落盘)
2384
- const validation = validateParsedProjects(parsedProjects, cwd)
2385
- if (!validation.ok) {
2386
- console.error(`❌ 项目列表校验失败: ${validation.errors.join('; ')}`)
2387
- console.error(' step 2 完成,但不展开 perProject 步骤。请检查 --output 中的项目列表。')
2388
- steps[currentIdx].validationError = validation.errors.join('; ')
2389
390
  }
2390
391
 
2391
- // 自动注册 + 保存 runtime + 展开 perProject 步骤(仅在校验通过时)
2392
- const projectsDir = join(specBase, 'projects')
2393
- if (validation.ok) {
2394
- for (const proj of parsedProjects) {
2395
- const pName = proj.id
2396
- const projYaml = join(projectsDir, `${pName}.yaml`)
2397
- if (!existsSync(projYaml)) {
2398
- mkdirSync(projectsDir, { recursive: true })
2399
- const candidates = [
2400
- join(cwd, pName),
2401
- join(cwd, 'backend', pName),
2402
- join(cwd, 'packages', pName),
2403
- join(cwd, 'apps', pName),
2404
- join(cwd, 'services', pName),
2405
- ]
2406
- const detected = candidates.find(c => existsSync(c))
2407
- const regPath = detected || join(cwd, pName)
2408
- writeFileSync(projYaml, `name: ${pName}\npath: ${regPath}\nstatus: active\n`)
2409
- console.log(` 📝 自动注册子项目: ${pName} → ${regPath}`)
2410
- }
2411
- }
2412
-
2413
- // 保存 runtime 状态
2414
- const scanStatePath = join(specBase, '.runtime', 'scan-projects.json')
2415
- mkdirSync(join(specBase, '.runtime'), { recursive: true })
2416
- let scanState = { projects: projectNames, expanded: false }
2417
- if (existsSync(scanStatePath)) {
2418
- try { scanState = JSON.parse(readFileSync(scanStatePath, 'utf8')) } catch {}
2419
- }
2420
-
2421
- // 收集当前步骤之后所有 perProject 步骤
2422
- const stageDef = stageRegistry[stageName]
2423
- const allSteps = stageDef?.steps || []
2424
- const perProjectSteps = allSteps.filter(s => s.perProject)
2425
-
2426
- // 防重复展开
2427
- const alreadyExpanded = scanState.expanded || steps.some(s => s.name?.match(/\[.+\]\s*$/))
2428
- if (!alreadyExpanded && perProjectSteps.length > 0) {
2429
- // 找到当前步骤(step 2)在动态 steps 中的位置
2430
- const insertBase = currentIdx + 1
2431
- let insertPos = insertBase
2432
- for (const pName of projectNames) {
2433
- // 读取项目配置获取 projectRoot
2434
- const projYaml = join(specBase, 'projects', `${pName}.yaml`)
2435
- let projectRoot = '.'
2436
- if (existsSync(projYaml)) {
2437
- const yamlContent = readFileSync(projYaml, 'utf8')
2438
- const pathMatch = yamlContent.match(/^path:\s*(.+)/m)
2439
- if (pathMatch) projectRoot = pathMatch[1].trim()
2440
- }
2441
- const docOutputDir = platformOpts.specRoot ? `${specBase}/docs/${pName}` : `.sillyspec/docs/${pName}`
2442
- const contextPrefix = `\n---\n## 当前项目\n- **项目名**: ${pName}\n- **项目路径**: ${projectRoot}\n- **文档输出**: ${docOutputDir}\n\n⚠️ 本步骤只处理上面这个项目,不要处理其他项目。\n---\n\n`
2443
-
2444
- for (const ppStep of perProjectSteps) {
2445
- steps.splice(insertPos, 0, {
2446
- name: `${ppStep.name} [${pName}]`,
2447
- project: pName,
2448
- status: 'pending',
2449
- prompt: contextPrefix + ppStep.prompt,
2450
- outputHint: ppStep.outputHint,
2451
- optional: ppStep.optional
2452
- })
2453
- insertPos++
2454
- }
2455
- }
2456
- // 移除原始的 perProject 步骤(未展开的版本)
2457
- for (let i = steps.length - 1; i >= 0; i--) {
2458
- if (steps[i].perProject && !steps[i].name?.includes('[')) {
2459
- steps.splice(i, 1)
2460
- }
2461
- }
2462
- console.log(` 📝 已按项目展开 ${perProjectSteps.length} 个步骤 × ${projectNames.length} 个项目 = ${perProjectSteps.length * projectNames.length} 个项目步骤`)
2463
- console.log(` 📁 扫描项目:${projectNames.join(', ')}`)
2464
- // 标记已展开,防止 resume 重复插入
2465
- scanState.expanded = true
2466
- writeFileSync(scanStatePath, JSON.stringify(scanState))
2467
- } // end !alreadyExpanded
2468
- } // end validation.ok
2469
- } // end step 2
2470
-
2471
- const nextPendingIdx = steps.findIndex(s => s.status === 'pending' || s.status === 'in-progress')
392
+ progress.lastActive = new Date().toLocaleString('zh-CN',{hour12:false})
393
+ pm._write(cwd, progress)
2472
394
 
2473
- if (nextPendingIdx === -1) {
2474
- // 也检查是否有 waiting 的步骤
2475
- const hasWaiting = steps.some(s => s.status === 'waiting')
2476
- if (hasWaiting) {
2477
- // 有等待中的步骤,阶段未完成
2478
- progress.lastActive = new Date().toLocaleString('zh-CN',{hour12:false})
2479
- await pm._write(cwd, progress, changeName)
2480
- const wsIdx = steps.findIndex(s => s.status === 'waiting')
2481
- console.log(`\n⏸️ 阶段暂停:Step ${wsIdx + 1} 等待用户输入`)
2482
- if (steps[wsIdx].waitReason) console.log(` 原因:${steps[wsIdx].waitReason}`)
2483
- return { stageCompleted: false, currentIdx, nextPendingIdx: -1 }
2484
- }
2485
- // quick 阶段完成前强制检查 quicklog 是否创建
2486
- if (stageName === 'quick') {
2487
- const quicklogDir = join(specBase, 'quicklog')
2488
- const hasQuicklog = existsSync(quicklogDir) && readdirSync(quicklogDir).some(f => f.endsWith('.md') && f.startsWith('QUICKLOG'))
2489
- if (!hasQuicklog) {
2490
- console.error(`\n❌ quick 阶段完成校验失败:未检测到 QUICKLOG 记录文件。`)
2491
- console.error(` step 2 要求创建 quicklog 记录,但文件不存在。`)
2492
- console.error(` 请先创建 quicklog 记录再 --done,或使用 --skip-approval 跳过此校验。`)
2493
- return { stageCompleted: false, currentIdx, nextPendingIdx: -1 }
2494
- }
2495
- if (progress.quickGuard) {
2496
- const review = await auditQuickCompletion(cwd, progress.quickGuard, { isConfirm })
2497
- progress.quickGuard.review = review
2498
- progress.quickGuard.completedAt = new Date().toISOString()
2499
- printQuickAuditReview(review)
2500
- if (review.status === 'blocked') {
2501
- steps[currentIdx].status = 'pending'
2502
- steps[currentIdx].completedAt = null
2503
- if (outputText) steps[currentIdx].output = null
2504
- process.exit(1)
2505
- }
2506
- try {
2507
- const { unlinkSync } = await import('fs')
2508
- const guardFile = join(specBase, '.runtime', 'quick-guard.json')
2509
- unlinkSync(guardFile)
2510
- } catch {}
2511
- progress.lastQuickReview = review
2512
- delete progress.quickGuard
395
+ // deriveState 轻量校验
396
+ try {
397
+ const { deriveState } = await import('./derive.js')
398
+ const result = deriveState(cwd, { mode: 'light', fix: true, pm, progress })
399
+ if (result.fixed > 0) {
400
+ console.log(`⚠️ 状态修复:${result.fixed} 个步骤已从 artifacts 恢复`)
2513
401
  }
2514
- }
2515
-
2516
- stageData.status = 'completed'
2517
- stageData.completedAt = new Date().toLocaleString('zh-CN',{hour12:false})
2518
- progress.lastActive = new Date().toLocaleString('zh-CN',{hour12:false})
2519
- await pm._write(cwd, progress, changeName)
2520
- triggerSync(cwd, changeName, platformOpts)
402
+ } catch {}
2521
403
 
2522
404
  // Append to user-inputs.md
2523
405
  if (outputText) {
2524
- const inputsPath = join(specBase, '.runtime', 'user-inputs.md')
2525
- const entry = `\n## ${new Date().toLocaleString('zh-CN',{hour12:false})} | ${changeName || '?'} | ${stageName}: ${steps[currentIdx].name}\n${inputText ? "- 输入:" + inputText + "\n" : ""}- 输出:${outputText}\n`
406
+ const inputsPath = join(cwd, '.sillyspec', '.runtime', 'user-inputs.md')
407
+ const entry = `\n## ${new Date().toLocaleString('zh-CN',{hour12:false})} | ${stageName}: ${steps[currentIdx].name}\n${inputText ? "- 输入:" + inputText + "\n" : ""}- 输出:${outputText}\n`
2526
408
  appendFileSync(inputsPath, entry)
2527
409
  }
2528
410
 
2529
- // 平台模式:scan 完成后生成 manifest.json + post-check
2530
- if (stageName === 'scan' && (platformOpts.specRoot || platformOpts.runtimeRoot)) {
2531
- try {
2532
- stageData.scanMeta = stageData.scanMeta || {}; stageData.scanMeta.manifestWritten = false; // 默认失败
2533
- const { mkdirSync, writeFileSync, readFileSync: _readFileSync } = await import('fs')
2534
- const { join } = await import('path')
2535
- const { execSync } = await import('child_process')
2536
- const manifestDir = platformOpts.specRoot
2537
- mkdirSync(manifestDir, { recursive: true })
2538
- let sourceCommit = null
2539
- let sourceCommitError = null
2540
- try {
2541
- const gitResult = safeGit(cwd, ['rev-parse', 'HEAD'])
2542
- sourceCommit = gitResult.value
2543
- sourceCommitError = gitResult.error
2544
- } catch (e) {
2545
- sourceCommitError = e.message
2546
- }
2547
- const manifest = {
2548
- workspace_id: platformOpts.workspaceId || null,
2549
- scan_run_id: platformOpts.scanRunId || null,
2550
- source_root: cwd,
2551
- spec_root: platformOpts.specRoot || null,
2552
- runtime_root: platformOpts.runtimeRoot || null,
2553
- source_commit: sourceCommit,
2554
- source_commit_error: sourceCommit === null ? (sourceCommitError || 'unknown') : undefined,
2555
- generated_at: new Date().toISOString(),
2556
- schema_version: 1,
2557
- postcheck_result_path: null,
2558
- workflow_runs_dir: platformOpts.runtimeRoot
2559
- ? join(platformOpts.runtimeRoot, 'scan-runs', platformOpts.scanRunId || 'unknown', 'workflow-runs')
2560
- : null,
2561
- platform_pointer_path: join(cwd, '.sillyspec-platform.json'),
2562
- platform_pointer_status: POINTER_STATUS.ACTIVE,
2563
- }
2564
- const manifestPath = join(manifestDir, 'manifest.json')
2565
- writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n')
2566
- console.log(`📄 manifest.json 已写入: ${manifestPath}`)
2567
- stageData.scanMeta = stageData.scanMeta || {}; stageData.scanMeta.manifestWritten = true;
2568
- if (!sourceCommit) {
2569
- console.log(`⚠️ source_commit 无法获取(可能非 git 目录),已设为 null`)
2570
- }
2571
- // 清理平台参数临时文件
2572
- const { unlinkSync } = await import('fs')
2573
- const platformOptsFile = join(manifestDir, '.runtime', 'platform-scan.json')
2574
- try { unlinkSync(platformOptsFile) } catch {}
2575
-
2576
- // CLI 层 post-check(替代旧的简单检查)
2577
- const { runScanPostCheck, printScanPostCheckResult, formatStructuredResult, writeStructuredResult } = await import('./scan-postcheck.js')
2578
- const postResult = runScanPostCheck({
2579
- cwd,
2580
- specDir: platformOpts.specRoot,
2581
- outputText,
2582
- scanMeta: {
2583
- projectListParsed: stageData.scanMeta?.projectListParsed ?? null,
2584
- manifestWritten: stageData.scanMeta?.manifestWritten ?? null,
2585
- },
2586
- })
2587
- printScanPostCheckResult(postResult)
2588
-
2589
- // 生成结构化 JSON 并写入 runtime(供 SillyHub 消费)
2590
- const structured = formatStructuredResult(postResult, {
2591
- workspace_id: platformOpts.workspaceId,
2592
- scan_run_id: platformOpts.scanRunId,
2593
- source_root: cwd,
2594
- spec_root: platformOpts.specRoot,
2595
- runtime_root: platformOpts.runtimeRoot,
2596
- })
2597
- const postcheckJsonPath = writeStructuredResult(structured, platformOpts.specRoot, {
2598
- runtimeRoot: platformOpts.runtimeRoot,
2599
- scanRunId: platformOpts.scanRunId,
2600
- })
2601
- if (postcheckJsonPath) {
2602
- console.log(`📄 postcheck-result.json 已写入: ${postcheckJsonPath}`)
2603
- manifest.postcheck_result_path = postcheckJsonPath
2604
- }
2605
-
2606
- // 将 post-check 结果写入 manifest
2607
- manifest.scan_post_check = {
2608
- status: postResult.status,
2609
- checks: postResult.checks,
2610
- }
2611
- // 更新 manifest
2612
- writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n')
2613
- console.log(`📄 manifest.json 已更新(含 post-check 结果)`)
2614
-
2615
- // 更新平台指针状态为 scan_completed
2616
- const pointerPath = join(cwd, '.sillyspec-platform.json')
2617
- try {
2618
- const pointer = JSON.parse(_readFileSync(pointerPath, 'utf8'))
2619
- pointer.status = POINTER_STATUS.SCAN_COMPLETED
2620
- pointer.completedAt = new Date().toISOString()
2621
- pointer.scanStatus = postResult.status
2622
- writeFileSync(pointerPath, JSON.stringify(pointer, null, 2) + '\n')
2623
- } catch {}
2624
-
2625
- // failed_post_check 时强制阻止 clean success
2626
- if (postResult.status === 'failed_post_check') {
2627
- stageData.status = SCAN_STATUS.FAILED_POST_CHECK
2628
- stageData.completedAt = new Date().toLocaleString('zh-CN',{hour12:false})
2629
- await pm._write(cwd, progress, changeName)
2630
- triggerSync(cwd, changeName, platformOpts)
2631
- console.error(`\n❌ scan post-check 失败,状态设为 failed_post_check。不允许 clean success。`)
2632
- console.error(` 请检查上方错误信息并修复后重新 scan。`)
2633
- // 平台模式:exit(1) 让 daemon/SillyHub 感知非 0 退出码(manifest.json 已落盘,不会被撤销)
2634
- if (platformOpts.specRoot || platformOpts.runtimeRoot) {
2635
- console.error(' 平台模式:CLI 将以 exit code 1 退出,通知 SillyHub scan 失败。')
2636
- process.exit(1)
2637
- }
2638
- // 接口与 plan contract (run.js:2551 附近 plan 失败分支) 对齐:
2639
- // 返回 { stageCompleted:false, currentIdx, nextPendingIdx: currentIdx }
2640
- // 让上层 runStage 走"完成但不推进"分支,--done 被拒
2641
- return { stageCompleted: false, currentIdx, nextPendingIdx: currentIdx }
2642
- } else if (postResult.status === 'completed_with_warnings') {
2643
- // 警告不阻止完成,但记录
2644
- stageData.status = 'completed'
2645
- stageData.completedAt = new Date().toLocaleString('zh-CN',{hour12:false})
2646
- await pm._write(cwd, progress, changeName)
2647
- }
2648
- } catch (e) {
2649
- console.warn(`⚠️ manifest.json 写入失败: ${e.message}`)
2650
- }
2651
- }
2652
-
2653
- // 非 platform 模式 scan 也做轻量 post-check + 结构化输出
2654
- if (stageName === 'scan' && !platformOpts.specRoot && !platformOpts.runtimeRoot) {
2655
- const { runScanPostCheck, printScanPostCheckResult, formatStructuredResult, writeStructuredResult } = await import('./scan-postcheck.js')
2656
- const postResult = runScanPostCheck({ cwd, specDir: null, outputText })
2657
- printScanPostCheckResult(postResult)
2658
- // 结构化结果写入 .sillyspec/.runtime/
2659
- const structured = formatStructuredResult(postResult, { source_root: cwd })
2660
- const postcheckJsonPath = writeStructuredResult(structured, join(cwd, '.sillyspec'))
2661
- if (postcheckJsonPath) {
2662
- console.log(`📄 postcheck-result.json 已写入: ${postcheckJsonPath}`)
2663
- }
2664
- }
2665
-
2666
- // 防御性守卫变量:确认所有步骤确实标记为 completed
2667
- const actualCompleted = steps.filter(s => s.status === 'completed').length
2668
- const actualTotal = steps.length
2669
-
2670
- validateMetadata(cwd, stageName, specBase)
2671
-
2672
- // 验证关键文件是否在正确的变更目录下(仅当所有步骤确实完成时才校验)
2673
- if (actualCompleted === actualTotal && actualTotal > 0) {
2674
- validateFileLocations(cwd, stageName, progress, changeName, specBase)
2675
- }
2676
-
2677
- // 辅助阶段完成后重置步骤
2678
- const stageDef = stageRegistry[stageName]
2679
- if (stageDef?.auxiliary) {
2680
- const freshSteps = (stageDef.steps || []).map(s => ({
2681
- name: s.name,
2682
- status: 'pending',
2683
- output: null,
2684
- completedAt: null
2685
- }))
2686
- stageData.steps = freshSteps
2687
- stageData.status = 'pending'
2688
- stageData.completedAt = null
2689
- if (progress.currentStage === stageName) progress.currentStage = ''
2690
- await pm._write(cwd, progress, changeName)
2691
- }
411
+ // 验证:检查生成的文件是否包含 author created_at
412
+ validateMetadata(cwd, stageName)
2692
413
 
2693
414
  const total = steps.length
2694
415
  console.log(`✅ ${stageName} 阶段已完成(${total}/${total} 步)`)
2695
-
2696
- if (stageName === 'execute') {
2697
- // execute run summary:展示真实可得的结构化信息
2698
- try {
2699
- const lastOutput = steps[steps.length - 1]?.output || ''
2700
- const summary = formatExecuteSummary({
2701
- changeName,
2702
- stepsCompleted: total,
2703
- stepsTotal: total,
2704
- agentSummary: lastOutput,
2705
- cwd,
2706
- })
2707
- console.log(`\n${summary}`)
2708
- } catch (e) {
2709
- // summary 失败不影响主流程
2710
- console.log('\n👉 下一步:sillyspec run verify(验证通过后才能归档)')
2711
- }
2712
- } else if (stageName === 'verify') {
2713
- console.log('\n👉 下一步:sillyspec run archive(验证通过,可以归档了)')
2714
- } else if (stageName === 'archive') {
2715
- console.log('\n👉 归档完成!现在可以提交了:git commit -m "..."')
2716
- } else {
2717
- console.log(`\n下一步由你决定:sillyspec run <stage>(brainstorm/plan/execute/verify/archive 等)`)
2718
- }
2719
-
2720
- // 阶段完成校验 — 防御性守卫:仅当所有步骤确实标记为 completed 时才跑 validator
2721
- if (actualCompleted === actualTotal && actualTotal > 0) {
2722
- const projectName = progress.project || basename(cwd)
2723
- const contractResult = runValidators(stageName, cwd, changeName, { projectName, specRoot: platformOpts?.specRoot })
2724
- if (contractResult.errors.length > 0) {
2725
- console.error(`\n❌ 阶段 ${stageName} 校验失败:`)
2726
- for (const err of contractResult.errors) {
2727
- console.error(` - ${err}`)
2728
- }
2729
- console.error(`\n 提示:修复缺失产物后重新运行此步骤,或使用 --skip-approval 跳过校验`)
2730
- }
2731
- if (contractResult.warnings.length > 0) {
2732
- console.warn(`\n⚠️ 阶段 ${stageName} 校验警告:`)
2733
- for (const w of contractResult.warnings) {
2734
- console.warn(` - ${w}`)
2735
- }
2736
- }
2737
-
2738
- // ── Plan postcheck contract:plan.md 必须满足 execute 契约 ──
2739
- if (stageName === 'plan') {
2740
- const planFile = resolveChangeDir(cwd, progress, platformOpts?.specRoot)
2741
- const planPath = planFile ? join(planFile, 'plan.md') : null
2742
- if (planPath && existsSync(planPath)) {
2743
- const { validatePlanForExecute } = await import('./stages/execute.js')
2744
- const planContent = readFileSync(planPath, 'utf8')
2745
- const planValidation = validatePlanForExecute(planContent)
2746
- if (!planValidation.ok) {
2747
- console.error(`\n❌ Plan → Execute Contract 校验失败:`)
2748
- for (const err of planValidation.errors) console.error(` - ${err}`)
2749
- console.error(`\n plan.md 不满足 execute 契约,请修复后重新完成此步骤。`)
2750
- // 阻断 completed
2751
- progress.lastActive = new Date().toLocaleString('zh-CN',{hour12:false})
2752
- await pm._write(cwd, progress, changeName)
2753
- triggerSync(cwd, changeName, platformOpts)
2754
- return { stageCompleted: false, currentIdx, nextPendingIdx: currentIdx }
2755
- }
2756
- if (planValidation.warnings.length > 0) {
2757
- console.warn(`\n⚠️ Plan contract 警告(不阻断完成):`)
2758
- for (const w of planValidation.warnings) console.warn(` - ${w}`)
2759
- }
2760
- if (planValidation.ok) {
2761
- console.log(`\n✅ Plan → Execute Contract 校验通过(${planValidation.tasks.length} tasks, ${planValidation.waves.length} waves)`)
2762
- }
2763
- }
2764
- }
2765
-
2766
- // ── Execute Task Review Gate:所有 task 必须有 review.json 且 verdict 通过 ──
2767
- if (stageName === 'execute') {
2768
- try {
2769
- const { validateTaskReviews, printReviewResult, writeVerifyRequiredEvidence } = await import('./task-review.js')
2770
- const effectiveSpecBase = platformOpts?.specRoot || specBase
2771
- const planFile = resolveChangeDir(cwd, progress, platformOpts?.specRoot)
2772
- const planPath = planFile ? join(planFile, 'plan.md') : null
2773
-
2774
- if (planPath && existsSync(planPath)) {
2775
- const planContent = readFileSync(planPath, 'utf8')
2776
- const runtimeRoot = join(effectiveSpecBase, '.runtime')
2777
-
2778
- // execute run id:从变更专属标记文件读取
2779
- const runIdFile = join(runtimeRoot, `current-execute-run-id-${changeName}`)
2780
- let executeRunId = ''
2781
- try {
2782
- if (existsSync(runIdFile)) {
2783
- executeRunId = readFileSync(runIdFile, 'utf8').trim()
2784
- }
2785
- } catch {}
2786
- if (!executeRunId) {
2787
- const { generateExecuteRunId } = await import('./task-review.js')
2788
- executeRunId = generateExecuteRunId()
2789
- }
2790
-
2791
- const reviewResult = validateTaskReviews({ planContent, runtimeRoot, executeRunId })
2792
- printReviewResult(reviewResult)
2793
-
2794
- if (!reviewResult.ok) {
2795
- // Task review 校验失败,阻断 execute 完成
2796
- // 检查是否存在 checkbox 已勾但 review 不通过的情况
2797
- const uncheckedTasks = reviewResult.errors.filter(e => e.includes('缺少 review.json'))
2798
- if (uncheckedTasks.length > 0) {
2799
- console.error('\n⚠️ 部分任务已在 plan.md 中勾选,但 review.json 不存在。')
2800
- console.error(' 请取消勾选这些任务的 checkbox,或补充对应的 review.json。')
2801
- }
2802
- progress.lastActive = new Date().toLocaleString('zh-CN',{hour12:false})
2803
- await pm._write(cwd, progress, changeName)
2804
- triggerSync(cwd, changeName, platformOpts)
2805
- return { stageCompleted: false, currentIdx, nextPendingIdx: currentIdx }
2806
- }
2807
-
2808
- // cannot_verify 的 requiredEvidence 写入 change 目录,供 verify 阶段消费
2809
- if (reviewResult.requiredEvidence.length > 0) {
2810
- const evidencePath = writeVerifyRequiredEvidence(join(effectiveSpecBase, 'changes', changeName), reviewResult.requiredEvidence)
2811
- if (evidencePath) {
2812
- console.log(`📄 verify-required-evidence.json 已写入: ${evidencePath}`)
2813
- console.log(' verify 阶段必须满足这些证据要求。')
2814
- }
2815
- }
2816
- }
2817
- } catch (e) {
2818
- console.warn(`⚠️ Task Review Gate 异常: ${e.message}`)
2819
- // 不阻断,但记录异常
2820
- }
2821
- }
2822
- } else if (actualCompleted < actualTotal) {
2823
- // 实际步骤未全部完成,跳过 validator(状态可能不同步)
2824
- console.log(`\n⚠️ 阶段校验跳过:${actualTotal} 步中仅 ${actualCompleted} 步标记为已完成,可能存在状态不同步。如确认阶段已完成,请运行 --status 确认。`)
416
+ if (next) {
417
+ console.log(`\n下一步:sillyspec run ${next}`)
418
+ console.log(`或:/sillyspec:${next}`)
2825
419
  }
2826
-
2827
- // ── execute 阶段完成时条件性清理 worktree(不依赖 AI agent 的完成确认步骤)──
2828
- if (stageName === 'execute' && changeName) {
2829
- try {
2830
- const { WorktreeManager } = await import('./worktree.js');
2831
- const wm = new WorktreeManager({ cwd });
2832
- const meta = wm.getMeta(changeName);
2833
- if (!meta) {
2834
- console.log('🔗 Worktree: n/a (no meta)');
2835
- } else if (meta.mode === 'native-worktree') {
2836
- console.log('🔗 Worktree: kept (外部隔离环境)');
2837
- } else if (meta.mode === 'in-place-fallback') {
2838
- console.log('🔗 Worktree: n/a (in-place 模式)');
2839
- } else {
2840
- const check = wm.hasUnappliedChanges(changeName);
2841
- if (check.hasChanges) {
2842
- console.log(`🔗 Worktree: pending apply (${check.changedFiles.length} 个未应用变更)`);
2843
- console.log(` 下一步: sillyspec worktree apply ${changeName}`);
2844
- } else {
2845
- const cleanResult = wm.cleanup(changeName);
2846
- if (cleanResult.result === 'skipped' || cleanResult.result === 'kept') {
2847
- console.log(`🔗 Worktree: ${cleanResult.result}`);
2848
- } else {
2849
- console.log(`🔗 Worktree: ${cleanResult.result}`);
2850
- if (cleanResult.details?.length > 0) {
2851
- for (const d of cleanResult.details) {
2852
- if (d.startsWith('⚠️')) console.log(` ${d}`);
2853
- }
2854
- }
2855
- }
2856
- }
2857
- }
2858
- } catch (e) {
2859
- console.warn(`🔗 Worktree: check failed — ${e.message}`);
2860
- }
2861
- }
2862
-
2863
- return { stageCompleted: true, currentIdx, nextPendingIdx: -1 }
420
+ return
2864
421
  }
2865
422
 
2866
423
  progress.lastActive = new Date().toLocaleString('zh-CN',{hour12:false})
2867
- await pm._write(cwd, progress, changeName)
2868
- triggerSync(cwd, changeName, platformOpts)
424
+ pm._write(cwd, progress)
2869
425
 
2870
426
  // Append to user-inputs.md
2871
427
  if (outputText) {
2872
- const inputsPath = join(specBase, '.runtime', 'user-inputs.md')
2873
- const entry = `\n## ${new Date().toLocaleString('zh-CN',{hour12:false})} | ${changeName || '?'} | ${stageName}: ${steps[currentIdx].name}\n${inputText ? "- 输入:" + inputText + "\n" : ""}- 输出:${outputText}\n`
428
+ const inputsPath = join(cwd, '.sillyspec', '.runtime', 'user-inputs.md')
429
+ const entry = `\n## ${new Date().toLocaleString('zh-CN',{hour12:false})} | ${stageName}: ${steps[currentIdx].name}\n${inputText ? "- 输入:" + inputText + "\n" : ""}- 输出:${outputText}\n`
2874
430
  appendFileSync(inputsPath, entry)
2875
431
  }
2876
432
 
2877
433
  const defSteps = await getStageSteps(stageName, cwd, progress)
2878
434
  console.log(`✅ Step ${currentIdx + 1}/${steps.length} 完成:${steps[currentIdx].name}\n`)
2879
-
2880
- // Workflow post_check:scan 深度扫描完成后自动检查产物
2881
- if (stageName === 'scan' && steps[currentIdx]?.name?.includes('深度扫描')) {
2882
- try {
2883
- const { loadWorkflow, runPostCheck, formatCheckReport, saveWorkflowRun } = await import('./workflow.js')
2884
- const wf = loadWorkflow(cwd, 'scan-docs')
2885
- if (wf) {
2886
- // 确定当前项目(优先级链):
2887
- // progress.project (dbProjectName,平台模式真实项目名,与 outputStep 占位符渲染对齐)
2888
- // > change?.project (变更对象的项目字段,平台模式 change 创建时传入)
2889
- // > steps[idx].project (perProject 展开标记,兼容旧模式)
2890
- // > steps[idx].name 正则提取 [xxx] 后缀
2891
- // > null(回退检查所有项目)
2892
- // task-05 修复:日志显示项目名变 frontend 是 perProject 误展开 bug,
2893
- // 用 progress.project(与 outputStep 占位符渲染路径一致)修正 myaaa/frontend 分裂。
2894
- const currentProjectName = progress.project
2895
- || (typeof change !== 'undefined' && change ? change.project : null)
2896
- || steps[currentIdx].project
2897
- || (steps[currentIdx].name.match(/\[([^\]]+)\]\s*$/) || [])[1]
2898
- || null
2899
-
2900
- // 确定要检查的项目列表
2901
- let projectsToCheck = []
2902
- if (currentProjectName) {
2903
- // 按项目展开模式:只检查当前项目
2904
- projectsToCheck = [currentProjectName]
2905
- } else {
2906
- // 兼容旧模式(未展开):检查所有项目
2907
- const projectsDir = join(specBase, 'projects')
2908
- const projectFiles = existsSync(projectsDir)
2909
- ? readdirSync(projectsDir).filter(f => f.endsWith('.yaml'))
2910
- : []
2911
- projectsToCheck = projectFiles.map(f => f.replace(/\.yaml$/, ''))
2912
- }
2913
-
2914
- let anyFailed = false
2915
- for (const pName of projectsToCheck) {
2916
- const result = runPostCheck(wf, cwd, pName, {}, specBase)
2917
- const report = formatCheckReport(result)
2918
- console.log(report)
2919
- if (result.status === 'fail') {
2920
- anyFailed = true
2921
- // retry_prompts 由 _checkWorkflow 自动生成
2922
- for (const rp of (result.retry_prompts || [])) {
2923
- console.log(`\n🔄 重试提示(项目 ${pName}):\n`)
2924
- console.log(rp.prompt)
2925
- }
2926
- }
2927
- const saved = saveWorkflowRun(result, {
2928
- cwd,
2929
- source: 'run.js',
2930
- stage: 'scan',
2931
- step: steps[currentIdx]?.name,
2932
- ...(platformOpts.runtimeRoot ? { runtimeRoot: platformOpts.runtimeRoot } : {}),
2933
- ...(platformOpts.scanRunId ? { scanRunId: platformOpts.scanRunId } : {})
2934
- })
2935
- if (saved) console.log(`📁 结果已归档:${saved}`)
2936
- }
2937
- if (anyFailed) {
2938
- console.log(`\n⚠️ 存在检查失败项,请按上面的重试提示修复后再继续。`)
2939
- // task-07: 阻断推进(与 task-06 平台模式 scan-postcheck 失败分支 return 结构对齐)
2940
- // scan 深度扫描产物校验未通过时,不允许 clean success / 进入下一 step,
2941
- // 让上层走"完成但不推进"分支,--done 被拒。
2942
- return { stageCompleted: false, currentIdx, nextPendingIdx: currentIdx }
2943
- }
2944
- }
2945
- } catch (e) {
2946
- console.warn(`⚠️ workflow 检查跳过:${e.message}`)
2947
- }
2948
- }
2949
-
2950
- // Workflow post_check:archive extract-module-impact 完成后检查产物
2951
- if (stageName === 'archive' && steps[currentIdx]?.name?.includes('extract-module-impact')) {
2952
- try {
2953
- const { loadWorkflow, runPostCheck, formatCheckReport, saveWorkflowRun } = await import('./workflow.js')
2954
- const wf = loadWorkflow(cwd, 'archive-impact')
2955
- if (wf && changeName) {
2956
- const raw = JSON.stringify(wf)
2957
- const resolved = JSON.parse(raw.replace(/<change-name>/g, changeName))
2958
- const result = runPostCheck(resolved, cwd, 'sillyspec', {}, specBase)
2959
- // 只报告 impact-analyzer 的结果(doc-syncer 是后续步骤)
2960
- const impactResult = (result.roles || []).find(r => r.id === 'impact-analyzer')
2961
- if (impactResult) {
2962
- const icon = impactResult.status === 'pass' ? '✅' : '❌'
2963
- console.log(`${icon} module-impact.md 检查${impactResult.status === 'pass' ? '通过' : '失败'}`)
2964
- for (const f of (result.failures || []).filter(f => f.role_id === 'impact-analyzer')) {
2965
- console.log(` └─ ${f}`)
2966
- }
2967
- }
2968
- const saved = saveWorkflowRun(result, {
2969
- cwd,
2970
- source: 'run.js',
2971
- stage: 'archive',
2972
- step: steps[currentIdx]?.name,
2973
- ...(platformOpts.runtimeRoot ? { runtimeRoot: platformOpts.runtimeRoot } : {}),
2974
- ...(platformOpts.scanRunId ? { scanRunId: platformOpts.scanRunId } : {})
2975
- })
2976
- if (saved) console.log(`📁 结果已归档:${saved}`)
2977
- }
2978
- } catch (e) {
2979
- console.warn(`⚠️ workflow 检查跳过:${e.message}`)
2980
- }
2981
- }
2982
-
2983
- if (printNext) {
2984
- await outputStep(stageName, nextPendingIdx, defSteps, cwd, changeName, progress.project || null, platformOpts)
2985
- }
2986
- return { stageCompleted: false, currentIdx, nextPendingIdx }
435
+ outputStep(stageName, nextPendingIdx, defSteps, cwd)
2987
436
  }
2988
437
 
2989
- async function skipStep(pm, progress, stageName, cwd, changeName) {
438
+ async function skipStep(pm, progress, stageName, cwd) {
2990
439
  const stageData = progress.stages[stageName]
2991
440
  if (!stageData || !stageData.steps) {
2992
441
  console.error(`❌ 阶段 ${stageName} 未初始化`)
@@ -2994,16 +443,10 @@ async function skipStep(pm, progress, stageName, cwd, changeName) {
2994
443
  }
2995
444
 
2996
445
  const steps = stageData.steps
2997
- const currentIdx = steps.findIndex(s => s.status === 'pending' || s.status === 'in-progress')
446
+ const currentIdx = steps.findIndex(s => s.status === 'pending')
2998
447
 
2999
448
  if (currentIdx === -1) {
3000
- const wsIdx = steps.findIndex(s => s.status === 'waiting')
3001
- if (wsIdx !== -1) {
3002
- console.error(`⏸️ Step ${wsIdx + 1} 正在等待用户输入,不能跳过。`)
3003
- console.error(` 请先使用 --continue --answer "..." 继续,或用 --reset 重置。`)
3004
- } else {
3005
- console.error('没有待跳过的步骤')
3006
- }
449
+ console.error('没有待跳过的步骤')
3007
450
  process.exit(1)
3008
451
  }
3009
452
 
@@ -3017,22 +460,15 @@ async function skipStep(pm, progress, stageName, cwd, changeName) {
3017
460
  steps[currentIdx].status = 'skipped'
3018
461
  steps[currentIdx].skippedAt = new Date().toLocaleString('zh-CN',{hour12:false})
3019
462
  progress.lastActive = new Date().toLocaleString('zh-CN',{hour12:false})
3020
- await pm._write(cwd, progress, changeName)
3021
- triggerSync(cwd, changeName, platformOpts)
463
+ pm._write(cwd, progress)
3022
464
 
3023
465
  console.log(`⏭️ Step ${currentIdx + 1}/${steps.length} 已跳过:${steps[currentIdx].name}`)
3024
466
 
3025
- const nextPendingIdx = steps.findIndex(s => s.status === 'pending' || s.status === 'in-progress')
467
+ // 输出下一步
468
+ const nextPendingIdx = steps.findIndex(s => s.status === 'pending')
3026
469
  if (nextPendingIdx !== -1 && defSteps) {
3027
470
  console.log('')
3028
- await outputStep(stageName, nextPendingIdx, defSteps, cwd, changeName, progress.project || null, platformOpts)
3029
- } else {
3030
- const wsIdx = steps.findIndex(s => s.status === 'waiting')
3031
- if (wsIdx !== -1) {
3032
- console.log(`\n⏸️ Step ${wsIdx + 1}/${steps.length} 正在等待:${steps[wsIdx].name}`)
3033
- if (steps[wsIdx].waitReason) console.log(` 原因:${steps[wsIdx].waitReason}`)
3034
- console.log(` 继续:sillyspec run ${stageName} --continue --answer "..."${changeName ? ` --change ${changeName}` : ''}`)
3035
- }
471
+ outputStep(stageName, nextPendingIdx, defSteps, cwd)
3036
472
  }
3037
473
  }
3038
474
 
@@ -3041,12 +477,8 @@ function showStatus(progress, stageName) {
3041
477
  const stageDef = stageRegistry[stageName]
3042
478
 
3043
479
  if (!stageData || !stageData.steps || stageData.steps.length === 0) {
3044
- console.log(`阶段:${stageName}(${stageDef?.title || stageName})`)
480
+ console.log(`阶段:${stageName}(${stageDef.title})`)
3045
481
  console.log(`进度:未初始化`)
3046
- if (stageData?.status) {
3047
- console.log(`状态:${stageData.status}`)
3048
- if (stageData.staleReason) console.log(`⚠️ 失效原因:${stageData.staleReason}`)
3049
- }
3050
482
  return
3051
483
  }
3052
484
 
@@ -3054,28 +486,12 @@ function showStatus(progress, stageName) {
3054
486
  const completed = steps.filter(s => s.status === 'completed' || s.status === 'skipped').length
3055
487
  const bar = '█'.repeat(completed) + '░'.repeat(steps.length - completed)
3056
488
 
3057
- console.log(`阶段:${stageName}(${stageDef?.title || stageName})`)
3058
- console.log(`进度:[${bar}] ${completed}/${steps.length}`)
489
+ console.log(`阶段:${stageName}(${stageDef.title})`)
490
+ console.log(`进度:[${bar}] ${completed}/${steps.length}\n`)
3059
491
 
3060
- // ── Revision v1 信息 ──
3061
- if (stageData.status === 'revising') {
3062
- console.log(`\n🔧 修订中 (revision ${stageData.revision || 1})`)
3063
- if (stageData.reopenedFromStep) console.log(` 从步骤:${stageData.reopenedFromStep}`)
3064
- if (stageData.reopenedAt) console.log(` 重开时间:${stageData.reopenedAt}`)
3065
- }
3066
- if (stageData.status === 'stale') {
3067
- console.log(`\n⚠️ 已失效`)
3068
- if (stageData.staleReason) console.log(` 原因:${stageData.staleReason}`)
3069
- console.log(` 建议:sillyspec run ${stageName} --reopen --from-step 1`)
3070
- }
3071
- if (stageData.status === 'completed') {
3072
- console.log(`\n✅ 已完成`)
3073
- }
3074
-
3075
- console.log('')
3076
-
3077
- const firstPending = steps.findIndex(s => s.status === 'pending' || s.status === 'in-progress')
492
+ const firstPending = steps.findIndex(s => s.status === 'pending')
3078
493
 
494
+ // 批量进度
3079
495
  if (progress.batchProgress) {
3080
496
  const bp = progress.batchProgress
3081
497
  const bpTotal = bp.total || 0
@@ -3093,19 +509,13 @@ function showStatus(progress, stageName) {
3093
509
  }
3094
510
 
3095
511
  steps.forEach((step, i) => {
3096
- const icon = step.status === 'completed' ? '✅' : step.status === 'skipped' ? '⏭️' : step.status === 'waiting' ? '⏸️' : '⬜'
3097
- const isCurrent = (step.status === 'pending' || step.status === 'in-progress') && i === firstPending
3098
- const isWaiting = step.status === 'waiting'
3099
- console.log(`${icon} Step ${i + 1}: ${step.name}${isCurrent ? ' ← 当前' : ''}${isWaiting ? ' [WAITING]' : ''}`)
3100
- if (isWaiting) {
3101
- if (step.waitReason) console.log(` 原因:${step.waitReason}`)
3102
- if (step.waitOptions) console.log(` 选项:${formatWaitOptions(step.waitOptions)}`)
3103
- if (step.waitedAt) console.log(` 等待时间:${step.waitedAt}`)
3104
- }
512
+ const icon = step.status === 'completed' ? '✅' : step.status === 'skipped' ? '⏭️' : '⬜'
513
+ const isCurrent = step.status === 'pending' && i === firstPending
514
+ console.log(`${icon} Step ${i + 1}: ${step.name}${isCurrent ? ' ← 当前' : ''}`)
3105
515
  })
3106
516
  }
3107
517
 
3108
- async function resetStage(pm, progress, stageName, cwd, changeName, platformOpts = {}) {
518
+ async function resetStage(pm, progress, stageName, cwd) {
3109
519
  const defSteps = await getStageSteps(stageName, cwd, progress)
3110
520
  progress.stages[stageName] = {
3111
521
  status: 'in-progress',
@@ -3114,245 +524,129 @@ async function resetStage(pm, progress, stageName, cwd, changeName, platformOpts
3114
524
  steps: defSteps ? defSteps.map(s => ({ name: s.name, status: 'pending' })) : []
3115
525
  }
3116
526
  progress.lastActive = new Date().toLocaleString('zh-CN',{hour12:false})
3117
- await pm._write(cwd, progress, changeName)
3118
- triggerSync(cwd, changeName, platformOpts)
527
+ pm._write(cwd, progress)
3119
528
  console.log(`🔄 ${stageName} 阶段已重置`)
3120
529
  }
3121
530
 
3122
531
  /**
3123
532
  * auto 模式:自动推进 brainstorm → plan → execute → verify
3124
533
  */
3125
- async function runAutoMode(pm, progress, cwd, flags, changeName, platformOpts = {}) {
3126
- const flowStages = ['brainstorm', 'plan', 'execute', 'verify', 'archive']
534
+ async function runAutoMode(pm, progress, cwd, flags) {
535
+ const flowStages = ['brainstorm', 'plan', 'execute', 'verify']
3127
536
  const isDone = flags.includes('--done')
537
+ let outputText = null
3128
538
  const outputIdx = flags.indexOf('--output')
3129
- const outputText = outputIdx !== -1 && flags[outputIdx + 1] ? flags[outputIdx + 1] : null
539
+ if (outputIdx !== -1 && flags[outputIdx + 1]) outputText = flags[outputIdx + 1]
540
+ let inputText = null
3130
541
  const inputIdx = flags.indexOf('--input')
3131
- const inputText = inputIdx !== -1 && flags[inputIdx + 1] ? flags[inputIdx + 1] : null
3132
- const skipApproval = flags.includes('--skip-approval')
3133
- const explicitMode = (() => {
3134
- const m = flags.indexOf('--mode')
3135
- return m !== -1 && flags[m + 1] ? flags[m + 1] : null
3136
- })()
3137
- const specBase = platformOpts?.specRoot || join(cwd, '.sillyspec')
3138
-
3139
- // Helper: 在 auto 模式下获取步骤定义
3140
- const getAutoSteps = async (stage) => {
3141
- if (stage === 'brainstorm') {
3142
- return brainstormAutoDef.steps
3143
- }
3144
- return getStageSteps(stage, cwd, progress, platformOpts?.specRoot || null)
3145
- }
3146
-
3147
- const nextInFlow = (stage) => {
3148
- const i = flowStages.indexOf(stage)
3149
- return i >= 0 && i < flowStages.length - 1 ? flowStages[i + 1] : null
3150
- }
3151
- const firstOpenStage = () => flowStages.find(s => progress.stages?.[s]?.status !== 'completed')
3152
- const ensureAutoStage = async (stage) => {
3153
- const stageChanged = progress.currentStage !== stage
3154
- progress.currentStage = stage
3155
- // Auto 模式下 brainstorm 使用 artifact-first 步骤
3156
- if (stage === 'brainstorm') {
3157
- const existingSteps = progress.stages?.brainstorm?.steps
3158
- const isAutoModeSteps = existingSteps?.length === 4 && existingSteps?.[0]?.name === '状态检查与上下文加载'
3159
- if (!isAutoModeSteps) {
3160
- if (!progress.stages) progress.stages = {}
3161
- progress.stages.brainstorm = {
3162
- status: 'in-progress',
3163
- startedAt: new Date().toLocaleString('zh-CN', { hour12: false }),
3164
- completedAt: null,
3165
- steps: brainstormAutoDef.steps.map(s => ({ name: s.name, status: 'pending' }))
3166
- }
3167
- await pm._write(cwd, progress, changeName)
3168
- triggerSync(cwd, changeName, platformOpts)
3169
- progress = await pm.read(cwd, changeName)
3170
- return progress
3171
- }
3172
- }
3173
- const changed = await ensureStageSteps(progress, stage, cwd)
3174
- if (stageChanged || changed) {
3175
- await pm._write(cwd, progress, changeName)
3176
- triggerSync(cwd, changeName, platformOpts)
3177
- }
3178
- progress = await pm.read(cwd, changeName)
3179
- return progress
3180
- }
3181
-
3182
- // ── Classify change on first entry ──
3183
- if (!progress.stages?.brainstorm?.status && !progress.stages?.plan?.status) {
3184
- const { classifyChange } = await import('./classify-change.js')
3185
- const classification = classifyChange({ description: inputText || '', explicitMode })
3186
- if (classification.mode === 'quick') {
3187
- console.log(`📊 auto 模式分类:${classification.mode}(${classification.reason})`)
3188
- console.log(` 此变更建议使用 quick 模式,运行:sillyspec run quick "${inputText || '需求'}"`)
3189
- return
3190
- }
3191
- console.log(`📊 auto 模式分类:${classification.mode}(${classification.reason})`)
3192
- }
3193
-
3194
- let currentStage = progress.currentStage
3195
- if (!currentStage || progress.stages?.[currentStage]?.status === 'completed') {
3196
- currentStage = firstOpenStage()
3197
- }
3198
- if (!currentStage) {
3199
- console.log('All auto flow stages are complete.')
3200
- return
3201
- }
3202
- if (!flowStages.includes(currentStage)) {
3203
- const openStage = firstOpenStage()
3204
- if (!openStage) {
3205
- console.log('All auto flow stages are complete.')
3206
- return
3207
- }
3208
- console.log(`⚠️ 当前阶段 ${currentStage} 不在 auto 流程中,自动跳转到 ${openStage}`)
3209
- currentStage = openStage
3210
- }
3211
- await ensureAutoStage(currentStage)
542
+ if (inputIdx !== -1 && flags[inputIdx + 1]) inputText = flags[inputIdx + 1]
3212
543
 
3213
544
  if (!isDone) {
545
+ // 首次启动:显示当前状态和下一步
546
+ const currentStage = progress.currentStage || flowStages[0]
547
+ let effectiveStage = currentStage
548
+ const stageIdx = flowStages.indexOf(currentStage)
549
+ if (stageIdx === -1) {
550
+ // 当前阶段不在 auto 流程中(如 scan/quick/archive),自动跳到第一个未完成的流程阶段
551
+ effectiveStage = flowStages.find(s => {
552
+ const sd = progress.stages[s]
553
+ return !sd || sd.status !== 'completed'
554
+ }) || flowStages[0]
555
+ progress.currentStage = effectiveStage
556
+ pm._write(cwd, progress)
557
+ console.log(`ℹ️ 当前阶段 ${currentStage} 不在 auto 流程中,自动切换到 ${effectiveStage}`)
558
+ }
559
+ // 显示进度概览
3214
560
  console.log('════════════════════════════════════════')
3215
- console.log(' SillySpec Auto Mode')
3216
- if (changeName) console.log(` Change: ${changeName}`)
561
+ console.log(' 🤖 SillySpec Auto Mode')
3217
562
  console.log('════════════════════════════════════════')
3218
- console.log(` Flow: ${flowStages.join(' -> ')}`)
3219
- console.log(` Current: ${currentStage}`)
3220
- for (const stage of flowStages) {
3221
- const stageData = progress.stages?.[stage]
563
+ console.log(` 流程: ${flowStages.join(' ')}`)
564
+ console.log(` 当前: ${currentStage}`)
565
+ for (let i = 0; i < flowStages.length; i++) {
566
+ const s = flowStages[i]
567
+ const stageData = progress.stages[s]
568
+ const done = stageData?.status === 'completed'
569
+ const active = s === currentStage
3222
570
  const total = stageData?.steps?.length || '?'
3223
- const completed = stageData?.steps?.filter(step => step.status === 'completed' || step.status === 'skipped').length || 0
3224
- const marker = stageData?.status === 'completed' ? 'done' : stage === currentStage ? 'active' : 'pending'
3225
- console.log(` ${marker} ${stage} (${completed}/${total})`)
571
+ const completed = stageData?.steps?.filter(st => st.status === 'completed').length || 0
572
+ const icon = done ? '' : active ? '🔵' : ''
573
+ console.log(` ${icon} ${s} (${completed}/${total})`)
3226
574
  }
3227
575
  console.log('')
3228
-
3229
- const defSteps = await getAutoSteps(currentStage)
3230
- const pendingIdx = progress.stages[currentStage]?.steps?.findIndex(step => step.status === 'pending' || step.status === 'in-progress') ?? -1
576
+ // 输出当前步骤 prompt
577
+ const steps = await getStageSteps(currentStage, cwd, progress)
578
+ if (!steps) {
579
+ console.error(`❌ 无法获取 ${currentStage} 步骤`)
580
+ process.exit(1)
581
+ }
582
+ const pendingIdx = steps.findIndex(s => s.status === 'pending')
3231
583
  if (pendingIdx === -1) {
3232
- const wsIdx = progress.stages[currentStage]?.steps?.findIndex(step => step.status === 'waiting') ?? -1
3233
- if (wsIdx !== -1) {
3234
- const ws = progress.stages[currentStage].steps[wsIdx]
3235
- console.log(`⏸️ Step ${wsIdx + 1} 等待用户输入:${ws.name}`)
3236
- if (ws.waitReason) console.log(` 原因:${ws.waitReason}`)
3237
- console.log(` 继续:sillyspec run auto --continue --answer "..."`)
3238
- return
584
+ // 阶段已完成,提示进入下一阶段
585
+ const next = getNextStage(currentStage)
586
+ if (next) {
587
+ console.log(`✅ ${currentStage} 已完成,下一步:sillyspec run auto --done --output "${currentStage} 完成"`)
588
+ } else {
589
+ console.log('🎉 全部流程已完成!')
3239
590
  }
3240
- const next = nextInFlow(currentStage)
3241
- if (next) console.log(`${currentStage} is complete. Run: sillyspec run auto --done --output "${currentStage} complete"`)
3242
- else console.log('All auto flow stages are complete.')
3243
591
  return
3244
592
  }
3245
- // execute 阶段启动前检查审批
3246
- if (currentStage === 'execute' && !skipApproval) {
3247
- const approval = await checkApproval(cwd, changeName, platformOpts)
3248
- if (approval) {
3249
- if (approval.status === 'rejected') {
3250
- console.error(`❌ 变更 ${changeName} 的执行已被拒绝:${approval.reason || '无原因'}`)
3251
- process.exit(1)
3252
- }
3253
- if (approval.status === 'pending') {
3254
- console.log(`⏳ 变更 ${changeName} 的执行审批待处理中...`)
3255
- console.log(' 提示:使用 --skip-approval 跳过审批检查')
3256
- }
3257
- }
3258
- }
3259
- await outputStep(currentStage, pendingIdx, defSteps, cwd, changeName, progress.project || null, platformOpts)
593
+ outputStepPrompt(steps, pendingIdx, currentStage, cwd, progress)
3260
594
  return
3261
595
  }
3262
596
 
597
+ // --done:完成当前步骤,如果阶段完成则自动推进
3263
598
  if (!outputText) {
3264
- console.error('auto --done requires --output')
599
+ console.error('auto --done 需要 --output 参数')
3265
600
  process.exit(1)
3266
601
  }
3267
602
 
3268
- const result = await completeStep(pm, progress, currentStage, cwd, outputText, inputText, { printNext: false, changeName, platformOpts })
3269
- if (!result) return
3270
- progress = await pm.read(cwd, changeName)
3271
-
3272
- const nextPendingIdx = progress.stages[currentStage]?.steps?.findIndex(step => step.status === 'pending' || step.status === 'in-progress') ?? -1
3273
- if (nextPendingIdx !== -1) {
3274
- const defSteps = await getAutoSteps(currentStage)
3275
- // execute 阶段启动前检查审批
3276
- if (currentStage === 'execute' && !skipApproval) {
3277
- const approval = await checkApproval(cwd, changeName, platformOpts)
3278
- if (approval) {
3279
- if (approval.status === 'rejected') {
3280
- console.error(`❌ 变更 ${changeName} 的执行已被拒绝:${approval.reason || '无原因'}`)
3281
- process.exit(1)
3282
- }
3283
- if (approval.status === 'pending') {
3284
- console.log(`⏳ 变更 ${changeName} 的执行审批待处理中...`)
3285
- console.log(' 提示:使用 --skip-approval 跳过审批检查')
3286
- }
3287
- }
3288
- }
3289
- await outputStep(currentStage, nextPendingIdx, defSteps, cwd, changeName, progress.project || null, platformOpts)
3290
- return
3291
- }
603
+ const currentStage = progress.currentStage
604
+ const stageIdx = flowStages.indexOf(currentStage)
605
+ if (stageIdx === -1) {
606
+ // 非流程阶段,找到第一个未完成的流程阶段
607
+ const effectiveStage = flowStages.find(s => {
608
+ const sd = progress.stages[s]
609
+ return !sd || sd.status !== 'completed'
610
+ }) || flowStages[0]
611
+ progress.currentStage = effectiveStage
612
+ pm._write(cwd, progress)
613
+ console.log(`ℹ️ 当前阶段 ${currentStage} 不在 auto 流程中,自动切换到 ${effectiveStage}`)
614
+ // effectiveStage 继续
615
+ const completed = await completeStep(pm, progress, effectiveStage, cwd, outputText, inputText)
616
+ if (!completed) return
617
+ } else {
3292
618
 
3293
- const next = nextInFlow(currentStage)
3294
- if (!next) {
3295
- console.log('\nAll auto flow stages are complete.')
3296
- return
3297
- }
619
+ // 完成当前步骤
620
+ const completed = await completeStep(pm, progress, currentStage, cwd, outputText, inputText)
621
+ if (!completed) return
3298
622
 
3299
- // ── next-action.json 驱动:brainstorm → plan 推进判断 ──
3300
- if (currentStage === 'brainstorm' && next === 'plan') {
3301
- const changeDir = resolveChangeDir(cwd, progress, platformOpts?.specRoot || null)
3302
- if (changeDir) {
3303
- const nextActionFile = join(changeDir, 'brainstorm', 'next-action.json')
3304
- try {
3305
- const nextAction = JSON.parse(readFileSync(nextActionFile, 'utf8'))
3306
- if (nextAction.has_blocking_questions === true) {
3307
- console.log(`\n⏸️ brainstorm 有阻塞问题,无法自动进入 plan:`)
3308
- for (const q of (nextAction.questions || [])) {
3309
- console.log(` Q-${q.id}: ${q.question}`)
3310
- if (q.options) console.log(` 选项:${q.options.join(' / ')}`)
3311
- if (q.recommended) console.log(` 推荐:${q.recommended}`)
3312
- }
3313
- console.log(`\n 请回答阻塞问题后继续:sillyspec run auto --done --output "已回答"`)
3314
- return
623
+ // 检查阶段是否完成
624
+ const nextPendingIdx = progress.stages[currentStage]?.steps?.findIndex(s => s.status === 'pending')
625
+ if (nextPendingIdx === -1) {
626
+ // 阶段已完成
627
+ const next = getNextStage(currentStage)
628
+ if (next) {
629
+ console.log(`\n✅ ${currentStage} 阶段完成,自动进入 ${next}`)
630
+ // 输出下一阶段第一步 prompt
631
+ const nextSteps = await getStageSteps(next, cwd, progress)
632
+ if (nextSteps) {
633
+ const firstPending = nextSteps.findIndex(s => s.status === 'pending')
634
+ if (firstPending !== -1) {
635
+ outputStepPrompt(nextSteps, firstPending, next, cwd, progress)
3315
636
  }
3316
- console.log(`\n✅ next-action.json: ${nextAction.status},自动进入 plan`)
3317
- } catch (e) {
3318
- // next-action.json 不存在或格式错误,继续推进(向后兼容)
3319
- console.log(`\n⚠️ next-action.json 未找到,继续进入 plan`)
3320
637
  }
638
+ } else {
639
+ console.log('\n🎉 全部流程已完成!建议运行 /sillyspec:commit 提交改动')
3321
640
  }
3322
- }
3323
-
3324
- progress.currentStage = next
3325
- if (!progress.stages[next]) {
3326
- progress.stages[next] = { status: 'pending', steps: [], startedAt: null, completedAt: null }
3327
- }
3328
- if (progress.stages[next].status === 'pending' || !progress.stages[next].status) {
3329
- progress.stages[next].status = 'in-progress'
3330
- progress.stages[next].startedAt = new Date().toLocaleString('zh-CN',{hour12:false})
3331
- }
3332
- progress.lastActive = new Date().toLocaleString('zh-CN',{hour12:false})
3333
- await ensureStageSteps(progress, next, cwd)
3334
- await pm._write(cwd, progress, changeName)
3335
- triggerSync(cwd, changeName, platformOpts)
3336
- progress = await pm.read(cwd, changeName)
3337
-
3338
- console.log(`\n${currentStage} complete. Auto advanced to ${next}.`)
3339
- const nextSteps = await getAutoSteps(next)
3340
- const firstPending = progress.stages[next]?.steps?.findIndex(step => step.status === 'pending' || step.status === 'in-progress') ?? -1
3341
- if (firstPending !== -1) {
3342
- // execute 阶段启动前检查审批
3343
- if (next === 'execute' && !skipApproval) {
3344
- const approval = await checkApproval(cwd, changeName, platformOpts)
3345
- if (approval) {
3346
- if (approval.status === 'rejected') {
3347
- console.error(`❌ 变更 ${changeName} 的执行已被拒绝:${approval.reason || '无原因'}`)
3348
- process.exit(1)
3349
- }
3350
- if (approval.status === 'pending') {
3351
- console.log(`⏳ 变更 ${changeName} 的执行审批待处理中...`)
3352
- console.log(' 提示:使用 --skip-approval 跳过审批检查')
3353
- }
641
+ } else {
642
+ // 阶段内下一步
643
+ const steps = await getStageSteps(currentStage, cwd, progress)
644
+ if (steps) {
645
+ const firstPending = steps.findIndex(s => s.status === 'pending')
646
+ if (firstPending !== -1) {
647
+ outputStepPrompt(steps, firstPending, currentStage, cwd, progress)
3354
648
  }
3355
649
  }
3356
- await outputStep(next, firstPending, nextSteps, cwd, changeName, progress.project || null, platformOpts)
3357
650
  }
651
+ } // else stageIdx !== -1
3358
652
  }