sillyspec 3.26.6 → 3.26.8

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 (42) hide show
  1. package/.claude/skills/sillyspec-doctor/SKILL.md +2 -0
  2. package/.claude/skills/sillyspec-execute/SKILL.md +1 -1
  3. package/.claude/skills/sillyspec-knowledge/SKILL.md +1 -1
  4. package/.claude/skills/sillyspec-plan/SKILL.md +1 -1
  5. package/.husky/pre-push +9 -2
  6. package/bin/sillyspec.js +1 -1
  7. package/package.json +3 -3
  8. package/packages/dashboard/server/index.js +8 -1
  9. package/src/config-schema.js +305 -287
  10. package/src/docs-check.js +333 -0
  11. package/src/docs-debt.js +226 -0
  12. package/src/docs-gate.js +106 -0
  13. package/src/doctor-diagnostics.js +23 -2
  14. package/src/index.js +1880 -1750
  15. package/src/init.js +81 -14
  16. package/src/modules.js +490 -486
  17. package/src/progress.js +37 -13
  18. package/src/quicklog.js +19 -4
  19. package/src/run/command.js +54 -24
  20. package/src/run/complete-handlers.js +1254 -1128
  21. package/src/run/complete.js +8 -34
  22. package/src/run/gates.js +32 -3
  23. package/src/run/prompt.js +713 -633
  24. package/src/run/quick-audit.js +137 -123
  25. package/src/run/shared.js +1109 -929
  26. package/src/run/stage.js +8 -3
  27. package/src/scan-staleness.js +90 -0
  28. package/src/setup.js +27 -9
  29. package/src/sillyhub-mcp/client.js +149 -14
  30. package/src/stage-contract-spec.js +15 -0
  31. package/src/stage-contract.js +8 -5
  32. package/src/stages/brainstorm-auto.js +0 -1
  33. package/src/stages/brainstorm.js +1 -1
  34. package/src/stages/execute.js +985 -967
  35. package/src/stages/plan-postcheck.js +93 -36
  36. package/src/stages/verify.js +1 -1
  37. package/src/sync.js +60 -32
  38. package/src/task-review.js +38 -6
  39. package/src/verify-postcheck.js +22 -5
  40. package/src/worktree-apply.js +6 -3
  41. package/src/worktree-deps.js +5 -3
  42. package/src/worktree.js +4 -2
@@ -1,967 +1,985 @@
1
- import { existsSync, readFileSync, readdirSync, writeFileSync } from 'fs'
2
- import path from 'path'
3
- import { buildContractMatrix, buildConsumerInjection, buildContractFieldInjection } from '../contract-matrix.js'
4
- import { getRule } from '../stage-contract-spec.js'
5
- import { renderDispatchInstruction } from '../dispatch/strategy.js'
6
- import { isPathASupported } from '../dispatch/backends/sillyhub-mcp.js'
7
- import { readMcpConfig } from '../sillyhub-mcp/config.js'
8
- import { gitQuiet } from '../git-helper.js'
9
- import { parseRepo } from './plan-postcheck.js'
10
-
11
- /**
12
- * 校验 plan.md 是否满足 execute 执行契约
13
- * @param {string} planContent - plan.md 文件内容
14
- * @returns {{ ok: boolean, errors: string[], warnings: string[], tasks: object[], waves: object[] }}
15
- */
16
- export function validatePlanForExecute(planContent) {
17
- const errors = []
18
- const warnings = []
19
-
20
- if (!planContent || !planContent.trim()) {
21
- return { ok: false, errors: ['plan.md 内容为空'], warnings, tasks: [], waves: [] }
22
- }
23
-
24
- const waves = parseWavesFromPlan(planContent)
25
-
26
- // 收集所有 task
27
- const allTasks = []
28
- for (const wave of waves) {
29
- for (const task of wave.tasks) {
30
- allTasks.push(task)
31
- }
32
- }
33
-
34
- // 检查 1: 至少有一个 checkbox task
35
- if (allTasks.length === 0) {
36
- // 诊断根因(坑 plan-md-format-contract-hidden):笼统报"没有找到 checkbox task"逼 agent 试错。
37
- // 区分:无 Wave/Tasks 段 / Wave 标题格式不对 / task 用 ### 标题 / checkbox 被非 Wave 标题打断。
38
- const diags = diagnoseNoTaskRootCause(planContent)
39
- errors.push('plan.md 中没有找到 checkbox task(格式: "- [ ] task-XX: 任务名")')
40
- for (const d of diags) errors.push(` 诊断:${d}`)
41
- return { ok: false, errors, warnings, tasks: allTasks, waves }
42
- }
43
-
44
- // 检查 2: task id 唯一性
45
- const idCounts = {}
46
- for (const task of allTasks) {
47
- if (task.index != null) {
48
- const key = `task-${task.index}`
49
- idCounts[key] = (idCounts[key] || 0) + 1
50
- }
51
- }
52
- for (const [id, count] of Object.entries(idCounts)) {
53
- if (count > 1) {
54
- errors.push(`task id 重复: ${id} 出现 ${count} 次`)
55
- }
56
- }
57
-
58
- // 检查 3: task id 连续性(从 1 开始)
59
- const ids = allTasks
60
- .map(t => t.index)
61
- .filter(i => i != null)
62
- .sort((a, b) => a - b)
63
- if (ids.length > 0) {
64
- const expected = Array.from({ length: ids.length }, (_, i) => ids[0] + i)
65
- // 只检查以 task-01 起始的情况(常见模式)
66
- if (ids[0] === 1) {
67
- for (let i = 0; i < ids.length; i++) {
68
- if (ids[i] !== i + 1) {
69
- errors.push(getRule('plan.task-id-continuity').failMessage.replaceAll('${expected}', String(i + 1).padStart(2, '0')).replaceAll('${actual}', String(ids[i]).padStart(2, '0')))
70
- break
71
- }
72
- }
73
- }
74
- }
75
-
76
- // 检查 4: task name 非空
77
- for (const task of allTasks) {
78
- if (!task.name || !task.name.trim()) {
79
- errors.push(`task-${String(task.index || '?').padStart(2, '0')}: 任务名为空`)
80
- }
81
- }
82
-
83
- // 检查 5: task 无 id 的 warning(不限制只在有 id 时检查)
84
- for (const wave of waves) {
85
- for (const task of wave.tasks) {
86
- if (task.index == null) {
87
- warnings.push(`Wave ${wave.index}: task "${task.name}" 没有 task id(建议格式 task-XX: 名称)`)
88
- }
89
- }
90
- }
91
-
92
- return { ok: errors.length === 0, errors, warnings, tasks: allTasks, waves }
93
- }
94
-
95
- export const definition = {
96
- name: 'execute',
97
- title: '波次执行',
98
- description: '子代理并行 + 强制 TDD + 两阶段审查',
99
- steps: [] // 动态构建,由 buildExecuteSteps() 生成
100
- }
101
-
102
- // 固定前缀步骤定义
103
- const fixedPrefix = [
104
- {
105
- name: '进度确认',
106
- migratedFrom: ['状态检查'],
107
- prompt: `检查当前进度,确认可以执行 execute。用 \`sillyspec progress show\` 查流程进度,不要用 \`sillyspec status\`(项目级快照,不推进流程)。
108
-
109
- ### 操作
110
- 1. 运行 \`sillyspec progress show\`
111
- 2. 确认 currentStage 为 execute
112
- 3. 如果不是 → 检查是否有未完成的 tasks.md
113
- 4. 确认执行范围($ARGUMENTS 指定 wave/task 或全部)
114
-
115
- ### 输出
116
- 当前状态 + 执行范围确认`,
117
- outputHint: '当前状态 + 执行范围',
118
- optional: false
119
- },
120
- {
121
- name: '加载上下文',
122
- prompt: `加载计划、设计和代码库上下文。
123
-
124
- ### 操作
125
- 1. 读取 tasks.md(执行计划)
126
- 2. 读取 design.md(技术方案)
127
- 3. 读取 CONVENTIONS.md、ARCHITECTURE.md
128
- 4. 读取 local.yaml(构建命令);若 local.yaml 不存在,先 \`sillyspec local detect\` 生成骨架再读取
129
- 5. 加载项目总览 \`.sillyspec/docs/<project>/scan/PROJECT.md\`(如存在)
130
-
131
- ### 模块文档加载
132
- 6. 读取 \`.sillyspec/docs/<project>/modules/_module-map.yaml\`(不存在则跳过以下步骤)
133
- 7. 根据 plan.md 中的任务文件路径匹配 _module-map.yaml 中的模块
134
- 8. 读取匹配到的 \`.sillyspec/docs/<project>/modules/<module>.md\`
135
- 9. 实现代码时遵循模块文档中描述的接口约定、数据流和依赖关系
136
- 10. **利用模块索引快速定位源码**:
137
- - 用 entrypoints 字段直接找到模块对外 API 的源码位置
138
- - 用 main_symbols 字段找到核心类/函数的定义位置
139
- - 子代理优先读模块卡片理解语义,再读 entrypoints/main_symbols 对应的源码
140
-
141
- ### 符号影响面扩展检查
142
- 11. **符号影响面扫描**(Critical — execute 前必做):
143
- - 读取所有 tasks/task-NN.md,提取每个任务涉及的修改文件
144
- - 对每个修改文件,检查是否涉及以下变更类型:
145
- - class 构造函数参数变更(新增/删除/修改参数)
146
- - 接口(interface)定义变更
147
- - DTO / 类型定义变更
148
- - API client 方法签名变更
149
- - 函数/方法签名变更(参数增删改)
150
- - 如果涉及上述变更类型,执行调用点搜索:
151
- \`\`\`bash
152
- rg "new ClassName\(" src/
153
- rg "ClassName\(" src/
154
- rg "methodName\(" src/
155
- rg "import.*from.*filePath" src/
156
- \`\`\`
157
- - 将搜索到的调用点与 plan.md 和 tasks/task-NN.md 的 allowed_paths 对比
158
- - **发现调用点不在任何 task 的 allowed_paths 中 → 直接阻断 execute**
159
- - 报告:列出每个受影响符号、调用点位置、是否在任务范围内
160
- - 如果调用点不在范围内但任务明确写了"不改原因",记录但不阻断
161
-
162
- ### 输出
163
- 已加载的上下文摘要(含模块文档 + 源码锚点)`,
164
- outputHint: '上下文摘要',
165
- optional: false
166
- },
167
- {
168
- name: '确认 worktree 路径',
169
- prompt: `确认当前 worktree 状态,提取隔离路径。
170
-
171
- ### 操作
172
- 1. 运行 \`sillyspec worktree meta <change-name>\` 读取 meta.json
173
- 2. 从输出中提取 worktreePath、branch、mode 字段
174
- 3. 确认 worktree 目录存在(如果是 worktree/native-worktree 模式)
175
- 4. **确认工具链可用**:worktree 内项目工具链(lint/format/test 二进制,如 ruff / prettier / uv)可能不全——对本次会用到的工具先跑一次 \`--version\` 确认;缺失则按项目方式安装(Python 项目 \`uv tool install ruff\` / \`uv sync\`,Node 项目 node_modules 已由 CLI 链接主仓)。不要等到 commit 才发现二进制不在 PATH 被 hook 拦
176
-
177
- ### 铁律
178
- - **worktree 已由 CLI 在 execute 阶段启动时自动创建,不要自行创建或跳过**
179
- - **后续所有子代理的 cwd 必须设为该 worktree 路径**
180
- - 如果 meta.json 不存在(说明创建失败),停止并报错
181
- - **不要自行检查 git dirty/uncommitted 状态来判断是否可以进入 worktree,CLI 已自动处理**
182
-
183
- ### 输出
184
- worktree 路径 + 分支名 + 模式
185
-
186
- `,
187
- outputHint: 'worktree 路径 + 分支名 + 模式',
188
- optional: false
189
- },
190
- {
191
- name: '确认执行范围',
192
- prompt: `解析任务,确认执行范围和确认模式。
193
-
194
- ### 操作
195
- 1. 从 plan 中解析 Wave 分组和任务列表
196
- 2. 模型档位:若 tasks.md 中某 task 标注了 [model:xxx],启动该 task 子代理时按标签选模型(档位由 plan 阶段或用户在 tasks.md 显式标注,execute 不在此自动建议——关键词→档位无统一映射,自动建议反而易误导)
197
- 3. 确认频率:默认每个 Wave 完成后展示结果(wave 模式);用户口头指定按 Task 展示或全自动时遵从
198
- 4. 查询知识库:读取 \`.sillyspec/knowledge/INDEX.md\`,根据 Task 关键词匹配
199
-
200
- ### 知识命中报告
201
- {KNOWLEDGE_HIT_REPORT}
202
-
203
- 如上所示的知识条目与本次任务相关。请阅读这些条目以获取项目约定和已知模式。
204
- 如无命中条目(Status: no matches),跳过本节。
205
-
206
- ### 铁律
207
- - **不要询问用户确认频率**,默认 wave 模式;用户已明确口头指定时遵从其指定`,
208
- outputHint: 'Wave 分组 + 模型分配',
209
- optional: false
210
- }
211
- ]
212
-
213
- // 全局验收步骤定义
214
- const acceptanceSteps = [
215
- {
216
- name: '对照设计检查',
217
- mode: 'acceptance',
218
- prompt: `对照 design.md 检查所有实现是否与设计一致。
219
-
220
- ### 执行方式(CLI 按变更规模判定,占位符由 run.js 注入)
221
- tier: {REVIEW_TIER}({REVIEW_TIER_REASON})
222
- - tier=self:当前 agent 汇总执行(对照 design.md 逐项检查 + 偏差说明)
223
- - tier=independent:必须用 Agent tool 启动一个独立的 QA 子代理(独立上下文,不共享实现者的分析),子代理对照 design.md 逐项检查实现一致性并输出 review.json。review.json 产物契约(CLI Stage Review Gate 将硬校验,schema + 完整示例 + docHash 算法如下,照抄改值;reviewedFiles 除主文档 design.md 外可追加 git diff 涉及的源码文件):
224
- {REVIEW_JSON_CONTRACT}
225
- 该 acceptance review 同时覆盖"代码审查"视角(风格/bug/安全/冗余),后续代码审查步骤仅需轻量复审。
226
-
227
- ### 操作
228
- 1. 读取 design.md(技术方案)
229
- 2. 逐一对照 design.md 中的设计要点与实际代码实现
230
- 3. 检查接口签名、数据结构、模块划分是否一致
231
- 4. 记录偏差项(偏差 ≠ 错误,可能是合理的实现调整)
232
-
233
- ### 输出
234
- 检查清单:每项设计要点的实现状态 ✅/⚠️/❌ + 偏差说明`,
235
- outputHint: '设计对照检查清单',
236
- optional: false
237
- },
238
- {
239
- name: '运行测试',
240
- mode: 'acceptance',
241
- prompt: `运行所有测试,验证代码质量。
242
-
243
- ### 执行方式
244
- 本步骤由当前 agent 执行,不需要启动独立子代理。
245
-
246
- ### 操作
247
- 1. 读取 local.yaml 获取构建和测试命令;若 local.yaml 不存在,先 \`sillyspec local detect\` 生成骨架再读取
248
- 2. 运行测试套件(单元测试、集成测试)
249
- 3. 运行 lint 检查 **+ 格式化**:凡变更涉及的源码,既跑 lint check 也跑 formatter(如 \`ruff format\` / \`prettier --write\` / \`black\`),不要只跑 check——只 check 不 format 会把格式问题留到 commit 时被 pre-commit hook 拦截
250
- 4. 如果有测试失败 → 分析原因,标注是代码问题还是测试本身的问题
251
- 5. 汇总测试结果
252
-
253
- ### 铁律
254
- - 长测试/构建/lint 命令必须**前台同步执行**,禁止 run_in_background:true / & / nohup / disown——后台任务易被会话生命周期回收导致中断无果
255
-
256
- ### 输出
257
- 测试结果摘要:通过/失败/跳过数量 + 失败项分析`,
258
- outputHint: '测试结果摘要',
259
- optional: false
260
- },
261
- {
262
- name: '代码审查',
263
- mode: 'acceptance',
264
- prompt: `对本次变更进行代码审查。
265
-
266
- ### 执行方式
267
- 本步骤由当前 agent 或一个 QA agent 汇总执行,不需要为每个文件启动独立子代理。
268
-
269
- ### 操作
270
- 1. 检查 git diff 查看所有变更
271
- 2. 审查要点:
272
- - 代码风格是否符合 CONVENTIONS.md
273
- - 是否有明显的 bug 或安全漏洞
274
- - 是否有未处理的 TODO/FIXME
275
- - 错误处理是否完善
276
- - 是否有冗余代码或可简化的逻辑
277
- 3. 对照 ARCHITECTURE.md 检查架构合规性
278
-
279
- ### 输出
280
- 审查结果:问题列表(严重程度 + 建议修复方式)+ 总体评价`,
281
- outputHint: '代码审查结果',
282
- optional: true
283
- }
284
- ]
285
-
286
- // 固定后缀步骤定义
287
- const fixedSuffix = [
288
- {
289
- name: '知识库审阅',
290
- prompt: `检查本轮执行产生的新知识。
291
-
292
- ### 操作
293
- 1. 检查 \`.sillyspec/knowledge/uncategorized.md\` 中待确认条目
294
- 2. 如有 → 提示用户审阅
295
- 3. 用户确认后改为 [已确认],可归类到专题文件
296
-
297
- ### 输出
298
- 新知识条目数量 + 审阅提示(或"无新知识")`,
299
- outputHint: '知识条目数量',
300
- optional: true
301
- },
302
- {
303
- name: '完成确认',
304
- prompt: `所有任务完成后的收尾。
305
-
306
- 先检查当前 worktree 的隔离模式:
307
- \`\`\`bash
308
- node -e "import('./src/worktree.js').then(w => { const wm = new w.WorktreeManager(); const m = wm.getMeta('<change-name>'); console.log(m ? JSON.stringify({mode: m.mode, path: m.worktreePath}) : 'no meta'); })"
309
- \`\`\`
310
-
311
- ### 操作(mode = worktree,SillySpec 创建的隔离 worktree)
312
-
313
- **自动审计流程(不需要用户确认代码):**
314
-
315
- 1. 运行 \`sillyspec worktree assess <change-name>\` 自动风险审计
316
- 2. 系统自动检查:
317
- - patch --check 是否通过
318
- - 变更是否在 allowed_paths 内
319
- - 主工作区是否有未提交 dirty(拦截;已提交推进交 --3way 自动三路合并)
320
- - 是否有高风险文件(lockfile/migration/配置/入口)
321
- - diff 规模是否异常
322
- 3. 输出 Apply Decision:
323
-
324
- \`\`\`
325
- Worktree Apply Decision
326
- ────────────────────────
327
- Decision: SAFE | WARNING | BLOCKED
328
- Changed files: N
329
- Additions: +N Deletions: -N
330
- Risky files: none | <list>
331
- Action: auto-applied | blocked
332
- \`\`\`
333
-
334
- 4. **SAFE** → 自动 \`sillyspec worktree apply <change-name>\` + cleanup
335
- 5. **WARNING** → 自动 apply(有警告但不阻断)+ cleanup
336
- 6. **BLOCKED** → 不 apply,输出原因,提示用户检查:
337
- - \`sillyspec worktree diff <change-name>\` 查看具体变更
338
- - \`sillyspec worktree cleanup <change-name>\` 丢弃
339
- 7. 建议下一步:\`sillyspec run verify\`
340
-
341
- ### 操作(mode = native-worktree,用户已有的 linked worktree)
342
- 1. 同上自动审计流程
343
- 2. SAFE/WARNING → \`sillyspec worktree apply <change-name>\`
344
- 3. **不要运行 cleanup**
345
- 4. 输出 Worktree: kept
346
- 5. 建议下一步:\`sillyspec run verify\`
347
-
348
- ### 操作(mode = in-place-fallback,降级模式无隔离目录)
349
- 1. 展示本次执行摘要(\`git diff\` 查看变更)
350
- 2. 跳过 apply 和 cleanup
351
- 3. 输出 Worktree: none
352
- 4. 建议下一步:\`sillyspec run verify\`
353
-
354
- ### 输出
355
- Apply Decision + 下一步建议
356
-
357
- ### 注意
358
- - 完成后运行 \`sillyspec run execute --done\` 即可自动推进阶段`,
359
- outputHint: 'apply 结果',
360
- optional: false
361
- }
362
- ]
363
-
364
- /**
365
- * 诊断 plan.md 无 checkbox task 的根因(坑 plan-md-format-contract-hidden)。
366
- * 纯函数:读 planContent 文本,返回诊断提示数组。validatePlanForExecute 无 task 时追加到报错,
367
- * 帮 agent 区分 4 条隐性格式契约(Wave 标题字面 / task checkbox / task-XX 编号 / ### 打断 Wave 段)。
368
- * @param {string} planContent
369
- * @returns {string[]}
370
- */
371
- function diagnoseNoTaskRootCause(planContent) {
372
- const diags = []
373
- const hasWaveHeading = /^#+\s*Wave\s+\d+/im.test(planContent) // "## Wave N"(字面)
374
- const hasWaveWord = /^#+\s*(wave\s*\d+|w\d+|波次\s*\d+)/im.test(planContent) // 疑似 Wave 标题(含 W1/Wave1/波次1)
375
- const hasCheckboxTask = /^[-*]\s*\[[ x]\]\s*task-\d+/im.test(planContent) // "- [ ] task-XX:" checkbox
376
- const hasTaskHeading = /^#+\s*task-\d+/im.test(planContent) // "### task-XX:" 标题(非 checkbox)
377
- const hasTasksSection = /^#+\s*(tasks?|任务)\s*$/im.test(planContent) // "## Tasks" / "## 任务"
378
-
379
- if (!hasWaveHeading && !hasTasksSection) {
380
- if (hasWaveWord) {
381
- diags.push('Wave 标题格式不对:必须字面 "## Wave N"(Wave + 空格 + 数字),"## W1" / "## Wave1" / "## 波次1" 都不被识别')
382
- } else if (hasCheckboxTask) {
383
- diags.push('有 task checkbox 但不在 "## Wave N"(full)或 "## Tasks"(light/none)段内——非任务区的 checkbox 不收')
384
- } else {
385
- diags.push('plan.md 缺任务区:full 需 "## Wave 1" 标题,light/none 需 "## Tasks" 标题')
386
- }
387
- }
388
- if (hasTaskHeading) {
389
- diags.push('task 用了 "### task-XX:" 标题(非 checkbox):标题会打断 Wave 段导致后续 checkbox 全被跳过,请改用 "- [ ] task-XX: 名称"(英文冒号)')
390
- }
391
- if (hasWaveHeading && hasCheckboxTask && !hasTaskHeading) {
392
- diags.push('有 Wave 段也有 checkbox task 但未收容:checkbox 可能落在非任务区(如 "## 自检" 段)或被 "###" 标题打断,请检查 task 是否紧跟在 "## Wave N" 下')
393
- }
394
- return diags
395
- }
396
-
397
- /**
398
- * 从 plan 文件解析 Wave 分组
399
- */
400
- function parseWavesFromPlan(planContent) {
401
- const waves = []
402
- const lines = planContent.split('\n')
403
- let currentWave = null
404
- let currentTask = null
405
- // light/none plan.md 用 `## Tasks`(无 `## Wave N`)包任务,需识别为隐式任务区,
406
- // 让其中的 task checkbox 能被收进惰性创建的隐式 Wave(见下方 taskMatch 分支)。
407
- let inImplicitTaskSection = false
408
-
409
- for (const line of lines) {
410
- const waveMatch = line.match(/^#+\s*Wave\s+(\d+)/i)
411
- if (waveMatch) {
412
- currentWave = { index: parseInt(waveMatch[1]), tasks: [] }
413
- currentTask = null
414
- inImplicitTaskSection = false
415
- waves.push(currentWave)
416
- continue
417
- }
418
-
419
- // 任何非 Wave 的标题行:
420
- // 1) 退出当前显式 Wave 段,避免「## 自检」段里的 - [x] checkbox 被误当 task 定义解析
421
- // (导致 Contract 校验报 task id 重复/不连续,详见 docs/sillyspec/plan-postcheck-self-check-checkbox-false-dup.md)
422
- // 2) 识别 light/none 的 `## Tasks`/`## 任务` 任务区(无 Wave 标题),置位隐式任务区标志
423
- const headingMatch = line.match(/^#+\s+(.+?)\s*$/)
424
- if (headingMatch) {
425
- currentWave = null
426
- currentTask = null
427
- const headingText = headingMatch[1].trim().toLowerCase()
428
- inImplicitTaskSection = /^(tasks?|任务)$/.test(headingText)
429
- continue
430
- }
431
-
432
- const taskMatch = line.match(/^[-*]\s*\[[ x]\]\s*(.+)/)
433
- if (taskMatch) {
434
- const taskNoMatch = taskMatch[1].match(/\btask-(\d+)\b/i)
435
- // full plan.md:task 必须在显式 Wave 段内(currentWave 非 null),正常收容。
436
- // light/none plan.md(无 Wave 标题,任务在 `## Tasks` 下):在隐式任务区内,遇含
437
- // task-XX 编号的 checkbox 时惰性创建隐式 Wave 收容,否则 validatePlanForExecute 会报
438
- // "没有找到 checkbox task"。详见 docs/sillyspec/plan-light-needs-wave-heading.md
439
- // 不收的情况:非任务区(## 自检/## 验收 等)的 checkbox,或任务区内无 task-XX 编号的 checkbox。
440
- if (!currentWave) {
441
- if (!inImplicitTaskSection || !taskNoMatch) continue
442
- const nextIndex = waves.length === 0 ? 1 : (waves[waves.length - 1].index || waves.length) + 1
443
- currentWave = { index: nextIndex, tasks: [], implicit: true }
444
- currentTask = null
445
- waves.push(currentWave)
446
- }
447
- currentTask = {
448
- index: taskNoMatch ? parseInt(taskNoMatch[1], 10) : null,
449
- name: taskMatch[1].trim(),
450
- file: '',
451
- steps: '',
452
- reference: ''
453
- }
454
- // 兼容旧格式:任务名后跟 (文件路径)
455
- const fileMatch = taskMatch[1].match(/\(([^)]+)\)$/)
456
- if (fileMatch) {
457
- currentTask.file = fileMatch[1]
458
- currentTask.name = taskMatch[1].replace(/\([^)]+\)$/, '').trim()
459
- }
460
- currentWave.tasks.push(currentTask)
461
- continue
462
- }
463
-
464
- // 解析子行信息(修改/参考/步骤)
465
- if (currentTask) {
466
- const modMatch = line.match(/^\s+-\s*修改:\s*(.+)/)
467
- if (modMatch) { currentTask.file = modMatch[1].trim(); continue }
468
-
469
- const refMatch = line.match(/^\s+-\s*参考:\s*(.+)/)
470
- if (refMatch) { currentTask.reference = refMatch[1].trim(); continue }
471
-
472
- const stepMatch = line.match(/^\s+-\s*步骤:/)
473
- if (stepMatch) { currentTask.steps = line.replace(/^\s+-\s*步骤:\s*/, '').trim(); continue }
474
-
475
- // 步骤续行(数字开头的子步骤)
476
- if (currentTask.steps && line.match(/^\s+\d+\./)) {
477
- currentTask.steps += '\n' + line.trim()
478
- }
479
- }
480
- }
481
-
482
- return waves
483
- }
484
-
485
- /**
486
- * 同步判定派发后端模式(不发网络,零回归关键)— task-07
487
- *
488
- * stub 下 isPathASupported()=false → 有配置也只 local-fallback(加提示),不注入完整 SillyHub 指令。
489
- * 现有测试套件不设 SILLYHUB_MCP_URL/TOKEN 且无 local.yaml mcp 段 → readMcpConfig 返回 null → 默认 'local' → buildWavePrompt 不注入派发段,
490
- * 输出与改前字节一致(零回归,D-005)。
491
- *
492
- * @returns {'local' | 'local-fallback' | 'sillyhub'}
493
- * - local:无 MCP 配置(零回归,与现状一致)
494
- * - local-fallback:有配置但路径A 未落地(isPathASupported()=false),派发仍走 Local + 短提示
495
- * - sillyhub:有配置且路径A 落地(isPathASupported()=true),注入完整 SillyHub 派发指令
496
- */
497
- export function getDispatchMode() {
498
- const hasConfig = !!readMcpConfig(process.cwd())
499
- if (!hasConfig) return 'local'
500
- return isPathASupported() ? 'sillyhub' : 'local-fallback'
501
- }
502
-
503
- /**
504
- * 解析 task 卡的 repo 归属(W3 task-08,D-012)。
505
- * 读 changeDir/tasks/task-<num>.md 的 frontmatter `repo:` 字段;缺省/无卡/'main' → 'main'。
506
- * 单仓 change(无 repo: 字段)恒返 'main',零回归。
507
- *
508
- * @param {{ index?: number, name?: string }} task - wave.tasks 元素
509
- * @param {string|null} changeDir - change 目录绝对路径
510
- * @returns {string} repoKey,'main' 或 parseRepo 返回值
511
- */
512
- function resolveTaskRepo(task, changeDir) {
513
- if (!changeDir || !task || task.index == null) return 'main'
514
- const num = String(task.index).padStart(2, '0')
515
- const taskFile = path.join(changeDir, 'tasks', `task-${num}.md`)
516
- if (!existsSync(taskFile)) return 'main'
517
- try {
518
- const content = readFileSync(taskFile, 'utf8')
519
- const repo = parseRepo(content)
520
- return repo || 'main'
521
- } catch {
522
- return 'main'
523
- }
524
- }
525
-
526
- /**
527
- * 把 base_commit 锡点写入 task 卡 frontmatter(W3 task-08,D-010)。
528
- * 派发跨仓 task 前 CLI 实时 git rev-parse HEAD 落盘——锁定子代理起改的 base 锚点,
529
- * 防止同 Wave 多 task 改同一跨仓仓时 HEAD 推进致 diff 范围漂移(design §5.3 约束① / R-01)。
530
- *
531
- * 策略:幂等写——frontmatter 有 base_commit 行则就地替换,无则紧跟 repo 行后插入(
532
- * 无 frontmatter 时整段创建)。仅改 frontmatter 不动正文。已存在相同值不重复写。
533
- *
534
- * @param {string} taskFilePath - task-NN.md 绝对路径
535
- * @param {string} baseCommit - 跨仓仓 HEAD sha
536
- * @returns {boolean} 是否实际写入(false = 文件不存在 / 写失败 / 值未变)
537
- */
538
- function writeBaseCommitToTaskCard(taskFilePath, baseCommit) {
539
- if (!taskFilePath || !baseCommit) return false
540
- if (!existsSync(taskFilePath)) return false
541
- let content
542
- try {
543
- content = readFileSync(taskFilePath, 'utf8')
544
- } catch {
545
- return false
546
- }
547
- const fmMatch = content.match(/^---\n([\s\S]*?)\n---/)
548
- if (!fmMatch) return false
549
- const fm = fmMatch[1]
550
- const baseLineRe = /^base_commit:\s*.*$/m
551
- let newFm
552
- if (baseLineRe.test(fm)) {
553
- const existing = (fm.match(baseLineRe) || [''])[0]
554
- if (existing === `base_commit: ${baseCommit}`) return false // 值未变,幂等跳过
555
- newFm = fm.replace(baseLineRe, `base_commit: ${baseCommit}`)
556
- } else {
557
- // 紧跟 repo 行后插入(与 D-010 协议同源顺序 repo→base_commit→head_commit)
558
- const repoLineRe = /^repo:\s*.*$/m
559
- const anchor = `base_commit: ${baseCommit}\n`
560
- if (repoLineRe.test(fm)) {
561
- newFm = fm.replace(repoLineRe, m => m + '\n' + anchor.trimEnd())
562
- } else {
563
- // 无 repo 行:插到 frontmatter 首部(frontmatter 首字段)
564
- newFm = anchor.trimEnd() + '\n' + fm
565
- }
566
- }
567
- const newContent = content.replace(/^---\n[\s\S]*?\n---/, `---\n${newFm}\n---`)
568
- if (newContent === content) return false
569
- try {
570
- writeFileSync(taskFilePath, newContent)
571
- return true
572
- } catch {
573
- return false
574
- }
575
- }
576
-
577
- /**
578
- * 为 Wave 生成 prompt(强制子代理执行)
579
- *
580
- * W3 task-08(D-012 per-task workdir):签名加可选 `ctx`(MultiRepoContext)参数。
581
- * - 无 ctx(单仓 change / 旧调用方)→ 退化为单 worktreePath 单值 worktreeSection(零回归)
582
- * - 有 ctx → 按 task 逐个解析 repo 归属,构造 per-task workdir 多值表,跨仓 task 派发前
583
- * 落 base_commit 锡点(D-010),注入跨仓 task commit 指引 + head_commit 落盘指引。
584
- * 同 Wave 允许主仓+跨仓 task 混合(各独立 Task 调用各传 workdir,不强制同 Wave 同 repo)。
585
- */
586
- export function buildWavePrompt(wave, waveIndex, changeDir, worktreePath, options = {}) {
587
- const ctx = options.ctx || null
588
- // ── Contract Matrix:检查是否有 provider/consumer 契约需要注入 ──
589
- let contractInjection = ''
590
- let prototypeInjection = ''
591
- if (changeDir) {
592
- try {
593
- const planFile = path.join(changeDir, 'plan.md')
594
- if (existsSync(planFile)) {
595
- const planContent = readFileSync(planFile, 'utf8')
596
- // 收集本 wave 所有 task(端点注入与字段注入共用)
597
- const waveTasks = wave.tasks.map((t, ti) => {
598
- const num = String(t.index || (ti + 1)).padStart(2, '0')
599
- return `task-${num}`
600
- })
601
-
602
- // 1) 端点级契约(provider/consumer via buildContractMatrix)
603
- const contracts = buildContractMatrix(planContent, changeDir)
604
- const relevantContracts = contracts.filter(c => waveTasks.includes(c.consumer))
605
- if (relevantContracts.length > 0) {
606
- contractInjection = `
607
- ### API Contract Matrix
608
- 本 Wave 存在前端/后端跨 task 契约:
609
- ${relevantContracts.map(c => `- **${c.consumer}** 消费 **${c.provider}** 产出的 API`).join('\n')}
610
- `
611
- for (const taskName of waveTasks) {
612
- const injection = buildConsumerInjection(changeDir, path.join(changeDir, '..', '..'), taskName, contracts)
613
- if (injection) {
614
- contractInjection += `
615
- ### 子代理 ${taskName} 的端点契约注入
616
- 为 ${taskName} 启动子代理时,在子代理 prompt 末尾追加以下内容:
617
-
618
- <contract-injection>
619
- ${injection}
620
- </contract-injection>
621
- `
622
- }
623
- }
624
- }
625
-
626
- // 2) 字段级契约(expects_from ↔ provides)
627
- // 命中「provider 漏字段、consumer fallback 编造 → 运行时 403/500」这类 bug:
628
- // 把 consumer 期望字段 vs provider 承诺字段显式注入子代理 prompt
629
- for (const taskName of waveTasks) {
630
- const fi = buildContractFieldInjection(changeDir, taskName)
631
- if (fi) {
632
- contractInjection += `
633
- ### 子代理 ${taskName} 的字段契约注入
634
- 为 ${taskName} 启动子代理时,在子代理 prompt 末尾追加以下内容:
635
-
636
- <contract-field-injection>
637
- ${fi}
638
- </contract-field-injection>
639
- `
640
- }
641
- }
642
- }
643
- } catch (e) {
644
- // 契约注入是 best-effort:失败不阻断 execute,只记录
645
- console.warn(` ⚠️ 契约注入跳过: ${e?.message || e}`)
646
- }
647
-
648
- // 原型引用注入:brainstorm 阶段的 HTML 原型(确认过的布局/组件/交互),
649
- // 让 execute 实现前端/UI task 时参考原型,而非凭 design 文字重新发明(避免原型浪费)。
650
- try {
651
- const prototypes = readdirSync(changeDir).filter(f => /^prototype-.*\.html$/i.test(f))
652
- if (prototypes.length > 0) {
653
- const protoRelDir = `.sillyspec/changes/${path.basename(changeDir)}`
654
- prototypeInjection = `
655
- ### 📐 原型参考(brainstorm 可视化确认)
656
- 本次变更有 HTML 原型(brainstorm 阶段确认过的视觉/交互),实现前端/UI 相关 task 时参考:
657
- ${prototypes.map(p => `- \`${path.join(protoRelDir, p)}\``).join('\n')}
658
-
659
- 照原型的布局/组件/交互实现,不要凭 design.md 文字重新发明。纯后端/无 UI 的 task 忽略本节。
660
- `
661
- }
662
- } catch {}
663
- }
664
-
665
- // 构建任务摘要(不再内联完整蓝图,减少上下文污染)
666
- const taskSummary = wave.tasks.map((t, ti) => {
667
- const taskNum = String(t.index || (ti + 1)).padStart(2, '0')
668
- const taskRelPath = changeDir
669
- ? `.sillyspec/changes/${path.basename(changeDir)}/tasks/task-${taskNum}.md`
670
- : `task-${taskNum}.md`
671
- const fileInfo = t.file ? ` (${t.file})` : ''
672
- return `task-${taskNum}: ${t.name}${fileInfo} → ${taskRelPath}`
673
- }).join('\n')
674
-
675
- const taskList = wave.tasks.map((t, ti) => {
676
- const taskNum = String(t.index || (ti + 1)).padStart(2, '0')
677
- let s = `- [ ] ${t.name}`
678
- if (t.file) s += ` (${t.file})`
679
- return s
680
- }).join('\n')
681
-
682
- // ── per-task workdir 解析(W3 task-08,D-012)──
683
- // 无 ctx(单仓 change)→ 全 task workdir=主仓 worktreePath,沿用旧单值逻辑(零回归)。
684
- // 有 ctx → 按 task 卡 repo: 字段逐个解析:主仓 task workdir=主仓 worktreePath,跨仓 task
685
- // workdir=ctx.resolve(repo).worktreePath(跨仓仓根)。同 Wave 允许主仓+跨仓混合。
686
- const taskRepos = wave.tasks.map(t => ctx ? resolveTaskRepo(t, changeDir) : 'main')
687
- const hasCrossRepoWave = ctx ? taskRepos.some(r => r !== 'main') : false
688
-
689
- // per-task workdir 映射(用于多值 worktreeSection + 跨仓 commit 指引)
690
- // 无 ctx 或无跨仓 task → 不构造(沿用旧单值 worktreePath 注入)
691
- let perTaskWorkdirs = null
692
- let crossRepoCommitSection = ''
693
- let baseCommitWrites = [] // [{ taskNum, repo, taskFile, written }] 跨仓 task base 锡点落盘记录
694
- if (ctx && hasCrossRepoWave) {
695
- perTaskWorkdirs = wave.tasks.map((t, ti) => {
696
- const taskNum = String(t.index || (ti + 1)).padStart(2, '0')
697
- const repo = taskRepos[ti]
698
- const entry = ctx.resolve(repo)
699
- const workdir = entry ? entry.worktreePath : (worktreePath || '')
700
- return { taskNum, repo, workdir }
701
- })
702
-
703
- // D-010 跨仓 task base 锡点:派发前实时 git rev-parse HEAD 落 task 卡 base_commit
704
- // 仅对跨仓 task(repo≠'main')落盘,主仓 task 锚 meta.baseHash(MultiRepoContext 已处理)
705
- for (const item of perTaskWorkdirs) {
706
- if (item.repo === 'main') continue
707
- const entry = ctx.resolve(item.repo)
708
- if (!entry) continue
709
- const baseCommit = gitQuiet(entry.gitDir, ['rev-parse', 'HEAD'])
710
- if (!baseCommit) continue // git 不可达已由 MultiRepoContext 构造时 fail-closed 拦截,此处置防御
711
- const taskFile = changeDir
712
- ? path.join(changeDir, 'tasks', `task-${item.taskNum}.md`)
713
- : null
714
- const written = taskFile ? writeBaseCommitToTaskCard(taskFile, baseCommit) : false
715
- baseCommitWrites.push({ taskNum: item.taskNum, repo: item.repo, taskFile, written, baseCommit })
716
- }
717
-
718
- // 跨仓 task commit 指引 + head_commit 落盘指引(D-010 回收时机)
719
- const crossLines = perTaskWorkdirs
720
- .filter(i => i.repo !== 'main')
721
- .map(i => `- task-${i.taskNum} → repo \`${i.repo}\`,workdir=\`${i.workdir}\``)
722
- .join('\n')
723
- crossRepoCommitSection = `
724
- ### 跨仓 task 派发与双锡点(必须严格遵守)
725
-
726
- 本 Wave 含跨仓 task(直接改跨仓仓主干,**不经主仓 worktree**):
727
-
728
- ${crossLines}
729
-
730
- **派发跨仓 task 前(base 锡点,CLI 已在 prompt 构造时落盘):**
731
- - 跨仓 task 卡 frontmatter 的 \`base_commit\` 已由 CLI 实时 \`git -C <跨仓仓根> rev-parse HEAD\` 锁定(base 锡点,约束①)。子代理在此 HEAD 上改+commit,不受同 Wave 其他 task 推进 HEAD 影响。
732
-
733
- **回收跨仓 task review 前(head 锡点,你必须在子代理完成后落盘):**
734
- - 跨仓 task 子代理完成 commit 后、写 review.json / 勾选 checkbox **之前**,你必须运行 \`git -C <跨仓仓根> rev-parse HEAD\` 把结果写入该 task 卡 frontmatter \`head_commit:\` 字段(与 \`base_commit:\` 同源 frontmatter 锡点写入)。
735
- - review.json 的 \`base\` 取 task 卡 \`base_commit\`、\`head\` 取 task 卡 \`head_commit\`(**非瞬时 HEAD**),锁 diff 范围不漂移。
736
-
737
- **跨仓 task 子代理 prompt 必须注入:**
738
- > 该 task 改的是 \`<repo>\` 仓,workdir=\`<跨仓仓根>\`。**直接在该仓主干工作区改+commit(git add + git commit 到该仓主干),不经主仓 worktree、不建分支。** commit 到该仓主干即落盘,apply 阶段对跨仓 task 为 no-op(design §5.4 G1)。
739
- `
740
- }
741
-
742
- // worktreeSection:无 ctx / 无跨仓 task → 旧单值(零回归);有 ctx 含跨仓 task → per-task 多值表
743
- let worktreeSection
744
- if (ctx && hasCrossRepoWave && perTaskWorkdirs) {
745
- const workdirLines = perTaskWorkdirs.map(i =>
746
- ` - task-${i.taskNum} (repo: ${i.repo}) → workdir: "${i.workdir}"`
747
- ).join('\n')
748
- worktreeSection = `
749
- ### 工作目录(必须严格遵守,per-task)
750
-
751
- 调用 Task 工具启动子代理时,**workdir 参数是强制必传的**,且本 Wave 内不同 task 的 workdir 不同(主仓 task vs 跨仓 task)。
752
-
753
- **per-task workdir 表(每个子代理按其 task 对应的 workdir 启动):**
754
-
755
- ${workdirLines}
756
-
757
- 不传 workdir 或传错 workdir 会导致:主仓 task 写到跨仓仓 / 跨仓 task 写到主仓 worktree,破坏隔离与 apply 归属。
758
-
759
- \`\`\`json
760
- {
761
- "subagent_type": "general",
762
- "workdir": "<按上方 per-task 表选>",
763
- "prompt": "在此编写任务描述..."
764
- }
765
- \`\`\`
766
-
767
- ### 注意
768
- 蓝图文件(tasks.md / design.md / proposal.md / requirements.md / tasks/task-XX.md)在主工作区 .sillyspec/changes/<change>/ 下,它们不在跨仓仓也不在 worktree 中。读取蓝图时使用主工作区路径,不要拼接到 worktree / 跨仓仓路径下。
769
- `
770
- } else {
771
- worktreeSection = (worktreePath)
772
- ? `
773
- ### 工作目录(必须严格遵守)
774
-
775
- 调用 Task 工具启动子代理时,**workdir 参数是强制必传的**。
776
- 不传 workdir 会导致子代理把文件写到主工作区而非 worktree,破坏隔离。
777
-
778
- \`\`\`json
779
- {
780
- "subagent_type": "general",
781
- "workdir": "${worktreePath}",
782
- "prompt": "在此编写任务描述..."
783
- }
784
- \`\`\`
785
-
786
- ### 注意
787
- 蓝图文件(tasks.md / design.md / proposal.md / requirements.md)在主工作区 .sillyspec/changes/<change>/ 下,它们可能不在 worktree 中。读取蓝图时使用主工作区路径,不要拼接到 worktree 路径下。
788
- `
789
- : ''
790
- }
791
-
792
- // ── 派发后端段注入(task-07,D-006 / D-007 / D-008 接入)──
793
- // 同步判定(不发网络,零回归关键):local(无 MCP 配置)→ dispatchSection='' → 本 prompt 与改前
794
- // 字节一致;sillyhub(配置 + 路径A 落地)→ 注入完整 SillyHub 派发指令(一 Wave 一 mission /
795
- // dispatch_worker / 轮询 list_workers / 超时 kill lease 防双写 / 回收 + Local 兜底);local-fallback
796
- // (配置但路径A 未落地)→ 短提示,派发仍走 Local(与默认行为一致)。worktreePath 为空时不注入
797
- // (无 worktree 无谓派发指令)。测试/调用方可经 options.dispatchMode 覆盖,避免 env 污染。
798
- const dispatchMode = options.dispatchMode || getDispatchMode()
799
- let dispatchSection = ''
800
- if (worktreePath && dispatchMode === 'sillyhub') {
801
- // 路径A 落地后:注入完整 SillyHub 派发指令(renderDispatchInstruction 已含 Local 兜底全文)
802
- const contract = {
803
- brief: '本 Wave 任务(见上方任务摘要与 tasks/task-XX.md)',
804
- worktreePath,
805
- branch: options.branch || '<未提供 branch>',
806
- allowedPaths: [],
807
- readOnly: false,
808
- runId: '{EXECUTE_RUN_ID}', // 占位符,prompt.js 注入阶段替换为真实 run id
809
- }
810
- const { instruction } = renderDispatchInstruction(contract, { available: true })
811
- dispatchSection = `\n### 派发后端:SillyHub MCP(探测可用,一 Wave 一 mission)\n\n本次 Wave 派发经 SillyHub MCP。按以下派发指令执行(含 create_mission / dispatch_worker / 轮询 list_workers / 超时 kill lease 防双写 / 回收 + Local 兜底):\n\n${instruction}\n`
812
- } else if (worktreePath && dispatchMode === 'local-fallback') {
813
- // 有 MCP 配置但路径A 未落地:加短提示,派发仍走 Local(与默认行为一致)
814
- dispatchSection = `\n### 派发后端提示:SillyHub MCP 已配置但路径A 未落地\n\n检测到 local.yaml mcp 段或 env 配置,但 SillyHub \`dispatch_worker\` 尚不支持 \`worktree_path\`(路径A 跨仓未落地)。本次派发走 Local(本机 Agent tool),与默认行为一致——上方「执行方式」与「工作目录」段适用。\n`
815
- }
816
- // dispatchMode === 'local'(无配置)或 worktreePath 为空 → dispatchSection = '' → 输出与改前字节一致(零回归)
817
-
818
- return `## Wave ${waveIndex}: 执行以下任务
819
-
820
- ## 执行方式(必须严格遵守)
821
-
822
- **每个任务必须由独立子代理执行,你不要自己写代码。**
823
-
824
- 你的角色是调度者 + 审查者:
825
- 1. 为每个任务启动一个子代理(Agent tool),同 Wave 内可并行
826
- 2. 子代理完成后审查结果
827
- 3. 勾选 plan.md 中的 checkbox
828
- 4. 记录改动文件和测试结果
829
-
830
- ${worktreeSection}${crossRepoCommitSection}${dispatchSection}
831
- ### 任务摘要(按需读取完整蓝图)
832
- 为每个任务启动子代理时,**只需告知任务目标和蓝图文件路径,让子代理按需读取**:
833
-
834
- ${taskSummary}
835
-
836
- 子代理 prompt 要点:
837
- 1. 任务目标(简短描述)
838
- 2. 蓝图文件路径(让子代理自行读取详情)
839
- 3. 编码铁律:先读后写、TDD、不编造方法、只做蓝图里写的事、遵守边界处理规则、不超出 allowed_paths
840
- 4. 如存在模块文档(.sillyspec/docs/*/modules/),按需读取涉及模块的 <module>.md 参考接口约定和数据流
841
- 5. 任务含测试代码时,把下方「测试用例设计」整段复制进子代理 prompt,要求子代理按此设计测试用例
842
-
843
- {{include: testcase-design}}
844
-
845
- ### Wave 开始前
846
- 1. 读取 design.md 的「非目标」与「兼容策略」章节(如存在),确保子代理不超范围、不破坏旧逻辑
847
- 2. 读取 plan.md 了解全局任务划分和依赖关系
848
- 3. 确认本 Wave 的输入/输出契约(前置 Wave 产出了什么,本 Wave 需要消费什么)
849
- 4. 检查前置 Wave 的产出是否完整(文件是否存在、测试是否通过)
850
- 5. **上下文分层加载**:
851
- - 🔥 热上下文:design.md 非目标/兼容策略 + 当前 Wave 任务(必须加载)
852
- - 🌡️ 温上下文:CONVENTIONS.md + ARCHITECTURE.md(需要时加载)
853
- - ❄️ 冷上下文:其他变更的 design.md、历史 plan.md(不要主动加载,除非明确需要)
854
-
855
- ### 中断续跑(如曾中断恢复)
856
- execute 按 Wave 持久化进度,task 级进度靠 plan.md checkbox 勾选。若本 Wave 曾因 429/API 配额/崩溃中断:
857
- - plan.md 中**已勾选 \`- [x]\` 的 task 已完成,跳过不重跑**(子代理也可能在完成前中断,重跑前先确认该 task 产出文件是否完整)
858
- - 用 \`sillyspec status\` 查当前进度,重新 \`sillyspec run execute\` 会回到当前 Wave step 继续,**不要从零重置或重跑已完成 Wave**
859
- - 本 Wave 已完成但不完整(产出缺文件)的 task 补做,不牵连其他 task
860
-
861
- ${contractInjection}${prototypeInjection}
862
- ### 本 Wave 任务
863
- ${taskList}
864
-
865
- ### 调度要求
866
- 1. **同一 Wave 内的任务必须并行启动子代理,禁止串行等待。** Wave 的定义就是"无依赖、可并行",不要自行分析依赖关系。如果有依赖应该在 plan.md 的不同 Wave 中。
867
- 2. **Reverse Sync**:子代理报告实现与 design.md 不一致时,先检查是代码错了还是文档有遗漏
868
- 3. **不要频繁编译!** 编译很慢,只在以下情况运行:
869
- - 写了大量代码后需要验证语法正确性
870
- - 最后一个 Wave 完成后做一次全量编译验证
871
- - 用户明确要求编译时
872
- 4. 每个任务完成后:
873
- - **先写 review.json 再勾选 checkbox**(见下方 Task Review Gate)
874
- - **既跑 lint check 也跑 formatter**:凡变更涉及的源码跑项目的 lint 检查 **和** 格式化(如 \`ruff format\` / \`prettier --write\`),不要只跑 check——只 check 不 format 会把格式问题留到 commit 时被 pre-commit hook 拦截(worktree 内二进制可能缺失,先 \`which <bin>\` 确认,缺则 \`uv tool install\` / \`uv sync\`)
875
- - 记录改动文件和测试结果
876
- 5. 遇到 BLOCKED → 记录原因,选择:重试/跳过/停止
877
-
878
- ### Task Review Gate(必须执行,不可跳过)
879
-
880
- 每个子代理完成后、勾选 checkbox **之前**,你必须创建 task review。
881
-
882
- **操作步骤:**
883
- 1. 读取当前 task 的 git diff(从 task 开始到完成的变更)
884
- 2. 对照 plan.md 中该 task 的描述和 tasks/task-XX.md(如果存在)检查实现是否符合要求
885
- 3. 写入 review.json 文件
886
- 4. **只有 review.json 写入成功后,才允许勾选 plan.md 中的 checkbox**
887
-
888
- **review.json 路径:**
889
-
890
- task-XX 对应:{SPEC_ROOT}/.runtime/execute-runs/{EXECUTE_RUN_ID}/tasks/task-XX/review.json
891
-
892
- 本 execute run 的固定 ID 是:{EXECUTE_RUN_ID}
893
- **所有 task 的 review.json 必须使用这个 ID,不要自行创建新目录。**
894
-
895
- **review.json 必填字段:**
896
-
897
- { "name_zh": "任务评审", "schemaVersion": {REVIEW_SCHEMA_VERSION}, "task": "task-XX", "base": "<git-base-commit>", "head": "<git-head-commit>",
898
- "changedFiles": ["src/foo.js"], "specVerdict": "pass|fail|cannot_verify",
899
- "qualityVerdict": "pass|fail|cannot_verify", "reviewerNotes": "评审说明",
900
- "requiredEvidence": [] }
901
-
902
- **评审铁律:**
903
- - 不信任 implementer 自报结果,对照 diff 和 task brief 验证
904
- - 只看当前 task 的 diff,不做全仓库漫游审查
905
- - \`cannot_verify\` 只在确实无法验证且有待补充证据时使用,且 requiredEvidence 必须非空
906
- - \`sillyspec run execute --done\` 会校验所有 task 的 review.json,缺失或 fail 会阻断完成
907
-
908
- ### module-impact.md 更新(主代理在本 Wave 所有 task 完成后汇总)
909
- 本 Wave 内所有 task 子代理完成、review.json 写好后,**由你(主代理/调度者)**汇总本 Wave 的实际代码变更,更新 {SPEC_ROOT}/changes/<change>/module-impact.md(plan 阶段已生成首版):
910
- - 基于本 Wave 各 task 的实际 git diff(不是计划)+ {SPEC_ROOT}/docs/<project>/modules/_module-map.yaml 对照
911
- - 更新受影响模块的影响类型/说明(实际改动可能与 plan 首版预估不同,据实修正)
912
- - **不由各 task 子代理分别改**(同 Wave 并行子代理改同一文件会互相覆盖)——只由主代理在 Wave 收尾统一更新一次
913
- - 无 _module-map.yaml 时跳过模块匹配,仅按文件清单更新 unmapped 部分
914
- 这是可选更新(不阻断 execute 完成),但保持 module-impact 与实际变更一致利于 verify 核对与 archive 终审。
915
-
916
- ### 完成后
917
- 1. 为每个后端 router task,扫描变更文件提取 API 端点 artifact:
918
- - 在变更文件中搜索所有 router 注册路径(@router.get/post/put/delete)
919
- - 将端点清单写入 {SPEC_ROOT}/.runtime/contract-artifacts/<task-name>/endpoints.json
920
- - 格式: { "task": "task-XX", "type": "backend_endpoints", "endpoints": [{ "method": "GET", "path": "/api/ppm/xxx" }] }
921
- `
922
- }
923
-
924
- /**
925
- * 动态构建 execute 步骤列表
926
- * @param {string|null} planFilePath - plan 文件路径,null 则用默认 3 Wave
927
- * @param {{ worktreePath?: string, noWorktree?: boolean }} options
928
- * @returns {Array} 步骤列表
929
- */
930
- export function buildExecuteSteps(planFilePath = null, options = {}) {
931
- const noWorktree = !!options.noWorktree
932
- let waves
933
- let changeDir = null
934
-
935
- if (planFilePath && existsSync(planFilePath)) {
936
- const planContent = readFileSync(planFilePath, 'utf8')
937
- // Plan → Execute 契约由 plan 阶段完成时的 postcheck 把关(run.js completeStep),
938
- // 此处只负责解析 waves,避免 buildExecuteSteps 与进程退出耦合。
939
- waves = parseWavesFromPlan(planContent)
940
- changeDir = path.dirname(planFilePath)
941
- }
942
-
943
- // 没解析出 Wave(plan 不存在或不含可识别 task)→ 默认 3 Wave(向后兼容)
944
- if (!waves || waves.length === 0) {
945
- waves = []
946
- for (let i = 1; i <= 3; i++) {
947
- waves.push({ index: i, tasks: [{ name: `默认任务 ${i}`, file: 'TBD' }] })
948
- }
949
- }
950
-
951
- // 尝试获取 worktree 路径(可能由前缀步骤创建)
952
- const worktreePath = options.worktreePath || null
953
-
954
- // W3 task-08:ctx 由 execute 启动入口(task-09)透传,进程级单例贯穿 execute/apply/verify
955
- // (D-013)。缺省=null → buildWavePrompt 退化为单仓单 worktreePath(零回归)。
956
- const ctx = options.ctx || null
957
-
958
- const waveSteps = waves.map((wave, i) => ({
959
- name: `Wave ${i + 1} 执行`,
960
- mode: 'implementation',
961
- prompt: buildWavePrompt(wave, i + 1, changeDir, worktreePath, { dispatchMode: options.dispatchMode, branch: options.branch, ctx }),
962
- outputHint: `Wave ${i + 1} 执行结果`,
963
- optional: false
964
- }))
965
-
966
- return [...fixedPrefix, ...waveSteps, ...acceptanceSteps, ...fixedSuffix]
967
- }
1
+ import { existsSync, readFileSync, readdirSync, writeFileSync } from 'fs'
2
+ import path from 'path'
3
+ import { buildContractMatrix, buildConsumerInjection, buildContractFieldInjection } from '../contract-matrix.js'
4
+ import { getRule } from '../stage-contract-spec.js'
5
+ import { renderDispatchInstruction } from '../dispatch/strategy.js'
6
+ import { isPathASupported } from '../dispatch/backends/sillyhub-mcp.js'
7
+ import { readMcpConfig } from '../sillyhub-mcp/config.js'
8
+ import { gitQuiet } from '../git-helper.js'
9
+ import { parseRepo } from './plan-postcheck.js'
10
+
11
+ /**
12
+ * 校验 plan.md 是否满足 execute 执行契约
13
+ * @param {string} planContent - plan.md 文件内容
14
+ * @returns {{ ok: boolean, errors: string[], warnings: string[], tasks: object[], waves: object[] }}
15
+ */
16
+ export function validatePlanForExecute(planContent) {
17
+ const errors = []
18
+ const warnings = []
19
+
20
+ if (!planContent || !planContent.trim()) {
21
+ return { ok: false, errors: ['plan.md 内容为空'], warnings, tasks: [], waves: [] }
22
+ }
23
+
24
+ const waves = parseWavesFromPlan(planContent)
25
+
26
+ // 检查 0: Wave 编号带字母后缀(如 "## Wave 2b")→ 显式报错(坑 plan-wave-letter-suffix)
27
+ // 解析正则 ^#+\s*Wave\s+(\d+) 不锚定结尾,"Wave 2b" 会被 parseInt 截断成 Wave 2 静默收容,
28
+ // 与显式 Wave 2 合并为同一 Wave 强制并行——串行意图静默失效且无任何提示,排查到源码才明白。
29
+ const letterSuffixWaves = [...planContent.matchAll(/^#+\s*Wave\s+(\d+)([a-z])(?![a-z0-9])/gim)]
30
+ for (const m of letterSuffixWaves) {
31
+ errors.push(
32
+ `Wave 编号带字母后缀:${m[0].trim()}("${m[1]}${m[2]}")不被支持。` +
33
+ `解析器只认纯数字编号("## Wave N"),字母后缀会被截断为 "${m[1]}" 与显式 Wave ${m[1]} 静默合并强制并行。` +
34
+ `解法:改顺序纯数字编号(Wave 3/4…)或并入相邻 Wave`
35
+ )
36
+ }
37
+
38
+ // 收集所有 task
39
+ const allTasks = []
40
+ for (const wave of waves) {
41
+ for (const task of wave.tasks) {
42
+ allTasks.push(task)
43
+ }
44
+ }
45
+
46
+ // 检查 1: 至少有一个 checkbox task
47
+ if (allTasks.length === 0) {
48
+ // 诊断根因(坑 plan-md-format-contract-hidden):笼统报"没有找到 checkbox task"逼 agent 试错。
49
+ // 区分:无 Wave/Tasks 段 / Wave 标题格式不对 / task 用 ### 标题 / checkbox 被非 Wave 标题打断。
50
+ const diags = diagnoseNoTaskRootCause(planContent)
51
+ errors.push('plan.md 中没有找到 checkbox task(格式: "- [ ] task-XX: 任务名")')
52
+ for (const d of diags) errors.push(` 诊断:${d}`)
53
+ return { ok: false, errors, warnings, tasks: allTasks, waves }
54
+ }
55
+
56
+ // 检查 2: task id 唯一性
57
+ const idCounts = {}
58
+ for (const task of allTasks) {
59
+ if (task.index != null) {
60
+ const key = `task-${task.index}`
61
+ idCounts[key] = (idCounts[key] || 0) + 1
62
+ }
63
+ }
64
+ for (const [id, count] of Object.entries(idCounts)) {
65
+ if (count > 1) {
66
+ errors.push(`task id 重复: ${id} 出现 ${count} 次`)
67
+ }
68
+ }
69
+
70
+ // 检查 3: task id 连续性(从 1 开始)
71
+ const ids = allTasks
72
+ .map(t => t.index)
73
+ .filter(i => i != null)
74
+ .sort((a, b) => a - b)
75
+ if (ids.length > 0) {
76
+ const expected = Array.from({ length: ids.length }, (_, i) => ids[0] + i)
77
+ // 只检查以 task-01 起始的情况(常见模式)
78
+ if (ids[0] === 1) {
79
+ for (let i = 0; i < ids.length; i++) {
80
+ if (ids[i] !== i + 1) {
81
+ errors.push(getRule('plan.task-id-continuity').failMessage.replaceAll('${expected}', String(i + 1).padStart(2, '0')).replaceAll('${actual}', String(ids[i]).padStart(2, '0')))
82
+ break
83
+ }
84
+ }
85
+ }
86
+ }
87
+
88
+ // 检查 4: task name 非空
89
+ for (const task of allTasks) {
90
+ if (!task.name || !task.name.trim()) {
91
+ errors.push(`task-${String(task.index || '?').padStart(2, '0')}: 任务名为空`)
92
+ }
93
+ }
94
+
95
+ // 检查 5: task 无 id 的 warning(不限制只在有 id 时检查)
96
+ for (const wave of waves) {
97
+ for (const task of wave.tasks) {
98
+ if (task.index == null) {
99
+ warnings.push(`Wave ${wave.index}: task "${task.name}" 没有 task id(建议格式 task-XX: 名称)`)
100
+ }
101
+ }
102
+ }
103
+
104
+ return { ok: errors.length === 0, errors, warnings, tasks: allTasks, waves }
105
+ }
106
+
107
+ export const definition = {
108
+ name: 'execute',
109
+ title: '波次执行',
110
+ description: '子代理并行 + 强制 TDD + 两阶段审查',
111
+ steps: [] // 动态构建,由 buildExecuteSteps() 生成
112
+ }
113
+
114
+ // 固定前缀步骤定义
115
+ const fixedPrefix = [
116
+ {
117
+ name: '进度确认',
118
+ migratedFrom: ['状态检查'],
119
+ prompt: `检查当前进度,确认可以执行 execute。用 \`sillyspec progress show\` 查流程进度,不要用 \`sillyspec status\`(项目级快照,不推进流程)。
120
+
121
+ ### 操作
122
+ 1. 运行 \`sillyspec progress show\`
123
+ 2. 确认 currentStage 为 execute
124
+ 3. 如果不是 → 检查是否有未完成的 tasks.md
125
+ 4. 确认执行范围($ARGUMENTS 指定 wave/task 或全部)
126
+
127
+ ### 输出
128
+ 当前状态 + 执行范围确认`,
129
+ outputHint: '当前状态 + 执行范围',
130
+ optional: false
131
+ },
132
+ {
133
+ name: '加载上下文',
134
+ prompt: `加载计划、设计和代码库上下文。
135
+
136
+ ### 操作
137
+ 1. 读取 tasks.md(执行计划)
138
+ 2. 读取 design.md(技术方案)
139
+ 3. 读取 CONVENTIONS.md、ARCHITECTURE.md
140
+ 4. 读取 local.yaml(构建命令);若 local.yaml 不存在,先 \`sillyspec local detect\` 生成骨架再读取
141
+ 5. 加载项目总览 \`.sillyspec/docs/<project>/scan/PROJECT.md\`(如存在)
142
+
143
+ ### 模块文档加载
144
+ 6. 读取 \`.sillyspec/docs/<project>/modules/_module-map.yaml\`(不存在则跳过以下步骤)
145
+ 7. 根据 plan.md 中的任务文件路径匹配 _module-map.yaml 中的模块
146
+ 8. 读取匹配到的 \`.sillyspec/docs/<project>/modules/<module>.md\`
147
+ 9. 实现代码时遵循模块文档中描述的接口约定、数据流和依赖关系
148
+ 10. **利用模块索引快速定位源码**:
149
+ - 用 entrypoints 字段直接找到模块对外 API 的源码位置
150
+ - 用 main_symbols 字段找到核心类/函数的定义位置
151
+ - 子代理优先读模块卡片理解语义,再读 entrypoints/main_symbols 对应的源码
152
+
153
+ ### 符号影响面扩展检查
154
+ 11. **符号影响面扫描**(Critical — execute 前必做):
155
+ - 读取所有 tasks/task-NN.md,提取每个任务涉及的修改文件
156
+ - 对每个修改文件,检查是否涉及以下变更类型:
157
+ - class 构造函数参数变更(新增/删除/修改参数)
158
+ - 接口(interface)定义变更
159
+ - DTO / 类型定义变更
160
+ - API client 方法签名变更
161
+ - 函数/方法签名变更(参数增删改)
162
+ - 如果涉及上述变更类型,执行调用点搜索:
163
+ \`\`\`bash
164
+ rg "new ClassName\(" src/
165
+ rg "ClassName\(" src/
166
+ rg "methodName\(" src/
167
+ rg "import.*from.*filePath" src/
168
+ \`\`\`
169
+ - 将搜索到的调用点与 plan.md 和 tasks/task-NN.md 的 allowed_paths 对比
170
+ - **发现调用点不在任何 task 的 allowed_paths 中 → 直接阻断 execute**
171
+ - 报告:列出每个受影响符号、调用点位置、是否在任务范围内
172
+ - 如果调用点不在范围内但任务明确写了"不改原因",记录但不阻断
173
+
174
+ ### 输出
175
+ 已加载的上下文摘要(含模块文档 + 源码锚点)`,
176
+ outputHint: '上下文摘要',
177
+ optional: false
178
+ },
179
+ {
180
+ name: '确认 worktree 路径',
181
+ prompt: `确认当前 worktree 状态,提取隔离路径。
182
+
183
+ ### 操作
184
+ 1. 运行 \`sillyspec worktree meta <change-name>\` 读取 meta.json
185
+ 2. 从输出中提取 worktreePath、branch、mode 字段
186
+ 3. 确认 worktree 目录存在(如果是 worktree/native-worktree 模式)
187
+ 4. **确认工具链可用**:worktree 内项目工具链(lint/format/test 二进制,如 ruff / prettier / uv)可能不全——对本次会用到的工具先跑一次 \`--version\` 确认;缺失则按项目方式安装(Python 项目 \`uv tool install ruff\` / \`uv sync\`,Node 项目 node_modules 已由 CLI 链接主仓)。不要等到 commit 才发现二进制不在 PATH 被 hook 拦
188
+
189
+ ### 铁律
190
+ - **worktree 已由 CLI 在 execute 阶段启动时自动创建,不要自行创建或跳过**
191
+ - **后续所有子代理的 cwd 必须设为该 worktree 路径**
192
+ - 如果 meta.json 不存在(说明创建失败),停止并报错
193
+ - **不要自行检查 git dirty/uncommitted 状态来判断是否可以进入 worktree,CLI 已自动处理**
194
+
195
+ ### 输出
196
+ worktree 路径 + 分支名 + 模式
197
+
198
+ `,
199
+ outputHint: 'worktree 路径 + 分支名 + 模式',
200
+ optional: false
201
+ },
202
+ {
203
+ name: '确认执行范围',
204
+ prompt: `解析任务,确认执行范围和确认模式。
205
+
206
+ ### 操作
207
+ 1. 从 plan 中解析 Wave 分组和任务列表
208
+ 2. 模型档位:若 tasks.md 中某 task 标注了 [model:xxx],启动该 task 子代理时按标签选模型(档位由 plan 阶段或用户在 tasks.md 显式标注,execute 不在此自动建议——关键词→档位无统一映射,自动建议反而易误导)
209
+ 3. 确认频率:默认每个 Wave 完成后展示结果(wave 模式);用户口头指定按 Task 展示或全自动时遵从
210
+ 4. 查询知识库:读取 \`.sillyspec/knowledge/INDEX.md\`,根据 Task 关键词匹配
211
+
212
+ ### 知识命中报告
213
+ {KNOWLEDGE_HIT_REPORT}
214
+
215
+ 如上所示的知识条目与本次任务相关。请阅读这些条目以获取项目约定和已知模式。
216
+ 如无命中条目(Status: no matches),跳过本节。
217
+
218
+ ### 模块文档欠账(CLI 算事实)
219
+ {DOCS_DEBT}
220
+
221
+ 如上为 CLI 用 git 事实算出的本变更触及模块文档欠账(无输出=无欠账或归属数据缺失)。
222
+ 欠账处理:Wave 收尾时顺手同步对应模块卡(变更索引追加条目),不必为此停下。
223
+
224
+ ### 铁律
225
+ - **不要询问用户确认频率**,默认 wave 模式;用户已明确口头指定时遵从其指定`,
226
+ outputHint: 'Wave 分组 + 模型分配',
227
+ optional: false
228
+ }
229
+ ]
230
+
231
+ // 全局验收步骤定义
232
+ const acceptanceSteps = [
233
+ {
234
+ name: '对照设计检查',
235
+ mode: 'acceptance',
236
+ prompt: `对照 design.md 检查所有实现是否与设计一致。
237
+
238
+ ### 执行方式(CLI 按变更规模判定,占位符由 run.js 注入)
239
+ tier: {REVIEW_TIER}({REVIEW_TIER_REASON})
240
+ - tier=self:当前 agent 汇总执行(对照 design.md 逐项检查 + 偏差说明)
241
+ - tier=independent:必须用 Agent tool 启动一个独立的 QA 子代理(独立上下文,不共享实现者的分析),子代理对照 design.md 逐项检查实现一致性并输出 review.json。review.json 产物契约(CLI Stage Review Gate 将硬校验,schema + 完整示例 + docHash 算法如下,照抄改值;reviewedFiles 除主文档 design.md 外可追加 git diff 涉及的源码文件):
242
+ {REVIEW_JSON_CONTRACT}
243
+ 该 acceptance review 同时覆盖"代码审查"视角(风格/bug/安全/冗余),后续代码审查步骤仅需轻量复审。
244
+
245
+ ### 操作
246
+ 1. 读取 design.md(技术方案)
247
+ 2. 逐一对照 design.md 中的设计要点与实际代码实现
248
+ 3. 检查接口签名、数据结构、模块划分是否一致
249
+ 4. 记录偏差项(偏差 ≠ 错误,可能是合理的实现调整)
250
+
251
+ ### 输出
252
+ 检查清单:每项设计要点的实现状态 ✅/⚠️/❌ + 偏差说明`,
253
+ outputHint: '设计对照检查清单',
254
+ optional: false
255
+ },
256
+ {
257
+ name: '运行测试',
258
+ mode: 'acceptance',
259
+ prompt: `运行所有测试,验证代码质量。
260
+
261
+ ### 执行方式
262
+ 本步骤由当前 agent 执行,不需要启动独立子代理。
263
+
264
+ ### 操作
265
+ 1. 读取 local.yaml 获取构建和测试命令;若 local.yaml 不存在,先 \`sillyspec local detect\` 生成骨架再读取
266
+ 2. 运行测试套件(单元测试、集成测试)
267
+ 3. 运行 lint 检查 **+ 格式化**:凡变更涉及的源码,既跑 lint check 也跑 formatter(如 \`ruff format\` / \`prettier --write\` / \`black\`),不要只跑 check——只 check 不 format 会把格式问题留到 commit 时被 pre-commit hook 拦截
268
+ 4. 如果有测试失败 → 分析原因,标注是代码问题还是测试本身的问题
269
+ 5. 汇总测试结果
270
+
271
+ ### 铁律
272
+ - 长测试/构建/lint 命令必须**前台同步执行**,禁止 run_in_background:true / & / nohup / disown——后台任务易被会话生命周期回收导致中断无果
273
+
274
+ ### 输出
275
+ 测试结果摘要:通过/失败/跳过数量 + 失败项分析`,
276
+ outputHint: '测试结果摘要',
277
+ optional: false
278
+ },
279
+ {
280
+ name: '代码审查',
281
+ mode: 'acceptance',
282
+ prompt: `对本次变更进行代码审查。
283
+
284
+ ### 执行方式
285
+ 本步骤由当前 agent 或一个 QA agent 汇总执行,不需要为每个文件启动独立子代理。
286
+
287
+ ### 操作
288
+ 1. 检查 git diff 查看所有变更
289
+ 2. 审查要点:
290
+ - 代码风格是否符合 CONVENTIONS.md
291
+ - 是否有明显的 bug 或安全漏洞
292
+ - 是否有未处理的 TODO/FIXME
293
+ - 错误处理是否完善
294
+ - 是否有冗余代码或可简化的逻辑
295
+ 3. 对照 ARCHITECTURE.md 检查架构合规性
296
+
297
+ ### 输出
298
+ 审查结果:问题列表(严重程度 + 建议修复方式)+ 总体评价`,
299
+ outputHint: '代码审查结果',
300
+ optional: true
301
+ }
302
+ ]
303
+
304
+ // 固定后缀步骤定义
305
+ const fixedSuffix = [
306
+ {
307
+ name: '知识库审阅',
308
+ prompt: `检查本轮执行产生的新知识。
309
+
310
+ ### 操作
311
+ 1. 检查 \`.sillyspec/knowledge/uncategorized.md\` 中待确认条目
312
+ 2. 如有 → 提示用户审阅
313
+ 3. 用户确认后改为 [已确认],可归类到专题文件
314
+
315
+ ### 输出
316
+ 新知识条目数量 + 审阅提示(或"无新知识")`,
317
+ outputHint: '知识条目数量',
318
+ optional: true
319
+ },
320
+ {
321
+ name: '完成确认',
322
+ prompt: `所有任务完成后的收尾。
323
+
324
+ 先检查当前 worktree 的隔离模式:
325
+ \`\`\`bash
326
+ node -e "import('./src/worktree.js').then(w => { const wm = new w.WorktreeManager(); const m = wm.getMeta('<change-name>'); console.log(m ? JSON.stringify({mode: m.mode, path: m.worktreePath}) : 'no meta'); })"
327
+ \`\`\`
328
+
329
+ ### 操作(mode = worktree,SillySpec 创建的隔离 worktree)
330
+
331
+ **自动审计流程(不需要用户确认代码):**
332
+
333
+ 1. 运行 \`sillyspec worktree assess <change-name>\` 自动风险审计
334
+ 2. 系统自动检查:
335
+ - patch --check 是否通过
336
+ - 变更是否在 allowed_paths 内
337
+ - 主工作区是否有未提交 dirty(拦截;已提交推进交 --3way 自动三路合并)
338
+ - 是否有高风险文件(lockfile/migration/配置/入口)
339
+ - diff 规模是否异常
340
+ 3. 输出 Apply Decision:
341
+
342
+ \`\`\`
343
+ Worktree Apply Decision
344
+ ────────────────────────
345
+ Decision: SAFE | WARNING | BLOCKED
346
+ Changed files: N
347
+ Additions: +N Deletions: -N
348
+ Risky files: none | <list>
349
+ Action: auto-applied | blocked
350
+ \`\`\`
351
+
352
+ 4. **SAFE** → 自动 \`sillyspec worktree apply <change-name>\` + cleanup
353
+ 5. **WARNING** → 自动 apply(有警告但不阻断)+ cleanup
354
+ 6. **BLOCKED** → 不 apply,输出原因,提示用户检查:
355
+ - \`sillyspec worktree diff <change-name>\` 查看具体变更
356
+ - \`sillyspec worktree cleanup <change-name>\` 丢弃
357
+ 7. 建议下一步:\`sillyspec run verify\`
358
+
359
+ ### 操作(mode = native-worktree,用户已有的 linked worktree)
360
+ 1. 同上自动审计流程
361
+ 2. SAFE/WARNING → \`sillyspec worktree apply <change-name>\`
362
+ 3. **不要运行 cleanup**
363
+ 4. 输出 Worktree: kept
364
+ 5. 建议下一步:\`sillyspec run verify\`
365
+
366
+ ### 操作(mode = in-place-fallback,降级模式无隔离目录)
367
+ 1. 展示本次执行摘要(\`git diff\` 查看变更)
368
+ 2. 跳过 apply 和 cleanup
369
+ 3. 输出 Worktree: none
370
+ 4. 建议下一步:\`sillyspec run verify\`
371
+
372
+ ### 输出
373
+ Apply Decision + 下一步建议
374
+
375
+ ### 注意
376
+ - 完成后运行 \`sillyspec run execute --done\` 即可自动推进阶段`,
377
+ outputHint: 'apply 结果',
378
+ optional: false
379
+ }
380
+ ]
381
+
382
+ /**
383
+ * 诊断 plan.md 无 checkbox task 的根因(坑 plan-md-format-contract-hidden)。
384
+ * 纯函数:读 planContent 文本,返回诊断提示数组。validatePlanForExecute 无 task 时追加到报错,
385
+ * 帮 agent 区分 4 条隐性格式契约(Wave 标题字面 / task checkbox / task-XX 编号 / ### 打断 Wave 段)。
386
+ * @param {string} planContent
387
+ * @returns {string[]}
388
+ */
389
+ function diagnoseNoTaskRootCause(planContent) {
390
+ const diags = []
391
+ const hasWaveHeading = /^#+\s*Wave\s+\d+/im.test(planContent) // "## Wave N"(字面)
392
+ const hasWaveWord = /^#+\s*(wave\s*\d+|w\d+|波次\s*\d+)/im.test(planContent) // 疑似 Wave 标题(含 W1/Wave1/波次1)
393
+ const hasCheckboxTask = /^[-*]\s*\[[ x]\]\s*task-\d+/im.test(planContent) // "- [ ] task-XX:" checkbox
394
+ const hasTaskHeading = /^#+\s*task-\d+/im.test(planContent) // "### task-XX:" 标题(非 checkbox)
395
+ const hasTasksSection = /^#+\s*(tasks?|任务)\s*$/im.test(planContent) // "## Tasks" / "## 任务"
396
+
397
+ if (!hasWaveHeading && !hasTasksSection) {
398
+ if (hasWaveWord) {
399
+ diags.push('Wave 标题格式不对:必须字面 "## Wave N"(Wave + 空格 + 数字),"## W1" / "## Wave1" / "## 波次1" 都不被识别')
400
+ } else if (hasCheckboxTask) {
401
+ diags.push('有 task checkbox 但不在 "## Wave N"(full)或 "## Tasks"(light/none)段内——非任务区的 checkbox 不收')
402
+ } else {
403
+ diags.push('plan.md 缺任务区:full 需 "## Wave 1" 标题,light/none 需 "## Tasks" 标题')
404
+ }
405
+ }
406
+ if (hasTaskHeading) {
407
+ diags.push('task 用了 "### task-XX:" 标题(非 checkbox):标题会打断 Wave 段导致后续 checkbox 全被跳过,请改用 "- [ ] task-XX: 名称"(英文冒号)')
408
+ }
409
+ if (hasWaveHeading && hasCheckboxTask && !hasTaskHeading) {
410
+ diags.push('有 Wave 段也有 checkbox task 但未收容:checkbox 可能落在非任务区(如 "## 自检" 段)或被 "###" 标题打断,请检查 task 是否紧跟在 "## Wave N" 下')
411
+ }
412
+ return diags
413
+ }
414
+
415
+ /**
416
+ * 从 plan 文件解析 Wave 分组
417
+ */
418
+ function parseWavesFromPlan(planContent) {
419
+ const waves = []
420
+ const lines = planContent.split('\n')
421
+ let currentWave = null
422
+ let currentTask = null
423
+ // light/none plan.md 用 `## Tasks`(无 `## Wave N`)包任务,需识别为隐式任务区,
424
+ // 让其中的 task checkbox 能被收进惰性创建的隐式 Wave(见下方 taskMatch 分支)。
425
+ let inImplicitTaskSection = false
426
+
427
+ for (const line of lines) {
428
+ const waveMatch = line.match(/^#+\s*Wave\s+(\d+)/i)
429
+ if (waveMatch) {
430
+ currentWave = { index: parseInt(waveMatch[1]), tasks: [] }
431
+ currentTask = null
432
+ inImplicitTaskSection = false
433
+ waves.push(currentWave)
434
+ continue
435
+ }
436
+
437
+ // 任何非 Wave 的标题行:
438
+ // 1) 退出当前显式 Wave 段,避免「## 自检」段里的 - [x] checkbox 被误当 task 定义解析
439
+ // (导致 Contract 校验报 task id 重复/不连续,详见 docs/sillyspec/plan-postcheck-self-check-checkbox-false-dup.md)
440
+ // 2) 识别 light/none 的 `## Tasks`/`## 任务` 任务区(无 Wave 标题),置位隐式任务区标志
441
+ const headingMatch = line.match(/^#+\s+(.+?)\s*$/)
442
+ if (headingMatch) {
443
+ currentWave = null
444
+ currentTask = null
445
+ const headingText = headingMatch[1].trim().toLowerCase()
446
+ inImplicitTaskSection = /^(tasks?|任务)$/.test(headingText)
447
+ continue
448
+ }
449
+
450
+ const taskMatch = line.match(/^[-*]\s*\[[ x]\]\s*(.+)/)
451
+ if (taskMatch) {
452
+ const taskNoMatch = taskMatch[1].match(/\btask-(\d+)\b/i)
453
+ // full plan.md:task 必须在显式 Wave 段内(currentWave 非 null),正常收容。
454
+ // light/none plan.md(无 Wave 标题,任务在 `## Tasks` 下):在隐式任务区内,遇含
455
+ // task-XX 编号的 checkbox 时惰性创建隐式 Wave 收容,否则 validatePlanForExecute 会报
456
+ // "没有找到 checkbox task"。详见 docs/sillyspec/plan-light-needs-wave-heading.md
457
+ // 不收的情况:非任务区(## 自检/## 验收 等)的 checkbox,或任务区内无 task-XX 编号的 checkbox。
458
+ if (!currentWave) {
459
+ if (!inImplicitTaskSection || !taskNoMatch) continue
460
+ const nextIndex = waves.length === 0 ? 1 : (waves[waves.length - 1].index || waves.length) + 1
461
+ currentWave = { index: nextIndex, tasks: [], implicit: true }
462
+ currentTask = null
463
+ waves.push(currentWave)
464
+ }
465
+ currentTask = {
466
+ index: taskNoMatch ? parseInt(taskNoMatch[1], 10) : null,
467
+ name: taskMatch[1].trim(),
468
+ file: '',
469
+ steps: '',
470
+ reference: ''
471
+ }
472
+ // 兼容旧格式:任务名后跟 (文件路径)
473
+ const fileMatch = taskMatch[1].match(/\(([^)]+)\)$/)
474
+ if (fileMatch) {
475
+ currentTask.file = fileMatch[1]
476
+ currentTask.name = taskMatch[1].replace(/\([^)]+\)$/, '').trim()
477
+ }
478
+ currentWave.tasks.push(currentTask)
479
+ continue
480
+ }
481
+
482
+ // 解析子行信息(修改/参考/步骤)
483
+ if (currentTask) {
484
+ const modMatch = line.match(/^\s+-\s*修改:\s*(.+)/)
485
+ if (modMatch) { currentTask.file = modMatch[1].trim(); continue }
486
+
487
+ const refMatch = line.match(/^\s+-\s*参考:\s*(.+)/)
488
+ if (refMatch) { currentTask.reference = refMatch[1].trim(); continue }
489
+
490
+ const stepMatch = line.match(/^\s+-\s*步骤:/)
491
+ if (stepMatch) { currentTask.steps = line.replace(/^\s+-\s*步骤:\s*/, '').trim(); continue }
492
+
493
+ // 步骤续行(数字开头的子步骤)
494
+ if (currentTask.steps && line.match(/^\s+\d+\./)) {
495
+ currentTask.steps += '\n' + line.trim()
496
+ }
497
+ }
498
+ }
499
+
500
+ return waves
501
+ }
502
+
503
+ /**
504
+ * 同步判定派发后端模式(不发网络,零回归关键)— task-07
505
+ *
506
+ * stub 下 isPathASupported()=false → 有配置也只 local-fallback(加提示),不注入完整 SillyHub 指令。
507
+ * 现有测试套件不设 SILLYHUB_MCP_URL/TOKEN 且无 local.yaml mcp 段 → readMcpConfig 返回 null → 默认 'local' → buildWavePrompt 不注入派发段,
508
+ * 输出与改前字节一致(零回归,D-005)。
509
+ *
510
+ * @returns {'local' | 'local-fallback' | 'sillyhub'}
511
+ * - local:无 MCP 配置(零回归,与现状一致)
512
+ * - local-fallback:有配置但路径A 未落地(isPathASupported()=false),派发仍走 Local + 短提示
513
+ * - sillyhub:有配置且路径A 落地(isPathASupported()=true),注入完整 SillyHub 派发指令
514
+ */
515
+ export function getDispatchMode() {
516
+ const hasConfig = !!readMcpConfig(process.cwd())
517
+ if (!hasConfig) return 'local'
518
+ return isPathASupported() ? 'sillyhub' : 'local-fallback'
519
+ }
520
+
521
+ /**
522
+ * 解析 task 卡的 repo 归属(W3 task-08,D-012)。
523
+ * 读 changeDir/tasks/task-<num>.md 的 frontmatter `repo:` 字段;缺省/无卡/'main' → 'main'。
524
+ * 单仓 change(无 repo: 字段)恒返 'main',零回归。
525
+ *
526
+ * @param {{ index?: number, name?: string }} task - wave.tasks 元素
527
+ * @param {string|null} changeDir - change 目录绝对路径
528
+ * @returns {string} repoKey,'main' 或 parseRepo 返回值
529
+ */
530
+ function resolveTaskRepo(task, changeDir) {
531
+ if (!changeDir || !task || task.index == null) return 'main'
532
+ const num = String(task.index).padStart(2, '0')
533
+ const taskFile = path.join(changeDir, 'tasks', `task-${num}.md`)
534
+ if (!existsSync(taskFile)) return 'main'
535
+ try {
536
+ const content = readFileSync(taskFile, 'utf8')
537
+ const repo = parseRepo(content)
538
+ return repo || 'main'
539
+ } catch {
540
+ return 'main'
541
+ }
542
+ }
543
+
544
+ /**
545
+ * 把 base_commit 锡点写入 task 卡 frontmatter(W3 task-08,D-010)。
546
+ * 派发跨仓 task 前 CLI 实时 git rev-parse HEAD 落盘——锁定子代理起改的 base 锚点,
547
+ * 防止同 Wave 多 task 改同一跨仓仓时 HEAD 推进致 diff 范围漂移(design §5.3 约束① / R-01)。
548
+ *
549
+ * 策略:幂等写——frontmatter 有 base_commit 行则就地替换,无则紧跟 repo 行后插入(
550
+ * 无 frontmatter 时整段创建)。仅改 frontmatter 不动正文。已存在相同值不重复写。
551
+ *
552
+ * @param {string} taskFilePath - task-NN.md 绝对路径
553
+ * @param {string} baseCommit - 跨仓仓 HEAD sha
554
+ * @returns {boolean} 是否实际写入(false = 文件不存在 / 写失败 / 值未变)
555
+ */
556
+ function writeBaseCommitToTaskCard(taskFilePath, baseCommit) {
557
+ if (!taskFilePath || !baseCommit) return false
558
+ if (!existsSync(taskFilePath)) return false
559
+ let content
560
+ try {
561
+ content = readFileSync(taskFilePath, 'utf8')
562
+ } catch {
563
+ return false
564
+ }
565
+ const fmMatch = content.match(/^---\n([\s\S]*?)\n---/)
566
+ if (!fmMatch) return false
567
+ const fm = fmMatch[1]
568
+ const baseLineRe = /^base_commit:\s*.*$/m
569
+ let newFm
570
+ if (baseLineRe.test(fm)) {
571
+ const existing = (fm.match(baseLineRe) || [''])[0]
572
+ if (existing === `base_commit: ${baseCommit}`) return false // 值未变,幂等跳过
573
+ newFm = fm.replace(baseLineRe, `base_commit: ${baseCommit}`)
574
+ } else {
575
+ // 紧跟 repo 行后插入(与 D-010 协议同源顺序 repo→base_commit→head_commit)
576
+ const repoLineRe = /^repo:\s*.*$/m
577
+ const anchor = `base_commit: ${baseCommit}\n`
578
+ if (repoLineRe.test(fm)) {
579
+ newFm = fm.replace(repoLineRe, m => m + '\n' + anchor.trimEnd())
580
+ } else {
581
+ // 无 repo 行:插到 frontmatter 首部(frontmatter 首字段)
582
+ newFm = anchor.trimEnd() + '\n' + fm
583
+ }
584
+ }
585
+ const newContent = content.replace(/^---\n[\s\S]*?\n---/, `---\n${newFm}\n---`)
586
+ if (newContent === content) return false
587
+ try {
588
+ writeFileSync(taskFilePath, newContent)
589
+ return true
590
+ } catch {
591
+ return false
592
+ }
593
+ }
594
+
595
+ /**
596
+ * 为 Wave 生成 prompt(强制子代理执行)
597
+ *
598
+ * W3 task-08(D-012 per-task workdir):签名加可选 `ctx`(MultiRepoContext)参数。
599
+ * - 无 ctx(单仓 change / 旧调用方)→ 退化为单 worktreePath 单值 worktreeSection(零回归)
600
+ * - 有 ctx → 按 task 逐个解析 repo 归属,构造 per-task workdir 多值表,跨仓 task 派发前
601
+ * 落 base_commit 锡点(D-010),注入跨仓 task commit 指引 + head_commit 落盘指引。
602
+ * 同 Wave 允许主仓+跨仓 task 混合(各独立 Task 调用各传 workdir,不强制同 Wave 同 repo)。
603
+ */
604
+ export function buildWavePrompt(wave, waveIndex, changeDir, worktreePath, options = {}) {
605
+ const ctx = options.ctx || null
606
+ // ── Contract Matrix:检查是否有 provider/consumer 契约需要注入 ──
607
+ let contractInjection = ''
608
+ let prototypeInjection = ''
609
+ if (changeDir) {
610
+ try {
611
+ const planFile = path.join(changeDir, 'plan.md')
612
+ if (existsSync(planFile)) {
613
+ const planContent = readFileSync(planFile, 'utf8')
614
+ // 收集本 wave 所有 task(端点注入与字段注入共用)
615
+ const waveTasks = wave.tasks.map((t, ti) => {
616
+ const num = String(t.index || (ti + 1)).padStart(2, '0')
617
+ return `task-${num}`
618
+ })
619
+
620
+ // 1) 端点级契约(provider/consumer via buildContractMatrix)
621
+ const contracts = buildContractMatrix(planContent, changeDir)
622
+ const relevantContracts = contracts.filter(c => waveTasks.includes(c.consumer))
623
+ if (relevantContracts.length > 0) {
624
+ contractInjection = `
625
+ ### API Contract Matrix
626
+ 本 Wave 存在前端/后端跨 task 契约:
627
+ ${relevantContracts.map(c => `- **${c.consumer}** 消费 **${c.provider}** 产出的 API`).join('\n')}
628
+ `
629
+ for (const taskName of waveTasks) {
630
+ const injection = buildConsumerInjection(changeDir, path.join(changeDir, '..', '..'), taskName, contracts)
631
+ if (injection) {
632
+ contractInjection += `
633
+ ### 子代理 ${taskName} 的端点契约注入
634
+ 为 ${taskName} 启动子代理时,在子代理 prompt 末尾追加以下内容:
635
+
636
+ <contract-injection>
637
+ ${injection}
638
+ </contract-injection>
639
+ `
640
+ }
641
+ }
642
+ }
643
+
644
+ // 2) 字段级契约(expects_from ↔ provides)
645
+ // 命中「provider 漏字段、consumer fallback 编造 → 运行时 403/500」这类 bug:
646
+ // 把 consumer 期望字段 vs provider 承诺字段显式注入子代理 prompt
647
+ for (const taskName of waveTasks) {
648
+ const fi = buildContractFieldInjection(changeDir, taskName)
649
+ if (fi) {
650
+ contractInjection += `
651
+ ### 子代理 ${taskName} 的字段契约注入
652
+ 为 ${taskName} 启动子代理时,在子代理 prompt 末尾追加以下内容:
653
+
654
+ <contract-field-injection>
655
+ ${fi}
656
+ </contract-field-injection>
657
+ `
658
+ }
659
+ }
660
+ }
661
+ } catch (e) {
662
+ // 契约注入是 best-effort:失败不阻断 execute,只记录
663
+ console.warn(` ⚠️ 契约注入跳过: ${e?.message || e}`)
664
+ }
665
+
666
+ // 原型引用注入:brainstorm 阶段的 HTML 原型(确认过的布局/组件/交互),
667
+ // 让 execute 实现前端/UI task 时参考原型,而非凭 design 文字重新发明(避免原型浪费)。
668
+ try {
669
+ const prototypes = readdirSync(changeDir).filter(f => /^prototype-.*\.html$/i.test(f))
670
+ if (prototypes.length > 0) {
671
+ const protoRelDir = `.sillyspec/changes/${path.basename(changeDir)}`
672
+ prototypeInjection = `
673
+ ### 📐 原型参考(brainstorm 可视化确认)
674
+ 本次变更有 HTML 原型(brainstorm 阶段确认过的视觉/交互),实现前端/UI 相关 task 时参考:
675
+ ${prototypes.map(p => `- \`${path.join(protoRelDir, p)}\``).join('\n')}
676
+
677
+ 照原型的布局/组件/交互实现,不要凭 design.md 文字重新发明。纯后端/无 UI 的 task 忽略本节。
678
+ `
679
+ }
680
+ } catch {}
681
+ }
682
+
683
+ // 构建任务摘要(不再内联完整蓝图,减少上下文污染)
684
+ const taskSummary = wave.tasks.map((t, ti) => {
685
+ const taskNum = String(t.index || (ti + 1)).padStart(2, '0')
686
+ const taskRelPath = changeDir
687
+ ? `.sillyspec/changes/${path.basename(changeDir)}/tasks/task-${taskNum}.md`
688
+ : `task-${taskNum}.md`
689
+ const fileInfo = t.file ? ` (${t.file})` : ''
690
+ return `task-${taskNum}: ${t.name}${fileInfo} → ${taskRelPath}`
691
+ }).join('\n')
692
+
693
+ const taskList = wave.tasks.map((t, ti) => {
694
+ const taskNum = String(t.index || (ti + 1)).padStart(2, '0')
695
+ let s = `- [ ] ${t.name}`
696
+ if (t.file) s += ` (${t.file})`
697
+ return s
698
+ }).join('\n')
699
+
700
+ // ── per-task workdir 解析(W3 task-08,D-012)──
701
+ // 无 ctx(单仓 change)→ 全 task workdir=主仓 worktreePath,沿用旧单值逻辑(零回归)。
702
+ // 有 ctx → 按 task 卡 repo: 字段逐个解析:主仓 task workdir=主仓 worktreePath,跨仓 task
703
+ // workdir=ctx.resolve(repo).worktreePath(跨仓仓根)。同 Wave 允许主仓+跨仓混合。
704
+ const taskRepos = wave.tasks.map(t => ctx ? resolveTaskRepo(t, changeDir) : 'main')
705
+ const hasCrossRepoWave = ctx ? taskRepos.some(r => r !== 'main') : false
706
+
707
+ // per-task workdir 映射(用于多值 worktreeSection + 跨仓 commit 指引)
708
+ // 无 ctx 或无跨仓 task → 不构造(沿用旧单值 worktreePath 注入)
709
+ let perTaskWorkdirs = null
710
+ let crossRepoCommitSection = ''
711
+ let baseCommitWrites = [] // [{ taskNum, repo, taskFile, written }] 跨仓 task base 锡点落盘记录
712
+ if (ctx && hasCrossRepoWave) {
713
+ perTaskWorkdirs = wave.tasks.map((t, ti) => {
714
+ const taskNum = String(t.index || (ti + 1)).padStart(2, '0')
715
+ const repo = taskRepos[ti]
716
+ const entry = ctx.resolve(repo)
717
+ const workdir = entry ? entry.worktreePath : (worktreePath || '')
718
+ return { taskNum, repo, workdir }
719
+ })
720
+
721
+ // D-010 跨仓 task base 锡点:派发前实时 git rev-parse HEAD 落 task 卡 base_commit
722
+ // 仅对跨仓 task(repo≠'main')落盘,主仓 task 锚 meta.baseHash(MultiRepoContext 已处理)
723
+ for (const item of perTaskWorkdirs) {
724
+ if (item.repo === 'main') continue
725
+ const entry = ctx.resolve(item.repo)
726
+ if (!entry) continue
727
+ const baseCommit = gitQuiet(entry.gitDir, ['rev-parse', 'HEAD'])
728
+ if (!baseCommit) continue // git 不可达已由 MultiRepoContext 构造时 fail-closed 拦截,此处置防御
729
+ const taskFile = changeDir
730
+ ? path.join(changeDir, 'tasks', `task-${item.taskNum}.md`)
731
+ : null
732
+ const written = taskFile ? writeBaseCommitToTaskCard(taskFile, baseCommit) : false
733
+ baseCommitWrites.push({ taskNum: item.taskNum, repo: item.repo, taskFile, written, baseCommit })
734
+ }
735
+
736
+ // 跨仓 task commit 指引 + head_commit 落盘指引(D-010 回收时机)
737
+ const crossLines = perTaskWorkdirs
738
+ .filter(i => i.repo !== 'main')
739
+ .map(i => `- task-${i.taskNum} → repo \`${i.repo}\`,workdir=\`${i.workdir}\``)
740
+ .join('\n')
741
+ crossRepoCommitSection = `
742
+ ### 跨仓 task 派发与双锡点(必须严格遵守)
743
+
744
+ 本 Wave 含跨仓 task(直接改跨仓仓主干,**不经主仓 worktree**):
745
+
746
+ ${crossLines}
747
+
748
+ **派发跨仓 task 前(base 锡点,CLI 已在 prompt 构造时落盘):**
749
+ - 跨仓 task 卡 frontmatter 的 \`base_commit\` 已由 CLI 实时 \`git -C <跨仓仓根> rev-parse HEAD\` 锁定(base 锡点,约束①)。子代理在此 HEAD 上改+commit,不受同 Wave 其他 task 推进 HEAD 影响。
750
+
751
+ **回收跨仓 task review 前(head 锡点,你必须在子代理完成后落盘):**
752
+ - 跨仓 task 子代理完成 commit 后、写 review.json / 勾选 checkbox **之前**,你必须运行 \`git -C <跨仓仓根> rev-parse HEAD\` 把结果写入该 task 卡 frontmatter \`head_commit:\` 字段(与 \`base_commit:\` 同源 frontmatter 锡点写入)。
753
+ - review.json 的 \`base\` 取 task 卡 \`base_commit\`、\`head\` 取 task 卡 \`head_commit\`(**非瞬时 HEAD**),锁 diff 范围不漂移。
754
+
755
+ **跨仓 task 子代理 prompt 必须注入:**
756
+ > 该 task 改的是 \`<repo>\` 仓,workdir=\`<跨仓仓根>\`。**直接在该仓主干工作区改+commit(git add + git commit 到该仓主干),不经主仓 worktree、不建分支。** commit 到该仓主干即落盘,apply 阶段对跨仓 task 为 no-op(design §5.4 G1)。
757
+ `
758
+ }
759
+
760
+ // worktreeSection:无 ctx / 无跨仓 task → 旧单值(零回归);有 ctx 含跨仓 task → per-task 多值表
761
+ let worktreeSection
762
+ if (ctx && hasCrossRepoWave && perTaskWorkdirs) {
763
+ const workdirLines = perTaskWorkdirs.map(i =>
764
+ ` - task-${i.taskNum} (repo: ${i.repo}) → workdir: "${i.workdir}"`
765
+ ).join('\n')
766
+ worktreeSection = `
767
+ ### 工作目录(必须严格遵守,per-task)
768
+
769
+ 调用 Task 工具启动子代理时,**workdir 参数是强制必传的**,且本 Wave 内不同 task 的 workdir 不同(主仓 task vs 跨仓 task)。
770
+
771
+ **per-task workdir 表(每个子代理按其 task 对应的 workdir 启动):**
772
+
773
+ ${workdirLines}
774
+
775
+ 不传 workdir 或传错 workdir 会导致:主仓 task 写到跨仓仓 / 跨仓 task 写到主仓 worktree,破坏隔离与 apply 归属。
776
+
777
+ \`\`\`json
778
+ {
779
+ "subagent_type": "general",
780
+ "workdir": "<按上方 per-task 表选>",
781
+ "prompt": "在此编写任务描述..."
782
+ }
783
+ \`\`\`
784
+
785
+ ### 注意
786
+ 蓝图文件(tasks.md / design.md / proposal.md / requirements.md / tasks/task-XX.md)在主工作区 .sillyspec/changes/<change>/ 下,它们不在跨仓仓也不在 worktree 中。读取蓝图时使用主工作区路径,不要拼接到 worktree / 跨仓仓路径下。
787
+ `
788
+ } else {
789
+ worktreeSection = (worktreePath)
790
+ ? `
791
+ ### 工作目录(必须严格遵守)
792
+
793
+ 调用 Task 工具启动子代理时,**workdir 参数是强制必传的**。
794
+ 不传 workdir 会导致子代理把文件写到主工作区而非 worktree,破坏隔离。
795
+
796
+ \`\`\`json
797
+ {
798
+ "subagent_type": "general",
799
+ "workdir": "${worktreePath}",
800
+ "prompt": "在此编写任务描述..."
801
+ }
802
+ \`\`\`
803
+
804
+ ### 注意
805
+ 蓝图文件(tasks.md / design.md / proposal.md / requirements.md)在主工作区 .sillyspec/changes/<change>/ 下,它们可能不在 worktree 中。读取蓝图时使用主工作区路径,不要拼接到 worktree 路径下。
806
+ `
807
+ : ''
808
+ }
809
+
810
+ // ── 派发后端段注入(task-07,D-006 / D-007 / D-008 接入)──
811
+ // 同步判定(不发网络,零回归关键):local(无 MCP 配置)→ dispatchSection='' → 本 prompt 与改前
812
+ // 字节一致;sillyhub(配置 + 路径A 落地)→ 注入完整 SillyHub 派发指令(一 Wave 一 mission /
813
+ // dispatch_worker / 轮询 list_workers / 超时 kill lease 防双写 / 回收 + Local 兜底);local-fallback
814
+ // (配置但路径A 未落地)→ 短提示,派发仍走 Local(与默认行为一致)。worktreePath 为空时不注入
815
+ // (无 worktree 无谓派发指令)。测试/调用方可经 options.dispatchMode 覆盖,避免 env 污染。
816
+ const dispatchMode = options.dispatchMode || getDispatchMode()
817
+ let dispatchSection = ''
818
+ if (worktreePath && dispatchMode === 'sillyhub') {
819
+ // 路径A 落地后:注入完整 SillyHub 派发指令(renderDispatchInstruction 已含 Local 兜底全文)
820
+ const contract = {
821
+ brief: '本 Wave 任务(见上方任务摘要与 tasks/task-XX.md)',
822
+ worktreePath,
823
+ branch: options.branch || '<未提供 branch>',
824
+ allowedPaths: [],
825
+ readOnly: false,
826
+ runId: '{EXECUTE_RUN_ID}', // 占位符,prompt.js 注入阶段替换为真实 run id
827
+ }
828
+ const { instruction } = renderDispatchInstruction(contract, { available: true })
829
+ dispatchSection = `\n### 派发后端:SillyHub MCP(探测可用,一 Wave 一 mission)\n\n本次 Wave 派发经 SillyHub MCP。按以下派发指令执行(含 create_mission / dispatch_worker / 轮询 list_workers / 超时 kill lease 防双写 / 回收 + Local 兜底):\n\n${instruction}\n`
830
+ } else if (worktreePath && dispatchMode === 'local-fallback') {
831
+ // 有 MCP 配置但路径A 未落地:加短提示,派发仍走 Local(与默认行为一致)
832
+ dispatchSection = `\n### 派发后端提示:SillyHub MCP 已配置但路径A 未落地\n\n检测到 local.yaml mcp 段或 env 配置,但 SillyHub \`dispatch_worker\` 尚不支持 \`worktree_path\`(路径A 跨仓未落地)。本次派发走 Local(本机 Agent tool),与默认行为一致——上方「执行方式」与「工作目录」段适用。\n`
833
+ }
834
+ // dispatchMode === 'local'(无配置)或 worktreePath 为空 → dispatchSection = '' → 输出与改前字节一致(零回归)
835
+
836
+ return `## Wave ${waveIndex}: 执行以下任务
837
+
838
+ ## 执行方式(必须严格遵守)
839
+
840
+ **每个任务必须由独立子代理执行,你不要自己写代码。**
841
+
842
+ 你的角色是调度者 + 审查者:
843
+ 1. 为每个任务启动一个子代理(Agent tool),同 Wave 内可并行
844
+ 2. 子代理完成后审查结果
845
+ 3. 勾选 plan.md 中的 checkbox
846
+ 4. 记录改动文件和测试结果
847
+
848
+ ${worktreeSection}${crossRepoCommitSection}${dispatchSection}
849
+ ### 任务摘要(按需读取完整蓝图)
850
+ 为每个任务启动子代理时,**只需告知任务目标和蓝图文件路径,让子代理按需读取**:
851
+
852
+ ${taskSummary}
853
+
854
+ 子代理 prompt 要点:
855
+ 1. 任务目标(简短描述)
856
+ 2. 蓝图文件路径(让子代理自行读取详情)
857
+ 3. 编码铁律:先读后写、TDD、不编造方法、只做蓝图里写的事、遵守边界处理规则、不超出 allowed_paths
858
+ 4. 如存在模块文档(.sillyspec/docs/*/modules/),按需读取涉及模块的 <module>.md 参考接口约定和数据流
859
+ 5. 任务含测试代码时,把下方「测试用例设计」整段复制进子代理 prompt,要求子代理按此设计测试用例
860
+
861
+ {{include: testcase-design}}
862
+
863
+ ### Wave 开始前
864
+ 1. 读取 design.md 的「非目标」与「兼容策略」章节(如存在),确保子代理不超范围、不破坏旧逻辑
865
+ 2. 读取 plan.md 了解全局任务划分和依赖关系
866
+ 3. 确认本 Wave 的输入/输出契约(前置 Wave 产出了什么,本 Wave 需要消费什么)
867
+ 4. 检查前置 Wave 的产出是否完整(文件是否存在、测试是否通过)
868
+ 5. **上下文分层加载**:
869
+ - 🔥 热上下文:design.md 非目标/兼容策略 + 当前 Wave 任务(必须加载)
870
+ - 🌡️ 温上下文:CONVENTIONS.md + ARCHITECTURE.md(需要时加载)
871
+ - ❄️ 冷上下文:其他变更的 design.md、历史 plan.md(不要主动加载,除非明确需要)
872
+
873
+ ### 中断续跑(如曾中断恢复)
874
+ execute 按 Wave 持久化进度,task 级进度靠 plan.md checkbox 勾选。若本 Wave 曾因 429/API 配额/崩溃中断:
875
+ - plan.md 中**已勾选 \`- [x]\` 的 task 已完成,跳过不重跑**(子代理也可能在完成前中断,重跑前先确认该 task 产出文件是否完整)
876
+ - 用 \`sillyspec status\` 查当前进度,重新 \`sillyspec run execute\` 会回到当前 Wave step 继续,**不要从零重置或重跑已完成 Wave**
877
+ - 本 Wave 已完成但不完整(产出缺文件)的 task 补做,不牵连其他 task
878
+
879
+ ${contractInjection}${prototypeInjection}
880
+ ### 本 Wave 任务
881
+ ${taskList}
882
+
883
+ ### 调度要求
884
+ 1. **同一 Wave 内的任务必须并行启动子代理,禁止串行等待。** Wave 的定义就是"无依赖、可并行",不要自行分析依赖关系。如果有依赖应该在 plan.md 的不同 Wave 中。
885
+ 2. **Reverse Sync**:子代理报告实现与 design.md 不一致时,先检查是代码错了还是文档有遗漏
886
+ 3. **不要频繁编译!** 编译很慢,只在以下情况运行:
887
+ - 写了大量代码后需要验证语法正确性
888
+ - 最后一个 Wave 完成后做一次全量编译验证
889
+ - 用户明确要求编译时
890
+ 4. 每个任务完成后:
891
+ - **先写 review.json 再勾选 checkbox**(见下方 Task Review Gate)
892
+ - **既跑 lint check 也跑 formatter**:凡变更涉及的源码跑项目的 lint 检查 **和** 格式化(如 \`ruff format\` / \`prettier --write\`),不要只跑 check——只 check 不 format 会把格式问题留到 commit 时被 pre-commit hook 拦截(worktree 内二进制可能缺失,先 \`which <bin>\` 确认,缺则 \`uv tool install\` / \`uv sync\`)
893
+ - 记录改动文件和测试结果
894
+ 5. 遇到 BLOCKED → 记录原因,选择:重试/跳过/停止
895
+
896
+ ### Task Review Gate(必须执行,不可跳过)
897
+
898
+ 每个子代理完成后、勾选 checkbox **之前**,你必须创建 task review。
899
+
900
+ **操作步骤:**
901
+ 1. 读取当前 task 的 git diff(从 task 开始到完成的变更)
902
+ 2. 对照 plan.md 中该 task 的描述和 tasks/task-XX.md(如果存在)检查实现是否符合要求
903
+ 3. 写入 review.json 文件
904
+ 4. **只有 review.json 写入成功后,才允许勾选 plan.md 中的 checkbox**
905
+
906
+ **review.json 路径:**
907
+
908
+ task-XX 对应:{SPEC_ROOT}/.runtime/execute-runs/{EXECUTE_RUN_ID}/tasks/task-XX/review.json
909
+
910
+ 本 execute run 的固定 ID 是:{EXECUTE_RUN_ID}
911
+ **所有 task 的 review.json 必须使用这个 ID,不要自行创建新目录。**
912
+
913
+ **review.json 必填字段:**
914
+
915
+ { "name_zh": "任务评审", "schemaVersion": {REVIEW_SCHEMA_VERSION}, "task": "task-XX", "base": "<git-base-commit>", "head": "<git-head-commit>",
916
+ "changedFiles": ["src/foo.js"], "specVerdict": "pass|fail|cannot_verify",
917
+ "qualityVerdict": "pass|fail|cannot_verify", "reviewerNotes": "评审说明",
918
+ "requiredEvidence": [] }
919
+
920
+ **评审铁律:**
921
+ - 不信任 implementer 自报结果,对照 diff 和 task brief 验证
922
+ - 只看当前 task 的 diff,不做全仓库漫游审查
923
+ - \`cannot_verify\` 只在确实无法验证且有待补充证据时使用,且 requiredEvidence 必须非空
924
+ - \`sillyspec run execute --done\` 会校验所有 task 的 review.json,缺失或 fail 会阻断完成
925
+
926
+ ### module-impact.md 更新(主代理在本 Wave 所有 task 完成后汇总)
927
+ 本 Wave 内所有 task 子代理完成、review.json 写好后,**由你(主代理/调度者)**汇总本 Wave 的实际代码变更,更新 {SPEC_ROOT}/changes/<change>/module-impact.md(plan 阶段已生成首版):
928
+ - 基于本 Wave 各 task 的实际 git diff(不是计划)+ {SPEC_ROOT}/docs/<project>/modules/_module-map.yaml 对照
929
+ - 更新受影响模块的影响类型/说明(实际改动可能与 plan 首版预估不同,据实修正)
930
+ - **不由各 task 子代理分别改**(同 Wave 并行子代理改同一文件会互相覆盖)——只由主代理在 Wave 收尾统一更新一次
931
+ - 无 _module-map.yaml 时跳过模块匹配,仅按文件清单更新 unmapped 部分
932
+ 这是可选更新(不阻断 execute 完成),但保持 module-impact 与实际变更一致利于 verify 核对与 archive 终审。
933
+
934
+ ### 完成后
935
+ 1. 为每个后端 router task,扫描变更文件提取 API 端点 artifact:
936
+ - 在变更文件中搜索所有 router 注册路径(@router.get/post/put/delete)
937
+ - 将端点清单写入 {SPEC_ROOT}/.runtime/contract-artifacts/<task-name>/endpoints.json
938
+ - 格式: { "task": "task-XX", "type": "backend_endpoints", "endpoints": [{ "method": "GET", "path": "/api/ppm/xxx" }] }
939
+ `
940
+ }
941
+
942
+ /**
943
+ * 动态构建 execute 步骤列表
944
+ * @param {string|null} planFilePath - plan 文件路径,null 则用默认 3 Wave
945
+ * @param {{ worktreePath?: string, noWorktree?: boolean }} options
946
+ * @returns {Array} 步骤列表
947
+ */
948
+ export function buildExecuteSteps(planFilePath = null, options = {}) {
949
+ const noWorktree = !!options.noWorktree
950
+ let waves
951
+ let changeDir = null
952
+
953
+ if (planFilePath && existsSync(planFilePath)) {
954
+ const planContent = readFileSync(planFilePath, 'utf8')
955
+ // Plan → Execute 契约由 plan 阶段完成时的 postcheck 把关(run.js completeStep),
956
+ // 此处只负责解析 waves,避免 buildExecuteSteps 与进程退出耦合。
957
+ waves = parseWavesFromPlan(planContent)
958
+ changeDir = path.dirname(planFilePath)
959
+ }
960
+
961
+ // 没解析出 Wave(plan 不存在或不含可识别 task)→ 默认 3 Wave(向后兼容)
962
+ if (!waves || waves.length === 0) {
963
+ waves = []
964
+ for (let i = 1; i <= 3; i++) {
965
+ waves.push({ index: i, tasks: [{ name: `默认任务 ${i}`, file: 'TBD' }] })
966
+ }
967
+ }
968
+
969
+ // 尝试获取 worktree 路径(可能由前缀步骤创建)
970
+ const worktreePath = options.worktreePath || null
971
+
972
+ // W3 task-08:ctx 由 execute 启动入口(task-09)透传,进程级单例贯穿 execute/apply/verify
973
+ // (D-013)。缺省=null → buildWavePrompt 退化为单仓单 worktreePath(零回归)。
974
+ const ctx = options.ctx || null
975
+
976
+ const waveSteps = waves.map((wave, i) => ({
977
+ name: `Wave ${i + 1} 执行`,
978
+ mode: 'implementation',
979
+ prompt: buildWavePrompt(wave, i + 1, changeDir, worktreePath, { dispatchMode: options.dispatchMode, branch: options.branch, ctx }),
980
+ outputHint: `Wave ${i + 1} 执行结果`,
981
+ optional: false
982
+ }))
983
+
984
+ return [...fixedPrefix, ...waveSteps, ...acceptanceSteps, ...fixedSuffix]
985
+ }