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
@@ -104,8 +104,10 @@ export function detectConcurrentChanges(cwd, { changeName, linkedChanges = [], o
104
104
  if (!file) continue
105
105
 
106
106
  // rule1:落 .sillyspec/changes/<dir>/ 且 <dir> 非本变更非关联 → otherActiveChanges 去重。
107
+ // 'archive' 排除(坑 archive-dir-as-other-change):归档移动产生 .sillyspec/changes/archive/...
108
+ // 脏路径,extractChangeDir 返回 'archive',不排除会把归档动作误报成「他者活跃变更: archive」。
107
109
  const dir = extractChangeDir(file)
108
- if (dir && !ownChangeSet.has(normalizeGitPath(dir))) {
110
+ if (dir && dir !== 'archive' && !ownChangeSet.has(normalizeGitPath(dir))) {
109
111
  otherActiveSet.add(dir)
110
112
  continue
111
113
  }
package/src/run/gates.js CHANGED
@@ -26,15 +26,16 @@ import { runValidators } from '../stage-contract.js'
26
26
  import { handleScanStageCompleted, handleExecuteWorktreeCleanup } from './complete-handlers.js'
27
27
  import { detectConcurrentChanges, formatConcurrentWarning } from './concurrent-detect.js'
28
28
  import { stageRegistry } from '../stages/index.js'
29
+ import { normalizeTaskId } from '../taskcard.js'
29
30
 
30
31
  /**
31
- * 从 plan.md 提取全部 task id(task-XX)——符号影响面覆盖度校验用。
32
- * 只认 checkbox 行 `- [ ] task-XX:` / `- [x] task-XX:`(与 execute.js parseWavesFromPlan 的
33
- * 收容口径一致:### 标题式 task / 非 task 区 checkbox 不算),light plan 的 `## Tasks` 段同样收。
32
+ * 从任务注册表(tasks.md)提取全部 task id(task-XX)——符号影响面覆盖度校验用。
33
+ * 只认 checkbox 行 `- [ ] task-XX:` / `- [x] task-XX:`(与 execute.js parseTaskRegistry 同口径)。
34
+ * 2026-08-20-task-truth-unify:源从 plan.md 迁 tasks.md(任务唯一真相);ql-xxx 行不收。
34
35
  */
35
- function extractTaskIdsFromPlan(planContent) {
36
- if (!planContent) return []
37
- return [...String(planContent).matchAll(/^[-*]\s*\[[ x]\]\s*(?:.*\b)?task-(\d+)\b/gim)]
36
+ function extractTaskIdsFromRegistry(tasksContent) {
37
+ if (!tasksContent) return []
38
+ return [...String(tasksContent).matchAll(/^[-*]\s*\[[ xX]\]\s*task-(\d+)\b/gim)]
38
39
  .map(m => `task-${m[1].padStart(2, '0')}`)
39
40
  }
40
41
 
@@ -56,8 +57,10 @@ export function validateSymbolImpactReport({ reportContent, planContent }) {
56
57
  if (!reportContent || !String(reportContent).trim()) {
57
58
  return { ok: false, errors: ['symbol-impact.md 不存在或内容为空——「加载上下文」步的符号影响面报告必须落盘(逐 task 一行结论,「无签名级变更」也要显式写)'] }
58
59
  }
59
- const taskIds = extractTaskIdsFromPlan(planContent)
60
- if (taskIds.length === 0) return { ok: true, errors } // plan 无 task 可列(默认兜底形态)→ 无可校验对象
60
+ // 2026-08-20-task-truth-unify:task 清单源迁 tasks.md(参数名 planContent 保持兼容既有调用,
61
+ // 语义=任务注册表内容;调用方 enforceSymbolImpactGate 已改读 tasks.md)
62
+ const taskIds = extractTaskIdsFromRegistry(planContent)
63
+ if (taskIds.length === 0) return { ok: true, errors } // 注册表无 task 可列(默认兜底形态)→ 无可校验对象
61
64
  const missing = taskIds.filter(id => !String(reportContent).includes(id))
62
65
  for (const id of missing) {
63
66
  errors.push(`${id} 未在 symbol-impact.md 中出现——每个 task 都要有一行结论(含「无签名级变更」的显式声明)`)
@@ -72,14 +75,14 @@ export function validateSymbolImpactReport({ reportContent, planContent }) {
72
75
  export async function enforceSymbolImpactGate(stageName, changeName, currentStepName, specBase) {
73
76
  if (stageName !== 'execute') return
74
77
  if (!currentStepName || !currentStepName.includes('加载上下文')) return
75
- const planPath = join(specBase, 'changes', changeName || '', 'plan.md')
78
+ const tasksPath = join(specBase, 'changes', changeName || '', 'tasks.md')
76
79
  const reportPath = join(specBase, 'changes', changeName || '', 'symbol-impact.md')
77
- if (!existsSync(planPath)) return // plan.md 缺失场景已有 plan 阶段 gate 把关,此处不重复拦
78
- let planContent = ''
79
- try { planContent = readFileSync(planPath, 'utf8') } catch { return }
80
+ if (!existsSync(tasksPath)) return // 注册表缺失场景已有 plan 阶段 gate 把关,此处不重复拦
81
+ let tasksContent = ''
82
+ try { tasksContent = readFileSync(tasksPath, 'utf8') } catch { return }
80
83
  let reportContent = null
81
84
  try { reportContent = readFileSync(reportPath, 'utf8') } catch { /* 缺失由 validate 报 */ }
82
- const result = validateSymbolImpactReport({ reportContent, planContent })
85
+ const result = validateSymbolImpactReport({ reportContent, planContent: tasksContent })
83
86
  if (result.ok) return
84
87
  console.error('❌ ── 符号影响面报告校验阻断(本次 --done 未完成,进度未推进)──')
85
88
  console.error(` 「加载上下文」步的符号影响面检查报告未落盘或不完整:`)
@@ -89,6 +92,26 @@ export async function enforceSymbolImpactGate(stageName, changeName, currentStep
89
92
  process.exit(1)
90
93
  }
91
94
 
95
+ /**
96
+ * UI 原型软提醒(非阻断):brainstorm「生成规范文件」步 --done 时,design.md 命中前端文件
97
+ * 但变更目录无 prototype-*.html → console.warn 一行,用户可否决。程度判断(布局级才必须出)
98
+ * 是语义判断,CLI 不硬卡只提示——治 agent 对明显 UI 改造静默跳过原型的坑。
99
+ */
100
+ export function warnMissingUiPrototype(stageName, changeName, currentStepName, specBase) {
101
+ if (stageName !== 'brainstorm' || !currentStepName || !currentStepName.includes('生成规范文件')) return
102
+ if (!changeName || !specBase) return
103
+ const changeDir = join(specBase, 'changes', changeName)
104
+ const designPath = join(changeDir, 'design.md')
105
+ if (!existsSync(designPath)) return
106
+ try {
107
+ if (readdirSync(changeDir).some(f => /^prototype-.*\.html$/i.test(f))) return
108
+ } catch { return } // 目录不可读(平台模式 specRoot 差异等)→ 静默跳过,不制造噪音
109
+ const design = readFileSync(designPath, 'utf8')
110
+ const feExts = [...new Set((design.match(/\.(vue|svelte|astro|tsx|jsx|css|scss|sass|less|styl|html)\b/gi) || []).map(s => s.replace('.', '').toLowerCase()))]
111
+ if (feExts.length === 0) return
112
+ console.warn(` ⚠️ design.md 命中前端文件类型(${feExts.join('/')})但变更目录无 prototype-*.html——轻微样式改动可忽略;布局/交互流程级变化应补原型(sillyspec run brainstorm --reopen 回到「分段展示设计」,或直接让 agent 补生成)`)
113
+ }
114
+
92
115
  /**
93
116
  * 判断当前 execute step 所在 wave 是否全部 task 都声明 no_deps_verify: true(D-006@v2)。
94
117
  * 仅 wave 执行步骤(名如 "Wave N 执行")可 opt-out;非 wave 步骤恒返回 false(保守过门)。
@@ -105,10 +128,13 @@ function isCurrentWaveAllNoDepsVerify(stepName, changeDir) {
105
128
  const waveRe = new RegExp(`^##\\s*Wave\\s+${waveN}\\b[^\\n]*\\n([\\s\\S]*?)(?=\\n##\\s|\\n#\\s|$)`, 'm')
106
129
  const waveMatch = plan.match(waveRe)
107
130
  if (!waveMatch) return false
108
- const taskIds = [...waveMatch[1].matchAll(/^[-*]\s*\[[ x]\]\s*(task-\d+)/gim)].map(x => x[1])
131
+ // 2026-08-20-task-truth-unify:Wave 段任务为纯 ID 引用行("- task-XX"),不再有 checkbox
132
+ const taskIds = [...waveMatch[1].matchAll(/^[-*]\s+(task-\d+)\s*$/gim)].map(x => x[1])
109
133
  if (taskIds.length === 0) return false
110
134
  for (const id of taskIds) {
111
- const cardPath = join(changeDir, 'tasks', `${id}.md`)
135
+ // 体检 BUG-19:plan 常态写补零 task-01,但 AI 写出 task-3 时卡片实际是 task-03.md——
136
+ // 未归一则 existsSync 恒 false,wave 级 no_deps_verify 豁免静默失效(保守方向)
137
+ const cardPath = join(changeDir, 'tasks', `${normalizeTaskId(id)}.md`)
112
138
  if (!existsSync(cardPath)) return false // 卡片缺失 → 保守不跳
113
139
  const card = readFileSync(cardPath, 'utf8')
114
140
  const fm = card.match(/^---\n([\s\S]*?)\n---/)
@@ -118,6 +144,99 @@ function isCurrentWaveAllNoDepsVerify(stepName, changeDir) {
118
144
  return true
119
145
  }
120
146
 
147
+ /**
148
+ * doctor --align-execute-progress --confirm 的前置 review 门(坑 doctor-align-bypass-review-gate,
149
+ * 2026-08-20 实证):alignExecuteToPlan 直写 completed「绕过 completeStep 推导」,此前 execute 的
150
+ * Stage Review Gate 与 Task Review Gate 被整体跳过——而 worktree 清理后的恢复场景恰是最需要
151
+ * review 审计的时刻(实证靠 15 份 task review + verify 全程补足覆盖才没出事)。
152
+ *
153
+ * 本函数供 CLI 层(index.js doctor align 分支)在 confirm 落盘前调用,跑与正常完成路径同源的
154
+ * 只读校验(不自动生成 marker/runId——恢复读场景缺失就是缺失,如实报告):
155
+ * 1. Stage Review:tier=independent 时须存在有效 execute stage review.json(verdict 非 fail);
156
+ * tier=self 放行(与正常 gate 分级一致)。
157
+ * 2. Task Review:所有 task 的 review.json 齐备且 verdict 非 fail(runId 解析同 Task Review Gate:
158
+ * marker → 含 tasks/ 目录扫描;不 generate)。
159
+ *
160
+ * @param {{ cwd: string, changeName: string, specBase: string, platformOpts?: object }} opts
161
+ * @returns {Promise<boolean>} true=被门阻断(CLI 应放弃 align 并 exit 1);false=校验通过可继续
162
+ */
163
+ export async function enforceAlignExecuteReviewGate({ cwd, changeName, specBase, platformOpts = {} }) {
164
+ const effectiveSpecBase = platformOpts?.specRoot || specBase
165
+ const runtimeRoot = resolveRuntimeRoot(platformOpts, effectiveSpecBase)
166
+ const reviewChangeDir = resolveChangeDir(cwd, { currentChange: changeName }, platformOpts?.specRoot || null)
167
+ const blocked = (msgs) => {
168
+ console.error('\n❌ doctor --align-execute-progress 前置 review 校验未过——execute 完成审计不能绕过:')
169
+ for (const m of msgs) console.error(' - ' + m)
170
+ console.error(' 解法:补齐上述 review 后重跑 align(stage review:sillyspec register-stage-review --change ' + changeName + ' --stage execute;task review:按报错路径补 review.json)')
171
+ return true
172
+ }
173
+
174
+ // 1. Stage Review(tier 分级同正常 gate)
175
+ try {
176
+ const { classifyReviewTier } = await import('../review-tier.js')
177
+ const { validateStageReview, getLatestStageReviewRunId, printStageReviewResult } = await import('../stage-review.js')
178
+ const designPath = reviewChangeDir ? join(reviewChangeDir, 'design.md') : null
179
+ let planLevel = null
180
+ if (reviewChangeDir) {
181
+ const planPath = join(reviewChangeDir, 'plan.md')
182
+ if (existsSync(planPath)) {
183
+ const fmLine = readFileSync(planPath, 'utf8').split('\n').find(l => l.trim().startsWith('plan_level:'))
184
+ if (fmLine) planLevel = fmLine.split(':')[1].trim()
185
+ }
186
+ }
187
+ const tier = classifyReviewTier({ planLevel, designPath })
188
+ if (tier.tier === 'self') {
189
+ console.log('ℹ️ Stage Review: execute tier=self(' + tier.reason + '),align 前置门放行(不强制独立审查)')
190
+ } else {
191
+ // 只读解析(不 generate/写 marker):恢复场景下缺失即缺失
192
+ const reviewRunId = getLatestStageReviewRunId(runtimeRoot, 'execute', changeName)
193
+ if (!reviewRunId) {
194
+ return blocked(['execute stage review runId 无法定位(marker 缺失且 stage-reviews/ 无含 review 的 run 目录)'])
195
+ }
196
+ const searchDirs = [effectiveSpecBase, reviewChangeDir, cwd].filter(Boolean)
197
+ const reviewResult = validateStageReview({ stage: 'execute', reviewType: 'acceptance', runtimeRoot, reviewRunId, searchDirs })
198
+ printStageReviewResult(reviewResult, { stage: 'execute', reviewRunId, runtimeRoot })
199
+ if (!reviewResult.ok) return true // printStageReviewResult 已给明细,不再重复列
200
+ }
201
+ } catch (e) {
202
+ // fail-closed:门自身异常阻断 align(与正常 gate 一致),不静默放行
203
+ console.error('❌ align 前置 Stage Review 校验异常,阻断对齐: ' + e.message)
204
+ return true
205
+ }
206
+
207
+ // 2. Task Review(runId 只读解析:marker → 含 tasks/ 扫描;不 generate)
208
+ try {
209
+ const { validateTaskReviews, printReviewResult, resolveLatestExecuteRunIdWithTasks, isValidExecuteRunId } = await import('../task-review.js')
210
+ const planPath = reviewChangeDir ? join(reviewChangeDir, 'plan.md') : null
211
+ if (!planPath || !existsSync(planPath)) {
212
+ return blocked(['plan.md 不存在,Task Review 无从校验(align 的 checkbox 判定依赖同目录,疑似路径解析异常)'])
213
+ }
214
+ const planContent = readFileSync(planPath, 'utf8')
215
+ const runIdFile = join(runtimeRoot, `current-execute-run-id-${changeName}`)
216
+ let executeRunId = ''
217
+ try {
218
+ if (existsSync(runIdFile)) {
219
+ const c = readFileSync(runIdFile, 'utf8').trim()
220
+ if (c && isValidExecuteRunId(c)) executeRunId = c
221
+ }
222
+ } catch {}
223
+ if (!executeRunId) {
224
+ executeRunId = resolveLatestExecuteRunIdWithTasks({ runtimeRoot, changeName }) || ''
225
+ }
226
+ if (!executeRunId) {
227
+ return blocked(['execute runId 无法定位(marker 缺失且 execute-runs/ 无含 review 的 run 目录)——15/N 份 task review 所在 run 不可寻,无法对账'])
228
+ }
229
+ const reviewResult = validateTaskReviews({ planContent, runtimeRoot, executeRunId, changeDir: reviewChangeDir, gitDir: cwd })
230
+ printReviewResult(reviewResult, { runtimeRoot, executeRunId })
231
+ if (!reviewResult.ok) return true // 报错明细已在 printReviewResult 输出
232
+ console.log('✅ align 前置 review 校验通过(stage review + task review 均齐备),继续对齐')
233
+ } catch (e) {
234
+ console.error('❌ align 前置 Task Review 校验异常,阻断对齐: ' + e.message)
235
+ return true
236
+ }
237
+ return false
238
+ }
239
+
121
240
  /**
122
241
  * execute deps 验证硬门(change 2026-06-28-worktree-deps-provision / D-001@v1, D-003@v1, D-006@v2)。
123
242
  * depsStatus 不达标且非 wave 级 opt-out 时阻断 --done:置 step=blocked + exit(1),与 requiresWait 同范式。
@@ -178,19 +297,34 @@ export async function enforceReviewJsonGate(stageName, cwd, changeName, step, st
178
297
  const planPath = join(specBase, 'changes', changeName, 'plan.md')
179
298
  if (!existsSync(runIdFile) || !existsSync(planPath)) return true
180
299
  const { validateCheckedTaskReviews, resolveLatestExecuteRunIdWithTasks, isValidExecuteRunId } = await import('../task-review.js')
181
- let executeRunId = readFileSync(runIdFile, 'utf8').trim()
300
+ // existsSync 与 read 之间 marker 可能被并发 cleanup/归档删除(多 agent 场景):读失败按
301
+ //「marker 缺失」处理,走漂移兜底重定位,不让 ENOENT 冒成顶层 stack(与 :426-435 口径对齐)
302
+ let executeRunId = ''
303
+ try {
304
+ executeRunId = readFileSync(runIdFile, 'utf8').trim()
305
+ } catch {
306
+ console.warn('⚠️ execute run marker 读取失败(可能被并发清理),改扫真实 run 目录')
307
+ executeRunId = resolveLatestExecuteRunIdWithTasks({ runtimeRoot, changeName }) || ''
308
+ }
182
309
  // marker 是 agent 可写内容:格式校验防注入/穿越,非法视为缺失走漂移兜底重定位
183
310
  if (executeRunId && !isValidExecuteRunId(executeRunId)) {
184
311
  console.warn(`⚠️ execute run marker 内容非法(期望 exec-YYYY-MM-DD-HHMMSS,实得 ${JSON.stringify(executeRunId.slice(0, 60))}),改扫真实 run 目录`)
185
312
  executeRunId = ''
186
313
  }
187
- const planContent = readFileSync(planPath, 'utf8')
314
+ // 2026-08-20-task-truth-unify:已勾 task 清单源迁 tasks.md(勾选唯一落点);tasks.md 缺失
315
+ // 回退 planPath 内容(旧变更兼容读侧)。planPath 存在性已在上方把关。
316
+ const tasksRegistryPath = join(specBase, 'changes', changeName, 'tasks.md')
317
+ const planContent = existsSync(tasksRegistryPath)
318
+ ? readFileSync(tasksRegistryPath, 'utf8')
319
+ : readFileSync(planPath, 'utf8')
320
+ // marker 读失败且重定位无果(无任何含 tasks/ 的 run):无 run 可校验,放行(下游 Task Review Gate 兜底)
321
+ if (!executeRunId) return true
188
322
  // marker 漂移兜底(gate-atom-a 正确修法):marker 指向的 run 缺 tasks/(generateExecuteRunId 只写
189
323
  // marker 不建目录,漂移后新 run 不继承旧 review)时,无视 marker 改扫 execute-runs/ 取 mtime 最新
190
324
  // 且真正含 tasks/ 的 run,用其齐备的 review.json 校验,避免误报「review.json 不存在」。注意不能用
191
325
  // resolveLatestExecuteRunId——它见 marker 非空即原样返回(不校验目录),恰是本场景要绕开的值。
192
326
  if (executeRunId && !existsSync(join(runtimeRoot, 'execute-runs', executeRunId, 'tasks'))) {
193
- const relocated = resolveLatestExecuteRunIdWithTasks({ runtimeRoot })
327
+ const relocated = resolveLatestExecuteRunIdWithTasks({ runtimeRoot, changeName })
194
328
  if (relocated && relocated !== executeRunId) {
195
329
  console.warn(`⚠️ execute run marker 漂移:${executeRunId} 无 tasks/,改用真实含 review 的 run ${relocated}`)
196
330
  executeRunId = relocated
@@ -331,18 +465,21 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
331
465
  console.log('\n✅ 验证通过,下一步:sillyspec run archive')
332
466
  }
333
467
 
334
- // ── Plan postcheck contract:plan.md 必须满足 execute 契约 ──
468
+ // ── Plan postcheck contract:tasks.md(注册表)× plan.md(Wave 引用)须满足 execute 契约 ──
335
469
  if (stageName === 'plan') {
336
470
  const planFile = resolveChangeDir(cwd, progress, platformOpts?.specRoot)
337
471
  const planPath = planFile ? join(planFile, 'plan.md') : null
338
472
  if (planPath && existsSync(planPath)) {
339
473
  const { validatePlanForExecute } = await import('../stages/execute.js')
340
- const planContent = readFileSync(planPath, 'utf8')
341
- const planValidation = validatePlanForExecute(planContent)
474
+ // CRLF 归一:Windows 编辑器/python 写文件可产生 CRLF,归一后交校验(与 plan-postcheck.js 同口径)
475
+ const planContent = readFileSync(planPath, 'utf8').replace(/\r\n/g, '\n')
476
+ const tasksPath = join(planFile, 'tasks.md')
477
+ const tasksContent = existsSync(tasksPath) ? readFileSync(tasksPath, 'utf8').replace(/\r\n/g, '\n') : ''
478
+ const planValidation = validatePlanForExecute(tasksContent, planContent)
342
479
  if (!planValidation.ok) {
343
480
  console.error(`\n❌ Plan → Execute Contract 校验失败:`)
344
481
  for (const err of planValidation.errors) console.error(` - ${err}`)
345
- console.error(`\n plan.md 不满足 execute 契约,请修复后重新完成此步骤。`)
482
+ console.error(`\n tasks.md/plan.md 不满足 execute 契约,请修复后重新完成此步骤。`)
346
483
  // 阻断 completed
347
484
  return await rollbackCompletionAndReturn(pm, progress, stageData, steps, currentIdx, cwd, changeName, platformOpts)
348
485
  }
@@ -415,7 +552,12 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
415
552
  const planPath = planFile ? join(planFile, 'plan.md') : null
416
553
 
417
554
  if (planPath && existsSync(planPath)) {
418
- const planContent = readFileSync(planPath, 'utf8')
555
+ // 2026-08-20-task-truth-unify:任务清单源迁 tasks.md(validateTaskReviews 内按
556
+ // checkbox 行解析,tasks.md 行格式兼容原正则);缺失回退 plan.md(旧变更兼容读侧)
557
+ const tasksRegistryPath = join(planFile, 'tasks.md')
558
+ const planContent = existsSync(tasksRegistryPath)
559
+ ? readFileSync(tasksRegistryPath, 'utf8')
560
+ : readFileSync(planPath, 'utf8')
419
561
  const runtimeRoot = resolveRuntimeRoot(platformOpts, effectiveSpecBase)
420
562
 
421
563
  // execute run id:从变更专属标记文件读取(agent 可写内容,格式校验防注入/穿越)
@@ -436,7 +578,7 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
436
578
  // marker 缺失:先扫描 execute-runs/ 既有目录找回真实 runId(与 getLatestStageReviewRunId
437
579
  // 目录扫描兜底同语义),避免 marker 丢失而 agent 已用旧 runId 落盘时,直接 generate 新 ID
438
580
  // 找不到旧 review、误判缺 review.json。仅当确实无既有 run 才 generate 新 ID 并落盘。
439
- const { generateExecuteRunId, resolveLatestExecuteRunId } = await import('../task-review.js')
581
+ const { generateExecuteRunId, resolveLatestExecuteRunId, stampExecuteRunChange } = await import('../task-review.js')
440
582
  executeRunId = resolveLatestExecuteRunId({ runtimeRoot, changeName }) || ''
441
583
  if (!executeRunId) {
442
584
  executeRunId = generateExecuteRunId()
@@ -446,6 +588,7 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
446
588
  //(gate 自身写 run 目录失败不能静默放行完成)。
447
589
  mkdirSync(join(runtimeRoot, 'execute-runs', executeRunId, 'tasks'), { recursive: true })
448
590
  writeFileSync(runIdFile, executeRunId + '\n')
591
+ stampExecuteRunChange(runtimeRoot, executeRunId, changeName)
449
592
  }
450
593
  }
451
594
 
@@ -480,7 +623,7 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
480
623
  // 检查是否存在 checkbox 已勾但 review 不通过的情况
481
624
  const uncheckedTasks = reviewResult.errors.filter(e => e.includes('缺少 review.json'))
482
625
  if (uncheckedTasks.length > 0) {
483
- console.error('\n⚠️ 部分任务已在 plan.md 中勾选,但 review.json 不存在。')
626
+ console.error('\n⚠️ 部分任务已在 tasks.md 中勾选,但 review.json 不存在。')
484
627
  console.error(` 请取消勾选这些任务的 checkbox,或补充对应的 review.json(execute run ID: ${executeRunId})。`)
485
628
  }
486
629
  return await rollbackCompletionAndReturn(pm, progress, stageData, steps, currentIdx, cwd, changeName, platformOpts)
@@ -731,6 +874,10 @@ export async function completeStageGates({ stageName, cwd, changeName, platformO
731
874
  stageData.steps = freshSteps
732
875
  stageData.status = 'pending'
733
876
  stageData.completedAt = null
877
+ // scan 的 quick 档 profile 随步骤表一并失效(坑 scan-quick-profile-step-mismatch 收尾):
878
+ // getStageSteps 感知 scanProfile 返回 3 步表,重置后 DB 是 11 步注册表,残留 profile 会让
879
+ // 下次 --status 走 ensureStageSteps 时 11 vs 3 误报漂移重播种。下次 run scan 会重算 profile。
880
+ if (stageName === 'scan' && stageData.scanProfile) delete stageData.scanProfile
734
881
  if (progress.currentStage === stageName) progress.currentStage = ''
735
882
  pm._write(cwd, progress, changeName)
736
883
  }
package/src/run/prompt.js CHANGED
@@ -183,8 +183,11 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
183
183
  console.log(`project: ${projectName}`)
184
184
  if (changeName) {
185
185
  console.log(`change: ${changeName}`)
186
- const isPlatform = platformOpts?.specRoot || platformOpts?.runtimeRoot
187
- const changeDirBase = isPlatform ? platformOpts.specRoot : '.sillyspec'
186
+ // changeDir 根(坑 change-dir-base-mismatch):平台/漂移锚定用绝对根(specDriftAnchor 防
187
+ // worktree 副本错位),普通本地保持相对 '.sillyspec' 展示契约(test 钉死)。此前
188
+ // isPlatform 用 specRoot||runtimeRoot 判定、取值只拿 specRoot——runtimeRoot-only 平台组合
189
+ // join(null,...) 直接 TypeError。
190
+ const changeDirBase = platformOpts?.specRoot || platformOpts?.specDriftAnchor || '.sillyspec'
188
191
  const changeDir = join(changeDirBase, 'changes', changeName)
189
192
  console.log(`changeDir: ${changeDir}`)
190
193
  }
@@ -257,7 +260,9 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
257
260
  if (existsSync(sessionGuardFile)) {
258
261
  qlIdVal = JSON.parse(readFileSync(sessionGuardFile, 'utf8')).quicklogId || ''
259
262
  }
260
- } catch {}
263
+ } catch (err) {
264
+ console.warn(`⚠️ quicklog-id 占位符读取 guard.json 失败: ${err && err.message ? err.message : err}`);
265
+ }
261
266
  promptText = promptText.replace(/<quicklog-id>/g, qlIdVal || '(未分配)')
262
267
  }
263
268
  // 替换 <linked-changes> 占位符(quick 阶段:从 .runtime/quick-sessions/<sessionId>/guard.json 读关联变更)
@@ -414,14 +419,16 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
414
419
  const { matchKnowledge } = await import('../knowledge-match.js')
415
420
  const effectiveSpecBase = resolvePromptSpecBase(platformOpts, cwd)
416
421
  const knowledgeDir = join(effectiveSpecBase, 'knowledge')
417
- // taskContext: changeName + plan.md task names for better matching
422
+ // taskContext: changeName + tasks.md 任务名(注册表唯一真相,2026-08-20-task-truth-unify)
418
423
  let taskContext = changeName || ''
419
424
  if (changeName) {
425
+ const tasksMdPath = join(effectiveSpecBase, 'changes', changeName, 'tasks.md')
420
426
  const planPath = join(effectiveSpecBase, 'changes', changeName, 'plan.md')
427
+ const srcPath = existsSync(tasksMdPath) ? tasksMdPath : planPath
421
428
  try {
422
- const planContent = readFileSync(planPath, 'utf8')
423
- // match both "- [ ] task-01: title" and "## task-01: title" formats
424
- const taskLines = [...planContent.matchAll(/(?:^\- \[[ x]\] |^## )task-\d+[^:]*:?\s*(.+)/gm)]
429
+ const registryContent = readFileSync(srcPath, 'utf8')
430
+ // match "- [ ] task-01: title" / "## task-01: title"(兼容旧 plan.md 内联两种形态)
431
+ const taskLines = [...registryContent.matchAll(/(?:^\- \[[ x]\] |^## )task-\d+[^:]*:?\s*(.+)/gm)]
425
432
  if (taskLines.length > 0) {
426
433
  taskContext += ' ' + taskLines.map(t => t[1]).join(' ')
427
434
  }
@@ -711,8 +718,9 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
711
718
  console.log('- **平台模式:local.yaml 中的 commands 必须在 package.json scripts 中真实存在,不存在的标记 unavailable。**')
712
719
  }
713
720
  if (changeName) {
714
- const isPlatform = platformOpts?.specRoot || platformOpts?.runtimeRoot
715
- const changeDirBase = isPlatform ? platformOpts.specRoot : '.sillyspec'
721
+ // changeDir 根与上方头部同源(坑 change-dir-base-mismatch):平台/漂移锚定绝对根,
722
+ // 普通本地保持相对 '.sillyspec' 展示;runtimeRoot-only 组合不再 join(null) 崩溃
723
+ const changeDirBase = platformOpts?.specRoot || platformOpts?.specDriftAnchor || '.sillyspec'
716
724
  const changeDir = join(changeDirBase, 'changes', changeName)
717
725
  console.log(`- **文件路径规则:所有变更文件必须写入 \`${changeDir}/\` 目录下。不要自己拼接路径,直接使用 changeDir 值。示例:\`${changeDir}/proposal.md\`**`)
718
726
  }
@@ -105,21 +105,15 @@ export function computeScanProfile(cwd, platformOpts) {
105
105
  }
106
106
 
107
107
  /**
108
- * 根据 scanProfile 裁剪步骤
109
- * quick: 3 步 — CLI preflight / AI generate / CLI postcheck
110
- * standard: 跳过续扫检测(4), 跳过可选步骤(9)
108
+ * quick 档 3 步表构建(唯一真相):applyScanProfileSteps(首进程裁剪)与 getStageSteps
109
+ * (后续进程重取定义)共用。此前 quick 步骤表只在首进程 applyScanProfileSteps 里内联构建,
110
+ * getStageSteps('scan') 恒返回 11 步注册表 → 同进程 outputStep 用注册表 defSteps[1] 渲染错位
111
+ * prompt;跨进程 ensureStageSteps 见 3 vs 11 触发长度漂移重播种,quick 已完成步丢失、
112
+ * agent 从未做过的注册表步骤被 --done(坑 scan-quick-profile-step-mismatch)。
111
113
  */
112
- export function applyScanProfileSteps(stageData, profile, cwd, platformOpts) {
113
- const steps = stageData.steps
114
- const mode = profile.mode
115
-
116
- if (mode === 'quick') {
117
- const specBase = platformOpts?.specRoot || join(cwd, '.sillyspec')
118
- const projectName = basename(cwd)
119
- const docsRoot = join(specBase, 'docs', projectName)
120
-
121
- // Step 1: CLI preflight(不调 AI,自动完成)
122
- const step1 = {
114
+ export function buildQuickScanSteps() {
115
+ return [
116
+ {
123
117
  name: '项目概览(自动探测)',
124
118
  status: 'pending',
125
119
  noAI: true,
@@ -127,9 +121,8 @@ export function applyScanProfileSteps(stageData, profile, cwd, platformOpts) {
127
121
  prompt: '',
128
122
  outputHint: 'preflight 结果',
129
123
  optional: false
130
- }
131
- // Step 2: AI 生成核心文档(唯一 AI roundtrip)
132
- const step2 = {
124
+ },
125
+ {
133
126
  name: '生成核心文档',
134
127
  status: 'pending',
135
128
  prompt: `## Quick Scan — 核心文档生成
@@ -156,13 +149,32 @@ export function applyScanProfileSteps(stageData, profile, cwd, platformOpts) {
156
149
  生成的文件列表`,
157
150
  outputHint: '文件列表',
158
151
  optional: false
152
+ },
153
+ {
154
+ // 名字与注册表末步一致(按名匹配/迁移语义);prompt 清空、noAI postcheck 自动执行
155
+ name: '自检和提交',
156
+ status: 'pending',
157
+ prompt: '',
158
+ outputHint: '结果',
159
+ optional: false,
160
+ noAI: true,
161
+ _cliAction: 'scanPostcheck'
159
162
  }
160
- // Step 3: CLI postcheck(不调 AI,自动完成)
161
- const selfCheck = steps.find(s => s.name === '自检和提交') || {
162
- name: '自检和提交', status: 'pending', prompt: '', outputHint: '结果', optional: false
163
- }
164
- const step3 = { ...selfCheck, status: 'pending', noAI: true, _cliAction: 'scanPostcheck', prompt: '' }
165
- stageData.steps = [step1, step2, step3]
163
+ ]
164
+ }
165
+
166
+ /**
167
+ * 根据 scanProfile 裁剪步骤
168
+ * quick: 3 步 — CLI preflight / AI generate / CLI postcheck
169
+ * standard: 跳过续扫检测(4), 跳过可选步骤(9)
170
+ */
171
+ export function applyScanProfileSteps(stageData, profile, cwd, platformOpts) {
172
+ const steps = stageData.steps
173
+ const mode = profile.mode
174
+
175
+ if (mode === 'quick') {
176
+ // 3 步表构建抽至 buildQuickScanSteps(getStageSteps 同源复用,防跨进程 3 vs 11 漂移)
177
+ stageData.steps = buildQuickScanSteps()
166
178
  return
167
179
  }
168
180