sillyspec 3.26.11 → 3.26.13

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 (58) hide show
  1. package/.claude/skills/sillyspec-archive/SKILL.md +1 -1
  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 +5 -5
  5. package/.claude/skills/sillyspec-quick/SKILL.md +5 -1
  6. package/README.md +7 -5
  7. package/SKILL.md +5 -4
  8. package/package.json +1 -1
  9. package/packages/dashboard/server/executor.js +10 -1
  10. package/packages/dashboard/server/index.js +108 -46
  11. package/src/config-schema.js +3 -0
  12. package/src/contract-matrix.js +22 -13
  13. package/src/db.js +24 -4
  14. package/src/dispatch/probe.js +99 -63
  15. package/src/docs-check.js +58 -13
  16. package/src/docs-debt.js +6 -2
  17. package/src/docs-gate.js +1 -0
  18. package/src/doctor-diagnostics.js +208 -21
  19. package/src/fs-atomic.js +3 -1
  20. package/src/git-helper.js +7 -4
  21. package/src/hooks/worktree-guard.js +8 -2
  22. package/src/index.js +161 -12
  23. package/src/init.js +3 -2
  24. package/src/local-detect.js +9 -1
  25. package/src/modules.js +7 -5
  26. package/src/progress/stage-machine.js +589 -578
  27. package/src/progress/step-store.js +1 -1
  28. package/src/progress.js +184 -30
  29. package/src/quicklog.js +47 -16
  30. package/src/run/command.js +31 -8
  31. package/src/run/complete-handlers.js +21 -14
  32. package/src/run/complete.js +1181 -1121
  33. package/src/run/gates.js +148 -25
  34. package/src/run/prompt.js +9 -5
  35. package/src/run/shared.js +138 -58
  36. package/src/run/stage.js +41 -1
  37. package/src/setup.js +8 -10
  38. package/src/sillyhub-mcp/client.js +74 -6
  39. package/src/spec-dir-typo.js +3 -24
  40. package/src/spec-sync.js +43 -8
  41. package/src/stage-contract.js +12 -38
  42. package/src/stages/archive.js +4 -3
  43. package/src/stages/brainstorm-auto.js +1 -1
  44. package/src/stages/brainstorm.js +2 -1
  45. package/src/stages/doctor.js +28 -13
  46. package/src/stages/execute.js +164 -153
  47. package/src/stages/plan-postcheck.js +49 -28
  48. package/src/stages/plan.js +57 -40
  49. package/src/stages/quick.js +3 -1
  50. package/src/stages/verify.js +1 -1
  51. package/src/sync.js +323 -221
  52. package/src/task-review.js +246 -61
  53. package/src/taskcard.js +178 -0
  54. package/src/verify-postcheck.js +48 -35
  55. package/src/worktree-apply.js +58 -34
  56. package/src/worktree-deps.js +120 -20
  57. package/src/worktree.js +55 -9
  58. package/.npmrc-publish +0 -2
@@ -9,24 +9,67 @@ import { gitQuiet } from '../git-helper.js'
9
9
  import { parseRepo } from './plan-postcheck.js'
10
10
 
11
11
  /**
12
- * 校验 plan.md 是否满足 execute 执行契约
12
+ * 任务注册表解析(2026-08-20-task-truth-unify D-001@v1:tasks.md 唯一任务真相)。
13
+ * 行契约:`- [ ] task-01: 名称 [model:xxx] (depends_on: task-01,02)`——标注均可选;
14
+ * ql-xxx 等 task-XX 前缀之外的行(quick 挂载条目)与注册表正交,不收。
15
+ * @param {string} tasksContent - tasks.md 文件内容
16
+ * @returns {Array<{index:number, id:string, name:string, done:boolean, model:string|null, dependsOn:string[], file:string, steps:string, reference:string}>}
17
+ */
18
+ export function parseTaskRegistry(tasksContent) {
19
+ const tasks = []
20
+ if (!tasksContent) return tasks
21
+ for (const line of String(tasksContent).split('\n')) {
22
+ const m = line.match(/^[-*]\s*\[([ xX])\]\s*task-(\d+)\b[::]?\s*(.*)/)
23
+ if (!m) continue
24
+ let name = (m[3] || '').trim()
25
+ let model = null
26
+ const modelMatch = name.match(/\[model:([^\]]+)\]/i)
27
+ if (modelMatch) { model = modelMatch[1].trim(); name = name.replace(modelMatch[0], '').trim() }
28
+ let dependsOn = []
29
+ const depMatch = name.match(/\(depends_on:\s*([^)]+)\)/i)
30
+ if (depMatch) {
31
+ dependsOn = depMatch[1].split(/[,,]/).map(s => s.trim().replace(/^task-/i, '')).filter(Boolean).map(n => `task-${n.padStart(2, '0')}`)
32
+ name = name.replace(depMatch[0], '').trim()
33
+ }
34
+ // 兼容行尾 (文件路径) 尾注(旧 plan 内联习惯在 tasks.md 沿用时同样收容)
35
+ let file = ''
36
+ const fileMatch = name.match(/\(([^()]+)\)$/)
37
+ if (fileMatch) { file = fileMatch[1].trim(); name = name.replace(/\([^()]+\)$/, '').trim() }
38
+ tasks.push({
39
+ index: parseInt(m[2], 10),
40
+ id: `task-${m[2].padStart(2, '0')}`,
41
+ name,
42
+ done: (m[1] === 'x' || m[1] === 'X'),
43
+ model,
44
+ dependsOn,
45
+ file,
46
+ steps: '',
47
+ reference: ''
48
+ })
49
+ }
50
+ return tasks
51
+ }
52
+
53
+ /**
54
+ * 校验 tasks.md(任务注册表)× plan.md(Wave ID 引用)是否满足 execute 执行契约。
55
+ * 新契约(D-001@v1):任务清单唯一真相在 tasks.md;plan.md Wave 段下为纯 ID 引用行
56
+ * (`- task-01`,不重抄任务名),交叉校验存在性/覆盖恰一次/编号连续。
57
+ * @param {string} tasksContent - tasks.md 文件内容
13
58
  * @param {string} planContent - plan.md 文件内容
14
59
  * @returns {{ ok: boolean, errors: string[], warnings: string[], tasks: object[], waves: object[] }}
15
60
  */
16
- export function validatePlanForExecute(planContent) {
61
+ export function validatePlanForExecute(tasksContent, planContent) {
17
62
  const errors = []
18
63
  const warnings = []
64
+ const plan = String(planContent || '')
65
+ const registry = parseTaskRegistry(tasksContent)
19
66
 
20
- if (!planContent || !planContent.trim()) {
21
- return { ok: false, errors: ['plan.md 内容为空'], warnings, tasks: [], waves: [] }
67
+ if (!plan.trim() && !String(tasksContent || '').trim()) {
68
+ return { ok: false, errors: ['plan.md 与 tasks.md 内容均为空'], warnings, tasks: [], waves: [] }
22
69
  }
23
70
 
24
- const waves = parseWavesFromPlan(planContent)
25
-
26
71
  // 检查 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)]
72
+ const letterSuffixWaves = [...plan.matchAll(/^#+\s*Wave\s+(\d+)([a-z])(?![a-z0-9])/gim)]
30
73
  for (const m of letterSuffixWaves) {
31
74
  errors.push(
32
75
  `Wave 编号带字母后缀:${m[0].trim()}("${m[1]}${m[2]}")不被支持。` +
@@ -35,70 +78,88 @@ export function validatePlanForExecute(planContent) {
35
78
  )
36
79
  }
37
80
 
38
- // 收集所有 task
39
- const allTasks = []
40
- for (const wave of waves) {
41
- for (const task of wave.tasks) {
42
- allTasks.push(task)
43
- }
81
+ // 检查 0.5: 旧格式残留——plan.md 内出现任务名级 checkbox 行(任务清单旧家)→ 指路迁移
82
+ const legacyCheckbox = [...plan.matchAll(/^[-*]\s*\[[ xX]\]\s*task-\d+\b[::]/gim)]
83
+ if (legacyCheckbox.length > 0) {
84
+ errors.push(
85
+ `plan.md 含 ${legacyCheckbox.length} 处旧格式任务 checkbox 行("- [ ] task-XX: 名称")。` +
86
+ `新契约(2026-08-20-task-truth-unify):任务清单唯一真相在 tasks.md,plan.md Wave 段下只写纯 ID 引用行。` +
87
+ `迁移:把任务 checkbox 行移入 tasks.md(保留 [model:xxx]/(depends_on: …) 行内标注),plan.md Wave 段改为 "- task-XX" 引用行`
88
+ )
44
89
  }
45
90
 
46
- // 检查 1: 至少有一个 checkbox task
91
+ const waves = parseWavesFromPlan(plan, registry)
92
+ const allTasks = registry
93
+
94
+ // 检查 0.8: Wave 标题格式不对(W1/Wave1/波次1 等)→ 引用行不被收容,静默退化隐式单 Wave
95
+ // 全并行——串行意图失效且无提示(原「标题格式不对」诊断的新契约承接)。仅在确有疑似标题
96
+ // 且无任何显式 Wave 被解析时报,正常 Wave 10/带括号标题不误伤。
97
+ const hasExplicitWave = waves.some(w => !w.implicit)
98
+ const waveLikeHeading = /^#+\s*(?:wave\s*\d+|w\d+|波次\s*\d+)/im
99
+ if (!hasExplicitWave && waveLikeHeading.test(plan)) {
100
+ errors.push(
101
+ `Wave 标题格式不对:必须字面 "## Wave N"(Wave + 空格 + 数字),"## W1" / "## Wave1" / "## 波次1" 都不被识别。` +
102
+ `其下的 "- task-XX" 引用行不会被收容——任务将退化为单个隐式 Wave 全并行,串行意图失效`
103
+ )
104
+ }
105
+
106
+ // 检查 1: 注册表非空(tasks.md 无 task-XX checkbox → 三类根因诊断)
47
107
  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: 任务名")')
108
+ const diags = diagnoseNoTaskRegistry(tasksContent, plan)
109
+ errors.push('tasks.md 中没有找到 task-XX checkbox(任务注册表为空,格式: "- [ ] task-XX: 任务名")')
52
110
  for (const d of diags) errors.push(` 诊断:${d}`)
53
111
  return { ok: false, errors, warnings, tasks: allTasks, waves }
54
112
  }
55
113
 
56
- // 检查 2: task id 唯一性
114
+ // 检查 2: task id 唯一性(tasks.md 内重复行)
57
115
  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
- }
116
+ for (const task of allTasks) idCounts[task.id] = (idCounts[task.id] || 0) + 1
64
117
  for (const [id, count] of Object.entries(idCounts)) {
65
- if (count > 1) {
66
- errors.push(`task id 重复: ${id} 出现 ${count} 次`)
67
- }
118
+ if (count > 1) errors.push(`task id 重复: ${id} 在 tasks.md 出现 ${count} 次`)
68
119
  }
69
120
 
70
121
  // 检查 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
- }
122
+ const ids = allTasks.map(t => t.index).sort((a, b) => a - b)
123
+ if (ids.length > 0 && ids[0] === 1) {
124
+ for (let i = 0; i < ids.length; i++) {
125
+ if (ids[i] !== i + 1) {
126
+ errors.push(getRule('plan.task-id-continuity').failMessage.replaceAll('${expected}', String(i + 1).padStart(2, '0')).replaceAll('${actual}', String(ids[i]).padStart(2, '0')))
127
+ break
84
128
  }
85
129
  }
86
130
  }
87
131
 
88
- // 检查 4: task name 非空
132
+ // 检查 4: task name 非空(注册表行)
89
133
  for (const task of allTasks) {
90
134
  if (!task.name || !task.name.trim()) {
91
- errors.push(`task-${String(task.index || '?').padStart(2, '0')}: 任务名为空`)
135
+ errors.push(`${task.id}: 任务名为空(tasks.md 行格式 "- [ ] ${task.id}: 一句话任务名")`)
92
136
  }
93
137
  }
94
138
 
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: 名称)`)
139
+ // 交叉校验:plan.md Wave 引用 × tasks.md 注册表(仅显式 Wave 结构参与;隐式 Wave 由
140
+ // parseWavesFromPlan 从注册表整体合成,天然全覆盖)
141
+ const explicitWaves = waves.filter(w => !w.implicit)
142
+ if (explicitWaves.length > 0) {
143
+ const refCount = {}
144
+ for (const wave of explicitWaves) {
145
+ for (const task of wave.tasks) {
146
+ refCount[task.id] = (refCount[task.id] || 0) + 1
147
+ if (task.dangling) {
148
+ errors.push(`plan.md Wave ${wave.index} 引用的 ${task.id} 不存在于 tasks.md 注册表(悬空引用)`)
149
+ }
100
150
  }
101
151
  }
152
+ // 覆盖恰一次:注册表每个 id 必须被引用(缺失)且只被一个 Wave 引用(重复)
153
+ for (const task of allTasks) {
154
+ const n = refCount[task.id] || 0
155
+ if (n === 0) errors.push(`${task.id} 未被任何 Wave 引用(plan.md Wave 段缺 "- ${task.id}" 引用行——未引用的任务不进 execute 步骤)`)
156
+ if (n > 1) errors.push(`${task.id} 被多个 Wave 重复引用 ${n} 次(一个任务只属一个 Wave;跨 Wave 依赖用 (depends_on: …) 声明)`)
157
+ }
158
+ // Wave 段空引用诊断
159
+ const emptyWave = explicitWaves.find(w => w.tasks.length === 0)
160
+ if (emptyWave) {
161
+ errors.push(`plan.md Wave ${emptyWave.index} 段下没有任何 "- task-XX" 引用行(新契约 Wave 段只收 ID 引用行)`)
162
+ }
102
163
  }
103
164
 
104
165
  return { ok: errors.length === 0, errors, warnings, tasks: allTasks, waves }
@@ -134,7 +195,7 @@ const fixedPrefix = [
134
195
  prompt: `加载计划、设计和代码库上下文。
135
196
 
136
197
  ### 操作
137
- 1. 读取 tasks.md(执行计划)
198
+ 1. 读取 tasks.md(任务注册表与勾选唯一真相;plan.md 只提供 Wave 分组/依赖结构——Wave 段下为纯 ID 引用行)
138
199
  2. 读取 design.md(技术方案)
139
200
  3. 读取 CONVENTIONS.md、ARCHITECTURE.md
140
201
  4. 读取 local.yaml(构建命令);若 local.yaml 不存在,先 \`sillyspec local detect\` 生成骨架再读取
@@ -386,123 +447,71 @@ Apply Decision + 下一步建议
386
447
  ]
387
448
 
388
449
  /**
389
- * 诊断 plan.md 无 checkbox task 的根因(坑 plan-md-format-contract-hidden)。
390
- * 纯函数:读 planContent 文本,返回诊断提示数组。validatePlanForExecute 无 task 时追加到报错,
391
- * 帮 agent 区分 4 条隐性格式契约(Wave 标题字面 / task checkbox / task-XX 编号 / ### 打断 Wave 段)。
450
+ * 诊断任务注册表为空的根因(坑 plan-md-format-contract-hidden 延续:笼统报错逼 agent 试错)。
451
+ * 三类根因:tasks.md 无 task-XX checkbox / plan.md Wave 段无 ID 引用 / 旧格式(任务 checkbox 还在 plan.md)。
452
+ * @param {string} tasksContent
392
453
  * @param {string} planContent
393
454
  * @returns {string[]}
394
455
  */
395
- function diagnoseNoTaskRootCause(planContent) {
456
+ function diagnoseNoTaskRegistry(tasksContent, planContent) {
396
457
  const diags = []
397
- const hasWaveHeading = /^#+\s*Wave\s+\d+/im.test(planContent) // "## Wave N"(字面)
398
- const hasWaveWord = /^#+\s*(wave\s*\d+|w\d+|波次\s*\d+)/im.test(planContent) // 疑似 Wave 标题(含 W1/Wave1/波次1)
399
- const hasCheckboxTask = /^[-*]\s*\[[ x]\]\s*task-\d+/im.test(planContent) // "- [ ] task-XX:" checkbox
400
- const hasTaskHeading = /^#+\s*task-\d+/im.test(planContent) // "### task-XX:" 标题(非 checkbox)
401
- const hasTasksSection = /^#+\s*(tasks?|任务)\s*$/im.test(planContent) // "## Tasks" / "## 任务"
402
-
403
- if (!hasWaveHeading && !hasTasksSection) {
404
- if (hasWaveWord) {
405
- diags.push('Wave 标题格式不对:必须字面 "## Wave N"(Wave + 空格 + 数字),"## W1" / "## Wave1" / "## 波次1" 都不被识别')
406
- } else if (hasCheckboxTask) {
407
- diags.push('有 task checkbox 但不在 "## Wave N"(full)或 "## Tasks"(light/none)段内——非任务区的 checkbox 不收')
408
- } else {
409
- diags.push('plan.md 缺任务区:full 需 "## Wave 1" 标题,light/none 需 "## Tasks" 标题')
410
- }
411
- }
412
- if (hasTaskHeading) {
413
- diags.push('task 用了 "### task-XX:" 标题(非 checkbox):标题会打断 Wave 段导致后续 checkbox 全被跳过,请改用 "- [ ] task-XX: 名称"(英文冒号)')
458
+ const tc = String(tasksContent || '')
459
+ const pc = String(planContent || '')
460
+ const hasTaskCheckboxInTasks = /^[-*]\s*\[[ xX]\]\s*task-\d+/im.test(tc)
461
+ const hasQlLines = /^[-*]\s*\[[ xX]\]\s*ql-/im.test(tc)
462
+ const hasLegacyInPlan = /^[-*]\s*\[[ xX]\]\s*task-\d+\b[::]/im.test(pc)
463
+ const hasWaveHeading = /^#+\s*Wave\s+\d+/im.test(pc)
464
+ const hasRefLine = /^[-*]\s+task-\d+\s*$/im.test(pc)
465
+
466
+ if (hasLegacyInPlan) {
467
+ diags.push('任务 checkbox 还在 plan.md(旧格式):请移入 tasks.md(行格式 "- [ ] task-XX: 一句话任务名",[model:xxx]/(depends_on: …) 标注随行),plan.md Wave 段改为 "- task-XX" 纯 ID 引用行')
468
+ } else if (!tc.trim()) {
469
+ diags.push('tasks.md 内容为空或缺失:plan 阶段应把展开后的任务清单写回 tasks.md(brainstorm 骨架只是名字级占位)')
470
+ } else if (!hasTaskCheckboxInTasks && hasQlLines) {
471
+ diags.push('tasks.md 只有 ql-xxx 行(quick 挂载条目),没有 task-XX 任务行——完整流程变更的任务清单尚未写入')
472
+ } else if (!hasTaskCheckboxInTasks) {
473
+ diags.push('tasks.md 无 "- [ ] task-XX:" 格式的任务行:检查行首格式(- 空格 [ ] 空格 task-XX: 英文冒号)')
414
474
  }
415
- if (hasWaveHeading && hasCheckboxTask && !hasTaskHeading) {
416
- diags.push('有 Wave 段也有 checkbox task 但未收容:checkbox 可能落在非任务区(如 "## 自检" 段)或被 "###" 标题打断,请检查 task 是否紧跟在 "## Wave N" 下')
475
+ if (hasTaskCheckboxInTasks && hasWaveHeading && !hasRefLine) {
476
+ diags.push('plan.md 有 Wave 段但没有 "- task-XX" 引用行:新契约 Wave 段只收纯 ID 引用行(如 "- task-01"),未引用的任务不进 execute')
417
477
  }
418
478
  return diags
419
479
  }
420
480
 
421
481
  /**
422
- * 从 plan 文件解析 Wave 分组
482
+ * 从 plan 解析 Wave 分组(新契约:Wave 段下纯 ID 引用行 `- task-XX`,任务详情在 tasks.md 注册表)。
483
+ * @param {string} planContent
484
+ * @param {Array<object>} registry - parseTaskRegistry 产物(id → 任务详情富化源)
423
485
  */
424
- function parseWavesFromPlan(planContent) {
486
+ function parseWavesFromPlan(planContent, registry = []) {
425
487
  const waves = []
426
- const lines = planContent.split('\n')
488
+ const regById = new Map(registry.map(t => [t.id, t]))
427
489
  let currentWave = null
428
- let currentTask = null
429
- // light/none plan.md 用 `## Tasks`(无 `## Wave N`)包任务,需识别为隐式任务区,
430
- // 让其中的 task checkbox 能被收进惰性创建的隐式 Wave(见下方 taskMatch 分支)。
431
- let inImplicitTaskSection = false
432
490
 
433
- for (const line of lines) {
491
+ for (const line of String(planContent || '').split('\n')) {
434
492
  const waveMatch = line.match(/^#+\s*Wave\s+(\d+)/i)
435
493
  if (waveMatch) {
436
494
  currentWave = { index: parseInt(waveMatch[1]), tasks: [] }
437
- currentTask = null
438
- inImplicitTaskSection = false
439
495
  waves.push(currentWave)
440
496
  continue
441
497
  }
442
-
443
- // 任何非 Wave 的标题行:
444
- // 1) 退出当前显式 Wave 段,避免「## 自检」段里的 - [x] checkbox 被误当 task 定义解析
445
- // (导致 Contract 校验报 task id 重复/不连续,详见 docs/sillyspec/plan-postcheck-self-check-checkbox-false-dup.md)
446
- // 2) 识别 light/none 的 `## Tasks`/`## 任务` 任务区(无 Wave 标题),置位隐式任务区标志
447
- const headingMatch = line.match(/^#+\s+(.+?)\s*$/)
448
- if (headingMatch) {
449
- currentWave = null
450
- currentTask = null
451
- const headingText = headingMatch[1].trim().toLowerCase()
452
- inImplicitTaskSection = /^(tasks?|任务)$/.test(headingText)
453
- continue
454
- }
455
-
456
- const taskMatch = line.match(/^[-*]\s*\[[ x]\]\s*(.+)/)
457
- if (taskMatch) {
458
- const taskNoMatch = taskMatch[1].match(/\btask-(\d+)\b/i)
459
- // full plan.md:task 必须在显式 Wave 段内(currentWave 非 null),正常收容。
460
- // light/none plan.md(无 Wave 标题,任务在 `## Tasks` 下):在隐式任务区内,遇含
461
- // task-XX 编号的 checkbox 时惰性创建隐式 Wave 收容,否则 validatePlanForExecute 会报
462
- // "没有找到 checkbox task"。详见 docs/sillyspec/plan-light-needs-wave-heading.md
463
- // 不收的情况:非任务区(## 自检/## 验收 等)的 checkbox,或任务区内无 task-XX 编号的 checkbox。
464
- if (!currentWave) {
465
- if (!inImplicitTaskSection || !taskNoMatch) continue
466
- const nextIndex = waves.length === 0 ? 1 : (waves[waves.length - 1].index || waves.length) + 1
467
- currentWave = { index: nextIndex, tasks: [], implicit: true }
468
- currentTask = null
469
- waves.push(currentWave)
470
- }
471
- currentTask = {
472
- index: taskNoMatch ? parseInt(taskNoMatch[1], 10) : null,
473
- name: taskMatch[1].trim(),
474
- file: '',
475
- steps: '',
476
- reference: ''
477
- }
478
- // 兼容旧格式:任务名后跟 (文件路径)
479
- const fileMatch = taskMatch[1].match(/\(([^)]+)\)$/)
480
- if (fileMatch) {
481
- currentTask.file = fileMatch[1]
482
- currentTask.name = taskMatch[1].replace(/\([^)]+\)$/, '').trim()
483
- }
484
- currentWave.tasks.push(currentTask)
485
- continue
486
- }
487
-
488
- // 解析子行信息(修改/参考/步骤)
489
- if (currentTask) {
490
- const modMatch = line.match(/^\s+-\s*修改:\s*(.+)/)
491
- if (modMatch) { currentTask.file = modMatch[1].trim(); continue }
492
-
493
- const refMatch = line.match(/^\s+-\s*参考:\s*(.+)/)
494
- if (refMatch) { currentTask.reference = refMatch[1].trim(); continue }
495
-
496
- const stepMatch = line.match(/^\s+-\s*步骤:/)
497
- if (stepMatch) { currentTask.steps = line.replace(/^\s+-\s*步骤:\s*/, '').trim(); continue }
498
-
499
- // 步骤续行(数字开头的子步骤)
500
- if (currentTask.steps && line.match(/^\s+\d+\./)) {
501
- currentTask.steps += '\n' + line.trim()
502
- }
498
+ // 任何非 Wave 的标题行退出当前 Wave 段(「## 自检」段的行不收,与旧解析同守卫)
499
+ if (/^#{1,6}\s+/.test(line)) { currentWave = null; continue }
500
+ const refMatch = line.match(/^[-*]\s+task-(\d+)\s*$/i)
501
+ if (refMatch && currentWave) {
502
+ const id = `task-${refMatch[1].padStart(2, '0')}`
503
+ const reg = regById.get(id)
504
+ currentWave.tasks.push(reg
505
+ ? { ...reg }
506
+ : { index: parseInt(refMatch[1], 10), id, name: '', done: false, model: null, dependsOn: [], file: '', steps: '', reference: '', dangling: true })
503
507
  }
504
508
  }
505
509
 
510
+ // 无显式 Wave 结构但注册表非空(light 级:任务全在 tasks.md、plan.md 只留策略)→
511
+ // 合成单隐式 Wave 收容全部任务(与旧 light `## Tasks` 隐式收容语义对齐,单 Wave 串行执行)
512
+ if (waves.length === 0 && registry.length > 0) {
513
+ waves.push({ index: 1, implicit: true, tasks: registry.map(t => ({ ...t })) })
514
+ }
506
515
  return waves
507
516
  }
508
517
 
@@ -855,7 +864,7 @@ ${workdirLines}
855
864
  你的角色是调度者 + 审查者(batch 只合并实现、不合并审查):
856
865
  1. 为每个任务启动一个子代理(Agent tool),或按上述三条件把多个任务合并为一个 batch 子代理,同 Wave 内可并行
857
866
  2. 子代理完成后审查结果——batch 子代理只做实现与自验,task 审查、review.json 产出与 checkbox 勾选仍归你(主 agent),在子代理返回后逐 task 进行;审查 batch 报告时逐 task 对照 allowed_paths 检查改动文件清单有无越权
858
- 3. 勾选 plan.md 中的 checkbox
867
+ 3. 勾选 tasks.md 中对应任务的 checkbox
859
868
  4. 记录改动文件和测试结果
860
869
 
861
870
  ${worktreeSection}${crossRepoCommitSection}${dispatchSection}
@@ -874,7 +883,7 @@ ${taskSummary}
874
883
  5. 任务含测试代码时,把下方「测试用例设计」整段复制进子代理 prompt,要求子代理按此设计测试用例
875
884
  6. **增量落盘与中断接手指引**:每完成一个可见产出(代码/测试/文档),立即写盘并执行一次最小验证(如语法检查、单跑相关测试)。工作过程中如被 429/API 配额/会话中断,应在最终回复里输出「已完成清单」(含文件路径、测试命令、当前卡点),不要只输出结论——主代理会依据磁盘产物和该清单判断哪些部分已完成,哪些需接手补做,避免重做已落盘的工作
876
885
  7. **任务边界铁律**:严格只实现本 task 的 \`allowed_paths\` 内文件;若 design.md/plan.md 明确指定了接口/回调/钩子接入位置,必须逐字遵守;不允许顺手实现其他 task 的内容(如 task-01 不要把 task-02 的接入也做了)。如发现必须改其他 task 文件才能继续,先回到主代理由主代理决定是否重分 Wave 或调整 plan,禁止子代理私自越界
877
- 8. **batch 子代理协议**(仅当按「执行方式」节条件合并 batch 时附加进该子代理 prompt):按 batch 内 task 顺序逐个完成实现闭环——读取 tasks/task-N.md → 实现 → 跑该 task 的 verify 命令 → 记录该 task 报告(改动文件清单 / verify 结果 / 卡点)→ 才开始下一个 task;最终回复输出逐 task 报告清单。禁止写 review.json、禁止勾选 plan.md checkbox——task 审查与勾选归主 agent,在子代理返回后逐 task 进行。越权即停:发现必须改 batch 内其他 task 或任何 batch 外 task 的 allowed_paths 文件 → 立即停止本 task 及后续,报告冲突文件与卡点,回主 agent 裁决(重分 Wave / 调整 plan / 回退独立子代理)。第 7 条任务边界铁律在 batch 语境下的「本 task」= 当前正在实现的 task
886
+ 8. **batch 子代理协议**(仅当按「执行方式」节条件合并 batch 时附加进该子代理 prompt):按 batch 内 task 顺序逐个完成实现闭环——读取 tasks/task-N.md → 实现 → 跑该 task 的 verify 命令 → 记录该 task 报告(改动文件清单 / verify 结果 / 卡点)→ 才开始下一个 task;最终回复输出逐 task 报告清单。禁止写 review.json、禁止勾选 tasks.md checkbox——task 审查与勾选归主 agent,在子代理返回后逐 task 进行。越权即停:发现必须改 batch 内其他 task 或任何 batch 外 task 的 allowed_paths 文件 → 立即停止本 task 及后续,报告冲突文件与卡点,回主 agent 裁决(重分 Wave / 调整 plan / 回退独立子代理)。第 7 条任务边界铁律在 batch 语境下的「本 task」= 当前正在实现的 task
878
887
 
879
888
  {{include: testcase-design}}
880
889
 
@@ -889,8 +898,8 @@ ${taskSummary}
889
898
  - ❄️ 冷上下文:其他变更的 design.md、历史 plan.md(不要主动加载,除非明确需要)
890
899
 
891
900
  ### 中断续跑(如曾中断恢复)
892
- execute 按 Wave 持久化进度,task 级进度靠 plan.md checkbox 勾选。若本 Wave 曾因 429/API 配额/崩溃中断:
893
- - plan.md 中**已勾选 \`- [x]\` 的 task 已完成,跳过不重跑**(子代理也可能在完成前中断,重跑前先确认该 task 产出文件是否完整)
901
+ execute 按 Wave 持久化进度,task 级进度靠 tasks.md checkbox 勾选。若本 Wave 曾因 429/API 配额/崩溃中断:
902
+ - tasks.md 中**已勾选 \`- [x]\` 的 task 已完成,跳过不重跑**(子代理也可能在完成前中断,重跑前先确认该 task 产出文件是否完整)
894
903
  - 用 \`sillyspec status\` 查当前进度,重新 \`sillyspec run execute\` 会回到当前 Wave step 继续,**不要从零重置或重跑已完成 Wave**
895
904
  - 本 Wave 已完成但不完整(产出缺文件)的 task 补做,不牵连其他 task
896
905
 
@@ -919,7 +928,7 @@ ${taskList}
919
928
  1. 读取当前 task 的 git diff(从 task 开始到完成的变更)
920
929
  2. 对照 plan.md 中该 task 的描述和 tasks/task-XX.md(如果存在)检查实现是否符合要求
921
930
  3. 写入 review.json 文件
922
- 4. **只有 review.json 写入成功后,才允许勾选 plan.md 中的 checkbox**
931
+ 4. **只有 review.json 写入成功后,才允许勾选 tasks.md 中对应任务的 checkbox**(勾选唯一落点;CLI 的 autoCheckPlanFromReviews 机器勾选器同样写 tasks.md,文件锁 .tasks.md.lock 串行化双路勾选)
923
932
 
924
933
  **review.json 路径:**
925
934
 
@@ -970,9 +979,11 @@ export function buildExecuteSteps(planFilePath = null, options = {}) {
970
979
 
971
980
  if (planFilePath && existsSync(planFilePath)) {
972
981
  const planContent = readFileSync(planFilePath, 'utf8')
973
- // Plan → Execute 契约由 plan 阶段完成时的 postcheck 把关(run.js completeStep),
974
- // 此处只负责解析 waves,避免 buildExecuteSteps 与进程退出耦合。
975
- waves = parseWavesFromPlan(planContent)
982
+ // 新契约(D-001@v1):任务注册表在 tasks.md(与 plan.md 同目录),Wave 结构在 plan.md
983
+ const tasksPath = path.join(path.dirname(planFilePath), 'tasks.md')
984
+ const tasksContent = existsSync(tasksPath) ? readFileSync(tasksPath, 'utf8') : ''
985
+ const registry = parseTaskRegistry(tasksContent)
986
+ waves = parseWavesFromPlan(planContent, registry)
976
987
  changeDir = path.dirname(planFilePath)
977
988
  }
978
989
 
@@ -344,7 +344,10 @@ function parseTaskWavesFromPlan(planPath) {
344
344
  const wm = line.match(/^#+\s*Wave\s+(\d+)/i)
345
345
  if (wm) { currentWave = parseInt(wm[1], 10); continue }
346
346
  if (/^#+\s/.test(line)) { currentWave = null; continue } // 非 Wave 标题退出当前 Wave 段
347
- const tm = line.match(/^[-*]\s*\[[ x]\]\s*(task-\d+)/i)
347
+ // 2026-08-20-task-truth-unify:Wave 段下为纯 ID 引用行(- task-XX);旧 checkbox 行同收
348
+ // (旧变更读侧兼容——新格式由 validatePlanForExecute 拦旧形态,此处不重复把关)
349
+ const tm = line.match(/^[-*]\s+(?:\[[ x]\]\s*)?(task-\d+)\s*[::\s]*$/i)
350
+ || line.match(/^[-*]\s*\[[ x]\]\s*(task-\d+)\b/i)
348
351
  if (tm && currentWave !== null) map.set(tm[1], currentWave)
349
352
  }
350
353
  return map
@@ -452,38 +455,46 @@ export function validateBlueprintConsistency(changeDir) {
452
455
  }
453
456
  }
454
457
 
455
- // plan.md 声明任务 ↔ tasks/ 卡片双向对账(债单 D-2b):
456
- // plan 列 17 任务只出 12 卡(尾部文档同步类任务漏卡)时,无卡任务不进 Wave、
457
- // 不受 execute 审计 → 零失败信号漏做。口径与 plan.js parseTaskCount/parseTaskNames 一致
458
- // (`- [ ] task-NN` checkbox;file 命名 task-NN.md + 卡内 id task-NN 均取,缺一即漏卡)。
458
+ // 任务声明 ↔ tasks/ 卡片双向对账(债单 D-2b;2026-08-20-task-truth-unify:声明源迁 tasks.md 注册表):
459
+ // 注册表列 17 任务只出 12 卡(尾部文档同步类任务漏卡)时,无卡任务不进 Wave、
460
+ // 不受 execute 审计 → 零失败信号漏做。口径与 execute.js parseTaskRegistry 一致
461
+ // (`- [ ] task-NN` checkbox 行;file 命名 task-NN.md + 卡内 id task-NN 均取,缺一即漏卡)。
459
462
  const planMdPath = pJoin(changeDir, 'plan.md')
460
- if (existsSync(planMdPath)) {
461
- const declaredIds = new Set()
463
+ const declaredIds = new Set()
464
+ const tasksMdPath = pJoin(changeDir, 'tasks.md')
465
+ if (existsSync(tasksMdPath)) {
466
+ for (const line of readFileSync(tasksMdPath, 'utf8').split('\n')) {
467
+ const m = line.match(/^[-*]\s*\[[ x]\]\s*(task-\d+)\b/i)
468
+ if (m) declaredIds.add(m[1].toLowerCase())
469
+ }
470
+ }
471
+ if (declaredIds.size === 0 && existsSync(planMdPath)) {
472
+ // 兼容读侧:tasks.md 缺失/无声明时回退解析 plan.md 旧 checkbox 声明(旧归档变更)
462
473
  for (const line of readFileSync(planMdPath, 'utf8').split('\n')) {
463
474
  const m = line.match(/^[-*]\s*\[[ x]\]\s*(task-\d+)\b/i)
464
475
  if (m) declaredIds.add(m[1].toLowerCase())
465
476
  }
466
- if (declaredIds.size > 0) {
467
- const tprRule = getRule('plan.task-plan-reconciliation')
468
- const cardIds = new Set([...taskInfo.keys()].map(id => id.toLowerCase()))
469
- const missing = [...declaredIds].filter(id => !cardIds.has(id))
470
- if (missing.length > 0) {
471
- errors.push(
472
- tprRule.data.messageMissingCards
473
- .replaceAll('${declared}', declaredIds.size)
474
- .replaceAll('${missing}', missing.join(', '))
475
- )
476
- }
477
- const orphans = [...cardIds].filter(id => !declaredIds.has(id))
478
- if (orphans.length > 0) {
479
- errors.push(
480
- tprRule.data.messageOrphanCards.replaceAll('${orphans}', orphans.join(', '))
481
- )
482
- }
477
+ }
478
+ if (declaredIds.size > 0) {
479
+ const tprRule = getRule('plan.task-plan-reconciliation')
480
+ const cardIds = new Set([...taskInfo.keys()].map(id => id.toLowerCase()))
481
+ const missing = [...declaredIds].filter(id => !cardIds.has(id))
482
+ if (missing.length > 0) {
483
+ errors.push(
484
+ tprRule.data.messageMissingCards
485
+ .replaceAll('${declared}', declaredIds.size)
486
+ .replaceAll('${missing}', missing.join(', '))
487
+ )
488
+ }
489
+ const orphans = [...cardIds].filter(id => !declaredIds.has(id))
490
+ if (orphans.length > 0) {
491
+ errors.push(
492
+ tprRule.data.messageOrphanCards.replaceAll('${orphans}', orphans.join(', '))
493
+ )
483
494
  }
484
- // declaredIds.size === 0:plan.md 无 task checkbox 声明(极端格式)→ 跳过对账,
485
- // 由 validatePlanArtifacts/validatePlanForExecute 把关,不在此误伤。
486
495
  }
496
+ // declaredIds.size === 0:注册表无 task 声明(极端格式)→ 跳过对账,
497
+ // 由 validatePlanArtifacts/validatePlanForExecute 把关,不在此误伤。
487
498
 
488
499
  // 拓扑排序 + 循环依赖
489
500
  const depMap = new Map()
@@ -915,7 +926,15 @@ export function validatePlanFeasibility(changeDir, projectRoot = null) {
915
926
  const content = readFileSync(pJoin(tasksDir, file), 'utf8')
916
927
  const fmMatch = content.match(/^---\n([\s\S]*?)\n---/)
917
928
  if (!fmMatch) {
918
- errors.push(fsRule.data.messageFrontmatter.replaceAll('${id}', file))
929
+ // 漏闭合 --- 高频坑细化:只报「缺少 YAML frontmatter」不具体,用户须逐轮试错。
930
+ // 有开头 --- 无闭合 / 完全无 --- 分开报并附修复动作(manifest messageFrontmatter 保持
931
+ // 基础句,细化文案内联——同 title_zh 内联先例)。骨架命令从源头保闭合 + LF。
932
+ const firstLine = (content.split('\n')[0] || '').trim()
933
+ errors.push(
934
+ firstLine === '---'
935
+ ? `${file}: frontmatter 未闭合——有开头 --- 但缺少结尾 ---。修复:补一行 --- 闭合(frontmatter 以 --- 行开始并以 --- 行结束),或 sillyspec taskcard <change> --task <task-id> 重新生成安全骨架后填充`
936
+ : `${fsRule.data.messageFrontmatter.replaceAll('${id}', file)}——文件必须以 --- 行开始。修复:sillyspec taskcard <change> --task <task-id> 生成安全骨架后填充`
937
+ )
919
938
  continue
920
939
  }
921
940
  const fm = fmMatch[1]
@@ -1261,7 +1280,9 @@ export async function executePlanPostcheck(context) {
1261
1280
  currentWaveTasks = null
1262
1281
  continue
1263
1282
  }
1264
- const tm = line.match(/^[-*]\s*\[[ x]\]\s*task-(\d+)/i)
1283
+ // 2026-08-20-task-truth-unify:Wave 段下纯 ID 引用行(- task-NN);旧 checkbox 行同收
1284
+ const tm = line.match(/^[-*]\s+(?:\[[ x]\]\s*)?task-(\d+)\s*[::\s]*$/i)
1285
+ || line.match(/^[-*]\s*\[[ x]\]\s*task-(\d+)/i)
1265
1286
  if (tm && currentWaveTasks) {
1266
1287
  currentWaveTasks.push(`task-${tm[1]}`)
1267
1288
  }