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
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(` 「加载上下文」步的符号影响面检查报告未落盘或不完整:`)
@@ -105,10 +108,13 @@ function isCurrentWaveAllNoDepsVerify(stepName, changeDir) {
105
108
  const waveRe = new RegExp(`^##\\s*Wave\\s+${waveN}\\b[^\\n]*\\n([\\s\\S]*?)(?=\\n##\\s|\\n#\\s|$)`, 'm')
106
109
  const waveMatch = plan.match(waveRe)
107
110
  if (!waveMatch) return false
108
- const taskIds = [...waveMatch[1].matchAll(/^[-*]\s*\[[ x]\]\s*(task-\d+)/gim)].map(x => x[1])
111
+ // 2026-08-20-task-truth-unify:Wave 段任务为纯 ID 引用行("- task-XX"),不再有 checkbox
112
+ const taskIds = [...waveMatch[1].matchAll(/^[-*]\s+(task-\d+)\s*$/gim)].map(x => x[1])
109
113
  if (taskIds.length === 0) return false
110
114
  for (const id of taskIds) {
111
- const cardPath = join(changeDir, 'tasks', `${id}.md`)
115
+ // 体检 BUG-19:plan 常态写补零 task-01,但 AI 写出 task-3 时卡片实际是 task-03.md——
116
+ // 未归一则 existsSync 恒 false,wave 级 no_deps_verify 豁免静默失效(保守方向)
117
+ const cardPath = join(changeDir, 'tasks', `${normalizeTaskId(id)}.md`)
112
118
  if (!existsSync(cardPath)) return false // 卡片缺失 → 保守不跳
113
119
  const card = readFileSync(cardPath, 'utf8')
114
120
  const fm = card.match(/^---\n([\s\S]*?)\n---/)
@@ -118,6 +124,99 @@ function isCurrentWaveAllNoDepsVerify(stepName, changeDir) {
118
124
  return true
119
125
  }
120
126
 
127
+ /**
128
+ * doctor --align-execute-progress --confirm 的前置 review 门(坑 doctor-align-bypass-review-gate,
129
+ * 2026-08-20 实证):alignExecuteToPlan 直写 completed「绕过 completeStep 推导」,此前 execute 的
130
+ * Stage Review Gate 与 Task Review Gate 被整体跳过——而 worktree 清理后的恢复场景恰是最需要
131
+ * review 审计的时刻(实证靠 15 份 task review + verify 全程补足覆盖才没出事)。
132
+ *
133
+ * 本函数供 CLI 层(index.js doctor align 分支)在 confirm 落盘前调用,跑与正常完成路径同源的
134
+ * 只读校验(不自动生成 marker/runId——恢复读场景缺失就是缺失,如实报告):
135
+ * 1. Stage Review:tier=independent 时须存在有效 execute stage review.json(verdict 非 fail);
136
+ * tier=self 放行(与正常 gate 分级一致)。
137
+ * 2. Task Review:所有 task 的 review.json 齐备且 verdict 非 fail(runId 解析同 Task Review Gate:
138
+ * marker → 含 tasks/ 目录扫描;不 generate)。
139
+ *
140
+ * @param {{ cwd: string, changeName: string, specBase: string, platformOpts?: object }} opts
141
+ * @returns {Promise<boolean>} true=被门阻断(CLI 应放弃 align 并 exit 1);false=校验通过可继续
142
+ */
143
+ export async function enforceAlignExecuteReviewGate({ cwd, changeName, specBase, platformOpts = {} }) {
144
+ const effectiveSpecBase = platformOpts?.specRoot || specBase
145
+ const runtimeRoot = resolveRuntimeRoot(platformOpts, effectiveSpecBase)
146
+ const reviewChangeDir = resolveChangeDir(cwd, { currentChange: changeName }, platformOpts?.specRoot || null)
147
+ const blocked = (msgs) => {
148
+ console.error('\n❌ doctor --align-execute-progress 前置 review 校验未过——execute 完成审计不能绕过:')
149
+ for (const m of msgs) console.error(' - ' + m)
150
+ console.error(' 解法:补齐上述 review 后重跑 align(stage review:sillyspec register-stage-review --change ' + changeName + ' --stage execute;task review:按报错路径补 review.json)')
151
+ return true
152
+ }
153
+
154
+ // 1. Stage Review(tier 分级同正常 gate)
155
+ try {
156
+ const { classifyReviewTier } = await import('../review-tier.js')
157
+ const { validateStageReview, getLatestStageReviewRunId, printStageReviewResult } = await import('../stage-review.js')
158
+ const designPath = reviewChangeDir ? join(reviewChangeDir, 'design.md') : null
159
+ let planLevel = null
160
+ if (reviewChangeDir) {
161
+ const planPath = join(reviewChangeDir, 'plan.md')
162
+ if (existsSync(planPath)) {
163
+ const fmLine = readFileSync(planPath, 'utf8').split('\n').find(l => l.trim().startsWith('plan_level:'))
164
+ if (fmLine) planLevel = fmLine.split(':')[1].trim()
165
+ }
166
+ }
167
+ const tier = classifyReviewTier({ planLevel, designPath })
168
+ if (tier.tier === 'self') {
169
+ console.log('ℹ️ Stage Review: execute tier=self(' + tier.reason + '),align 前置门放行(不强制独立审查)')
170
+ } else {
171
+ // 只读解析(不 generate/写 marker):恢复场景下缺失即缺失
172
+ const reviewRunId = getLatestStageReviewRunId(runtimeRoot, 'execute', changeName)
173
+ if (!reviewRunId) {
174
+ return blocked(['execute stage review runId 无法定位(marker 缺失且 stage-reviews/ 无含 review 的 run 目录)'])
175
+ }
176
+ const searchDirs = [effectiveSpecBase, reviewChangeDir, cwd].filter(Boolean)
177
+ const reviewResult = validateStageReview({ stage: 'execute', reviewType: 'acceptance', runtimeRoot, reviewRunId, searchDirs })
178
+ printStageReviewResult(reviewResult, { stage: 'execute', reviewRunId, runtimeRoot })
179
+ if (!reviewResult.ok) return true // printStageReviewResult 已给明细,不再重复列
180
+ }
181
+ } catch (e) {
182
+ // fail-closed:门自身异常阻断 align(与正常 gate 一致),不静默放行
183
+ console.error('❌ align 前置 Stage Review 校验异常,阻断对齐: ' + e.message)
184
+ return true
185
+ }
186
+
187
+ // 2. Task Review(runId 只读解析:marker → 含 tasks/ 扫描;不 generate)
188
+ try {
189
+ const { validateTaskReviews, printReviewResult, resolveLatestExecuteRunIdWithTasks, isValidExecuteRunId } = await import('../task-review.js')
190
+ const planPath = reviewChangeDir ? join(reviewChangeDir, 'plan.md') : null
191
+ if (!planPath || !existsSync(planPath)) {
192
+ return blocked(['plan.md 不存在,Task Review 无从校验(align 的 checkbox 判定依赖同目录,疑似路径解析异常)'])
193
+ }
194
+ const planContent = readFileSync(planPath, 'utf8')
195
+ const runIdFile = join(runtimeRoot, `current-execute-run-id-${changeName}`)
196
+ let executeRunId = ''
197
+ try {
198
+ if (existsSync(runIdFile)) {
199
+ const c = readFileSync(runIdFile, 'utf8').trim()
200
+ if (c && isValidExecuteRunId(c)) executeRunId = c
201
+ }
202
+ } catch {}
203
+ if (!executeRunId) {
204
+ executeRunId = resolveLatestExecuteRunIdWithTasks({ runtimeRoot, changeName }) || ''
205
+ }
206
+ if (!executeRunId) {
207
+ return blocked(['execute runId 无法定位(marker 缺失且 execute-runs/ 无含 review 的 run 目录)——15/N 份 task review 所在 run 不可寻,无法对账'])
208
+ }
209
+ const reviewResult = validateTaskReviews({ planContent, runtimeRoot, executeRunId, changeDir: reviewChangeDir, gitDir: cwd })
210
+ printReviewResult(reviewResult, { runtimeRoot, executeRunId })
211
+ if (!reviewResult.ok) return true // 报错明细已在 printReviewResult 输出
212
+ console.log('✅ align 前置 review 校验通过(stage review + task review 均齐备),继续对齐')
213
+ } catch (e) {
214
+ console.error('❌ align 前置 Task Review 校验异常,阻断对齐: ' + e.message)
215
+ return true
216
+ }
217
+ return false
218
+ }
219
+
121
220
  /**
122
221
  * execute deps 验证硬门(change 2026-06-28-worktree-deps-provision / D-001@v1, D-003@v1, D-006@v2)。
123
222
  * depsStatus 不达标且非 wave 级 opt-out 时阻断 --done:置 step=blocked + exit(1),与 requiresWait 同范式。
@@ -178,19 +277,34 @@ export async function enforceReviewJsonGate(stageName, cwd, changeName, step, st
178
277
  const planPath = join(specBase, 'changes', changeName, 'plan.md')
179
278
  if (!existsSync(runIdFile) || !existsSync(planPath)) return true
180
279
  const { validateCheckedTaskReviews, resolveLatestExecuteRunIdWithTasks, isValidExecuteRunId } = await import('../task-review.js')
181
- let executeRunId = readFileSync(runIdFile, 'utf8').trim()
280
+ // existsSync 与 read 之间 marker 可能被并发 cleanup/归档删除(多 agent 场景):读失败按
281
+ //「marker 缺失」处理,走漂移兜底重定位,不让 ENOENT 冒成顶层 stack(与 :426-435 口径对齐)
282
+ let executeRunId = ''
283
+ try {
284
+ executeRunId = readFileSync(runIdFile, 'utf8').trim()
285
+ } catch {
286
+ console.warn('⚠️ execute run marker 读取失败(可能被并发清理),改扫真实 run 目录')
287
+ executeRunId = resolveLatestExecuteRunIdWithTasks({ runtimeRoot, changeName }) || ''
288
+ }
182
289
  // marker 是 agent 可写内容:格式校验防注入/穿越,非法视为缺失走漂移兜底重定位
183
290
  if (executeRunId && !isValidExecuteRunId(executeRunId)) {
184
291
  console.warn(`⚠️ execute run marker 内容非法(期望 exec-YYYY-MM-DD-HHMMSS,实得 ${JSON.stringify(executeRunId.slice(0, 60))}),改扫真实 run 目录`)
185
292
  executeRunId = ''
186
293
  }
187
- const planContent = readFileSync(planPath, 'utf8')
294
+ // 2026-08-20-task-truth-unify:已勾 task 清单源迁 tasks.md(勾选唯一落点);tasks.md 缺失
295
+ // 回退 planPath 内容(旧变更兼容读侧)。planPath 存在性已在上方把关。
296
+ const tasksRegistryPath = join(specBase, 'changes', changeName, 'tasks.md')
297
+ const planContent = existsSync(tasksRegistryPath)
298
+ ? readFileSync(tasksRegistryPath, 'utf8')
299
+ : readFileSync(planPath, 'utf8')
300
+ // marker 读失败且重定位无果(无任何含 tasks/ 的 run):无 run 可校验,放行(下游 Task Review Gate 兜底)
301
+ if (!executeRunId) return true
188
302
  // marker 漂移兜底(gate-atom-a 正确修法):marker 指向的 run 缺 tasks/(generateExecuteRunId 只写
189
303
  // marker 不建目录,漂移后新 run 不继承旧 review)时,无视 marker 改扫 execute-runs/ 取 mtime 最新
190
304
  // 且真正含 tasks/ 的 run,用其齐备的 review.json 校验,避免误报「review.json 不存在」。注意不能用
191
305
  // resolveLatestExecuteRunId——它见 marker 非空即原样返回(不校验目录),恰是本场景要绕开的值。
192
306
  if (executeRunId && !existsSync(join(runtimeRoot, 'execute-runs', executeRunId, 'tasks'))) {
193
- const relocated = resolveLatestExecuteRunIdWithTasks({ runtimeRoot })
307
+ const relocated = resolveLatestExecuteRunIdWithTasks({ runtimeRoot, changeName })
194
308
  if (relocated && relocated !== executeRunId) {
195
309
  console.warn(`⚠️ execute run marker 漂移:${executeRunId} 无 tasks/,改用真实含 review 的 run ${relocated}`)
196
310
  executeRunId = relocated
@@ -331,18 +445,21 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
331
445
  console.log('\n✅ 验证通过,下一步:sillyspec run archive')
332
446
  }
333
447
 
334
- // ── Plan postcheck contract:plan.md 必须满足 execute 契约 ──
448
+ // ── Plan postcheck contract:tasks.md(注册表)× plan.md(Wave 引用)须满足 execute 契约 ──
335
449
  if (stageName === 'plan') {
336
450
  const planFile = resolveChangeDir(cwd, progress, platformOpts?.specRoot)
337
451
  const planPath = planFile ? join(planFile, 'plan.md') : null
338
452
  if (planPath && existsSync(planPath)) {
339
453
  const { validatePlanForExecute } = await import('../stages/execute.js')
340
- const planContent = readFileSync(planPath, 'utf8')
341
- const planValidation = validatePlanForExecute(planContent)
454
+ // CRLF 归一:Windows 编辑器/python 写文件可产生 CRLF,归一后交校验(与 plan-postcheck.js 同口径)
455
+ const planContent = readFileSync(planPath, 'utf8').replace(/\r\n/g, '\n')
456
+ const tasksPath = join(planFile, 'tasks.md')
457
+ const tasksContent = existsSync(tasksPath) ? readFileSync(tasksPath, 'utf8').replace(/\r\n/g, '\n') : ''
458
+ const planValidation = validatePlanForExecute(tasksContent, planContent)
342
459
  if (!planValidation.ok) {
343
460
  console.error(`\n❌ Plan → Execute Contract 校验失败:`)
344
461
  for (const err of planValidation.errors) console.error(` - ${err}`)
345
- console.error(`\n plan.md 不满足 execute 契约,请修复后重新完成此步骤。`)
462
+ console.error(`\n tasks.md/plan.md 不满足 execute 契约,请修复后重新完成此步骤。`)
346
463
  // 阻断 completed
347
464
  return await rollbackCompletionAndReturn(pm, progress, stageData, steps, currentIdx, cwd, changeName, platformOpts)
348
465
  }
@@ -415,7 +532,12 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
415
532
  const planPath = planFile ? join(planFile, 'plan.md') : null
416
533
 
417
534
  if (planPath && existsSync(planPath)) {
418
- const planContent = readFileSync(planPath, 'utf8')
535
+ // 2026-08-20-task-truth-unify:任务清单源迁 tasks.md(validateTaskReviews 内按
536
+ // checkbox 行解析,tasks.md 行格式兼容原正则);缺失回退 plan.md(旧变更兼容读侧)
537
+ const tasksRegistryPath = join(planFile, 'tasks.md')
538
+ const planContent = existsSync(tasksRegistryPath)
539
+ ? readFileSync(tasksRegistryPath, 'utf8')
540
+ : readFileSync(planPath, 'utf8')
419
541
  const runtimeRoot = resolveRuntimeRoot(platformOpts, effectiveSpecBase)
420
542
 
421
543
  // execute run id:从变更专属标记文件读取(agent 可写内容,格式校验防注入/穿越)
@@ -436,7 +558,7 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
436
558
  // marker 缺失:先扫描 execute-runs/ 既有目录找回真实 runId(与 getLatestStageReviewRunId
437
559
  // 目录扫描兜底同语义),避免 marker 丢失而 agent 已用旧 runId 落盘时,直接 generate 新 ID
438
560
  // 找不到旧 review、误判缺 review.json。仅当确实无既有 run 才 generate 新 ID 并落盘。
439
- const { generateExecuteRunId, resolveLatestExecuteRunId } = await import('../task-review.js')
561
+ const { generateExecuteRunId, resolveLatestExecuteRunId, stampExecuteRunChange } = await import('../task-review.js')
440
562
  executeRunId = resolveLatestExecuteRunId({ runtimeRoot, changeName }) || ''
441
563
  if (!executeRunId) {
442
564
  executeRunId = generateExecuteRunId()
@@ -446,6 +568,7 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
446
568
  //(gate 自身写 run 目录失败不能静默放行完成)。
447
569
  mkdirSync(join(runtimeRoot, 'execute-runs', executeRunId, 'tasks'), { recursive: true })
448
570
  writeFileSync(runIdFile, executeRunId + '\n')
571
+ stampExecuteRunChange(runtimeRoot, executeRunId, changeName)
449
572
  }
450
573
  }
451
574
 
@@ -480,7 +603,7 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
480
603
  // 检查是否存在 checkbox 已勾但 review 不通过的情况
481
604
  const uncheckedTasks = reviewResult.errors.filter(e => e.includes('缺少 review.json'))
482
605
  if (uncheckedTasks.length > 0) {
483
- console.error('\n⚠️ 部分任务已在 plan.md 中勾选,但 review.json 不存在。')
606
+ console.error('\n⚠️ 部分任务已在 tasks.md 中勾选,但 review.json 不存在。')
484
607
  console.error(` 请取消勾选这些任务的 checkbox,或补充对应的 review.json(execute run ID: ${executeRunId})。`)
485
608
  }
486
609
  return await rollbackCompletionAndReturn(pm, progress, stageData, steps, currentIdx, cwd, changeName, platformOpts)
package/src/run/prompt.js CHANGED
@@ -257,7 +257,9 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
257
257
  if (existsSync(sessionGuardFile)) {
258
258
  qlIdVal = JSON.parse(readFileSync(sessionGuardFile, 'utf8')).quicklogId || ''
259
259
  }
260
- } catch {}
260
+ } catch (err) {
261
+ console.warn(`⚠️ quicklog-id 占位符读取 guard.json 失败: ${err && err.message ? err.message : err}`);
262
+ }
261
263
  promptText = promptText.replace(/<quicklog-id>/g, qlIdVal || '(未分配)')
262
264
  }
263
265
  // 替换 <linked-changes> 占位符(quick 阶段:从 .runtime/quick-sessions/<sessionId>/guard.json 读关联变更)
@@ -414,14 +416,16 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
414
416
  const { matchKnowledge } = await import('../knowledge-match.js')
415
417
  const effectiveSpecBase = resolvePromptSpecBase(platformOpts, cwd)
416
418
  const knowledgeDir = join(effectiveSpecBase, 'knowledge')
417
- // taskContext: changeName + plan.md task names for better matching
419
+ // taskContext: changeName + tasks.md 任务名(注册表唯一真相,2026-08-20-task-truth-unify)
418
420
  let taskContext = changeName || ''
419
421
  if (changeName) {
422
+ const tasksMdPath = join(effectiveSpecBase, 'changes', changeName, 'tasks.md')
420
423
  const planPath = join(effectiveSpecBase, 'changes', changeName, 'plan.md')
424
+ const srcPath = existsSync(tasksMdPath) ? tasksMdPath : planPath
421
425
  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)]
426
+ const registryContent = readFileSync(srcPath, 'utf8')
427
+ // match "- [ ] task-01: title" / "## task-01: title"(兼容旧 plan.md 内联两种形态)
428
+ const taskLines = [...registryContent.matchAll(/(?:^\- \[[ x]\] |^## )task-\d+[^:]*:?\s*(.+)/gm)]
425
429
  if (taskLines.length > 0) {
426
430
  taskContext += ' ' + taskLines.map(t => t[1]).join(' ')
427
431
  }
package/src/run/shared.js CHANGED
@@ -11,7 +11,7 @@
11
11
  * 本模块仅 re-export,run/ 层现有调用方路径与行为不变。
12
12
  */
13
13
  import { basename, join, resolve, dirname, sep } from 'node:path'
14
- import { existsSync, readdirSync, readFileSync, statSync, mkdirSync, writeFileSync } from 'node:fs'
14
+ import { existsSync, readdirSync, readFileSync, statSync, mkdirSync, writeFileSync, appendFileSync } from 'node:fs'
15
15
  import { fileURLToPath } from 'node:url'
16
16
  import os from 'node:os'
17
17
 
@@ -336,12 +336,26 @@ export const PLATFORM_MANAGED_FILENAME = '.sillyspec-platform-managed'
336
336
 
337
337
  export function writePlatformPointer(cwd, platformOpts, extra = {}) {
338
338
  if (!platformOpts || (!platformOpts.specRoot && !platformOpts.runtimeRoot)) return false
339
+ // HUB-05:合并保留既有生命周期字段。scan 完成时指针被写入 status/completedAt/scanStatus
340
+ //(complete-handlers),但下一次任何平台模式 run(含只读 --status)都会重写指针——
341
+ // 恢复链只回填 specRoot 等四字段,不保留 status → 指针永远回 active、isPointerStale
342
+ // 恒 false、`platform pointer --cleanup` 的 STALE 分支不可达。extra 显式传值仍可覆盖。
343
+ let preserved = {}
344
+ try {
345
+ const existing = JSON.parse(readFileSync(join(cwd, '.sillyspec-platform.json'), 'utf8'))
346
+ if (existing && typeof existing === 'object') {
347
+ for (const k of ['status', 'completedAt', 'scanStatus']) {
348
+ if (existing[k] !== undefined && extra[k] === undefined) preserved[k] = existing[k]
349
+ }
350
+ }
351
+ } catch { /* 无既有指针/损坏 → 全新写入 */ }
339
352
  const payload = {
340
353
  specRoot: platformOpts.specRoot || null,
341
354
  runtimeRoot: platformOpts.runtimeRoot || null,
342
355
  workspaceId: platformOpts.workspaceId || null,
343
356
  scanRunId: platformOpts.scanRunId || null,
344
357
  savedAt: new Date().toISOString(),
358
+ ...preserved,
345
359
  ...extra,
346
360
  }
347
361
  // 声明文件字段集独立于指针 payload(D-E@v2 四字段:无 status/savedAt/scanRunId——
@@ -415,54 +429,90 @@ export function resolveChangeDir(cwd, progress, specDir = null) {
415
429
  // sync 总超时熔断:sync.js 是 best-effort 后台回传(每请求已有 10s 超时),但 sync() 可能串行多次
416
430
  // fetchJson 累积等待、阻塞 --done。给 8s 总超时,超时放弃(best-effort,失败不影响正确性,下次 --done 重试)。
417
431
  // 历史痛点:--done 在 sync 慢时体感 hang,用户被迫用外部 timeout 兜底。
432
+ // HUB-09:熔断超时时必须 abort 底层在飞请求(opts.signal 一路传到 fetch)——否则熔断后请求
433
+ // 仍在跑,平台可能已接受推送而客户端当作超时放弃(spec 树推送无 base_ts 自愈兜底)。
418
434
  const SYNC_TOTAL_TIMEOUT_MS = 8_000
419
435
 
436
+ /**
437
+ * 熔断 race + 确定性取消:超时触发 controller.abort() 并 resolve,在飞 fetch 收到
438
+ * AbortError 后自然失败(各调用方 catch 返回降级值,进程不挂起)。
439
+ * @param {() => Promise} op 接收 { signal } 的异步操作
440
+ * @param {number} timeoutMs 熔断时长(测试可缩短)
441
+ */
442
+ async function raceWithAbort(op, timeoutMs = SYNC_TOTAL_TIMEOUT_MS) {
443
+ const controller = new AbortController()
444
+ let timer
445
+ try {
446
+ await Promise.race([
447
+ op({ signal: controller.signal }),
448
+ new Promise((resolve) => { timer = setTimeout(() => { controller.abort(); resolve(null) }, timeoutMs) }),
449
+ ])
450
+ } finally {
451
+ clearTimeout(timer)
452
+ }
453
+ }
454
+
420
455
  // quick 会话 ID 形态(quick-<hex8>)。与 run/command.js QUICK_SID_RE、progress.js
421
456
  // initChange 的跳过实体目录判断同源;shared.js 不反向 import command.js(重模块),
422
457
  // 局部复制正则保持轻载——改形态时三处同改。
423
458
  const QUICK_SID_RE = /^quick-[0-9a-f]{8}$/
424
459
 
425
- export async function triggerSync(cwd, changeName, platformOpts = {}) {
460
+ export async function triggerSync(cwd, changeName, platformOpts = {}, opts = {}) {
426
461
  // 平台模式(SillyHub)走自己的回传链路,不走 CLI 内置 sync
427
462
  if (platformOpts?.specRoot || platformOpts?.runtimeRoot) return
428
463
  try {
429
- if (changeName && !existsSync(join(cwd, '.sillyspec', 'changes', changeName))) {
430
- // ql-20260818-011:quick 会话按设计无实体变更目录(progress.js initChange 同款
431
- // 跳过建目录),progress/四件套上行对它是孤儿数据;但 spec 树增量(QUICKLOG/
432
- // 模块文档的上行通道)以服务器清单为锚,与变更目录无关——降级只推 spec 树。
433
- // 其余形态(真实变更名拼错)维持静默 return,防噪音混入 spec 树通道。
434
- if (QUICK_SID_RE.test(changeName)) {
435
- // shared.js 在 src/run/,sync.js 在 src/ → 退一层
436
- const syncMod = await import('../sync.js')
437
- let timer
438
- try {
439
- await Promise.race([
440
- syncMod.syncSpecTreeOnly(changeName, cwd),
441
- new Promise((resolve) => { timer = setTimeout(resolve, SYNC_TOTAL_TIMEOUT_MS) }),
442
- ])
443
- } finally {
444
- clearTimeout(timer)
445
- }
446
- }
464
+ // ql-20260818-011:quick 会话按设计无实体变更目录(progress.js initChange 同款
465
+ // 跳过建目录),progress/四件套上行对它是孤儿数据;但 spec 树增量(QUICKLOG/
466
+ // 模块文档的上行通道)以服务器清单为锚,与变更目录无关——降级只推 spec 树。
467
+ if (changeName && QUICK_SID_RE.test(changeName) && !existsSync(join(cwd, '.sillyspec', 'changes', changeName))) {
468
+ // shared.js 在 src/run/,sync.js 在 src/ → 退一层
469
+ const syncMod = await import('../sync.js')
470
+ await raceWithAbort((sig) => syncMod.syncSpecTreeOnly(changeName, cwd, sig), opts.timeoutMs)
447
471
  return
448
472
  }
473
+ // 目录缺失的非 quick 变更不再静默 return(archive-final-state-sync):归档把
474
+ // changes/<name>/ 移到 changes/archive/<name>/ 并注销 DB 行后,第 4/5 步收尾的
475
+ // triggerSync 仍会触发——DB 里的终态(steps 完成态 + status='archived')与文件树
476
+ // 移动必须推平台,否则平台进度永远停在移动前的最后一步。sync() 内部对目录缺失
477
+ // 本就是 warn-and-continue(serializeForSync 从 DB 读不依赖目录);真实拼错名时
478
+ // 无 DB 行,sync() 在发出任何 HTTP 前即 return,仅多一行 warn(未连接平台则完全静默)。
449
479
  // shared.js 在 src/run/,sync.js 在 src/ → 退一层
450
480
  const syncMod = await import('../sync.js')
451
- let timer
452
- try {
453
- await Promise.race([
454
- syncMod.sync(changeName, cwd),
455
- new Promise((resolve) => { timer = setTimeout(resolve, SYNC_TOTAL_TIMEOUT_MS) }),
456
- ])
457
- } finally {
458
- clearTimeout(timer) // sync 先完成则清掉未触发的 timer,避免泄漏
459
- }
481
+ await raceWithAbort((sig) => syncMod.sync(changeName, cwd, sig), opts.timeoutMs)
460
482
  } catch (e) {
461
483
  // sync.js 不存在或同步失败,静默跳过
462
484
  console.warn('⚠️ 同步失败:', e.message)
463
485
  }
464
486
  }
465
487
 
488
+ /**
489
+ * 自动 pull 节流(体检 PERF-02):triggerPull/triggerPullActiveChange 在每条 stage 命令
490
+ * 启动时注入,agent 命令往往是秒级连发——每次都发 HTTP GET(daemon 挂/慢时单命令最多
491
+ * 阻塞 8s)。跨进程 marker 节流:10s 内已有自动 pull 则跳过(手动 `platform pull` 不走
492
+ * 此门,语义不变)。错过窗口的平台更新由下一条 >10s 的命令或 push 409 自愈兜底。
493
+ */
494
+ const AUTO_PULL_THROTTLE_MS = 10_000
495
+
496
+ function _autoPullThrottlePath(cwd) {
497
+ return join(cwd, '.sillyspec', '.runtime', 'auto-pull-throttle.json')
498
+ }
499
+
500
+ function _autoPullRecently(cwd) {
501
+ try {
502
+ const t = JSON.parse(readFileSync(_autoPullThrottlePath(cwd), 'utf8'))
503
+ return typeof t.at === 'number' && Date.now() - t.at < AUTO_PULL_THROTTLE_MS
504
+ } catch {
505
+ return false
506
+ }
507
+ }
508
+
509
+ function _stampAutoPull(cwd) {
510
+ try {
511
+ mkdirSync(dirname(_autoPullThrottlePath(cwd)), { recursive: true })
512
+ writeFileSync(_autoPullThrottlePath(cwd), JSON.stringify({ at: Date.now() }) + '\n', 'utf8')
513
+ } catch { /* best-effort:节流失效仅退化为逐次 pull */ }
514
+ }
515
+
466
516
  /**
467
517
  * 触发 pull(下行同步,task-10 / D-009 / FR-04 / FR-06)。
468
518
  * 复用 triggerSync 的 8s 熔断与 Best Effort 语义;未连接平台静默跳过(与现状一致)。
@@ -474,23 +524,18 @@ export async function triggerSync(cwd, changeName, platformOpts = {}) {
474
524
  * @param {string} changeName - 当前活跃变更(多变更时传 null 跳过,避免误拉)
475
525
  * @param {object} [platformOpts] - 平台模式 opts(specRoot/runtimeRoot 存在则跳过,走平台自有链路)
476
526
  */
477
- export async function triggerPull(cwd, changeName, platformOpts = {}) {
527
+ export async function triggerPull(cwd, changeName, platformOpts = {}, opts = {}) {
478
528
  // 平台模式(SillyHub)走自己的链路,跳过
479
529
  if (platformOpts?.specRoot || platformOpts?.runtimeRoot) return
480
530
  try {
481
531
  const syncMod = await import('../sync.js')
482
532
  const sm = new syncMod.SyncManager(cwd)
483
- // 未连接平台静默跳过(本地独立用户合法状态,不噪音)
533
+ // 未连接平台静默跳过(本地独立用户合法状态,不噪音)——节流 stamp 必须在此之后:
534
+ // 否则未连接的项目每次 run 都写 .sillyspec/.runtime/ 标记(凭空造目录)
484
535
  if (!sm._getPlatform()) return
485
- let timer
486
- try {
487
- await Promise.race([
488
- sm.pull(changeName, { skipIfLocalDirty: true }),
489
- new Promise((resolve) => { timer = setTimeout(resolve, SYNC_TOTAL_TIMEOUT_MS) }),
490
- ])
491
- } finally {
492
- clearTimeout(timer)
493
- }
536
+ if (_autoPullRecently(cwd)) return
537
+ _stampAutoPull(cwd)
538
+ await raceWithAbort((sig) => sm.pull(changeName, { skipIfLocalDirty: true, signal: sig.signal }), opts.timeoutMs)
494
539
  } catch (e) {
495
540
  // pull 失败静默跳过(Best Effort,失败不影响正确性)
496
541
  console.warn('⚠️ 拉取失败:', e.message)
@@ -513,6 +558,10 @@ export async function triggerPullActiveChange(cwd, platformOpts = {}) {
513
558
  } catch {
514
559
  return
515
560
  }
561
+ // PERF-02 节流(连接确认后才 stamp——未连接项目不得凭空写 .sillyspec/.runtime/):
562
+ // 与 triggerPull 共用同一 marker(本函数与它在同一命令启动链上互斥出现)
563
+ if (_autoPullRecently(cwd)) return
564
+ _stampAutoPull(cwd)
516
565
  let cn = null
517
566
  try {
518
567
  const { ProgressManager } = await import('../progress.js')
@@ -523,29 +572,48 @@ export async function triggerPullActiveChange(cwd, platformOpts = {}) {
523
572
  // progress 不可达则跳过(Best Effort)
524
573
  }
525
574
  if (!cn) return
526
- // 已确认连接 + 单活跃变更,调 pull(复用 triggerPull 的 8s 熔断;skipIfLocalDirty 保守守卫
575
+ // 已确认连接 + 单活跃变更,调 pull(复用 8s 熔断 + abort(HUB-09);skipIfLocalDirty 保守守卫
527
576
  // 同 triggerPull——本地脏时跳过 import,防平台旧快照覆盖本地领先进度,ql-20260818-008)
528
577
  try {
529
- let timer
530
- try {
531
- await Promise.race([
532
- sm.pull(cn, { skipIfLocalDirty: true }),
533
- new Promise((resolve) => { timer = setTimeout(resolve, SYNC_TOTAL_TIMEOUT_MS) }),
534
- ])
535
- } finally {
536
- clearTimeout(timer)
537
- }
578
+ await raceWithAbort((sig) => sm.pull(cn, { skipIfLocalDirty: true, signal: sig.signal }))
538
579
  } catch (e) {
539
580
  console.warn('⚠️ 拉取失败:', e.message)
540
581
  }
541
582
  }
542
583
 
584
+ /**
585
+ * 审批状态 unknown(体检 HUB-07)的统一处置:醒目多行警告 + 落 .runtime/approval-unknown.log 留痕。
586
+ *
587
+ * 语义边界:unknown = 已连接平台但 404/断网/超时/非 JSON——**不是审批通过**。放行是
588
+ * fail-open 的既定取舍(网络故障不应硬阻断每一条 execute),但静默放行会让团队审批
589
+ * 形同虚设且无迹可查;至少要(a)让操作者当场看见(b)留下可审计记录。
590
+ * 留痕选 .runtime 日志而非 QUICKLOG:QUICKLOG 是 quick 会话的任务记录,run 流程的
591
+ * 审批事件写进去会污染 quick 语义与边界审计。
592
+ * @param {string} cwd
593
+ * @param {string} changeName
594
+ * @param {string} reason checkApproval 返回的 reason
595
+ */
596
+ export function warnApprovalUnknown(cwd, changeName, reason) {
597
+ console.warn('')
598
+ console.warn('⚠️⚠️⚠️══════════════════════════════════════════════')
599
+ console.warn(`⚠️ 审批状态未知:变更「${changeName}」无法核实审批(${reason || '请求失败'})`)
600
+ console.warn('⚠️ 本次 execute 按 fail-open 放行——这不是审批通过;平台恢复后请到平台复核审批记录')
601
+ console.warn('⚠️ 排查:平台服务是否可达(sillyspec platform status)/ 网络代理;确认无需审批可 --skip-approval 显式跳过')
602
+ console.warn('⚠️ 本次放行已记录:.sillyspec/.runtime/approval-unknown.log')
603
+ console.warn('⚠️⚠️══════════════════════════════════════════════')
604
+ try {
605
+ const logDir = join(cwd, '.sillyspec', '.runtime')
606
+ mkdirSync(logDir, { recursive: true })
607
+ appendFileSync(join(logDir, 'approval-unknown.log'),
608
+ `${new Date().toISOString()} | change=${changeName} | reason=${reason || 'unknown'}\n`, 'utf8')
609
+ } catch { /* 留痕失败不阻断流程(警告横幅已输出) */ }
610
+ }
611
+
543
612
  /**
544
613
  * 审批检查:execute 阶段启动前检查(W6 Step8a 从 run.js 搬入,runStage + runAutoMode 共用)。
545
614
  * 平台模式走自己的链路,跳过;否则 await import sync.js。
546
615
  * @returns {{ status: string, reason?: string } | null}
547
- */
548
- export async function checkApproval(cwd, changeName, platformOpts = {}) {
616
+ */export async function checkApproval(cwd, changeName, platformOpts = {}) {
549
617
  // 平台模式不需要 CLI 内置审批检查
550
618
  if (platformOpts?.specRoot || platformOpts?.runtimeRoot) return null
551
619
  try {
@@ -774,7 +842,11 @@ export async function auditQuickCompletion(cwd, guard, options = {}) {
774
842
  ]
775
843
 
776
844
  for (const entry of currentEntries) {
777
- const status = entry.slice(0, 2).trim()
845
+ // porcelain 两字符状态码(X=暂存区 Y=工作区)不能先 trim:' D'.trim()==='D' 使第二分支恒假、
846
+ // 'DD'/'AD' 等 trim 后不等于 'D' 而漏检——--allow-delete 门对此类删除静默放行(体检 BUG-04)。
847
+ // 用原始两字符判定任一侧为 D 即删除。
848
+ const rawStatus = entry.slice(0, 2)
849
+ const status = rawStatus.trim()
778
850
  const file = parsePorcelainPath(entry) // 已去引号/处理 rename/归一化,修正首行丢首字符
779
851
  if (!file) continue
780
852
 
@@ -786,7 +858,7 @@ export async function auditQuickCompletion(cwd, guard, options = {}) {
786
858
  if (isBaselineFile(file)) continue
787
859
 
788
860
  result.changedFiles.push(file)
789
- if (status === 'D' || status === ' D') result.deletedFiles.push(file)
861
+ if (rawStatus[0] === 'D' || rawStatus[1] === 'D') result.deletedFiles.push(file)
790
862
  if (status === '??') result.newFiles.push(file)
791
863
 
792
864
  // 检查是否命中 baseline protected files
@@ -885,8 +957,12 @@ export async function auditQuickCompletion(cwd, guard, options = {}) {
885
957
  const mdChanged = result.changedFiles.filter((f) => f.endsWith('.md') && !deletedSet.has(f) && !isQuickMetadata(f, guard.linkedChanges))
886
958
  if (mdChanged.length > 0) {
887
959
  try {
888
- const { runDocsCheck } = await import('../docs-check.js')
889
- const dc = runDocsCheck({ projectRoot: cwd, docs: mdChanged })
960
+ const { runDocsCheck, readDocsCheckConfig } = await import('../docs-check.js')
961
+ // HUB-06:补传 crossRepoRoots(与 docs gate / index.js docs check 同口径)——
962
+ // 否则 repo:// 跨仓引用在这条链路恒跳过;无 local.yaml 配置时 undefined = 不启用
963
+ let quickCfg = {}
964
+ try { quickCfg = readDocsCheckConfig(cwd) || {} } catch { quickCfg = {} }
965
+ const dc = runDocsCheck({ projectRoot: cwd, docs: mdChanged, crossRepoRoots: quickCfg.crossRepoRoots })
890
966
  if (!dc.ok) {
891
967
  result.reasons.push(`本次改动文档含 ${dc.invalid.length} 处失效 file:line 引用(sillyspec docs check 可复现)`)
892
968
  result.docsCheckHint = { invalid: dc.invalid.length, total: dc.total }
@@ -906,7 +982,11 @@ export async function auditQuickCompletion(cwd, guard, options = {}) {
906
982
  const srcChangedFiles = result.changedFiles.filter((f) => f.startsWith('src/') && !deletedSet.has(f))
907
983
  if (srcChangedFiles.length > 0) {
908
984
  try {
909
- const { collectDocRefs, runDocsCheck } = await import('../docs-check.js')
985
+ const { collectDocRefs, runDocsCheck, readDocsCheckConfig } = await import('../docs-check.js')
986
+ // HUB-06:活文档(platform-interface-map.md 等最可能写 repo:// 引用的文档)同样补传
987
+ // crossRepoRoots;配置读一次提到循环外(per-cwd 恒同)
988
+ let livingCfg = {}
989
+ try { livingCfg = readDocsCheckConfig(cwd) || {} } catch { livingCfg = {} }
910
990
  const livingDocs = await resolveLivingDocs(cwd)
911
991
  const files = []
912
992
  const docs = []
@@ -918,7 +998,7 @@ export async function auditQuickCompletion(cwd, guard, options = {}) {
918
998
  if (hit.length === 0) continue
919
999
  files.push(...hit)
920
1000
  docs.push(docRel)
921
- const r = runDocsCheck({ projectRoot: cwd, docs: [docRel], keywordAssert: true })
1001
+ const r = runDocsCheck({ projectRoot: cwd, docs: [docRel], keywordAssert: true, crossRepoRoots: livingCfg.crossRepoRoots })
922
1002
  for (const x of r.invalid) invalidAll.push({ ...x, doc: docRel })
923
1003
  }
924
1004
  const invalid = matchInvalidRefsToChanged(invalidAll, srcChangedFiles)