sillyspec 3.25.1 → 3.25.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/.claude/skills/sillyspec-doctor/SKILL.md +9 -0
  2. package/.playwright-mcp/console-2026-07-27T15-45-50-621Z.log +1 -0
  3. package/.playwright-mcp/page-2026-07-27T15-45-51-459Z.yml +144 -0
  4. package/.playwright-mcp/prompt-final.png +0 -0
  5. package/.playwright-mcp/prompt-view.png +0 -0
  6. package/CLAUDE.md +18 -0
  7. package/package.json +1 -1
  8. package/src/change-risk-profile.js +60 -12
  9. package/src/check-primitives.js +65 -0
  10. package/src/index.js +135 -6
  11. package/src/progress/change-registry.js +217 -0
  12. package/src/progress/consistency-doctor.js +259 -0
  13. package/src/progress/shared.js +33 -0
  14. package/src/progress/stage-machine.js +535 -0
  15. package/src/progress/step-store.js +256 -0
  16. package/src/progress.js +59 -1191
  17. package/src/quicklog.js +3 -1
  18. package/src/run/command.js +127 -50
  19. package/src/run/complete-handlers.js +2 -1
  20. package/src/run/complete.js +725 -695
  21. package/src/run/gates.js +326 -321
  22. package/src/run/prompt.js +619 -604
  23. package/src/run/quick-audit.js +107 -102
  24. package/src/run/shared.js +43 -0
  25. package/src/stage-contract-engine.js +150 -0
  26. package/src/stage-contract-spec.js +517 -0
  27. package/src/stage-contract.js +83 -148
  28. package/src/stage-review.js +63 -0
  29. package/src/stages/brainstorm.js +3 -1
  30. package/src/stages/execute.js +5 -2
  31. package/src/stages/plan-postcheck.js +38 -27
  32. package/src/stages/plan.js +12 -49
  33. package/src/stages/propose.js +2 -1
  34. package/src/stages/scan.js +3 -1
  35. package/src/workflow.js +9 -17
  36. package/src/worktree-apply.js +5 -2
  37. package/src/worktree-deps.js +114 -32
  38. package/src/worktree.js +6 -3
  39. package/src/brainstorm-postcheck.js +0 -158
package/src/run/prompt.js CHANGED
@@ -1,604 +1,619 @@
1
- /**
2
- * run/prompt.js(W6 Step3 从 run.js 抽出)。
3
- *
4
- * prompt 渲染主干:outputStep(步骤 prompt 组装 + 占位符替换 + 平台/scanProfile/
5
- * 模块上下文/铁律注入)+ applyRootPlaceholders({SPEC_ROOT}/{DOCS_ROOT}/{PROJECTS_ROOT}/
6
- * {WORKFLOWS_ROOT}/{KNOWLEDGE_ROOT} 路径根占位符替换,平台/常规模式共用)+
7
- * loadModuleContextIndex/buildModuleContextInjection/parseModuleMapSimple
8
- * (_module-map.yaml 模块上下文匹配注入,仅 outputStep 用)。
9
- *
10
- * 安全锚:run.js 始终 barrel。outputStep + applyRootPlaceholders 由 run.js import 回来;
11
- * _outputStepForTest(output-step-render 测试)+ applyRootPlaceholders(prompt-placeholders
12
- * 测试)被 test 直接 import,run.js barrel re-export 契约保留。
13
- *
14
- * 路径修正(相对 src/run/):
15
- * - stageRegistry 从 '../stages/index.js';resolvePromptIncludes/safeGit/WAIT_MARKER_RE 从 './shared.js'
16
- * - 动态 import './knowledge-match.js'/'./task-review.js'/'./review-tier.js'/'./stage-review.js' → '../X.js'(src/ 下,退一层)
17
- * - 删除 outputStep 内死代码 `const { execSync } = await import('child_process')`(execSync 解构未用,实际走 safeGit)
18
- * - loadModuleContextIndex/buildModuleContextInjection 内 require('fs'/'path') 改顶部静态 import
19
- */
20
- import { basename, join } from 'node:path'
21
- import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs'
22
- import { stageRegistry } from '../stages/index.js'
23
- import { resolvePromptIncludes, safeGit, WAIT_MARKER_RE } from './shared.js'
24
-
25
- /**
26
- * 从 _module-map.yaml 读取模块上下文索引
27
- * 用于 brainstorm/plan/execute 阶段按任务命中模块精准注入上下文
28
- *
29
- * @param {string} specBase - 规范目录(.sillyspec 或 specRoot)
30
- * @param {string} projectName - 项目名
31
- * @returns {object|null} 解析后的模块索引,null 表示无索引
32
- */
33
- function loadModuleContextIndex(specBase, projectName) {
34
- try {
35
- const mapPath = join(specBase, 'docs', projectName, 'modules', '_module-map.yaml')
36
- if (!existsSync(mapPath)) return null
37
- const content = readFileSync(mapPath, 'utf8')
38
- return parseModuleMapSimple(content)
39
- } catch {
40
- return null
41
- }
42
- }
43
-
44
- /**
45
- * 根据 AI 输出的任务描述,匹配相关模块并生成上下文注入文本
46
- * 匹配策略:模块 id / role / doc 路径中的关键词
47
- *
48
- * @param {string} taskDescription - 任务描述(来自 plan.md / step prompt / outputText)
49
- * @param {object} moduleIndex - loadModuleContextIndex 返回值
50
- * @param {string} specBase - 规范目录
51
- * @param {string} projectName - 项目名
52
- * @returns {string} 上下文注入文本,空字符串表示无匹配模块
53
- */
54
- function buildModuleContextInjection(taskDescription, moduleIndex, specBase, projectName) {
55
- if (!moduleIndex || !taskDescription) return ''
56
-
57
- const taskLower = taskDescription.toLowerCase()
58
- const matched = []
59
-
60
- for (const [moduleId, data] of Object.entries(moduleIndex)) {
61
- let score = 0
62
- let matchReasons = []
63
- // 模块 id 匹配
64
- if (taskLower.includes(moduleId.toLowerCase())) { score += 3; matchReasons.push(`id:${moduleId}`) }
65
- // role 描述匹配
66
- if (data.role && taskLower.includes(data.role.toLowerCase())) { score += 2; matchReasons.push('role') }
67
- // core_files 路径匹配
68
- const coreFiles = data.paths || data.core_files || []
69
- for (const p of coreFiles) {
70
- if (taskLower.includes(p.toLowerCase())) { score += 1; matchReasons.push(`file:${p}`); break }
71
- }
72
- if (score > 0) matched.push({ moduleId, data, score, matchReasons })
73
- }
74
-
75
- if (matched.length === 0) return ''
76
-
77
- matched.sort((a, b) => b.score - a.score)
78
-
79
- let injection = '\n### 📦 模块上下文(按相关性排序,来自 Module Context Index)\n\n'
80
- injection += `> 以下模块上下文由 scan 阶段生成的 _module-map.yaml 自动匹配。\n`
81
- injection += `> Matched modules: ${matched.map(m => m.moduleId).join(', ')}\n`
82
- injection += `> Reasons: ${matched.map(m => m.matchReasons.join(', ')).join('; ')}\n\n`
83
-
84
- for (const { moduleId, data } of matched) {
85
- injection += `#### ${moduleId}\n`
86
- if (data.role) injection += `- **职责**: ${String(data.role).slice(0, 100)}\n`
87
- const riskLevel = data.risk_level || 'medium'
88
- injection += `- **风险等级**: ${riskLevel}\n`
89
- const coreFiles = data.paths || data.core_files || []
90
- if (coreFiles.length > 0) injection += `- **核心文件**: ${coreFiles.join(', ')}\n`
91
- if (data.doc) {
92
- const docPath = join(specBase, 'docs', projectName, data.doc)
93
- const exists = existsSync(docPath)
94
- injection += `- **模块文档**: ${data.doc}${exists ? ' ✅' : ' ⚠️ 不存在'}\n`
95
- }
96
- const deps = data.depends_on || []
97
- if (deps.length > 0) injection += `- **依赖**: ${deps.join(', ')}\n`
98
- const usedBy = data.used_by || []
99
- if (usedBy.length > 0) injection += `- **被引用**: ${usedBy.join(', ')}\n`
100
- injection += '\n'
101
- }
102
-
103
- return injection
104
- }
105
-
106
- // 复用 modules.js 的简单 YAML 解析(避免循环依赖)
107
- function parseModuleMapSimple(content) {
108
- const modules = {}
109
- let currentModule = null
110
- let currentKey = null
111
- let currentArray = null
112
-
113
- for (const line of content.split('\n')) {
114
- const moduleMatch = line.match(/^ ([a-zA-Z0-9_-]+):$/)
115
- if (moduleMatch) {
116
- if (currentArray && currentModule && currentKey) {
117
- modules[currentModule][currentKey] = currentArray
118
- }
119
- currentModule = moduleMatch[1]
120
- modules[currentModule] = {}
121
- currentKey = null
122
- currentArray = null
123
- continue
124
- }
125
- if (!currentModule) continue
126
-
127
- const arrayFieldMatch = line.match(/^ (depends_on|used_by|paths|tags|aliases|entrypoints|main_symbols|review_reasons|core_files|test_files|related_docs|verify_commands):$/)
128
- if (arrayFieldMatch) {
129
- if (currentArray && currentKey) modules[currentModule][currentKey] = currentArray
130
- currentKey = arrayFieldMatch[1]
131
- currentArray = []
132
- continue
133
- }
134
-
135
- const inlineArrayMatch = line.match(/^ (depends_on|used_by|paths|tags|aliases|entrypoints|main_symbols|review_reasons|core_files|test_files|related_docs|verify_commands): \[(.*)\]$/)
136
- if (inlineArrayMatch) {
137
- if (currentArray && currentKey) modules[currentModule][currentKey] = currentArray
138
- const vals = inlineArrayMatch[2].split(',').map(v => v.trim()).filter(Boolean)
139
- modules[currentModule][inlineArrayMatch[1]] = vals
140
- currentKey = null
141
- currentArray = null
142
- continue
143
- }
144
-
145
- const scalarMatch = line.match(/^ (status|doc|needs_review|role|risk_level): (.+)$/)
146
- if (scalarMatch) {
147
- if (currentArray && currentKey) { modules[currentModule][currentKey] = currentArray; currentArray = null; currentKey = null }
148
- modules[currentModule][scalarMatch[1]] = scalarMatch[2]
149
- continue
150
- }
151
-
152
- const itemMatch = line.match(/^ - (.+)$/)
153
- if (itemMatch && currentArray !== null) {
154
- currentArray.push(itemMatch[1].trim())
155
- continue
156
- }
157
- }
158
- if (currentArray && currentModule && currentKey) {
159
- modules[currentModule][currentKey] = currentArray
160
- }
161
- return modules
162
- }
163
- /**
164
- * 输出当前步骤的 prompt
165
- */
166
- export async function outputStep(stageName, stepIndex, steps, cwd, changeName, dbProjectName, platformOpts = {}, prevStepAnswer = null) {
167
- const step = steps[stepIndex]
168
- const total = steps.length
169
- // ── 越界防御 ──
170
- // steps/defSteps 来自 getStageSteps(如 buildPlanSteps),其长度可能与 progress.steps
171
- // 不一致(例如平台模式下 changeDir 解析失败导致 buildPlanSteps(null) 只返回 2 步 fixedPrefix)。
172
- // 此时 stepIndex 可能越界,访问 step.name 会 TypeError 崩溃。降级处理而非崩溃。
173
- if (!step) {
174
- console.error(`⚠️ 无法输出步骤 ${stepIndex + 1}/${total}:步骤定义缺失`)
175
- console.error(` stage=${stageName} stepIndex=${stepIndex} defSteps.length=${steps.length}`)
176
- console.error(` 可能原因:平台模式下 getStageSteps 未传 specRoot,或 plan/execute 步骤动态生成后 defSteps 未同步。`)
177
- console.error(` 请检查 --change 指定的变更目录是否存在、specRoot 是否正确,然后重试。`)
178
- return false
179
- }
180
- const projectName = dbProjectName || basename(cwd)
181
-
182
- // ── Revision context injection ──
183
- const revisionCtx = platformOpts?._revision
184
- if (revisionCtx) {
185
- console.log(`### 🔄 Revision Context`)
186
- console.log(`本阶段处于修订模式(revision ${revisionCtx.revision}),不是首次执行。`)
187
- console.log(`- 修订起始步骤:${revisionCtx.fromStep}`)
188
- console.log(`- 当前步骤之前已完成的步骤仍然有效,不需要重做。`)
189
- console.log(`- 当前步骤及之后的步骤需要重新生成或调整已有产物。`)
190
- console.log(`- 已有产物文件(design.md、plan.md 等)被保留,审视并更新它们,而不是从零创建。`)
191
- console.log(`- 不要绕过 CLI 进度追踪。\n`)
192
- }
193
-
194
- const personas = {
195
- brainstorm: `### 🎯 你的角色:资深架构师
196
- 你是一位有 15 年经验的系统架构师。先理解业务本质,再设计技术方案。决策附理由,方案列 trade-off。不确定就说不确定,不猜。`,
197
- plan: `### 📋 你的角色:技术项目经理
198
- 你是一位经验丰富的技术项目经理。任务拆解粒度均匀,依赖关系明确。每个任务有完成标准,Wave 间有依赖说明。条理清晰,不做模糊描述。`,
199
- execute: `### 💻 你的角色:高级工程师
200
- 你是一位严谨的高级工程师。先读规范再写代码,严格遵循 CONVENTIONS.md 和 plan.md。**你不是设计师,是执行者——按 plan 搬砖,禁止发散思维。** 发现 plan 不合理就停下来反馈,不要自己改方案。代码有清晰职责划分,边界处理完善。少说多做,遇到规范冲突优先问。`,
201
- verify: `### 🔍 你的角色:QA 专家
202
- 你是一位吹毛求疵的 QA 专家。假设所有代码都有 bug,用最坏情况测试。关注边界、异常、并发。有问题直说,用证据说话,不写"看起来没问题"。`,
203
- quick: `### 💻 你的角色:全栈老兵
204
- 你是一位实战经验丰富的全栈工程师。不纠结架构和流程,理解需求就直接干。不确定的地方先问清楚再动手,先读后写,改完就收。问题排查思路开阔,前端报错不一定是前端问题——可能是后端数据、浏览器兼容、甚至设备硬件。解决方案实用接地气,用户描述有误敢于直接指出。`,
205
- explore: `### 🧭 你的角色:技术探索伙伴
206
- 你帮助用户澄清问题、调查代码库、比较方案和暴露风险。探索阶段不写实现代码,不安装依赖,不把讨论强行推进成开发。`
207
- }
208
-
209
- console.log(`---`)
210
- console.log(`stage: ${stageName}`)
211
- console.log(`step: ${stepIndex + 1}/${total}`)
212
- console.log(`stepName: ${step.name}`)
213
- console.log(`project: ${projectName}`)
214
- if (changeName) {
215
- console.log(`change: ${changeName}`)
216
- const isPlatform = platformOpts?.specRoot || platformOpts?.runtimeRoot
217
- const changeDirBase = isPlatform ? platformOpts.specRoot : '.sillyspec'
218
- const changeDir = join(changeDirBase, 'changes', changeName)
219
- console.log(`changeDir: ${changeDir}`)
220
- }
221
- console.log(`---\n`)
222
- // persona 只在 stage 首步注入(step0)——角色设定一次即可,后续 step 重复注入纯属 token 浪费
223
- if (personas[stageName] && stepIndex === 0) {
224
- console.log(personas[stageName])
225
- console.log('')
226
- }
227
- // 注入全局护栏(如 _globalGuardrails)
228
- const stageDef = stageRegistry[stageName]
229
- const guardrails = stageDef && stageDef._globalGuardrails ? stageDef._globalGuardrails : ''
230
-
231
- console.log(`## Step ${stepIndex + 1}/${total}: ${step.name}\n`)
232
- if (guardrails) {
233
- if (stepIndex === 0) {
234
- console.log(guardrails.trim())
235
- console.log('')
236
- } else {
237
- // 护栏已在首步全文注入并留在 context;后续步只留一行精简提醒——
238
- // 防 context 压缩后 agent 遗忘安全约束(如 verify 禁破坏性 git/源码操作),又避免每步重复 ~1KB。
239
- console.log(`⛔ 本阶段护栏生效中(禁止破坏性操作,详见首步护栏)\n`)
240
- }
241
- }
242
- // 先解析 {{include: name}}(把外部模板片段拉进 prompt),再做下方占位符替换,
243
- // 保证模板内容里的 {SPEC_ROOT}/<change-name> 等也能被替换
244
- let promptText = resolvePromptIncludes(step.prompt)
245
- // 替换 prompt 中的占位符
246
- if (projectName && promptText.includes('<project>')) {
247
- promptText = promptText.replace(/<project>/g, projectName)
248
- }
249
- // 替换 <git-user> 占位符
250
- if (promptText.includes('<git-user>')) {
251
- try {
252
- const gitUser = safeGit(cwd, ['config', 'user.name']).value || 'unknown'
253
- promptText = promptText.replace(/<git-user>/g, gitUser)
254
- } catch {
255
- promptText = promptText.replace(/<git-user>/g, 'unknown')
256
- }
257
- }
258
- // 替换时间戳占位符
259
- const now = new Date()
260
- 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')
261
- 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')
262
- const nowDate = now.getFullYear() + '-' + String(now.getMonth()+1).padStart(2,'0') + '-' + String(now.getDate()).padStart(2,'0')
263
- promptText = promptText.replace(/<now-datetime>/g, nowDatetime)
264
- promptText = promptText.replace(/<now-timestamp>/g, nowTimestamp)
265
- promptText = promptText.replace(/<now-date>/g, nowDate)
266
- // 替换 <change-name> 占位符
267
- if (changeName && promptText.includes('<change-name>')) {
268
- promptText = promptText.replace(/<change-name>/g, changeName)
269
- }
270
- // 替换 <quick-session-id> 占位符(quick 阶段专用:sessionId == changeName == quick-<uuid8>,
271
- // 见 runStage 参数解析 quickSessionId 生成。告知 agent 本会话 id + --done 需带 --change)
272
- if (changeName && promptText.includes('<quick-session-id>')) {
273
- promptText = promptText.replace(/<quick-session-id>/g, changeName)
274
- }
275
- // 替换 <quicklog-id> 占位符(quick 阶段:从 session guard.json 读 CLI 分配的 ql-ID,
276
- // 供 agent 在模块文档变更索引等处引用)
277
- if (promptText.includes('<quicklog-id>')) {
278
- const specBaseQl = platformOpts?.specRoot || join(cwd, '.sillyspec')
279
- let qlIdVal = ''
280
- try {
281
- const sessionGuardFile = join(specBaseQl, '.runtime', 'quick-sessions', changeName, 'guard.json')
282
- if (existsSync(sessionGuardFile)) {
283
- qlIdVal = JSON.parse(readFileSync(sessionGuardFile, 'utf8')).quicklogId || ''
284
- }
285
- } catch {}
286
- promptText = promptText.replace(/<quicklog-id>/g, qlIdVal || '(未分配)')
287
- }
288
- // 替换 <linked-changes> 占位符(quick 阶段:从 .runtime/quick-sessions/<sessionId>/guard.json 读关联变更)
289
- if (promptText.includes('<linked-changes>')) {
290
- const specBaseLc = platformOpts?.specRoot || join(cwd, '.sillyspec')
291
- let linkedChanges = []
292
- try {
293
- // D-002:guard 按 session 存。changeName == quick-<uuid8>(见 runStage 参数解析)。回退读旧单文件(兼容 task-03 前)
294
- const sessionGuardFile = join(specBaseLc, '.runtime', 'quick-sessions', changeName, 'guard.json')
295
- const legacyGuardFile = join(specBaseLc, '.runtime', 'quick-guard.json')
296
- const guard = existsSync(sessionGuardFile)
297
- ? JSON.parse(readFileSync(sessionGuardFile, 'utf8'))
298
- : (existsSync(legacyGuardFile) ? JSON.parse(readFileSync(legacyGuardFile, 'utf8')) : null)
299
- if (guard) linkedChanges = Array.isArray(guard.linkedChanges) ? guard.linkedChanges : []
300
- } catch {}
301
- const display = linkedChanges.length > 0 ? linkedChanges.join(', ') : '(无)'
302
- promptText = promptText.replace(/<linked-changes>/g, display)
303
- }
304
- // 平台模式:注入路径覆盖指令
305
- if (platformOpts?.specRoot || platformOpts?.runtimeRoot) {
306
- const projectName = dbProjectName || basename(cwd)
307
- // platformOpts.specRoot 现在指向 specDir 本身(可能是 cwd/.sillyspec 或外部路径)
308
- const specSillyspec = platformOpts.specRoot || join(cwd, '.sillyspec')
309
- const docsRoot = join(specSillyspec, 'docs', projectName)
310
- const projectsRoot = join(specSillyspec, 'projects')
311
- const changesRoot = join(specSillyspec, 'changes')
312
- const workflowsRoot = join(specSillyspec, 'workflows')
313
- const knowledgeRoot = join(specSillyspec, 'knowledge')
314
-
315
- promptText = applyRootPlaceholders(promptText, { specRoot: specSillyspec, docsRoot, projectsRoot, workflowsRoot, knowledgeRoot })
316
-
317
- const platformDirectives = []
318
- platformDirectives.push(
319
- `## ⚠️ 平台模式 — 写入路径约束(必须严格遵守)\n` +
320
- `\n` +
321
- `规范目录(specDir): \`${specSillyspec}\`\n` +
322
- `- 文档根目录: \`${docsRoot}/\`\n` +
323
- `- 项目注册表: \`${projectsRoot}/\`\n` +
324
- `- 变更目录: \`${changesRoot}/\`\n` +
325
- `- 工作流目录: \`${workflowsRoot}/\`\n` +
326
- `- 术语目录: \`${knowledgeRoot}/\`\n` +
327
- `\n` +
328
- `### ⛔ 写入规则\n` +
329
- `1. **所有文档、配置、产物只能写入上述路径**。严禁写入源码目录或相对路径 \`.sillyspec/\`。\n` +
330
- `2. **不允许**从 cwd 推导文档路径,必须使用上面列出的绝对路径。\n` +
331
- `3. **源码扫描范围**必须排除:.sillyspec/、.claude/、.git/、node_modules/、dist/、build/、__pycache__/\n` +
332
- `4. **local.yaml 校验**:commands 中引用的命令必须在 package.json 的 scripts 中存在,不存在的标记为 unavailable,不能写 "配置良好"\n` +
333
- `\n` +
334
- `### ⛔ Write 工具规则\n` +
335
- `1. 如果 Write 返回 \"File has not been read yet\",正确动作是:先 Read 目标文件 → 再 Write 覆盖。\n` +
336
- `2. **不允许**用 cat >、tee、heredoc 等 Bash 方式绕过 Write 工具。\n` +
337
- `3. 如果 Write 和 Read 均失败,记录失败并停止当前 step。\n` +
338
- `\n` +
339
- `### 📍 Workflow YAML 占位符映射(task-05)\n` +
340
- `读取 \`{WORKFLOWS_ROOT}/scan-docs.yaml\` 时,yaml 内的占位符按以下映射替换为绝对路径:\n` +
341
- `- \`{SPEC_ROOT}\` → \`${specSillyspec}\`(规范目录根)\n` +
342
- `- \`<project>\` → 当前项目名(见下方 step 提示,等于 \`${projectName}\`)\n` +
343
- `- 例:\`{SPEC_ROOT}/docs/<project>/scan/ARCHITECTURE.md\` → \`${docsRoot}/scan/ARCHITECTURE.md\`\n` +
344
- `\n` +
345
- `创建目录: \`mkdir -p ${docsRoot}/{scan,modules,flows} ${projectsRoot} ${changesRoot}\`\n`
346
- )
347
- if (platformOpts.runtimeRoot) {
348
- const scanRunId = platformOpts.scanRunId || 'scan-' + new Date().toISOString().slice(0, 19).replace(/[T:]/g, '-')
349
- platformDirectives.push(
350
- `运行时产物写入: \`${platformOpts.runtimeRoot}/scan-runs/${scanRunId}/\`\n`
351
- )
352
- }
353
- if (platformOpts.workspaceId) {
354
- platformDirectives.push(`workspace_id: ${platformOpts.workspaceId}`)
355
- }
356
- // 平台 directives 前置条件:
357
- // - step0(对齐 persona 仅 step0 策略):路径列表/Write 规则/占位符映射一次建立即可;
358
- // - scan 阶段所有 step:scan 的 profileDirectives 本就每步注入(见下方 L917),平台路径
359
- // 约束也需每步可见——否则 quick profile 的 step0 是 noAI preflight(自动执行),run scan
360
- // 输出的首个可见 step 是 step1,会漏掉 specDir 路径,导致平台模式写入约束丢失。
361
- // 后续 step 靠 changeDir header + footer 平台规则(L1077+)维持,platformDirectives
362
- // 每步重复约 500 tokens 仅在 scan(步数有限)可接受。
363
- if (stepIndex === 0 || stageName === 'scan') {
364
- promptText = platformDirectives.join('\n') + '\n\n' + promptText
365
- }
366
- } else {
367
- // 常规模式(无平台 specRoot):占位符替换为 cwd/.sillyspec 下对应路径
368
- // 让用 {SPEC_ROOT}/{DOCS_ROOT} 等占位符的 prompt(如 quick/scan)在常规模式也写到正确位置
369
- const projectName = dbProjectName || basename(cwd)
370
- const specSillyspec = join(cwd, '.sillyspec')
371
- promptText = applyRootPlaceholders(promptText, {
372
- specRoot: specSillyspec,
373
- docsRoot: join(specSillyspec, 'docs', projectName),
374
- projectsRoot: join(specSillyspec, 'projects'),
375
- workflowsRoot: join(specSillyspec, 'workflows'),
376
- knowledgeRoot: join(specSillyspec, 'knowledge'),
377
- })
378
- }
379
-
380
- // 注入 scanProfile 硬约束指令
381
- if (stageName === 'scan' && platformOpts?.scanProfile) {
382
- const sp = platformOpts.scanProfile
383
- const profileDirectives = []
384
- profileDirectives.push(`## 📊 Scan Profile: ${sp.mode} (${sp.reason})`)
385
- if (sp.maxAgentCalls === 0) {
386
- profileDirectives.push(`**⛔ 严禁使用子代理(Agent/Task 工具)。** 必须在本 turn 内完成所有工作。`)
387
- } else if (sp.maxAgentCalls > 0) {
388
- profileDirectives.push(`**子代理上限:${sp.maxAgentCalls} 个。** 不要超出。`)
389
- }
390
- if (sp.maxDocs < 99) {
391
- profileDirectives.push(`**文档上限:${sp.maxDocs} 份。** 只生成核心文档,不要额外生成 flows/glossary/module-card。`)
392
- }
393
- profileDirectives.push(`--output 只需要列出文件名,不要写长篇总结。`)
394
- promptText = profileDirectives.join('\n') + '\n\n' + promptText
395
-
396
- // scanProfile 分支也要替换占位符(非 platform 模式也会走到这里)
397
- const _pName = dbProjectName || basename(cwd)
398
- const _specSS = platformOpts?.specRoot || join(cwd, '.sillyspec')
399
- const _docsRoot = join(_specSS, 'docs', _pName)
400
- promptText = applyRootPlaceholders(promptText, {
401
- specRoot: _specSS,
402
- docsRoot: _docsRoot,
403
- projectsRoot: join(_specSS, 'projects'),
404
- workflowsRoot: join(_specSS, 'workflows'),
405
- knowledgeRoot: join(_specSS, 'knowledge'),
406
- })
407
- } else {
408
- // 非 platform 模式也要替换占位符
409
- const projectName = dbProjectName || basename(cwd)
410
- const specSillyspec = join(cwd, '.sillyspec')
411
- const docsRoot = join(specSillyspec, 'docs', projectName)
412
- const projectsRoot = join(specSillyspec, 'projects')
413
- const workflowsRoot = join(specSillyspec, 'workflows')
414
- const knowledgeRoot = join(specSillyspec, 'knowledge')
415
- promptText = applyRootPlaceholders(promptText, { specRoot: specSillyspec, docsRoot, projectsRoot, workflowsRoot, knowledgeRoot })
416
- }
417
-
418
- // Knowledge hit report: execute 阶段注入匹配结果
419
- if (stageName === 'execute' && promptText.includes('{KNOWLEDGE_HIT_REPORT}')) {
420
- try {
421
- const { matchKnowledge } = await import('../knowledge-match.js')
422
- const effectiveSpecBase = platformOpts?.specRoot || join(cwd, '.sillyspec')
423
- const knowledgeDir = join(effectiveSpecBase, 'knowledge')
424
- // taskContext: changeName + plan.md task names for better matching
425
- let taskContext = changeName || ''
426
- if (changeName) {
427
- const planPath = join(effectiveSpecBase, 'changes', changeName, 'plan.md')
428
- try {
429
- const planContent = readFileSync(planPath, 'utf8')
430
- // match both "- [ ] task-01: title" and "## task-01: title" formats
431
- const taskLines = [...planContent.matchAll(/(?:^\- \[[ x]\] |^## )task-\d+[^:]*:?\s*(.+)/gm)]
432
- if (taskLines.length > 0) {
433
- taskContext += ' ' + taskLines.map(t => t[1]).join(' ')
434
- }
435
- } catch {}
436
- }
437
- const knowledgeResult = matchKnowledge(knowledgeDir, taskContext)
438
- promptText = promptText.replace(/\{KNOWLEDGE_HIT_REPORT\}/g, knowledgeResult.report)
439
- // 写入 runtime JSON
440
- const runtimeDir = join(effectiveSpecBase, '.runtime')
441
- mkdirSync(runtimeDir, { recursive: true })
442
- writeFileSync(join(runtimeDir, 'knowledge-hit-report.json'), JSON.stringify(knowledgeResult.json, null, 2) + '\n')
443
- } catch (e) {
444
- promptText = promptText.replace(/\{KNOWLEDGE_HIT_REPORT\}/g, 'Status: no matches (error: ' + e.message + ')')
445
- }
446
- }
447
-
448
- // Execute: 注入 currentExecuteRunId(从变更专属标记文件读取)
449
- if (stageName === 'execute' && promptText.includes('{EXECUTE_RUN_ID}')) {
450
- let runId = ''
451
- const execSpecBase = platformOpts?.specRoot || join(cwd, '.sillyspec')
452
- const runtimeRoot = platformOpts?.runtimeRoot || join(execSpecBase, '.runtime')
453
- const runIdFile = join(runtimeRoot, `current-execute-run-id-${changeName}`)
454
- try {
455
- if (existsSync(runIdFile)) {
456
- runId = readFileSync(runIdFile, 'utf8').trim()
457
- }
458
- } catch {}
459
- if (!runId) {
460
- const { generateExecuteRunId } = await import('../task-review.js')
461
- runId = generateExecuteRunId()
462
- // 落盘(与启动站点一致),保证 agent 收到的 ID == gate/checkbox 读取的 ID
463
- try { mkdirSync(runtimeRoot, { recursive: true }); writeFileSync(runIdFile, runId + '\n') } catch {}
464
- }
465
- promptText = promptText.replace(/\{EXECUTE_RUN_ID\}/g, runId)
466
- }
467
-
468
- // Stage Review Tier:brainstorm/plan/propose/execute 阶段注入审查分级占位符
469
- // (scanProfile 只在 scan 生效、change-risk-profile 只管 apply/verify,都不约束这些阶段的审查方式,
470
- // 故按 plan_level / 变更文件数分级:self 当前 agent 自审,independent 强制独立子代理 + review.json)
471
- if (['brainstorm', 'plan', 'propose', 'execute'].includes(stageName) && promptText.includes('{REVIEW_TIER}')) {
472
- try {
473
- const { classifyReviewTier } = await import('../review-tier.js')
474
- const { generateStageReviewRunId } = await import('../stage-review.js')
475
- const tierSpecBase = platformOpts?.specRoot || join(cwd, '.sillyspec')
476
- const tierChangeDir = changeName ? join(tierSpecBase, 'changes', changeName) : null
477
- const designPath = tierChangeDir ? join(tierChangeDir, 'design.md') : null
478
- let planLevel = null
479
- if (tierChangeDir) {
480
- const planPath = join(tierChangeDir, 'plan.md')
481
- if (existsSync(planPath)) {
482
- const fmLine = readFileSync(planPath, 'utf8').split('\n').find(l => l.trim().startsWith('plan_level:'))
483
- if (fmLine) planLevel = fmLine.split(':')[1].trim()
484
- }
485
- }
486
- const tier = classifyReviewTier({ planLevel, designPath })
487
- const reviewRunId = generateStageReviewRunId()
488
- promptText = promptText
489
- .split('{REVIEW_TIER}').join(tier.tier)
490
- .split('{REVIEW_TIER_REASON}').join(tier.reason)
491
- .split('{STAGE_REVIEW_RUN_ID}').join(reviewRunId)
492
- } catch (e) {
493
- // 降级 self,避免 prompt 残留占位符
494
- promptText = promptText
495
- .split('{REVIEW_TIER}').join('self')
496
- .split('{REVIEW_TIER_REASON}').join('分级异常降级 self: ' + e.message)
497
- .split('{STAGE_REVIEW_RUN_ID}').join('review-unknown')
498
- }
499
- }
500
-
501
- // 注入模块上下文(brainstorm/plan/execute 阶段,基于 Module Context Index)
502
- if (['brainstorm', 'plan', 'execute'].includes(stageName) && projectName) {
503
- const effectiveSpecBase = platformOpts?.specRoot || join(cwd, '.sillyspec')
504
- const moduleIndex = loadModuleContextIndex(effectiveSpecBase, projectName)
505
- if (moduleIndex && Object.keys(moduleIndex).length > 0) {
506
- // 尝试从 step prompt / changeName 匹配模块
507
- const taskDesc = step.prompt || changeName || ''
508
- const injection = buildModuleContextInjection(taskDesc, moduleIndex, effectiveSpecBase, projectName)
509
- if (injection) {
510
- promptText = injection + '\n' + promptText
511
- }
512
- }
513
- }
514
-
515
- // 平台模式 prompt 自检:确保没有裸相对输出路径
516
- // 只匹配正向写入指令中的裸路径,避免误杀「禁止写入 .sillyspec/」等安全说明
517
- if ((platformOpts?.specRoot || platformOpts?.runtimeRoot) && stageName === 'scan') {
518
- const writeCtx = /(?<!不要|禁止|严禁)(?:save[\s.]+to|write|create|mkdir|git add|写入|保存到|写入到)[^a-zA-Z]*\.sillyspec\/[a-z]/i
519
- if (writeCtx.test(promptText)) {
520
- console.error(`❌ [sillyspec] BUG: 平台模式 scan prompt 包含写入指令指向裸相对路径 .sillyspec/`)
521
- console.error(` 这会导致 agent 写入源码目录而非 spec-root,属于源码污染 bug。`)
522
- console.error(` 请将路径改为对应的 {DOCS_ROOT}/{PROJECTS_ROOT}/{WORKFLOWS_ROOT}/{KNOWLEDGE_ROOT}/{SPEC_ROOT} 占位符。`)
523
- process.exit(2) // 内部异常(SillySpec 自身 prompt 配置 bug)→ exit 2,与 machine-interface 三段契约一致
524
- }
525
- }
526
-
527
- if (prevStepAnswer) {
528
- console.log(`\n### 📩 上一步用户回答`)
529
- console.log(prevStepAnswer)
530
- }
531
-
532
- console.log(promptText)
533
- // 铁律拆分(W3 token 效率):通用流程纪律(文档优先/不跳步/不编造命令)只在首步注入——
534
- // 每步重复 ~800B 纯耗 context。但【平台写入规则 + 路径规则】是安全关键(防写错目录/绕过 Write),
535
- // 且依赖 changeName/platformOpts,必须【每步注入】(context 压缩丢失会让 agent 越界写源码)。
536
- if (stepIndex === 0) {
537
- console.log(`\n### ⚠️ 铁律`)
538
- console.log('- 文档优先:代码产出必须先有对应的设计/规范文档支撑。')
539
- console.log('- 只做本步骤描述的操作,不得自行扩展或跳过')
540
- console.log('- 不要回头修改已完成的步骤')
541
- console.log('- 不要编造不存在的 CLI 子命令')
542
- console.log('- 完成后立即执行 --done 命令,不得跳过')
543
- console.log('- 不要用 mv/rename 重命名变更目录,必须用 `sillyspec change-rename <旧名> <新名>`')
544
- console.log('- 文档类型文件(.md/.yaml/.json 等)头部必须包含 author(git 用户名)和 created_at(精确到秒)')
545
- console.log('- 执行构建/测试前必须先读 local.yaml,优先使用其中配置的命令、路径和环境变量;未配置时才使用默认值')
546
- }
547
- // 平台模式 + 路径规则(安全关键,每步注入;step1+ 起带精简标题,不复述通用铁律)
548
- if (platformOpts?.specRoot || platformOpts?.runtimeRoot || changeName) {
549
- if (stepIndex !== 0) console.log(`\n### ⚠️ 路径与平台规则(每步提醒,通用铁律见首步)`)
550
- if (platformOpts?.specRoot || platformOpts?.runtimeRoot) {
551
- const specSillyspec = platformOpts.specRoot || join(cwd, '.sillyspec')
552
- console.log(`- **平台模式:所有文件只能写入 \`${specSillyspec}/\` 下的对应子目录,严禁写入源码目录。**`)
553
- console.log('- **平台模式:Write 工具失败时,不允许用 cat > / tee / heredoc 等方式绕过。先 Read 再 Write,仍失败则记录并停止。**')
554
- console.log('- **平台模式:local.yaml 中的 commands 必须在 package.json scripts 中真实存在,不存在的标记 unavailable。**')
555
- }
556
- if (changeName) {
557
- const isPlatform = platformOpts?.specRoot || platformOpts?.runtimeRoot
558
- const changeDirBase = isPlatform ? platformOpts.specRoot : '.sillyspec'
559
- const changeDir = join(changeDirBase, 'changes', changeName)
560
- console.log(`- **文件路径规则:所有变更文件必须写入 \`${changeDir}/\` 目录下。不要自己拼接路径,直接使用 changeDir 值。示例:\`${changeDir}/proposal.md\`**`)
561
- }
562
- }
563
- const changeFlag = changeName ? ` --change ${changeName}` : ''
564
- // 检测当前 step prompt 是否包含 WAIT 指令(即可能需要等待用户)
565
- const stepPrompt = promptText || ''
566
- const requiresWait = step.requiresWait === true
567
- const conditionalWait = step.conditionalWait === true
568
- const mayNeedWait = WAIT_MARKER_RE.test(stepPrompt) || requiresWait || conditionalWait
569
-
570
- console.log(`\n### 完成后执行`)
571
- if (requiresWait) {
572
- console.log(`本步骤必须等待用户输入,不能直接 --done:`)
573
- console.log(`sillyspec run ${stageName} --wait --reason "${step.waitReason || '等待用户输入'}" --options "${(step.waitOptions || ['确认']).join(',')}"${changeFlag} --output "你的问题/方案摘要"`)
574
- console.log(``)
575
- console.log(`用户回答后执行:`)
576
- console.log(`sillyspec run ${stageName} --continue --answer "用户回答"${changeFlag}`)
577
- console.log(``)
578
- console.log(`收到回答并完成本步骤总结后,再执行:`)
579
- } else if (mayNeedWait) {
580
- console.log(`如果需要用户决策(选择方案/确认设计等):`)
581
- console.log(`sillyspec run ${stageName} --wait --reason "${step.waitReason || '等待原因'}" --options "${(step.waitOptions || ['选项1', '选项2']).join(',')}"${changeFlag} --output "你的摘要"`)
582
- console.log(``)
583
- console.log(`如果不需要用户决策,正常完成:`)
584
- }
585
- console.log(`sillyspec run ${stageName} --done${changeFlag} --input "用户原始需求/反馈" --output "你的摘要"`)
586
- }
587
- /**
588
- * 替换 prompt 文本中的路径根占位符 {SPEC_ROOT}/{DOCS_ROOT}/{PROJECTS_ROOT}/
589
- * {WORKFLOWS_ROOT}/{KNOWLEDGE_ROOT}。平台模式与常规模式共用:仅传入的 roots 值不同。
590
- * 占位符值都是绝对路径、互不包含其它占位符,故替换顺序不影响结果。
591
- *
592
- * 注:本函数由并行会话引入(outputStep 两处逐字重复逻辑的抽取),重构期间 run.js 一次
593
- * git checkout 误覆盖其未提交版本,此处按 test/prompt-placeholders.test.mjs 的契约重建,
594
- * 行为等价。
595
- */
596
- export function applyRootPlaceholders(text, roots) {
597
- return text
598
- .replaceAll('{SPEC_ROOT}', roots.specRoot)
599
- .replaceAll('{DOCS_ROOT}', roots.docsRoot)
600
- .replaceAll('{PROJECTS_ROOT}', roots.projectsRoot)
601
- .replaceAll('{WORKFLOWS_ROOT}', roots.workflowsRoot)
602
- .replaceAll('{KNOWLEDGE_ROOT}', roots.knowledgeRoot)
603
- }
604
-
1
+ /**
2
+ * run/prompt.js(W6 Step3 从 run.js 抽出)。
3
+ *
4
+ * prompt 渲染主干:outputStep(步骤 prompt 组装 + 占位符替换 + 平台/scanProfile/
5
+ * 模块上下文/铁律注入)+ applyRootPlaceholders({SPEC_ROOT}/{DOCS_ROOT}/{PROJECTS_ROOT}/
6
+ * {WORKFLOWS_ROOT}/{KNOWLEDGE_ROOT} 路径根占位符替换,平台/常规模式共用)+
7
+ * loadModuleContextIndex/buildModuleContextInjection/parseModuleMapSimple
8
+ * (_module-map.yaml 模块上下文匹配注入,仅 outputStep 用)。
9
+ *
10
+ * 安全锚:run.js 始终 barrel。outputStep + applyRootPlaceholders 由 run.js import 回来;
11
+ * _outputStepForTest(output-step-render 测试)+ applyRootPlaceholders(prompt-placeholders
12
+ * 测试)被 test 直接 import,run.js barrel re-export 契约保留。
13
+ *
14
+ * 路径修正(相对 src/run/):
15
+ * - stageRegistry 从 '../stages/index.js';resolvePromptIncludes/safeGit/WAIT_MARKER_RE 从 './shared.js'
16
+ * - 动态 import './knowledge-match.js'/'./task-review.js'/'./review-tier.js'/'./stage-review.js' → '../X.js'(src/ 下,退一层)
17
+ * - 删除 outputStep 内死代码 `const { execSync } = await import('child_process')`(execSync 解构未用,实际走 safeGit)
18
+ * - loadModuleContextIndex/buildModuleContextInjection 内 require('fs'/'path') 改顶部静态 import
19
+ */
20
+ import { basename, join } from 'node:path'
21
+ import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs'
22
+ import { stageRegistry } from '../stages/index.js'
23
+ import { resolvePromptIncludes, safeGit, WAIT_MARKER_RE } from './shared.js'
24
+ import { renderStageContract } from '../stage-contract-spec.js'
25
+
26
+ /**
27
+ * 从 _module-map.yaml 读取模块上下文索引
28
+ * 用于 brainstorm/plan/execute 阶段按任务命中模块精准注入上下文
29
+ *
30
+ * @param {string} specBase - 规范目录(.sillyspec 或 specRoot)
31
+ * @param {string} projectName - 项目名
32
+ * @returns {object|null} 解析后的模块索引,null 表示无索引
33
+ */
34
+ function loadModuleContextIndex(specBase, projectName) {
35
+ try {
36
+ const mapPath = join(specBase, 'docs', projectName, 'modules', '_module-map.yaml')
37
+ if (!existsSync(mapPath)) return null
38
+ const content = readFileSync(mapPath, 'utf8')
39
+ return parseModuleMapSimple(content)
40
+ } catch {
41
+ return null
42
+ }
43
+ }
44
+
45
+ /**
46
+ * 根据 AI 输出的任务描述,匹配相关模块并生成上下文注入文本
47
+ * 匹配策略:模块 id / role / doc 路径中的关键词
48
+ *
49
+ * @param {string} taskDescription - 任务描述(来自 plan.md / step prompt / outputText)
50
+ * @param {object} moduleIndex - loadModuleContextIndex 返回值
51
+ * @param {string} specBase - 规范目录
52
+ * @param {string} projectName - 项目名
53
+ * @returns {string} 上下文注入文本,空字符串表示无匹配模块
54
+ */
55
+ function buildModuleContextInjection(taskDescription, moduleIndex, specBase, projectName) {
56
+ if (!moduleIndex || !taskDescription) return ''
57
+
58
+ const taskLower = taskDescription.toLowerCase()
59
+ const matched = []
60
+
61
+ for (const [moduleId, data] of Object.entries(moduleIndex)) {
62
+ let score = 0
63
+ let matchReasons = []
64
+ // 模块 id 匹配
65
+ if (taskLower.includes(moduleId.toLowerCase())) { score += 3; matchReasons.push(`id:${moduleId}`) }
66
+ // role 描述匹配
67
+ if (data.role && taskLower.includes(data.role.toLowerCase())) { score += 2; matchReasons.push('role') }
68
+ // core_files 路径匹配
69
+ const coreFiles = data.paths || data.core_files || []
70
+ for (const p of coreFiles) {
71
+ if (taskLower.includes(p.toLowerCase())) { score += 1; matchReasons.push(`file:${p}`); break }
72
+ }
73
+ if (score > 0) matched.push({ moduleId, data, score, matchReasons })
74
+ }
75
+
76
+ if (matched.length === 0) return ''
77
+
78
+ matched.sort((a, b) => b.score - a.score)
79
+
80
+ let injection = '\n### 📦 模块上下文(按相关性排序,来自 Module Context Index)\n\n'
81
+ injection += `> 以下模块上下文由 scan 阶段生成的 _module-map.yaml 自动匹配。\n`
82
+ injection += `> Matched modules: ${matched.map(m => m.moduleId).join(', ')}\n`
83
+ injection += `> Reasons: ${matched.map(m => m.matchReasons.join(', ')).join('; ')}\n\n`
84
+
85
+ for (const { moduleId, data } of matched) {
86
+ injection += `#### ${moduleId}\n`
87
+ if (data.role) injection += `- **职责**: ${String(data.role).slice(0, 100)}\n`
88
+ const riskLevel = data.risk_level || 'medium'
89
+ injection += `- **风险等级**: ${riskLevel}\n`
90
+ const coreFiles = data.paths || data.core_files || []
91
+ if (coreFiles.length > 0) injection += `- **核心文件**: ${coreFiles.join(', ')}\n`
92
+ if (data.doc) {
93
+ const docPath = join(specBase, 'docs', projectName, data.doc)
94
+ const exists = existsSync(docPath)
95
+ injection += `- **模块文档**: ${data.doc}${exists ? ' ✅' : ' ⚠️ 不存在'}\n`
96
+ }
97
+ const deps = data.depends_on || []
98
+ if (deps.length > 0) injection += `- **依赖**: ${deps.join(', ')}\n`
99
+ const usedBy = data.used_by || []
100
+ if (usedBy.length > 0) injection += `- **被引用**: ${usedBy.join(', ')}\n`
101
+ injection += '\n'
102
+ }
103
+
104
+ return injection
105
+ }
106
+
107
+ // 复用 modules.js 的简单 YAML 解析(避免循环依赖)
108
+ function parseModuleMapSimple(content) {
109
+ const modules = {}
110
+ let currentModule = null
111
+ let currentKey = null
112
+ let currentArray = null
113
+
114
+ for (const line of content.split('\n')) {
115
+ const moduleMatch = line.match(/^ ([a-zA-Z0-9_-]+):$/)
116
+ if (moduleMatch) {
117
+ if (currentArray && currentModule && currentKey) {
118
+ modules[currentModule][currentKey] = currentArray
119
+ }
120
+ currentModule = moduleMatch[1]
121
+ modules[currentModule] = {}
122
+ currentKey = null
123
+ currentArray = null
124
+ continue
125
+ }
126
+ if (!currentModule) continue
127
+
128
+ const arrayFieldMatch = line.match(/^ (depends_on|used_by|paths|tags|aliases|entrypoints|main_symbols|review_reasons|core_files|test_files|related_docs|verify_commands):$/)
129
+ if (arrayFieldMatch) {
130
+ if (currentArray && currentKey) modules[currentModule][currentKey] = currentArray
131
+ currentKey = arrayFieldMatch[1]
132
+ currentArray = []
133
+ continue
134
+ }
135
+
136
+ const inlineArrayMatch = line.match(/^ (depends_on|used_by|paths|tags|aliases|entrypoints|main_symbols|review_reasons|core_files|test_files|related_docs|verify_commands): \[(.*)\]$/)
137
+ if (inlineArrayMatch) {
138
+ if (currentArray && currentKey) modules[currentModule][currentKey] = currentArray
139
+ const vals = inlineArrayMatch[2].split(',').map(v => v.trim()).filter(Boolean)
140
+ modules[currentModule][inlineArrayMatch[1]] = vals
141
+ currentKey = null
142
+ currentArray = null
143
+ continue
144
+ }
145
+
146
+ const scalarMatch = line.match(/^ (status|doc|needs_review|role|risk_level): (.+)$/)
147
+ if (scalarMatch) {
148
+ if (currentArray && currentKey) { modules[currentModule][currentKey] = currentArray; currentArray = null; currentKey = null }
149
+ modules[currentModule][scalarMatch[1]] = scalarMatch[2]
150
+ continue
151
+ }
152
+
153
+ const itemMatch = line.match(/^ - (.+)$/)
154
+ if (itemMatch && currentArray !== null) {
155
+ currentArray.push(itemMatch[1].trim())
156
+ continue
157
+ }
158
+ }
159
+ if (currentArray && currentModule && currentKey) {
160
+ modules[currentModule][currentKey] = currentArray
161
+ }
162
+ return modules
163
+ }
164
+ /**
165
+ * 输出当前步骤的 prompt
166
+ */
167
+ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, dbProjectName, platformOpts = {}, prevStepAnswer = null) {
168
+ const step = steps[stepIndex]
169
+ const total = steps.length
170
+ // ── 越界防御 ──
171
+ // steps/defSteps 来自 getStageSteps(如 buildPlanSteps),其长度可能与 progress.steps
172
+ // 不一致(例如平台模式下 changeDir 解析失败导致 buildPlanSteps(null) 只返回 2 步 fixedPrefix)。
173
+ // 此时 stepIndex 可能越界,访问 step.name 会 TypeError 崩溃。降级处理而非崩溃。
174
+ if (!step) {
175
+ console.error(`⚠️ 无法输出步骤 ${stepIndex + 1}/${total}:步骤定义缺失`)
176
+ console.error(` stage=${stageName} stepIndex=${stepIndex} defSteps.length=${steps.length}`)
177
+ console.error(` 可能原因:平台模式下 getStageSteps 未传 specRoot,或 plan/execute 步骤动态生成后 defSteps 未同步。`)
178
+ console.error(` 请检查 --change 指定的变更目录是否存在、specRoot 是否正确,然后重试。`)
179
+ return false
180
+ }
181
+ const projectName = dbProjectName || basename(cwd)
182
+
183
+ // ── Revision context injection ──
184
+ const revisionCtx = platformOpts?._revision
185
+ if (revisionCtx) {
186
+ console.log(`### 🔄 Revision Context`)
187
+ console.log(`本阶段处于修订模式(revision ${revisionCtx.revision}),不是首次执行。`)
188
+ console.log(`- 修订起始步骤:${revisionCtx.fromStep}`)
189
+ console.log(`- 当前步骤之前已完成的步骤仍然有效,不需要重做。`)
190
+ console.log(`- 当前步骤及之后的步骤需要重新生成或调整已有产物。`)
191
+ console.log(`- 已有产物文件(design.md、plan.md 等)被保留,审视并更新它们,而不是从零创建。`)
192
+ console.log(`- 不要绕过 CLI 进度追踪。\n`)
193
+ }
194
+
195
+ const personas = {
196
+ brainstorm: `### 🎯 你的角色:资深架构师
197
+ 你是一位有 15 年经验的系统架构师。先理解业务本质,再设计技术方案。决策附理由,方案列 trade-off。不确定就说不确定,不猜。`,
198
+ plan: `### 📋 你的角色:技术项目经理
199
+ 你是一位经验丰富的技术项目经理。任务拆解粒度均匀,依赖关系明确。每个任务有完成标准,Wave 间有依赖说明。条理清晰,不做模糊描述。`,
200
+ execute: `### 💻 你的角色:高级工程师
201
+ 你是一位严谨的高级工程师。先读规范再写代码,严格遵循 CONVENTIONS.md 和 plan.md。**你不是设计师,是执行者——按 plan 搬砖,禁止发散思维。** 发现 plan 不合理就停下来反馈,不要自己改方案。代码有清晰职责划分,边界处理完善。少说多做,遇到规范冲突优先问。`,
202
+ verify: `### 🔍 你的角色:QA 专家
203
+ 你是一位吹毛求疵的 QA 专家。假设所有代码都有 bug,用最坏情况测试。关注边界、异常、并发。有问题直说,用证据说话,不写"看起来没问题"。`,
204
+ quick: `### 💻 你的角色:全栈老兵
205
+ 你是一位实战经验丰富的全栈工程师。不纠结架构和流程,理解需求就直接干。不确定的地方先问清楚再动手,先读后写,改完就收。问题排查思路开阔,前端报错不一定是前端问题——可能是后端数据、浏览器兼容、甚至设备硬件。解决方案实用接地气,用户描述有误敢于直接指出。`,
206
+ explore: `### 🧭 你的角色:技术探索伙伴
207
+ 你帮助用户澄清问题、调查代码库、比较方案和暴露风险。探索阶段不写实现代码,不安装依赖,不把讨论强行推进成开发。`
208
+ }
209
+
210
+ console.log(`---`)
211
+ console.log(`stage: ${stageName}`)
212
+ console.log(`step: ${stepIndex + 1}/${total}`)
213
+ console.log(`stepName: ${step.name}`)
214
+ console.log(`project: ${projectName}`)
215
+ if (changeName) {
216
+ console.log(`change: ${changeName}`)
217
+ const isPlatform = platformOpts?.specRoot || platformOpts?.runtimeRoot
218
+ const changeDirBase = isPlatform ? platformOpts.specRoot : '.sillyspec'
219
+ const changeDir = join(changeDirBase, 'changes', changeName)
220
+ console.log(`changeDir: ${changeDir}`)
221
+ }
222
+ console.log(`---\n`)
223
+ // persona 只在 stage 首步注入(step0)——角色设定一次即可,后续 step 重复注入纯属 token 浪费
224
+ if (personas[stageName] && stepIndex === 0) {
225
+ console.log(personas[stageName])
226
+ console.log('')
227
+ }
228
+ // 注入全局护栏(如 _globalGuardrails)
229
+ const stageDef = stageRegistry[stageName]
230
+ const guardrails = stageDef && stageDef._globalGuardrails ? stageDef._globalGuardrails : ''
231
+
232
+ console.log(`## Step ${stepIndex + 1}/${total}: ${step.name}\n`)
233
+ if (guardrails) {
234
+ if (stepIndex === 0) {
235
+ console.log(guardrails.trim())
236
+ console.log('')
237
+ } else {
238
+ // 护栏已在首步全文注入并留在 context;后续步只留一行精简提醒——
239
+ // 防 context 压缩后 agent 遗忘安全约束(如 verify 禁破坏性 git/源码操作),又避免每步重复 ~1KB。
240
+ console.log(`⛔ 本阶段护栏生效中(禁止破坏性操作,详见首步护栏)\n`)
241
+ }
242
+ }
243
+ // 先解析 {{include: name}}(把外部模板片段拉进 prompt),再做下方占位符替换,
244
+ // 保证模板内容里的 {SPEC_ROOT}/<change-name> 等也能被替换
245
+ let promptText = resolvePromptIncludes(step.prompt)
246
+ // 替换 prompt 中的占位符
247
+ if (projectName && promptText.includes('<project>')) {
248
+ promptText = promptText.replace(/<project>/g, projectName)
249
+ }
250
+ // 替换 <git-user> 占位符
251
+ if (promptText.includes('<git-user>')) {
252
+ try {
253
+ const gitUser = safeGit(cwd, ['config', 'user.name']).value || 'unknown'
254
+ promptText = promptText.replace(/<git-user>/g, gitUser)
255
+ } catch {
256
+ promptText = promptText.replace(/<git-user>/g, 'unknown')
257
+ }
258
+ }
259
+ // 替换时间戳占位符
260
+ const now = new Date()
261
+ 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')
262
+ 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')
263
+ const nowDate = now.getFullYear() + '-' + String(now.getMonth()+1).padStart(2,'0') + '-' + String(now.getDate()).padStart(2,'0')
264
+ promptText = promptText.replace(/<now-datetime>/g, nowDatetime)
265
+ promptText = promptText.replace(/<now-timestamp>/g, nowTimestamp)
266
+ promptText = promptText.replace(/<now-date>/g, nowDate)
267
+ // 替换 <change-name> 占位符
268
+ if (changeName && promptText.includes('<change-name>')) {
269
+ promptText = promptText.replace(/<change-name>/g, changeName)
270
+ }
271
+ // 替换 <quick-session-id> 占位符(quick 阶段专用:sessionId == changeName == quick-<uuid8>,
272
+ // 见 runStage 参数解析 quickSessionId 生成。告知 agent 本会话 id + --done 需带 --change)
273
+ if (changeName && promptText.includes('<quick-session-id>')) {
274
+ promptText = promptText.replace(/<quick-session-id>/g, changeName)
275
+ }
276
+ // 替换 <quicklog-id> 占位符(quick 阶段:从 session guard.json 读 CLI 分配的 ql-ID,
277
+ // 供 agent 在模块文档变更索引等处引用)
278
+ if (promptText.includes('<quicklog-id>')) {
279
+ const specBaseQl = platformOpts?.specRoot || join(cwd, '.sillyspec')
280
+ let qlIdVal = ''
281
+ try {
282
+ const sessionGuardFile = join(specBaseQl, '.runtime', 'quick-sessions', changeName, 'guard.json')
283
+ if (existsSync(sessionGuardFile)) {
284
+ qlIdVal = JSON.parse(readFileSync(sessionGuardFile, 'utf8')).quicklogId || ''
285
+ }
286
+ } catch {}
287
+ promptText = promptText.replace(/<quicklog-id>/g, qlIdVal || '(未分配)')
288
+ }
289
+ // 替换 <linked-changes> 占位符(quick 阶段:从 .runtime/quick-sessions/<sessionId>/guard.json 读关联变更)
290
+ if (promptText.includes('<linked-changes>')) {
291
+ const specBaseLc = platformOpts?.specRoot || join(cwd, '.sillyspec')
292
+ let linkedChanges = []
293
+ try {
294
+ // D-002:guard 按 session 存。changeName == quick-<uuid8>(见 runStage 参数解析)。回退读旧单文件(兼容 task-03 前)
295
+ const sessionGuardFile = join(specBaseLc, '.runtime', 'quick-sessions', changeName, 'guard.json')
296
+ const legacyGuardFile = join(specBaseLc, '.runtime', 'quick-guard.json')
297
+ const guard = existsSync(sessionGuardFile)
298
+ ? JSON.parse(readFileSync(sessionGuardFile, 'utf8'))
299
+ : (existsSync(legacyGuardFile) ? JSON.parse(readFileSync(legacyGuardFile, 'utf8')) : null)
300
+ if (guard) linkedChanges = Array.isArray(guard.linkedChanges) ? guard.linkedChanges : []
301
+ } catch {}
302
+ const display = linkedChanges.length > 0 ? linkedChanges.join(', ') : '(无)'
303
+ promptText = promptText.replace(/<linked-changes>/g, display)
304
+ }
305
+ // 平台模式:注入路径覆盖指令
306
+ if (platformOpts?.specRoot || platformOpts?.runtimeRoot) {
307
+ const projectName = dbProjectName || basename(cwd)
308
+ // platformOpts.specRoot 现在指向 specDir 本身(可能是 cwd/.sillyspec 或外部路径)
309
+ const specSillyspec = platformOpts.specRoot || join(cwd, '.sillyspec')
310
+ const docsRoot = join(specSillyspec, 'docs', projectName)
311
+ const projectsRoot = join(specSillyspec, 'projects')
312
+ const changesRoot = join(specSillyspec, 'changes')
313
+ const workflowsRoot = join(specSillyspec, 'workflows')
314
+ const knowledgeRoot = join(specSillyspec, 'knowledge')
315
+
316
+ promptText = applyRootPlaceholders(promptText, { specRoot: specSillyspec, docsRoot, projectsRoot, workflowsRoot, knowledgeRoot })
317
+
318
+ const platformDirectives = []
319
+ platformDirectives.push(
320
+ `## ⚠️ 平台模式 — 写入路径约束(必须严格遵守)\n` +
321
+ `\n` +
322
+ `规范目录(specDir): \`${specSillyspec}\`\n` +
323
+ `- 文档根目录: \`${docsRoot}/\`\n` +
324
+ `- 项目注册表: \`${projectsRoot}/\`\n` +
325
+ `- 变更目录: \`${changesRoot}/\`\n` +
326
+ `- 工作流目录: \`${workflowsRoot}/\`\n` +
327
+ `- 术语目录: \`${knowledgeRoot}/\`\n` +
328
+ `\n` +
329
+ `### ⛔ 写入规则\n` +
330
+ `1. **所有文档、配置、产物只能写入上述路径**。严禁写入源码目录或相对路径 \`.sillyspec/\`。\n` +
331
+ `2. **不允许**从 cwd 推导文档路径,必须使用上面列出的绝对路径。\n` +
332
+ `3. **源码扫描范围**必须排除:.sillyspec/、.claude/、.git/、node_modules/、dist/、build/、__pycache__/\n` +
333
+ `4. **local.yaml 校验**:commands 中引用的命令必须在 package.json 的 scripts 中存在,不存在的标记为 unavailable,不能写 "配置良好"\n` +
334
+ `\n` +
335
+ `### ⛔ Write 工具规则\n` +
336
+ `1. 如果 Write 返回 \"File has not been read yet\",正确动作是:先 Read 目标文件 → 再 Write 覆盖。\n` +
337
+ `2. **不允许**用 cat >、tee、heredoc 等 Bash 方式绕过 Write 工具。\n` +
338
+ `3. 如果 Write 和 Read 均失败,记录失败并停止当前 step。\n` +
339
+ `\n` +
340
+ `### 📍 Workflow YAML 占位符映射(task-05)\n` +
341
+ `读取 \`{WORKFLOWS_ROOT}/scan-docs.yaml\` 时,yaml 内的占位符按以下映射替换为绝对路径:\n` +
342
+ `- \`{SPEC_ROOT}\` → \`${specSillyspec}\`(规范目录根)\n` +
343
+ `- \`<project>\` → 当前项目名(见下方 step 提示,等于 \`${projectName}\`)\n` +
344
+ `- 例:\`{SPEC_ROOT}/docs/<project>/scan/ARCHITECTURE.md\` → \`${docsRoot}/scan/ARCHITECTURE.md\`\n` +
345
+ `\n` +
346
+ `创建目录: \`mkdir -p ${docsRoot}/{scan,modules,flows} ${projectsRoot} ${changesRoot}\`\n`
347
+ )
348
+ if (platformOpts.runtimeRoot) {
349
+ const scanRunId = platformOpts.scanRunId || 'scan-' + new Date().toISOString().slice(0, 19).replace(/[T:]/g, '-')
350
+ platformDirectives.push(
351
+ `运行时产物写入: \`${platformOpts.runtimeRoot}/scan-runs/${scanRunId}/\`\n`
352
+ )
353
+ }
354
+ if (platformOpts.workspaceId) {
355
+ platformDirectives.push(`workspace_id: ${platformOpts.workspaceId}`)
356
+ }
357
+ // 平台 directives 前置条件:
358
+ // - step0(对齐 persona 仅 step0 策略):路径列表/Write 规则/占位符映射一次建立即可;
359
+ // - scan 阶段所有 step:scan 的 profileDirectives 本就每步注入(见下方 L917),平台路径
360
+ // 约束也需每步可见——否则 quick profile 的 step0 是 noAI preflight(自动执行),run scan
361
+ // 输出的首个可见 step 是 step1,会漏掉 specDir 路径,导致平台模式写入约束丢失。
362
+ // 后续 step 靠 changeDir header + footer 平台规则(L1077+)维持,platformDirectives
363
+ // 每步重复约 500 tokens 仅在 scan(步数有限)可接受。
364
+ if (stepIndex === 0 || stageName === 'scan') {
365
+ promptText = platformDirectives.join('\n') + '\n\n' + promptText
366
+ }
367
+ } else {
368
+ // 常规模式(无平台 specRoot):占位符替换为 cwd/.sillyspec 下对应路径
369
+ // 让用 {SPEC_ROOT}/{DOCS_ROOT} 等占位符的 prompt(如 quick/scan)在常规模式也写到正确位置
370
+ const projectName = dbProjectName || basename(cwd)
371
+ const specSillyspec = join(cwd, '.sillyspec')
372
+ promptText = applyRootPlaceholders(promptText, {
373
+ specRoot: specSillyspec,
374
+ docsRoot: join(specSillyspec, 'docs', projectName),
375
+ projectsRoot: join(specSillyspec, 'projects'),
376
+ workflowsRoot: join(specSillyspec, 'workflows'),
377
+ knowledgeRoot: join(specSillyspec, 'knowledge'),
378
+ })
379
+ }
380
+
381
+ // 注入 scanProfile 硬约束指令
382
+ if (stageName === 'scan' && platformOpts?.scanProfile) {
383
+ const sp = platformOpts.scanProfile
384
+ const profileDirectives = []
385
+ profileDirectives.push(`## 📊 Scan Profile: ${sp.mode} (${sp.reason})`)
386
+ if (sp.maxAgentCalls === 0) {
387
+ profileDirectives.push(`**⛔ 严禁使用子代理(Agent/Task 工具)。** 必须在本 turn 内完成所有工作。`)
388
+ } else if (sp.maxAgentCalls > 0) {
389
+ profileDirectives.push(`**子代理上限:${sp.maxAgentCalls} 个。** 不要超出。`)
390
+ }
391
+ if (sp.maxDocs < 99) {
392
+ profileDirectives.push(`**文档上限:${sp.maxDocs} 份。** 只生成核心文档,不要额外生成 flows/glossary/module-card。`)
393
+ }
394
+ profileDirectives.push(`--output 只需要列出文件名,不要写长篇总结。`)
395
+ promptText = profileDirectives.join('\n') + '\n\n' + promptText
396
+
397
+ // scanProfile 分支也要替换占位符(非 platform 模式也会走到这里)
398
+ const _pName = dbProjectName || basename(cwd)
399
+ const _specSS = platformOpts?.specRoot || join(cwd, '.sillyspec')
400
+ const _docsRoot = join(_specSS, 'docs', _pName)
401
+ promptText = applyRootPlaceholders(promptText, {
402
+ specRoot: _specSS,
403
+ docsRoot: _docsRoot,
404
+ projectsRoot: join(_specSS, 'projects'),
405
+ workflowsRoot: join(_specSS, 'workflows'),
406
+ knowledgeRoot: join(_specSS, 'knowledge'),
407
+ })
408
+ } else {
409
+ // 非 platform 模式也要替换占位符
410
+ const projectName = dbProjectName || basename(cwd)
411
+ const specSillyspec = join(cwd, '.sillyspec')
412
+ const docsRoot = join(specSillyspec, 'docs', projectName)
413
+ const projectsRoot = join(specSillyspec, 'projects')
414
+ const workflowsRoot = join(specSillyspec, 'workflows')
415
+ const knowledgeRoot = join(specSillyspec, 'knowledge')
416
+ promptText = applyRootPlaceholders(promptText, { specRoot: specSillyspec, docsRoot, projectsRoot, workflowsRoot, knowledgeRoot })
417
+ }
418
+
419
+ // Knowledge hit report: execute 阶段注入匹配结果
420
+ if (stageName === 'execute' && promptText.includes('{KNOWLEDGE_HIT_REPORT}')) {
421
+ try {
422
+ const { matchKnowledge } = await import('../knowledge-match.js')
423
+ const effectiveSpecBase = platformOpts?.specRoot || join(cwd, '.sillyspec')
424
+ const knowledgeDir = join(effectiveSpecBase, 'knowledge')
425
+ // taskContext: changeName + plan.md task names for better matching
426
+ let taskContext = changeName || ''
427
+ if (changeName) {
428
+ const planPath = join(effectiveSpecBase, 'changes', changeName, 'plan.md')
429
+ try {
430
+ const planContent = readFileSync(planPath, 'utf8')
431
+ // match both "- [ ] task-01: title" and "## task-01: title" formats
432
+ const taskLines = [...planContent.matchAll(/(?:^\- \[[ x]\] |^## )task-\d+[^:]*:?\s*(.+)/gm)]
433
+ if (taskLines.length > 0) {
434
+ taskContext += ' ' + taskLines.map(t => t[1]).join(' ')
435
+ }
436
+ } catch {}
437
+ }
438
+ const knowledgeResult = matchKnowledge(knowledgeDir, taskContext)
439
+ promptText = promptText.replace(/\{KNOWLEDGE_HIT_REPORT\}/g, knowledgeResult.report)
440
+ // 写入 runtime JSON
441
+ const runtimeDir = join(effectiveSpecBase, '.runtime')
442
+ mkdirSync(runtimeDir, { recursive: true })
443
+ writeFileSync(join(runtimeDir, 'knowledge-hit-report.json'), JSON.stringify(knowledgeResult.json, null, 2) + '\n')
444
+ } catch (e) {
445
+ promptText = promptText.replace(/\{KNOWLEDGE_HIT_REPORT\}/g, 'Status: no matches (error: ' + e.message + ')')
446
+ }
447
+ }
448
+
449
+ // Execute: 注入 currentExecuteRunId(从变更专属标记文件读取)
450
+ if (stageName === 'execute' && promptText.includes('{EXECUTE_RUN_ID}')) {
451
+ let runId = ''
452
+ const execSpecBase = platformOpts?.specRoot || join(cwd, '.sillyspec')
453
+ const runtimeRoot = platformOpts?.runtimeRoot || join(execSpecBase, '.runtime')
454
+ const runIdFile = join(runtimeRoot, `current-execute-run-id-${changeName}`)
455
+ try {
456
+ if (existsSync(runIdFile)) {
457
+ runId = readFileSync(runIdFile, 'utf8').trim()
458
+ }
459
+ } catch {}
460
+ if (!runId) {
461
+ const { generateExecuteRunId } = await import('../task-review.js')
462
+ runId = generateExecuteRunId()
463
+ // 落盘(与启动站点一致),保证 agent 收到的 ID == gate/checkbox 读取的 ID
464
+ try { mkdirSync(runtimeRoot, { recursive: true }); writeFileSync(runIdFile, runId + '\n') } catch {}
465
+ }
466
+ promptText = promptText.replace(/\{EXECUTE_RUN_ID\}/g, runId)
467
+ }
468
+
469
+ // Stage Review Tier:brainstorm/plan/propose/execute 阶段注入审查分级占位符
470
+ // (scanProfile 只在 scan 生效、change-risk-profile 只管 apply/verify,都不约束这些阶段的审查方式,
471
+ // 故按 plan_level / 变更文件数分级:self 当前 agent 自审,independent 强制独立子代理 + review.json)
472
+ if (['brainstorm', 'plan', 'propose', 'execute'].includes(stageName) && promptText.includes('{REVIEW_TIER}')) {
473
+ try {
474
+ const { classifyReviewTier } = await import('../review-tier.js')
475
+ const { generateStageReviewRunId, renderReviewJsonContract } = await import('../stage-review.js')
476
+ const tierSpecBase = platformOpts?.specRoot || join(cwd, '.sillyspec')
477
+ const tierChangeDir = changeName ? join(tierSpecBase, 'changes', changeName) : null
478
+ const designPath = tierChangeDir ? join(tierChangeDir, 'design.md') : null
479
+ let planLevel = null
480
+ if (tierChangeDir) {
481
+ const planPath = join(tierChangeDir, 'plan.md')
482
+ if (existsSync(planPath)) {
483
+ const fmLine = readFileSync(planPath, 'utf8').split('\n').find(l => l.trim().startsWith('plan_level:'))
484
+ if (fmLine) planLevel = fmLine.split(':')[1].trim()
485
+ }
486
+ }
487
+ const tier = classifyReviewTier({ planLevel, designPath })
488
+ const reviewRunId = generateStageReviewRunId()
489
+ // review.json 产物契约(schema + 示例 + docHash 算法 + 重算提示)事前注入,与 validateStageReviewSchema 同源
490
+ const reviewContractMd = renderReviewJsonContract({ stage: stageName, changeDir: tierChangeDir, reviewRunId, tier: tier.tier })
491
+ promptText = promptText
492
+ .split('{REVIEW_TIER}').join(tier.tier)
493
+ .split('{REVIEW_TIER_REASON}').join(tier.reason)
494
+ .split('{STAGE_REVIEW_RUN_ID}').join(reviewRunId)
495
+ .split('{REVIEW_JSON_CONTRACT}').join(reviewContractMd)
496
+ } catch (e) {
497
+ // 降级 self,避免 prompt 残留占位符
498
+ promptText = promptText
499
+ .split('{REVIEW_TIER}').join('self')
500
+ .split('{REVIEW_TIER_REASON}').join('分级异常降级 self: ' + e.message)
501
+ .split('{STAGE_REVIEW_RUN_ID}').join('review-unknown')
502
+ .split('{REVIEW_JSON_CONTRACT}').join('(review 契约注入失败,按 schemaVersion=1 + reviewType + verdicts∈pass/fail/cannot_verify + reviewedFiles + docHash=主文档 sha256 产出)')
503
+ }
504
+ }
505
+
506
+ // 注入模块上下文(brainstorm/plan/execute 阶段,基于 Module Context Index)
507
+ if (['brainstorm', 'plan', 'execute'].includes(stageName) && projectName) {
508
+ const effectiveSpecBase = platformOpts?.specRoot || join(cwd, '.sillyspec')
509
+ const moduleIndex = loadModuleContextIndex(effectiveSpecBase, projectName)
510
+ if (moduleIndex && Object.keys(moduleIndex).length > 0) {
511
+ // 尝试从 step prompt / changeName 匹配模块
512
+ const taskDesc = step.prompt || changeName || ''
513
+ const injection = buildModuleContextInjection(taskDesc, moduleIndex, effectiveSpecBase, projectName)
514
+ if (injection) {
515
+ promptText = injection + '\n' + promptText
516
+ }
517
+ }
518
+ }
519
+
520
+ // 平台模式 prompt 自检:确保没有裸相对输出路径
521
+ // 只匹配正向写入指令中的裸路径,避免误杀「禁止写入 .sillyspec/」等安全说明
522
+ if ((platformOpts?.specRoot || platformOpts?.runtimeRoot) && stageName === 'scan') {
523
+ const writeCtx = /(?<!不要|禁止|严禁)(?:save[\s.]+to|write|create|mkdir|git add|写入|保存到|写入到)[^a-zA-Z]*\.sillyspec\/[a-z]/i
524
+ if (writeCtx.test(promptText)) {
525
+ console.error(`❌ [sillyspec] BUG: 平台模式 scan prompt 包含写入指令指向裸相对路径 .sillyspec/`)
526
+ console.error(` 这会导致 agent 写入源码目录而非 spec-root,属于源码污染 bug。`)
527
+ console.error(` 请将路径改为对应的 {DOCS_ROOT}/{PROJECTS_ROOT}/{WORKFLOWS_ROOT}/{KNOWLEDGE_ROOT}/{SPEC_ROOT} 占位符。`)
528
+ process.exit(2) // 内部异常(SillySpec 自身 prompt 配置 bug)→ exit 2,与 machine-interface 三段契约一致
529
+ }
530
+ }
531
+
532
+ if (prevStepAnswer) {
533
+ console.log(`\n### 📩 上一步用户回答`)
534
+ console.log(prevStepAnswer)
535
+ }
536
+
537
+ // 完成契约(事前预知):该 stage 的机械校验通过条件,从 stage-contract-spec.js manifest 渲染,
538
+ // 与 CLI 完成校验严格同源(事前给的 == 事后查的)。仅 step0 注入(与 persona/guardrails 同模式,
539
+ // 一次建立即可;后续步靠 context 保留)。
540
+ if (stepIndex === 0) {
541
+ const stageContract = renderStageContract(stageName)
542
+ if (stageContract) {
543
+ promptText = `${promptText}\n\n${stageContract}`
544
+ }
545
+ }
546
+
547
+ console.log(promptText)
548
+ // 铁律拆分(W3 token 效率):通用流程纪律(文档优先/不跳步/不编造命令)只在首步注入——
549
+ // 每步重复 ~800B 纯耗 context。但【平台写入规则 + 路径规则】是安全关键(防写错目录/绕过 Write),
550
+ // 且依赖 changeName/platformOpts,必须【每步注入】(context 压缩丢失会让 agent 越界写源码)。
551
+ if (stepIndex === 0) {
552
+ console.log(`\n### ⚠️ 铁律`)
553
+ console.log('- 文档优先:代码产出必须先有对应的设计/规范文档支撑。')
554
+ console.log('- 只做本步骤描述的操作,不得自行扩展或跳过')
555
+ console.log('- 不要回头修改已完成的步骤')
556
+ console.log('- 不要编造不存在的 CLI 子命令')
557
+ console.log('- 完成后立即执行 --done 命令,不得跳过')
558
+ console.log('- 不要用 mv/rename 重命名变更目录,必须用 `sillyspec change-rename <旧名> <新名>`')
559
+ console.log('- 文档类型文件(.md/.yaml/.json 等)头部必须包含 author(git 用户名)和 created_at(精确到秒)')
560
+ console.log('- 执行构建/测试前必须先读 local.yaml,优先使用其中配置的命令、路径和环境变量;未配置时才使用默认值')
561
+ }
562
+ // 平台模式 + 路径规则(安全关键,每步注入;step1+ 起带精简标题,不复述通用铁律)
563
+ if (platformOpts?.specRoot || platformOpts?.runtimeRoot || changeName) {
564
+ if (stepIndex !== 0) console.log(`\n### ⚠️ 路径与平台规则(每步提醒,通用铁律见首步)`)
565
+ if (platformOpts?.specRoot || platformOpts?.runtimeRoot) {
566
+ const specSillyspec = platformOpts.specRoot || join(cwd, '.sillyspec')
567
+ console.log(`- **平台模式:所有文件只能写入 \`${specSillyspec}/\` 下的对应子目录,严禁写入源码目录。**`)
568
+ console.log('- **平台模式:Write 工具失败时,不允许用 cat > / tee / heredoc 等方式绕过。先 Read 再 Write,仍失败则记录并停止。**')
569
+ console.log('- **平台模式:local.yaml 中的 commands 必须在 package.json scripts 中真实存在,不存在的标记 unavailable。**')
570
+ }
571
+ if (changeName) {
572
+ const isPlatform = platformOpts?.specRoot || platformOpts?.runtimeRoot
573
+ const changeDirBase = isPlatform ? platformOpts.specRoot : '.sillyspec'
574
+ const changeDir = join(changeDirBase, 'changes', changeName)
575
+ console.log(`- **文件路径规则:所有变更文件必须写入 \`${changeDir}/\` 目录下。不要自己拼接路径,直接使用 changeDir 值。示例:\`${changeDir}/proposal.md\`**`)
576
+ }
577
+ }
578
+ const changeFlag = changeName ? ` --change ${changeName}` : ''
579
+ // 检测当前 step prompt 是否包含 WAIT 指令(即可能需要等待用户)
580
+ const stepPrompt = promptText || ''
581
+ const requiresWait = step.requiresWait === true
582
+ const conditionalWait = step.conditionalWait === true
583
+ const mayNeedWait = WAIT_MARKER_RE.test(stepPrompt) || requiresWait || conditionalWait
584
+
585
+ console.log(`\n### 完成后执行`)
586
+ if (requiresWait) {
587
+ console.log(`本步骤必须等待用户输入,不能直接 --done:`)
588
+ console.log(`sillyspec run ${stageName} --wait --reason "${step.waitReason || '等待用户输入'}" --options "${(step.waitOptions || ['确认']).join(',')}"${changeFlag} --output "你的问题/方案摘要"`)
589
+ console.log(``)
590
+ console.log(`用户回答后执行:`)
591
+ console.log(`sillyspec run ${stageName} --continue --answer "用户回答"${changeFlag}`)
592
+ console.log(``)
593
+ console.log(`收到回答并完成本步骤总结后,再执行:`)
594
+ } else if (mayNeedWait) {
595
+ console.log(`如果需要用户决策(选择方案/确认设计等):`)
596
+ console.log(`sillyspec run ${stageName} --wait --reason "${step.waitReason || '等待原因'}" --options "${(step.waitOptions || ['选项1', '选项2']).join(',')}"${changeFlag} --output "你的摘要"`)
597
+ console.log(``)
598
+ console.log(`如果不需要用户决策,正常完成:`)
599
+ }
600
+ console.log(`sillyspec run ${stageName} --done${changeFlag} --input "用户原始需求/反馈" --output "你的摘要"`)
601
+ }
602
+ /**
603
+ * 替换 prompt 文本中的路径根占位符 {SPEC_ROOT}/{DOCS_ROOT}/{PROJECTS_ROOT}/
604
+ * {WORKFLOWS_ROOT}/{KNOWLEDGE_ROOT}。平台模式与常规模式共用:仅传入的 roots 值不同。
605
+ * 占位符值都是绝对路径、互不包含其它占位符,故替换顺序不影响结果。
606
+ *
607
+ * 注:本函数由并行会话引入(outputStep 两处逐字重复逻辑的抽取),重构期间 run.js 一次
608
+ * git checkout 误覆盖其未提交版本,此处按 test/prompt-placeholders.test.mjs 的契约重建,
609
+ * 行为等价。
610
+ */
611
+ export function applyRootPlaceholders(text, roots) {
612
+ return text
613
+ .replaceAll('{SPEC_ROOT}', roots.specRoot)
614
+ .replaceAll('{DOCS_ROOT}', roots.docsRoot)
615
+ .replaceAll('{PROJECTS_ROOT}', roots.projectsRoot)
616
+ .replaceAll('{WORKFLOWS_ROOT}', roots.workflowsRoot)
617
+ .replaceAll('{KNOWLEDGE_ROOT}', roots.knowledgeRoot)
618
+ }
619
+