sillyspec 3.26.11 → 3.26.14

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 (62) 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 +200 -27
  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 +187 -30
  29. package/src/quicklog.js +47 -16
  30. package/src/run/command.js +88 -46
  31. package/src/run/complete-handlers.js +27 -15
  32. package/src/run/complete.js +1186 -1121
  33. package/src/run/concurrent-detect.js +3 -1
  34. package/src/run/gates.js +172 -25
  35. package/src/run/prompt.js +17 -9
  36. package/src/run/scan-profile.js +35 -23
  37. package/src/run/shared.js +164 -63
  38. package/src/run/stage.js +41 -1
  39. package/src/setup.js +8 -10
  40. package/src/sillyhub-mcp/client.js +74 -6
  41. package/src/spec-dir-typo.js +3 -24
  42. package/src/spec-sync.js +43 -8
  43. package/src/stage-contract.js +12 -38
  44. package/src/stage-review.js +40 -1
  45. package/src/stages/archive.js +4 -3
  46. package/src/stages/brainstorm-auto.js +1 -1
  47. package/src/stages/brainstorm.js +27 -13
  48. package/src/stages/doctor.js +29 -16
  49. package/src/stages/execute.js +170 -154
  50. package/src/stages/knowledge.js +18 -3
  51. package/src/stages/plan-postcheck.js +72 -37
  52. package/src/stages/plan.js +70 -48
  53. package/src/stages/quick.js +3 -1
  54. package/src/stages/verify.js +1 -1
  55. package/src/sync.js +339 -225
  56. package/src/task-review.js +281 -64
  57. package/src/taskcard.js +178 -0
  58. package/src/verify-postcheck.js +48 -35
  59. package/src/worktree-apply.js +58 -34
  60. package/src/worktree-deps.js +120 -20
  61. package/src/worktree.js +55 -9
  62. 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,89 @@ 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
- // 检查 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
- }
121
+ // 检查 3: task id 连续性(从 1 开始;ids[0]!==1 时整体跳过 = 兼容旧变更编号不从 1 起,
122
+ // 契约由 test/plan-execute-contract.test.mjs Case 10 钉死,勿改)
123
+ const ids = allTasks.map(t => t.index).sort((a, b) => a - b)
124
+ if (ids.length > 0 && ids[0] === 1) {
125
+ for (let i = 0; i < ids.length; i++) {
126
+ if (ids[i] !== i + 1) {
127
+ errors.push(getRule('plan.task-id-continuity').failMessage.replaceAll('${expected}', String(i + 1).padStart(2, '0')).replaceAll('${actual}', String(ids[i]).padStart(2, '0')))
128
+ break
84
129
  }
85
130
  }
86
131
  }
87
132
 
88
- // 检查 4: task name 非空
133
+ // 检查 4: task name 非空(注册表行)
89
134
  for (const task of allTasks) {
90
135
  if (!task.name || !task.name.trim()) {
91
- errors.push(`task-${String(task.index || '?').padStart(2, '0')}: 任务名为空`)
136
+ errors.push(`${task.id}: 任务名为空(tasks.md 行格式 "- [ ] ${task.id}: 一句话任务名")`)
92
137
  }
93
138
  }
94
139
 
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: 名称)`)
140
+ // 交叉校验:plan.md Wave 引用 × tasks.md 注册表(仅显式 Wave 结构参与;隐式 Wave 由
141
+ // parseWavesFromPlan 从注册表整体合成,天然全覆盖)
142
+ const explicitWaves = waves.filter(w => !w.implicit)
143
+ if (explicitWaves.length > 0) {
144
+ const refCount = {}
145
+ for (const wave of explicitWaves) {
146
+ for (const task of wave.tasks) {
147
+ refCount[task.id] = (refCount[task.id] || 0) + 1
148
+ if (task.dangling) {
149
+ errors.push(`plan.md Wave ${wave.index} 引用的 ${task.id} 不存在于 tasks.md 注册表(悬空引用)`)
150
+ }
100
151
  }
101
152
  }
153
+ // 覆盖恰一次:注册表每个 id 必须被引用(缺失)且只被一个 Wave 引用(重复)
154
+ for (const task of allTasks) {
155
+ const n = refCount[task.id] || 0
156
+ if (n === 0) errors.push(`${task.id} 未被任何 Wave 引用(plan.md Wave 段缺 "- ${task.id}" 引用行——未引用的任务不进 execute 步骤)`)
157
+ if (n > 1) errors.push(`${task.id} 被多个 Wave 重复引用 ${n} 次(一个任务只属一个 Wave;跨 Wave 依赖用 (depends_on: …) 声明)`)
158
+ }
159
+ // Wave 段空引用诊断
160
+ const emptyWave = explicitWaves.find(w => w.tasks.length === 0)
161
+ if (emptyWave) {
162
+ errors.push(`plan.md Wave ${emptyWave.index} 段下没有任何 "- task-XX" 引用行(新契约 Wave 段只收 ID 引用行)`)
163
+ }
102
164
  }
103
165
 
104
166
  return { ok: errors.length === 0, errors, warnings, tasks: allTasks, waves }
@@ -134,7 +196,7 @@ const fixedPrefix = [
134
196
  prompt: `加载计划、设计和代码库上下文。
135
197
 
136
198
  ### 操作
137
- 1. 读取 tasks.md(执行计划)
199
+ 1. 读取 tasks.md(任务注册表与勾选唯一真相;plan.md 只提供 Wave 分组/依赖结构——Wave 段下为纯 ID 引用行)
138
200
  2. 读取 design.md(技术方案)
139
201
  3. 读取 CONVENTIONS.md、ARCHITECTURE.md
140
202
  4. 读取 local.yaml(构建命令);若 local.yaml 不存在,先 \`sillyspec local detect\` 生成骨架再读取
@@ -386,123 +448,71 @@ Apply Decision + 下一步建议
386
448
  ]
387
449
 
388
450
  /**
389
- * 诊断 plan.md 无 checkbox task 的根因(坑 plan-md-format-contract-hidden)。
390
- * 纯函数:读 planContent 文本,返回诊断提示数组。validatePlanForExecute 无 task 时追加到报错,
391
- * 帮 agent 区分 4 条隐性格式契约(Wave 标题字面 / task checkbox / task-XX 编号 / ### 打断 Wave 段)。
451
+ * 诊断任务注册表为空的根因(坑 plan-md-format-contract-hidden 延续:笼统报错逼 agent 试错)。
452
+ * 三类根因:tasks.md 无 task-XX checkbox / plan.md Wave 段无 ID 引用 / 旧格式(任务 checkbox 还在 plan.md)。
453
+ * @param {string} tasksContent
392
454
  * @param {string} planContent
393
455
  * @returns {string[]}
394
456
  */
395
- function diagnoseNoTaskRootCause(planContent) {
457
+ function diagnoseNoTaskRegistry(tasksContent, planContent) {
396
458
  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: 名称"(英文冒号)')
459
+ const tc = String(tasksContent || '')
460
+ const pc = String(planContent || '')
461
+ const hasTaskCheckboxInTasks = /^[-*]\s*\[[ xX]\]\s*task-\d+/im.test(tc)
462
+ const hasQlLines = /^[-*]\s*\[[ xX]\]\s*ql-/im.test(tc)
463
+ const hasLegacyInPlan = /^[-*]\s*\[[ xX]\]\s*task-\d+\b[::]/im.test(pc)
464
+ const hasWaveHeading = /^#+\s*Wave\s+\d+/im.test(pc)
465
+ const hasRefLine = /^[-*]\s+task-\d+\s*$/im.test(pc)
466
+
467
+ if (hasLegacyInPlan) {
468
+ diags.push('任务 checkbox 还在 plan.md(旧格式):请移入 tasks.md(行格式 "- [ ] task-XX: 一句话任务名",[model:xxx]/(depends_on: …) 标注随行),plan.md Wave 段改为 "- task-XX" 纯 ID 引用行')
469
+ } else if (!tc.trim()) {
470
+ diags.push('tasks.md 内容为空或缺失:plan 阶段应把展开后的任务清单写回 tasks.md(brainstorm 骨架只是名字级占位)')
471
+ } else if (!hasTaskCheckboxInTasks && hasQlLines) {
472
+ diags.push('tasks.md 只有 ql-xxx 行(quick 挂载条目),没有 task-XX 任务行——完整流程变更的任务清单尚未写入')
473
+ } else if (!hasTaskCheckboxInTasks) {
474
+ diags.push('tasks.md 无 "- [ ] task-XX:" 格式的任务行:检查行首格式(- 空格 [ ] 空格 task-XX: 英文冒号)')
414
475
  }
415
- if (hasWaveHeading && hasCheckboxTask && !hasTaskHeading) {
416
- diags.push('有 Wave 段也有 checkbox task 但未收容:checkbox 可能落在非任务区(如 "## 自检" 段)或被 "###" 标题打断,请检查 task 是否紧跟在 "## Wave N" 下')
476
+ if (hasTaskCheckboxInTasks && hasWaveHeading && !hasRefLine) {
477
+ diags.push('plan.md 有 Wave 段但没有 "- task-XX" 引用行:新契约 Wave 段只收纯 ID 引用行(如 "- task-01"),未引用的任务不进 execute')
417
478
  }
418
479
  return diags
419
480
  }
420
481
 
421
482
  /**
422
- * 从 plan 文件解析 Wave 分组
483
+ * 从 plan 解析 Wave 分组(新契约:Wave 段下纯 ID 引用行 `- task-XX`,任务详情在 tasks.md 注册表)。
484
+ * @param {string} planContent
485
+ * @param {Array<object>} registry - parseTaskRegistry 产物(id → 任务详情富化源)
423
486
  */
424
- function parseWavesFromPlan(planContent) {
487
+ function parseWavesFromPlan(planContent, registry = []) {
425
488
  const waves = []
426
- const lines = planContent.split('\n')
489
+ const regById = new Map(registry.map(t => [t.id, t]))
427
490
  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
491
 
433
- for (const line of lines) {
492
+ for (const line of String(planContent || '').split('\n')) {
434
493
  const waveMatch = line.match(/^#+\s*Wave\s+(\d+)/i)
435
494
  if (waveMatch) {
436
495
  currentWave = { index: parseInt(waveMatch[1]), tasks: [] }
437
- currentTask = null
438
- inImplicitTaskSection = false
439
496
  waves.push(currentWave)
440
497
  continue
441
498
  }
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
- }
499
+ // 任何非 Wave 的标题行退出当前 Wave 段(「## 自检」段的行不收,与旧解析同守卫)
500
+ if (/^#{1,6}\s+/.test(line)) { currentWave = null; continue }
501
+ const refMatch = line.match(/^[-*]\s+task-(\d+)\s*$/i)
502
+ if (refMatch && currentWave) {
503
+ const id = `task-${refMatch[1].padStart(2, '0')}`
504
+ const reg = regById.get(id)
505
+ currentWave.tasks.push(reg
506
+ ? { ...reg }
507
+ : { index: parseInt(refMatch[1], 10), id, name: '', done: false, model: null, dependsOn: [], file: '', steps: '', reference: '', dangling: true })
503
508
  }
504
509
  }
505
510
 
511
+ // 无显式 Wave 结构但注册表非空(light 级:任务全在 tasks.md、plan.md 只留策略)→
512
+ // 合成单隐式 Wave 收容全部任务(与旧 light `## Tasks` 隐式收容语义对齐,单 Wave 串行执行)
513
+ if (waves.length === 0 && registry.length > 0) {
514
+ waves.push({ index: 1, implicit: true, tasks: registry.map(t => ({ ...t })) })
515
+ }
506
516
  return waves
507
517
  }
508
518
 
@@ -568,6 +578,10 @@ function writeBaseCommitToTaskCard(taskFilePath, baseCommit) {
568
578
  } catch {
569
579
  return false
570
580
  }
581
+ // CRLF 归一(坑 base-commit-crlf-frontmatter,plan-postcheck.js 同款):跨仓 task 卡被
582
+ // 编辑器/子代理写成 CRLF 后 `^---\n` 匹配失败 → base_commit 锚点静默不落盘,而 Wave
583
+ // prompt 仍声称「CLI 已落盘」。归一后按 LF 写回(taskcard 生成侧本就强制 LF)。
584
+ content = content.replace(/\r\n?/g, '\n')
571
585
  const fmMatch = content.match(/^---\n([\s\S]*?)\n---/)
572
586
  if (!fmMatch) return false
573
587
  const fm = fmMatch[1]
@@ -855,7 +869,7 @@ ${workdirLines}
855
869
  你的角色是调度者 + 审查者(batch 只合并实现、不合并审查):
856
870
  1. 为每个任务启动一个子代理(Agent tool),或按上述三条件把多个任务合并为一个 batch 子代理,同 Wave 内可并行
857
871
  2. 子代理完成后审查结果——batch 子代理只做实现与自验,task 审查、review.json 产出与 checkbox 勾选仍归你(主 agent),在子代理返回后逐 task 进行;审查 batch 报告时逐 task 对照 allowed_paths 检查改动文件清单有无越权
858
- 3. 勾选 plan.md 中的 checkbox
872
+ 3. 勾选 tasks.md 中对应任务的 checkbox
859
873
  4. 记录改动文件和测试结果
860
874
 
861
875
  ${worktreeSection}${crossRepoCommitSection}${dispatchSection}
@@ -874,7 +888,7 @@ ${taskSummary}
874
888
  5. 任务含测试代码时,把下方「测试用例设计」整段复制进子代理 prompt,要求子代理按此设计测试用例
875
889
  6. **增量落盘与中断接手指引**:每完成一个可见产出(代码/测试/文档),立即写盘并执行一次最小验证(如语法检查、单跑相关测试)。工作过程中如被 429/API 配额/会话中断,应在最终回复里输出「已完成清单」(含文件路径、测试命令、当前卡点),不要只输出结论——主代理会依据磁盘产物和该清单判断哪些部分已完成,哪些需接手补做,避免重做已落盘的工作
876
890
  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
891
+ 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
892
 
879
893
  {{include: testcase-design}}
880
894
 
@@ -889,8 +903,8 @@ ${taskSummary}
889
903
  - ❄️ 冷上下文:其他变更的 design.md、历史 plan.md(不要主动加载,除非明确需要)
890
904
 
891
905
  ### 中断续跑(如曾中断恢复)
892
- execute 按 Wave 持久化进度,task 级进度靠 plan.md checkbox 勾选。若本 Wave 曾因 429/API 配额/崩溃中断:
893
- - plan.md 中**已勾选 \`- [x]\` 的 task 已完成,跳过不重跑**(子代理也可能在完成前中断,重跑前先确认该 task 产出文件是否完整)
906
+ execute 按 Wave 持久化进度,task 级进度靠 tasks.md checkbox 勾选。若本 Wave 曾因 429/API 配额/崩溃中断:
907
+ - tasks.md 中**已勾选 \`- [x]\` 的 task 已完成,跳过不重跑**(子代理也可能在完成前中断,重跑前先确认该 task 产出文件是否完整)
894
908
  - 用 \`sillyspec status\` 查当前进度,重新 \`sillyspec run execute\` 会回到当前 Wave step 继续,**不要从零重置或重跑已完成 Wave**
895
909
  - 本 Wave 已完成但不完整(产出缺文件)的 task 补做,不牵连其他 task
896
910
 
@@ -919,7 +933,7 @@ ${taskList}
919
933
  1. 读取当前 task 的 git diff(从 task 开始到完成的变更)
920
934
  2. 对照 plan.md 中该 task 的描述和 tasks/task-XX.md(如果存在)检查实现是否符合要求
921
935
  3. 写入 review.json 文件
922
- 4. **只有 review.json 写入成功后,才允许勾选 plan.md 中的 checkbox**
936
+ 4. **只有 review.json 写入成功后,才允许勾选 tasks.md 中对应任务的 checkbox**(勾选唯一落点;CLI 的 autoCheckPlanFromReviews 机器勾选器同样写 tasks.md,文件锁 .tasks.md.lock 串行化双路勾选)
923
937
 
924
938
  **review.json 路径:**
925
939
 
@@ -970,9 +984,11 @@ export function buildExecuteSteps(planFilePath = null, options = {}) {
970
984
 
971
985
  if (planFilePath && existsSync(planFilePath)) {
972
986
  const planContent = readFileSync(planFilePath, 'utf8')
973
- // Plan → Execute 契约由 plan 阶段完成时的 postcheck 把关(run.js completeStep),
974
- // 此处只负责解析 waves,避免 buildExecuteSteps 与进程退出耦合。
975
- waves = parseWavesFromPlan(planContent)
987
+ // 新契约(D-001@v1):任务注册表在 tasks.md(与 plan.md 同目录),Wave 结构在 plan.md
988
+ const tasksPath = path.join(path.dirname(planFilePath), 'tasks.md')
989
+ const tasksContent = existsSync(tasksPath) ? readFileSync(tasksPath, 'utf8') : ''
990
+ const registry = parseTaskRegistry(tasksContent)
991
+ waves = parseWavesFromPlan(planContent, registry)
976
992
  changeDir = path.dirname(planFilePath)
977
993
  }
978
994
 
@@ -276,10 +276,25 @@ export async function cmdRefresh(dir, args, opts = {}) {
276
276
  // refresh 只写 generated/ 区
277
277
  mkdirSync(generatedDir, { recursive: true })
278
278
 
279
- // 扫描 scan 文档
279
+ // 扫描 scan 文档。项目名用 DB 注册名优先(坑 knowledge-basename-project-mismatch):
280
+ // scan 写侧 docs/<project> 的 project 来自 dbProjectName || basename(cwd),注册名 ≠ 目录名
281
+ // (projects yaml name/path 分离、平台模式 specRoot≠sourceRoot)时,用 basename(dir) 拼
282
+ // scanDir 永远找不到 → refresh 恒报 scan_docs_missing。DB 读失败回退 basename(与写侧
283
+ // default 同口径)。查不到注册名对应目录时再回退 basename 试一次(双形态兼容)。
280
284
  const base = opts.specDir || join(dir, '.sillyspec')
281
- const projectName = basename(dir)
282
- const scanDir = join(base, 'docs', projectName, 'scan')
285
+ let projectName = null
286
+ try {
287
+ const { DatabaseSync } = await import('node:sqlite')
288
+ const db = new DatabaseSync(join(base, '.runtime', 'sillyspec.db'), { readOnly: true })
289
+ const row = db.prepare('SELECT name FROM project LIMIT 1').get()
290
+ db.close()
291
+ if (row && row.name) projectName = row.name
292
+ } catch { /* db 不可读 → 走 basename 兜底 */ }
293
+ if (!projectName) projectName = basename(dir)
294
+ let scanDir = join(base, 'docs', projectName, 'scan')
295
+ if (!existsSync(scanDir) && projectName !== basename(dir)) {
296
+ scanDir = join(base, 'docs', basename(dir), 'scan')
297
+ }
283
298
 
284
299
  if (!existsSync(scanDir)) {
285
300
  output(false, {}, { code: 'scan_docs_missing', path: scanDir })