sillyspec 3.26.14 → 3.26.15

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 (50) hide show
  1. package/.claude/skills/sillyspec-archive/SKILL.md +2 -0
  2. package/.claude/skills/sillyspec-brainstorm/SKILL.md +1 -1
  3. package/.claude/skills/sillyspec-commit/SKILL.md +18 -60
  4. package/.claude/skills/sillyspec-continue/SKILL.md +2 -0
  5. package/.claude/skills/sillyspec-execute/SKILL.md +10 -4
  6. package/.claude/skills/sillyspec-plan/SKILL.md +10 -2
  7. package/.claude/skills/sillyspec-quick/SKILL.md +2 -2
  8. package/.claude/skills/sillyspec-resume/SKILL.md +2 -0
  9. package/.claude/skills/sillyspec-verify/SKILL.md +2 -0
  10. package/.claude/skills/sillyspec-workspace/SKILL.md +7 -1
  11. package/package.json +1 -1
  12. package/src/commit-suggest.js +202 -0
  13. package/src/config-cat.js +88 -0
  14. package/src/docs-check.js +48 -12
  15. package/src/doctor-diagnostics.js +49 -0
  16. package/src/hooks/worktree-guard.js +20 -1
  17. package/src/index.js +508 -6
  18. package/src/init.js +1 -1
  19. package/src/local-register.js +98 -0
  20. package/src/module-impact.js +146 -0
  21. package/src/progress/change-registry.js +43 -4
  22. package/src/progress/stage-machine.js +10 -5
  23. package/src/progress.js +13 -1
  24. package/src/quicklog.js +106 -1
  25. package/src/run/command.js +95 -1
  26. package/src/run/complete-handlers.js +56 -3
  27. package/src/run/complete.js +4 -7
  28. package/src/run/gates.js +62 -4
  29. package/src/run/multi-repo-context.js +3 -1
  30. package/src/run/next.js +128 -0
  31. package/src/run/prompt.js +20 -2
  32. package/src/run/shared.js +2 -2
  33. package/src/scan-postcheck.js +142 -2
  34. package/src/stage-contract-spec.js +1 -1
  35. package/src/stage-review.js +15 -6
  36. package/src/stages/archive.js +1 -1
  37. package/src/stages/brainstorm.js +1 -0
  38. package/src/stages/execute.js +105 -26
  39. package/src/stages/knowledge.js +3 -2
  40. package/src/stages/quick.js +14 -6
  41. package/src/stages/scan.js +1 -0
  42. package/src/stages/verify.js +7 -49
  43. package/src/task-review.js +218 -4
  44. package/src/verify-probes.js +264 -0
  45. package/src/workspace.js +125 -0
  46. package/src/worktree-apply.js +15 -1
  47. package/src/worktree.js +58 -8
  48. package/templates/claude-instruction.md +1 -1
  49. package/templates/prompts/taskcard-rules.md +1 -1
  50. package/templates/prompts/verify-probes.md +13 -47
@@ -283,7 +283,10 @@ export async function archiveChangeDirectory(pm, cwd, progress, specBase, platfo
283
283
  const alreadyArchivedDir = findAlreadyArchivedDir(archiveDir, archiveChangeName)
284
284
  if (alreadyArchivedDir) {
285
285
  console.log(`ℹ️ 源目录不存在但变更已在 archive/(${basename(alreadyArchivedDir)}),判定已归档,自愈进度 DB`)
286
- pm.unregisterChange(cwd, archiveChangeName)
286
+ // 终态一致化(坑 manual-archive-desync-status-only):手动搬目录绕过标准归档后,本自愈路径
287
+ // 不能只翻 status——同时收尾 archive 阶段(steps/stages/current_stage),否则推送平台的终态
288
+ // 是「已归档 + 归档 0/5 + 停在 execute」的矛盾体,详情页渲染成「进度丢失」
289
+ pm.unregisterChange(cwd, archiveChangeName, { archiveStepNames: typeof pm.archiveStepNamesForArchive === 'function' ? pm.archiveStepNamesForArchive() : null })
287
290
  await archiveWorktreeCleanup(cwd, archiveChangeName, specBase, platformOpts)
288
291
  console.log(`📦 已自愈归档:${archiveChangeName} → archive/${basename(alreadyArchivedDir)}/`)
289
292
  return alreadyArchivedDir
@@ -332,7 +335,9 @@ export async function archiveChangeDirectory(pm, cwd, progress, specBase, platfo
332
335
  process.exit(1)
333
336
  }
334
337
 
335
- pm.unregisterChange(cwd, archiveChangeName)
338
+ // 正常路径同样走终态一致化(坑 manual-archive-desync-status-only):标准 --done --confirm 流程
339
+ // 中 completeStep 随后也会逐项完成,此处先收尾是幂等写同值——保证任何走出本函数的归档终态一致
340
+ pm.unregisterChange(cwd, archiveChangeName, { archiveStepNames: typeof pm.archiveStepNamesForArchive === 'function' ? pm.archiveStepNamesForArchive() : null })
336
341
 
337
342
  // CLI 下沉 git add(坑4,FR-04):确定性暂存归档目录 + 模块文档,不靠 step5 prompt 驱动。
338
343
  // step5 prompt 的 git add 保留作幂等兜底;POSIX 路径跨平台(git 接受正斜杠)。
@@ -343,6 +348,53 @@ export async function archiveChangeDirectory(pm, cwd, progress, specBase, platfo
343
348
  safeGit(cwd, ['add', '--', '.sillyspec/docs/'])
344
349
  } catch {}
345
350
 
351
+ // ── 他者半归档残留探测(坑 archive-other-residual-rename,2026-08-21 实证)──
352
+ // 并行变更的手动归档把 R 残留(源目录 rename 目标行)留在暂存区;本变更归档提交时
353
+ // git status 显示它们,agent 会误判「还没提交完 / 要做第二次提交」。区分:
354
+ // 本变更:未暂存的源侧移动(` D changes/<me>/...` + 未跟踪 archive/<me>/...)→ 补暂存
355
+ // 让归档成单次原子提交;
356
+ // 他者:已暂存的 rename(`R changes/<他人>/... -> changes/archive/<他人>/...`)→ 只 warn
357
+ // 提示归属(不 stage 不动——别人的归别人的)。
358
+ try {
359
+ const raw = gitQuiet(cwd, ['status', '--porcelain'], { trim: false, timeout: 30000 })
360
+ if (raw && raw.trim()) {
361
+ const changesPrefix = '.sillyspec/changes/'
362
+ const minePaths = []
363
+ const othersResidual = []
364
+ for (const line of raw.split('\n')) {
365
+ if (!line || line.length < 4) continue
366
+ const x = line[0], y = line[1]
367
+ const body = line.slice(3).trim()
368
+ const arrow = body.indexOf(' -> ')
369
+ const src = arrow !== -1 ? body.slice(0, arrow).replace(/^"|"$/g, '') : null
370
+ const dst = (arrow !== -1 ? body.slice(arrow + 4) : body).replace(/^"|"$/g, '')
371
+ if (x === 'R' && y === ' ' && arrow !== -1 && dst.startsWith(changesPrefix + 'archive/')) {
372
+ // 已暂存 rename → 半归档残留;按目标目录名归属他者变更
373
+ const m = /^\.sillyspec\/changes\/archive\/([^/]+)\//.exec(dst)
374
+ const owner = m ? m[1] : '?'
375
+ if (owner !== archiveChangeName) othersResidual.push(owner)
376
+ } else if (x === ' ' && (y === 'D' || y === 'A' || y === 'M')) {
377
+ // 未暂存的源侧移动:源目录 D / 新位置 A——指向本变更的补暂存
378
+ const p = dst || src
379
+ if (p && (p.startsWith(changesPrefix + archiveChangeName + '/')
380
+ || p.startsWith(changesPrefix + 'archive/' + archiveChangeName + '/'))) {
381
+ minePaths.push(p)
382
+ }
383
+ }
384
+ }
385
+ if (minePaths.length > 0) {
386
+ try { safeGit(cwd, ['add', '-A', '--', changesPrefix]) } catch {}
387
+ console.log(`🧾 已补暂存本变更归档的源侧移动(${minePaths.length} 项,归档成单次原子提交)`)
388
+ }
389
+ if (othersResidual.length > 0) {
390
+ const owners = [...new Set(othersResidual)].filter(Boolean)
391
+ console.warn(`⚠️ 检测到「他者半归档」残留(暂存区存在其他变更的 rename 记录):${owners.join('、')}`)
392
+ console.warn(` 这些是别的变更此前手动归档留下的暂存项,不属于本次归档——git status 里看到它们是正常的,`)
393
+ console.warn(` 本变更归档已完成,无需为其做第二次提交;如需清理走它们自己的收尾(或 git restore --staged 后核对)。`)
394
+ }
395
+ }
396
+ } catch { /* 探测失败不阻断归档(advisory) */ }
397
+
346
398
  // 归档时清理可能残留的 worktree(自愈路径也复用,见上方 srcDir 缺失分支)。
347
399
  await archiveWorktreeCleanup(cwd, archiveChangeName, specBase, platformOpts)
348
400
 
@@ -1037,7 +1089,8 @@ async function closeSingleQuickLinkedChange({ pm, cwd, specBase, changeName, pla
1037
1089
  return { closed: false, reason: `移动目录失败:${e.message}` }
1038
1090
  }
1039
1091
 
1040
- pm.unregisterChange(cwd, changeName)
1092
+ // 终态一致化同标准归档(坑 manual-archive-desync-status-only):轻量归档路径不能只翻 status
1093
+ pm.unregisterChange(cwd, changeName, { archiveStepNames: typeof pm.archiveStepNamesForArchive === 'function' ? pm.archiveStepNamesForArchive() : null })
1041
1094
  await archiveWorktreeCleanup(cwd, changeName, specBase, platformOpts)
1042
1095
  safeGit(cwd, ['add', '--', `.sillyspec/changes/archive/${destName}/`])
1043
1096
 
@@ -456,7 +456,7 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
456
456
  console.log(`\n${summary}`)
457
457
  } catch (e) {
458
458
  // summary 失败不影响主流程
459
- console.log('\n👉 下一步:sillyspec run verify(验证通过后才能归档)')
459
+ console.log(`\n👉 下一步:sillyspec run verify${changeName ? ` --change ${changeName}` : ''}(验证通过后才能归档)`)
460
460
  }
461
461
  } else if (stageName === 'archive') {
462
462
  console.log('\n👉 归档完成!现在可以提交了:git commit -m "..."')
@@ -534,12 +534,9 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
534
534
  // 易被 tail 截断),agent 常到 --done 被拦才第一次见到模板。推进到末步的此刻用底部短块预告
535
535
  // 模板 + 可照抄命令,省一轮拦截往返(拦截路径保留兜底)。
536
536
  if (stageName === 'quick' && nextPendingIdx === steps.length - 1) {
537
- console.log(`\n📌 本步是 quick 末步:--done 的 --output 必须含四字段(CLI 硬校验,缺任一项会被拒、补全重跑不丢进度)。预告模板:`)
538
- console.log(` sillyspec run quick --done${changeName ? ` --change ${changeName}` : ''} --output "需求:一句话语义化短标题(即 QUICKLOG 条目标题)`)
539
- console.log(` 根因:为什么这样改(纯新增/样式则写「无,纯新增/纯样式」)`)
540
- console.log(` 方案:怎么改的`)
541
- console.log(` 结果:验证情况(测试数 / lint / typecheck / 部署状态)"`)
542
- console.log(` 四字段逐项一句话、不可「见前述」;可选 --file-notes "path::括注 || path2" 落多行文件括注`)
537
+ console.log(`\n📌 本步是 quick 末步:--done 必须给全四字段(CLI 硬校验,缺任一项会被拒、补全重跑不丢进度)。推荐四参数形式(CLI 自动合成,无格式事故面):`)
538
+ console.log(` sillyspec run quick --done${changeName ? ` --change ${changeName}` : ''} --req "一句话语义化短标题(即 QUICKLOG 条目标题)" --cause "为什么这样改(纯新增/样式则写「无,纯新增/纯样式」)" --solution "怎么改的" --result "验证情况(测试数 / lint / typecheck / 部署状态)"`)
539
+ console.log(` 兼容旧形式:--output "需求:… 根因:… 方案:… 结果:…"(四字段逐项一句话、不可「见前述」,正文内禁嵌套全角冒号);可选 --file-notes "path::括注 || path2" 落多行文件括注`)
543
540
  }
544
541
  }
545
542
  return { stageCompleted: false, currentIdx, nextPendingIdx }
package/src/run/gates.js CHANGED
@@ -65,9 +65,43 @@ export function validateSymbolImpactReport({ reportContent, planContent }) {
65
65
  for (const id of missing) {
66
66
  errors.push(`${id} 未在 symbol-impact.md 中出现——每个 task 都要有一行结论(含「无签名级变更」的显式声明)`)
67
67
  }
68
+ // 防骨架直接过门(2026-08-21 agent-手工产出审计项⑤):CLI 会代生成逐 task TODO 骨架
69
+ //(generateSymbolImpactSkeleton),占位未替换即拦——骨架消灭的是「从零手写/漏 task」错误面,
70
+ // 不是把「逐 task 结论」本身变成走过场。
71
+ const report = String(reportContent)
72
+ const todoPending = taskIds.filter(id => new RegExp(`^[-*]\\s*${id}[^\\n]*<!--TODO-->`, 'm').test(report))
73
+ for (const id of todoPending) {
74
+ errors.push(`${id} 的结论仍是骨架 <!--TODO--> 占位——替换为真实结论(无签名级变更也显式写「无」)`)
75
+ }
68
76
  return { ok: errors.length === 0, errors }
69
77
  }
70
78
 
79
+ /**
80
+ * 生成 symbol-impact.md 逐 task TODO 骨架(2026-08-21 审计项⑤「报错即生成」)。
81
+ *
82
+ * gate 此前只报错不代填 → agent 从零手写整份报告,忘一个 task 就被拦、再补再撞。
83
+ * 骨架从 tasks.md 注册表生成逐 task 占位行,agent 只需逐行填结论;占位 <!--TODO-->
84
+ * 由 validateSymbolImpactReport 拒绝,骨架不能直接过门(防偷懒)。
85
+ *
86
+ * @param {string} tasksContent - 任务注册表(tasks.md,回退 plan.md)内容
87
+ * @returns {string|null} 骨架全文(LF);注册表无 task 行 → null
88
+ */
89
+ export function generateSymbolImpactSkeleton(tasksContent) {
90
+ const taskIds = extractTaskIdsFromRegistry(tasksContent)
91
+ if (taskIds.length === 0) return null
92
+ const lines = [
93
+ '# 符号影响面报告',
94
+ '',
95
+ '> 骨架由 CLI 生成(`sillyspec symbol-impact --change <变更名>`,gate 失败时也会自动落一份)。',
96
+ '> 逐行把 `<!--TODO-->` 替换为真实结论:涉及签名级变更(构造函数参数/接口/DTO/方法签名增删改)',
97
+ '> 写变更类型 + 受影响调用点 + 是否在任务范围内;无签名级变更也要显式写「无签名级变更」。',
98
+ '> **gate 拒绝仍含 <!--TODO--> 的行**——骨架不能直接过门。',
99
+ '',
100
+ ]
101
+ for (const id of taskIds) lines.push(`- ${id}: <!--TODO-->`)
102
+ return lines.join('\n') + '\n'
103
+ }
104
+
71
105
  /**
72
106
  * 符号影响面报告硬门:execute「加载上下文」步 --done 时校验 symbol-impact.md 落盘 + task 覆盖度。
73
107
  * fail-closed:不通过 → 当前步标 blocked + exit 1(进度不推进)。非目标步骤/阶段直接放行。
@@ -88,7 +122,19 @@ export async function enforceSymbolImpactGate(stageName, changeName, currentStep
88
122
  console.error(` 「加载上下文」步的符号影响面检查报告未落盘或不完整:`)
89
123
  console.error(` 期望路径:${reportPath}`)
90
124
  for (const e of result.errors) console.error(` • ${e}`)
91
- console.error(' 修复:按「加载上下文」步操作 11 完成符号影响面扫描,逐 task 写结论(无签名级变更也要显式写「无」),落盘后重跑 execute --done。')
125
+ // 报错即生成(2026-08-21 审计项⑤):报告缺失时自动落一份逐 task TODO 骨架,agent 从
126
+ // 「从零手写整份」变「逐行填结论」;TODO 占位由 validate 拒绝,骨架不能直接过门。
127
+ let skeletonNote = ''
128
+ if (!existsSync(reportPath)) {
129
+ const skeleton = generateSymbolImpactSkeleton(tasksContent)
130
+ if (skeleton) {
131
+ try {
132
+ writeFileSync(reportPath, skeleton)
133
+ skeletonNote = `\n 📄 已代生成逐 task 骨架:${reportPath}(逐行替换 <!--TODO--> 为结论,无签名级变更也显式写「无」)`
134
+ } catch { /* 写失败只影响提示,不掩盖原始错误 */ }
135
+ }
136
+ }
137
+ console.error(` 修复:按「加载上下文」步操作 11 完成符号影响面扫描,逐 task 写结论后重跑 execute --done。${skeletonNote}`)
92
138
  process.exit(1)
93
139
  }
94
140
 
@@ -195,7 +241,7 @@ export async function enforceAlignExecuteReviewGate({ cwd, changeName, specBase,
195
241
  }
196
242
  const searchDirs = [effectiveSpecBase, reviewChangeDir, cwd].filter(Boolean)
197
243
  const reviewResult = validateStageReview({ stage: 'execute', reviewType: 'acceptance', runtimeRoot, reviewRunId, searchDirs })
198
- printStageReviewResult(reviewResult, { stage: 'execute', reviewRunId, runtimeRoot })
244
+ printStageReviewResult(reviewResult, { stage: 'execute', reviewRunId, runtimeRoot, changeName })
199
245
  if (!reviewResult.ok) return true // printStageReviewResult 已给明细,不再重复列
200
246
  }
201
247
  } catch (e) {
@@ -292,6 +338,18 @@ export async function enforceDepsGate(stageName, cwd, changeName, step, steps, c
292
338
  */
293
339
  export async function enforceReviewJsonGate(stageName, cwd, changeName, step, steps, currentIdx, specBase, platformOpts) {
294
340
  if (stageName !== 'execute' || !changeName) return true
341
+ // head 锡点自动落盘(2026-08-21 审计项③,D-010 补对称):跨仓 task base_commit 已在派发时
342
+ // CLI 落盘,head_commit 此前靠主 agent 按 prompt 手跑 rev-parse 手写(漏抄/抄错炸 review gate)。
343
+ // 每次 --done 时机幂等补齐(已存在不覆盖)。best-effort:失败只 warn,不阻断 --done 主流程。
344
+ try {
345
+ const { stampCrossRepoHeadCommits } = await import('../stages/execute.js')
346
+ const stamped = await stampCrossRepoHeadCommits({ changeName, cwd, specBase, platformOpts })
347
+ if (stamped && stamped.stamped > 0) {
348
+ console.log(`📌 已自动落盘跨仓 task head_commit 锡点:${stamped.stampedTasks.join(', ')}(幂等,此后 --done 不再重复写)`)
349
+ }
350
+ } catch (e) {
351
+ console.warn(`⚠️ head_commit 锡点自动落盘失败(降级留给 agent 手写): ${e.message}`)
352
+ }
295
353
  const runtimeRoot = resolveRuntimeRoot(platformOpts, specBase)
296
354
  const runIdFile = join(runtimeRoot, `current-execute-run-id-${changeName}`)
297
355
  const planPath = join(specBase, 'changes', changeName, 'plan.md')
@@ -340,7 +398,7 @@ export async function enforceReviewJsonGate(stageName, cwd, changeName, step, st
340
398
  console.error(` • ${f.taskId}(${kindLabel}): ${f.reviewPath}`)
341
399
  for (const e of f.errors) console.error(` - ${e}`)
342
400
  }
343
- console.error(' 修复:补全上述 review.json 字段后重跑 execute --done。')
401
+ console.error(' 修复:mechanics 字段(base/head/changedFiles/schemaVersion)缺错可跑 sillyspec backfill-reviews --change ' + changeName + ' --adopt 一键代填(verdict 保留);verdict/证据缺失需人工补全后重跑 execute --done。')
344
402
  process.exit(1)
345
403
  }
346
404
 
@@ -531,7 +589,7 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
531
589
  : 'acceptance'
532
590
  const searchDirs = [effectiveSpecBase, reviewChangeDir, cwd].filter(Boolean)
533
591
  const reviewResult = validateStageReview({ stage: stageName, reviewType, runtimeRoot, reviewRunId, searchDirs })
534
- printStageReviewResult(reviewResult, { stage: stageName, reviewRunId, runtimeRoot })
592
+ printStageReviewResult(reviewResult, { stage: stageName, reviewRunId, runtimeRoot, changeName })
535
593
  if (!reviewResult.ok) {
536
594
  return await rollbackCompletionAndReturn(pm, progress, stageData, steps, currentIdx, cwd, changeName, platformOpts)
537
595
  }
@@ -79,7 +79,9 @@ export class MultiRepoContext {
79
79
  throw new Error(
80
80
  `MultiRepoContext: 以下 repo 未在 local.yaml repos: 段注册:[${unregistered.join(', ')}]。` +
81
81
  `当前已注册 repo:[${registered.join(', ')}]。` +
82
- `请在 local.yaml repos: 段补注册后再启动 execute(约束② fail-closed,跨仓 apply 走错仓=数据所有权事故)。`
82
+ `一键注册(每个缺的 key 各跑一次,勿手编 YAML):` +
83
+ unregistered.map(k => `sillyspec local register-repo ${k} <${k} 仓根路径>`).join(';') +
84
+ `。补注册后再启动 execute(约束② fail-closed,跨仓 apply 走错仓=数据所有权事故)。`
83
85
  );
84
86
  }
85
87
 
@@ -0,0 +1,128 @@
1
+ /**
2
+ * next.js — `sillyspec next` 项目状态探测(吸收 continue/resume 两个 skill 的手工探测表,
3
+ * 2026-08-21 agent-手工产出审计第二批 G7/G8)。
4
+ *
5
+ * 两个 skill 各自维护一套「文件存在性状态机」推断表且内容重复,agent 每次手工跑
6
+ * ls/cat 探测再查表——纯机械探测是 CLI 的本职。本模块输出「状态描述 + 下一步命令 +
7
+ * 依据文件」,agent 按输出执行即可;skill 保留作兜底(CLI 不可用/输出异常时)。
8
+ *
9
+ * 纯 fs 探测(零 DB 依赖、零 token、快):进度库的权威状态归 `sillyspec progress show`,
10
+ * 本命令管「没有活跃进度时从产物文件反推该干什么」。决策表(两个 skill 合并):
11
+ *
12
+ * 1. HANDOFF.json 存在 → 交接恢复
13
+ * 2. 活跃变更(changes/ 非 archive)逐个推断:
14
+ * 无文件→补 proposal / 无 design.md→brainstorm / 无 tasks.md→brainstorm 末步 /
15
+ * 无 plan.md→plan / tasks.md 有未勾→execute / 全勾无 verify-result.md→verify /
16
+ * 有 verify-result.md→archive
17
+ * 3. 有 docs/<p>/scan/ 无活跃变更 → brainstorm(已扫描待开变更)
18
+ * 4. 有 REQUIREMENTS.md/ROADMAP.md 无变更 → brainstorm(绿地已有需求)
19
+ * 5. 什么都没有 → init(新项目)或 scan(棕地)
20
+ */
21
+ import { existsSync, readdirSync, readFileSync } from 'fs'
22
+ import { join } from 'path'
23
+ import { resolveSpecDir } from './shared.js'
24
+
25
+ /**
26
+ * 数 tasks.md checkbox:返回 { checked, total }(无文件 → {checked:0,total:0})
27
+ */
28
+ function countTasks(tasksPath) {
29
+ if (!existsSync(tasksPath)) return { checked: 0, total: 0 }
30
+ let checked = 0
31
+ let total = 0
32
+ for (const line of readFileSync(tasksPath, 'utf8').split('\n')) {
33
+ const m = line.match(/^[-*]\s*\[([ xX])\]\s*task-\d+\b/)
34
+ if (!m) continue
35
+ total++
36
+ if (m[1] !== ' ') checked++
37
+ }
38
+ return { checked, total }
39
+ }
40
+
41
+ /**
42
+ * 单个活跃变更的产物推断:返回 { state, next, evidence }
43
+ */
44
+ function inferChange(changeDir, name) {
45
+ const has = (f) => existsSync(join(changeDir, f))
46
+ const ev = (f) => join('.sillyspec', 'changes', name, f)
47
+ const t = countTasks(join(changeDir, 'tasks.md'))
48
+
49
+ if (!has('proposal.md') && !has('design.md') && !has('tasks.md') && !has('plan.md')) {
50
+ return { state: `${name}:变更目录为空`, next: 'sillyspec run brainstorm(完善 proposal)或清理该空目录', evidence: [ev('(无产物)')] }
51
+ }
52
+ if (!has('design.md')) {
53
+ return { state: `${name}:有 proposal 缺 design.md`, next: `sillyspec run brainstorm --change ${name}(推进到设计产物)`, evidence: [ev('proposal.md')] }
54
+ }
55
+ if (!has('tasks.md')) {
56
+ return { state: `${name}:有 design 缺 tasks.md`, next: `sillyspec run brainstorm --change ${name}(末步生成规范文件)`, evidence: [ev('design.md')] }
57
+ }
58
+ if (!has('plan.md')) {
59
+ return { state: `${name}:有 tasks.md 缺 plan.md`, next: `sillyspec run plan --change ${name}`, evidence: [ev('tasks.md')] }
60
+ }
61
+ if (t.total > 0 && t.checked < t.total) {
62
+ return {
63
+ state: `${name}:执行中(task ${t.checked}/${t.total})`,
64
+ next: `sillyspec run execute --change ${name}`,
65
+ evidence: [ev('plan.md'), ev('tasks.md')],
66
+ }
67
+ }
68
+ if (!has('verify-result.md')) {
69
+ return { state: `${name}:task 全勾,待验证`, next: `sillyspec run verify --change ${name}`, evidence: [ev('tasks.md'), ev('plan.md')] }
70
+ }
71
+ return { state: `${name}:已验证,待归档`, next: `sillyspec run archive --change ${name}`, evidence: [ev('verify-result.md')] }
72
+ }
73
+
74
+ /**
75
+ * 探测当前项目状态与下一步命令。
76
+ * @param {{ cwd: string, specDir?: string|null }} opts
77
+ * @returns {{ state: string, next: string, evidence: string[], activeChanges: Array<{name,checked,total}> }}
78
+ * activeChanges 附带各变更 task 勾选进度(供 --json 消费);evidence 为相对 spec 根的路径
79
+ */
80
+ export function detectNextStep({ cwd, specDir = null }) {
81
+ const specBase = resolveSpecDir(cwd, { specDir })
82
+ const changesRoot = join(specBase, 'changes')
83
+
84
+ // 1. HANDOFF 交接
85
+ for (const handoff of [join(cwd, 'HANDOFF.json'), join(specBase, 'HANDOFF.json')]) {
86
+ if (existsSync(handoff)) {
87
+ return { state: '存在 HANDOFF.json(上次会话交接)', next: '读 HANDOFF.json 后从交接点继续(sillyspec progress show 看活跃进度)', evidence: [handoff], activeChanges: [] }
88
+ }
89
+ }
90
+
91
+ // 2. 活跃变更(排除 archive)
92
+ let active = []
93
+ try {
94
+ active = readdirSync(changesRoot, { withFileTypes: true })
95
+ .filter(e => e.isDirectory() && e.name !== 'archive')
96
+ .map(e => e.name)
97
+ .sort()
98
+ } catch { /* 无 changes/ 目录 */ }
99
+ if (active.length > 0) {
100
+ // 多活跃时逐个推断全报告,next 取第一个(字典序最前)的;调用方输出含全部状态行
101
+ const inferences = active.map(name => inferChange(join(changesRoot, name), name))
102
+ const progress = active.map((name, i) => {
103
+ const t = countTasks(join(changesRoot, name, 'tasks.md'))
104
+ return { name, checked: t.checked, total: t.total, state: inferences[i].state }
105
+ })
106
+ return { state: inferences.map(i => i.state).join(';'), next: inferences[0].next, evidence: inferences[0].evidence, activeChanges: progress }
107
+ }
108
+
109
+ // 3. 已扫描无变更
110
+ const docsDir = join(specBase, 'docs')
111
+ try {
112
+ const scanned = readdirSync(docsDir, { withFileTypes: true })
113
+ .filter(e => e.isDirectory() && existsSync(join(docsDir, e.name, 'scan')))
114
+ if (scanned.length > 0) {
115
+ return { state: `已扫描(${scanned.length} 个项目文档),无活跃变更`, next: 'sillyspec run brainstorm "你的想法"', evidence: [join('.sillyspec', 'docs', scanned[0].name, 'scan')], activeChanges: [] }
116
+ }
117
+ } catch { /* 无 docs/ */ }
118
+
119
+ // 4. 绿地已有需求
120
+ for (const f of ['REQUIREMENTS.md', 'ROADMAP.md']) {
121
+ if (existsSync(join(specBase, f))) {
122
+ return { state: `绿地项目已有 ${f},无活跃变更`, next: 'sillyspec run brainstorm "你的想法"', evidence: [join('.sillyspec', f)], activeChanges: [] }
123
+ }
124
+ }
125
+
126
+ // 5. 什么都没有
127
+ return { state: '未初始化(无 .sillyspec 产物)', next: 'sillyspec init(新项目)或 sillyspec scan(棕地项目)', evidence: [], activeChanges: [] }
128
+ }
package/src/run/prompt.js CHANGED
@@ -21,7 +21,7 @@
21
21
  import { basename, join } from 'node:path'
22
22
  import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs'
23
23
  import { stageRegistry } from '../stages/index.js'
24
- import { resolvePromptIncludes, resolveRuntimeRoot, safeGit, WAIT_MARKER_RE } from './shared.js'
24
+ import { resolvePromptIncludes, resolveRuntimeRoot, safeGit, WAIT_MARKER_RE, QUICK_SID_RE } from './shared.js'
25
25
  import { renderStageContract } from '../stage-contract-spec.js'
26
26
  import { parseModuleMapSimple } from '../modules.js'
27
27
  import { REVIEW_SCHEMA_VERSION, isValidExecuteRunId } from '../task-review.js'
@@ -236,6 +236,16 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
236
236
  promptText = promptText.replace(/<now-datetime>/g, nowDatetime)
237
237
  promptText = promptText.replace(/<now-timestamp>/g, nowTimestamp)
238
238
  promptText = promptText.replace(/<now-date>/g, nowDate)
239
+ // <now-iso-datetime>(scan frontmatter updated_at 用;与 scan-fix-headers 同款秒级格式)
240
+ promptText = promptText.replace(/<now-iso-datetime>/g, now.toISOString().slice(0, 19).replace('T', ' '))
241
+ // <git-head-short>(scan frontmatter source_commit 用;CLI 代查,agent 勿自跑 rev-parse)
242
+ if (promptText.includes('<git-head-short>')) {
243
+ let headShort = 'unknown'
244
+ try {
245
+ headShort = safeGit(cwd, ['rev-parse', '--short', 'HEAD']).value || 'unknown'
246
+ } catch { /* git 不可用 → unknown,scan-postcheck 失配时再核 */ }
247
+ promptText = promptText.replace(/<git-head-short>/g, headShort)
248
+ }
239
249
  // 替换 {REVIEW_SCHEMA_VERSION} 占位符(task review 示例模板用,值=CLI 当前 REVIEW_SCHEMA_VERSION 常量,
240
250
  // 避免 agent 照抄 design 目标版本与 CLI 写侧常量漂移;与 stage review 契约 renderReviewJsonContract 动态注入同源)
241
251
  if (promptText.includes('{REVIEW_SCHEMA_VERSION}')) {
@@ -722,7 +732,15 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
722
732
  // 普通本地保持相对 '.sillyspec' 展示;runtimeRoot-only 组合不再 join(null) 崩溃
723
733
  const changeDirBase = platformOpts?.specRoot || platformOpts?.specDriftAnchor || '.sillyspec'
724
734
  const changeDir = join(changeDirBase, 'changes', changeName)
725
- console.log(`- **文件路径规则:所有变更文件必须写入 \`${changeDir}/\` 目录下。不要自己拼接路径,直接使用 changeDir 值。示例:\`${changeDir}/proposal.md\`**`)
735
+ // quick 会话(quick-<hex8>)按设计无实体变更目录(progress.js initChange 同款跳过),
736
+ // 纯代码 quick 根本不产 spec 文档——「所有变更文件必须写入 changes/<change>/」的硬规则
737
+ // 对它是误导(ql-20260821-011 实证:CLI 反复提示一个用不到的目录)。改述条件化:
738
+ // 仅「若本 quick 决定补文档」才落该目录,代码改动照常写源码目录。
739
+ if (QUICK_SID_RE.test(changeName)) {
740
+ console.log(`- **文件路径规则(quick 会话):纯代码改动直接写源码目录,无目录限制。仅当本 quick 需要落 spec 文档(design/plan 等,复杂任务建议升级完整流程)时才写 \`${changeDir}/\`;QUICKLOG/tasks.md 记录由 CLI 接管,不要手建。**`)
741
+ } else {
742
+ console.log(`- **文件路径规则:所有变更文件必须写入 \`${changeDir}/\` 目录下。不要自己拼接路径,直接使用 changeDir 值。示例:\`${changeDir}/proposal.md\`**`)
743
+ }
726
744
  }
727
745
  }
728
746
  const changeFlag = changeName ? ` --change ${changeName}` : ''
package/src/run/shared.js CHANGED
@@ -460,8 +460,8 @@ async function raceWithAbort(op, timeoutMs = SYNC_TOTAL_TIMEOUT_MS) {
460
460
 
461
461
  // quick 会话 ID 形态(quick-<hex8>)。与 run/command.js QUICK_SID_RE、progress.js
462
462
  // initChange 的跳过实体目录判断同源;shared.js 不反向 import command.js(重模块),
463
- // 局部复制正则保持轻载——改形态时三处同改。
464
- const QUICK_SID_RE = /^quick-[0-9a-f]{8}$/
463
+ // 局部复制正则保持轻载——改形态时三处同改(prompt.js 经本导出消费,勿再复制一份)。
464
+ export const QUICK_SID_RE = /^quick-[0-9a-f]{8}$/
465
465
 
466
466
  export async function triggerSync(cwd, changeName, platformOpts = {}, opts = {}) {
467
467
  // 平台模式(SillyHub)走自己的回传链路,不走 CLI 内置 sync
@@ -5,10 +5,11 @@
5
5
  * 平台模式下必须通过所有 check 才能 success,否则降级。
6
6
  */
7
7
 
8
- import { existsSync, readdirSync, readFileSync, mkdirSync, writeFileSync } from 'fs'
8
+ import { existsSync, readdirSync, readFileSync, mkdirSync, writeFileSync, renameSync, rmdirSync } from 'fs'
9
9
  import { join, basename } from 'path'
10
10
  import { SCAN_STATUS, CHECK_SEVERITY, SCAN_REQUIRED_DOCS, SCAN_REQUIRED_DOCS_QUICK } from './constants.js'
11
11
  import { validateScriptCommands } from './stages/cmd-existence.js'
12
+ import { git } from './git-helper.js'
12
13
 
13
14
  const REQUIRED_SCAN_DOCS = SCAN_REQUIRED_DOCS
14
15
 
@@ -112,7 +113,7 @@ export function runScanPostCheck({ cwd, specDir, outputText = '', scanMeta = {},
112
113
  checks.push({
113
114
  name: 'docs_missing_header',
114
115
  severity: CHECK_SEVERITY.WARNING,
115
- detail: `${docsMissingHeader.length} 份文档缺少 author/created_at: ${docsMissingHeader.join(', ')}`
116
+ detail: `${docsMissingHeader.length} 份文档缺少 author/created_at: ${docsMissingHeader.join(', ')}(sillyspec scan-fix-headers 一键补齐)`
116
117
  })
117
118
  }
118
119
 
@@ -369,3 +370,142 @@ export function printScanPostCheckResult(result) {
369
370
  }
370
371
  console.log(` 📋 最终状态: ${result.status}`)
371
372
  }
373
+
374
+ /**
375
+ * scan 文档 header 幂等补填(2026-08-21 agent-手工产出审计第三批 E3)。
376
+ *
377
+ * runScanPostCheck 对缺 author/created_at 只 warning,agent 手改常忘——本函数补齐:
378
+ * author = git user.name(回退 unknown),created_at = 当前时间。已有两键的文件不动;
379
+ * 有 frontmatter(--- 开头)就地插缺失键,无则补一段 frontmatter。
380
+ * `sillyspec scan-fix-headers [--project <名>]` 入口;与 runScanPostCheck 的
381
+ * 「头部 512 字符含 author:/created_at:」判定口径一致。
382
+ *
383
+ * @param {{ cwd: string, specDir?: string|null, project?: string|null }} opts
384
+ * @returns {{ fixed: string[], skipped: string[] }}
385
+ */
386
+ export function fixScanDocHeaders({ cwd, specDir = null, project = null }) {
387
+ const specBase = specDir ? specDir : join(cwd, '.sillyspec')
388
+ const docsRoot = join(specBase, 'docs')
389
+ const projects = project
390
+ ? [project]
391
+ : (existsSync(docsRoot) ? readdirSync(docsRoot, { withFileTypes: true }).filter(e => e.isDirectory()).map(e => e.name) : [])
392
+
393
+ let author = 'unknown'
394
+ try {
395
+ author = git(cwd, ['config', 'user.name']) || 'unknown'
396
+ } catch { /* git 不可用 → unknown */ }
397
+ const now = new Date().toISOString().slice(0, 19).replace('T', ' ')
398
+
399
+ const fixed = []
400
+ const skipped = []
401
+ for (const p of projects) {
402
+ const scanDir = join(docsRoot, p, 'scan')
403
+ if (!existsSync(scanDir)) continue
404
+ for (const f of readdirSync(scanDir)) {
405
+ if (!f.endsWith('.md')) continue
406
+ const filePath = join(scanDir, f)
407
+ let content
408
+ try {
409
+ content = readFileSync(filePath, 'utf8')
410
+ } catch {
411
+ continue
412
+ }
413
+ const header = content.slice(0, 512)
414
+ const hasAuthor = /author\s*:/.test(header)
415
+ const hasCreated = /created_at\s*:/.test(header)
416
+ if (hasAuthor && hasCreated) {
417
+ skipped.push(filePath)
418
+ continue
419
+ }
420
+ const inserts = []
421
+ if (!hasAuthor) inserts.push(`author: ${author}`)
422
+ if (!hasCreated) inserts.push(`created_at: ${now}`)
423
+ let newContent
424
+ if (content.startsWith('---\n') || content.startsWith('---\r\n')) {
425
+ // 有 frontmatter:插到开标签后(CRLF 归一写回,与其它 CLI 写入口径一致)
426
+ const normalized = content.replace(/\r\n?/g, '\n')
427
+ newContent = normalized.replace(/^---\n/, `---\n${inserts.join('\n')}\n`)
428
+ } else {
429
+ newContent = `---\n${inserts.join('\n')}\n---\n\n` + content
430
+ }
431
+ try {
432
+ writeFileSync(filePath, newContent)
433
+ fixed.push(filePath)
434
+ } catch { /* 只读文件等写失败 → 跳过不抛 */ }
435
+ }
436
+ }
437
+ return { fixed, skipped }
438
+ }
439
+
440
+ /**
441
+ * source_root 污染一键修复(2026-08-21 agent-手工产出审计第四批 C-3)。
442
+ *
443
+ * runScanPostCheck 对「agent 把产物写到 <source_root>/.sillyspec/ 而非平台 specRoot」只报
444
+ * FAILED 不修——agent 手工搬目录常搬错。本函数把检查枚举的同款清单(docs/projects/workflows/
445
+ * knowledge 目录 + manifest.json/local.yaml)搬到 specRoot 对应位置(已存在的不覆盖,报告
446
+ * skipped),搬完源目录若空则删除。与检查的路径口径逐字一致(pollutePaths/polluteFiles)。
447
+ *
448
+ * @param {{ cwd: string, specDir: string }} opts cwd=source_root,specDir=平台 spec_root 绝对路径
449
+ * @returns {{ moved: string[], skipped: string[], removedDirs: string[] }}
450
+ */
451
+ export function fixSourceRootLeak({ cwd, specDir }) {
452
+ if (!cwd || !specDir) return { moved: [], skipped: [], removedDirs: [] }
453
+ const moved = []
454
+ const skipped = []
455
+ const removedDirs = []
456
+ const pollutePaths = ['docs', 'projects', 'workflows', 'knowledge']
457
+ const polluteFiles = ['manifest.json', 'local.yaml']
458
+
459
+ // 目标目录确保存在
460
+ try { mkdirSync(join(specDir, '.runtime'), { recursive: true }) } catch {}
461
+
462
+ for (const file of polluteFiles) {
463
+ const src = join(cwd, '.sillyspec', file)
464
+ const dst = join(specDir, file)
465
+ if (!existsSync(src)) continue
466
+ if (existsSync(dst)) {
467
+ skipped.push(`local.yaml 已存在于 specRoot(保留两份,人工合并凭据后删源)`.replace('local.yaml', file))
468
+ continue
469
+ }
470
+ try {
471
+ renameSync(src, dst)
472
+ moved.push(`.sillyspec/${file} → specRoot/${file}`)
473
+ } catch (e) {
474
+ skipped.push(`.sillyspec/${file} 搬移失败: ${e.message}`)
475
+ }
476
+ }
477
+
478
+ for (const sub of pollutePaths) {
479
+ const srcDir = join(cwd, '.sillyspec', sub)
480
+ if (!existsSync(srcDir)) continue
481
+ let entries = []
482
+ try { entries = readdirSync(srcDir, { withFileTypes: true }) } catch { continue }
483
+ if (entries.length === 0) {
484
+ try { rmdirSync(srcDir); removedDirs.push(`.sillyspec/${sub}/(空目录)`) } catch {}
485
+ continue
486
+ }
487
+ const dstDir = join(specDir, sub)
488
+ mkdirSync(dstDir, { recursive: true })
489
+ let allMoved = true
490
+ for (const e of entries) {
491
+ const src = join(srcDir, e.name)
492
+ const dst = join(dstDir, e.name)
493
+ if (existsSync(dst)) {
494
+ skipped.push(`.sillyspec/${sub}/${e.name}(specRoot 已有同名,不覆盖——人工比对后择一保留)`)
495
+ allMoved = false
496
+ continue
497
+ }
498
+ try {
499
+ renameSync(src, dst)
500
+ moved.push(`.sillyspec/${sub}/${e.name} → specRoot/${sub}/${e.name}`)
501
+ } catch (err) {
502
+ skipped.push(`.sillyspec/${sub}/${e.name} 搬移失败: ${err.message}`)
503
+ allMoved = false
504
+ }
505
+ }
506
+ if (allMoved) {
507
+ try { rmdirSync(srcDir); removedDirs.push(`.sillyspec/${sub}/`) } catch {}
508
+ }
509
+ }
510
+ return { moved, skipped, removedDirs }
511
+ }
@@ -330,7 +330,7 @@ const PLAN_RULES = [
330
330
  id: 'plan.task-plan-reconciliation', stage: 'plan', source: 'validateBlueprintConsistency', severity: 'error', kind: 'task-plan-reconciliation',
331
331
  target: { root: 'change', path: 'tasks' },
332
332
  data: {
333
- messageMissingCards: 'plan.md 声明了 ${declared} 个任务,但 tasks/ 只有部分卡片。缺卡: ${missing}\n 无卡片的任务不进 Wave、不受 execute 审计(allowed_paths/review.json 都不覆盖)→ 必然漏做(典型:尾部文档同步类任务)。\n 修复:为每个缺卡任务补 task-NN.md(含 allowed_paths/验收标准),或从 plan.md 删除该任务。',
333
+ messageMissingCards: 'plan.md 声明了 ${declared} 个任务,但 tasks/ 只有部分卡片。缺卡: ${missing}\n 无卡任务的 allowed_paths/review.json 都不覆盖,不进 Wave、不受 execute 审计 → 必然漏做(典型:尾部文档同步类任务)。\n 修复:跑 sillyspec taskcard <change> --all 一键补齐全部缺卡安全骨架(已存在的卡不动,之后填充语义字段),或从 tasks.md 删除该任务声明。',
334
334
  messageOrphanCards: 'tasks/ 存在 plan.md 未声明的卡片: ${orphans}\n 这些任务不进任何 Wave,execute 不会执行。修复:在 plan.md 补声明,或删除孤儿卡片。',
335
335
  },
336
336
  spec: 'plan.md 中声明的每个 task-NN 必须有对应 tasks/task-NN.md 卡片(反向亦然),否则阻断——无卡片任务不受 execute 审计,必然漏做。',
@@ -65,9 +65,12 @@ export function renderReviewJsonContract({ stage, changeDir, reviewRunId, tier }
65
65
  '- `checklist`(可选): 扁平数组 —— 每项 { item: string, result: ∈ { ' + CHECKLIST_RESULTS.join(' / ') + ' }, note?: string }。注意是**扁平数组**,不是按层(定义/一致/可行性)嵌套对象',
66
66
  '- `reviewerNotes`: 说明(verdict=fail 时写明阻断项)',
67
67
  '',
68
- '### docHash 算法(必须等于 reviewedFiles[0] 的 sha256)',
69
- "- node: `crypto.createHash('sha256').update(require('fs').readFileSync(reviewedFiles[0])).digest('hex')`(对原始字节)",
70
- '- shell: `sha256sum <reviewedFiles[0]>` 取首列(hex)',
68
+ '### docHash(一键代填,勿手算)',
69
+ '- **推荐**:docHash 先占位(如 `"TODO"`),review.json 写完后跑',
70
+ ' `sillyspec register-stage-review --change <变更名> --stage ' + stageLbl + ' --refresh-hash`',
71
+ ' —— CLI 重算真实 sha256 代填,verdict/checklist 原样保留;主文档改版后同样跑这条',
72
+ ' (多阶段联动用 `--all` 一次处理)。gate 拦 docHash 失配时也是同一条命令修复。',
73
+ '- 兜底手算口径(极端无 CLI 场景):node `crypto.createHash(\'sha256\').update(readFileSync(...))` (原始字节)/ `sha256sum <file>` 首列 hex。',
71
74
  '- ⚠️ **review.json 写入后若 ' + mainDoc + ' 再被改,必须重算 docHash** —— gate 会重算 reviewedFiles[0] 的 sha256 比对,不符判伪造(历史翻车根因:design 改了 rev 后 docHash 仍是旧值)',
72
75
  '',
73
76
  '### 完整 JSON 示例(照抄改值)',
@@ -209,8 +212,9 @@ export function verifyStageReviewDocHash(review, searchDirs) {
209
212
  if (claimed !== actualRaw.toLowerCase() && claimed !== actualLf.toLowerCase()) {
210
213
  errors.push(
211
214
  `docHash 与主审查文档 ${primaryRel} 的实际内容不匹配。` +
212
- `若刚改过该文档(如 design 修订后忘重算),重算并更新 review.json 的 docHash 字段:` +
213
- `在基准目录 ${base} 下运行 sha256sum "${primaryRel}" 取首列 hex 填入;` +
215
+ `若刚改过该文档(如 design 修订后忘重算),一键修复:` +
216
+ `sillyspec register-stage-review --change <变更名> --stage <阶段> --refresh-hash` +
217
+ `(CLI 重算 docHash 代填,verdict 保留;多阶段联动加 --all);` +
214
218
  `若未改文档,则 review.json 疑似伪造(未真正读取文档)。`
215
219
  )
216
220
  return { ok: false, errors, warnings }
@@ -414,7 +418,7 @@ export function validateStageReview(opts) {
414
418
  * @param {object} context - { stage, reviewRunId } 用于错误提示路径
415
419
  */
416
420
  export function printStageReviewResult(result, context = {}) {
417
- const { stage, reviewRunId, runtimeRoot } = context
421
+ const { stage, reviewRunId, runtimeRoot, changeName } = context
418
422
  if (result.ok && result.warnings.length === 0) {
419
423
  console.log(`\n✅ Stage Review Gate — ${stage} 阶段独立审查通过`)
420
424
  return
@@ -435,6 +439,11 @@ export function printStageReviewResult(result, context = {}) {
435
439
  }
436
440
  console.error(`\n 提示:tier=independent 要求独立审查子代理产出 review.json,补全后重新 --done`)
437
441
  console.error(` 可用 sillyspec register-stage-review --change <名> --stage ${stage} [--from <已有review.json>] 一步生成 run 目录 + review.json 骨架(docHash 自动算)+ 写 marker + 自检,省掉手动建目录/写 marker`)
442
+ // docHash 失配精确指路(2026-08-21 agent-手工产出审计项②):改版后忘重算是最高频失败,
443
+ // 一键重算命令带上真实 change 名,agent 可直接照抄执行(--refresh-hash 保 verdict)。
444
+ if (result.errors.some(e => String(e).includes('docHash')) && changeName) {
445
+ console.error(` docHash 失配修复:sillyspec register-stage-review --change ${changeName} --stage ${stage} --refresh-hash(主文档改版多阶段联动用 --all)`)
446
+ }
438
447
  }
439
448
 
440
449
  if (result.warnings.length > 0) {