sillyspec 3.26.14 → 3.27.1

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 (70) 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-explore/SKILL.md +10 -3
  7. package/.claude/skills/sillyspec-plan/SKILL.md +10 -2
  8. package/.claude/skills/sillyspec-quick/SKILL.md +2 -2
  9. package/.claude/skills/sillyspec-resume/SKILL.md +2 -0
  10. package/.claude/skills/sillyspec-verify/SKILL.md +4 -0
  11. package/.claude/skills/sillyspec-workspace/SKILL.md +7 -1
  12. package/README.md +1 -1
  13. package/package.json +1 -1
  14. package/packages/dashboard/server/index.js +22 -6
  15. package/src/change-risk-profile.js +21 -6
  16. package/src/commit-suggest.js +202 -0
  17. package/src/config-cat.js +88 -0
  18. package/src/db.js +14 -3
  19. package/src/docs-check.js +598 -534
  20. package/src/doctor-diagnostics.js +102 -1
  21. package/src/endpoint-extractor.js +21 -2
  22. package/src/fs-atomic.js +4 -2
  23. package/src/git-helper.js +73 -5
  24. package/src/hooks/claude-pre-tool-use.cjs +10 -5
  25. package/src/hooks/worktree-guard.js +42 -4
  26. package/src/index.js +557 -12
  27. package/src/init.js +1 -1
  28. package/src/local-register.js +98 -0
  29. package/src/module-impact.js +166 -0
  30. package/src/module-resolve.js +211 -0
  31. package/src/modules.js +82 -2
  32. package/src/progress/change-registry.js +43 -4
  33. package/src/progress/stage-machine.js +10 -5
  34. package/src/progress/step-store.js +12 -8
  35. package/src/progress.js +19 -3
  36. package/src/quicklog.js +106 -1
  37. package/src/run/command.js +95 -1
  38. package/src/run/complete-handlers.js +73 -5
  39. package/src/run/complete.js +47 -14
  40. package/src/run/gates.js +144 -17
  41. package/src/run/multi-repo-context.js +3 -1
  42. package/src/run/next.js +128 -0
  43. package/src/run/prompt.js +36 -4
  44. package/src/run/quick-audit.js +5 -2
  45. package/src/run/shared.js +48 -20
  46. package/src/run/stage.js +37 -3
  47. package/src/scan-postcheck.js +142 -2
  48. package/src/stage-contract-spec.js +1 -1
  49. package/src/stage-contract.js +14 -1
  50. package/src/stage-review.js +15 -6
  51. package/src/stages/archive.js +4 -2
  52. package/src/stages/brainstorm.js +1 -0
  53. package/src/stages/execute.js +275 -44
  54. package/src/stages/explore.js +11 -2
  55. package/src/stages/knowledge.js +3 -2
  56. package/src/stages/quick.js +18 -9
  57. package/src/stages/scan.js +1 -0
  58. package/src/stages/verify.js +16 -51
  59. package/src/sync.js +44 -3
  60. package/src/task-review.js +273 -11
  61. package/src/taskcard.js +52 -22
  62. package/src/verify-postcheck.js +223 -6
  63. package/src/verify-probes.js +264 -0
  64. package/src/workspace.js +125 -0
  65. package/src/worktree-apply.js +43 -11
  66. package/src/worktree-deps.js +59 -5
  67. package/src/worktree.js +128 -14
  68. package/templates/claude-instruction.md +1 -1
  69. package/templates/prompts/taskcard-rules.md +1 -1
  70. package/templates/prompts/verify-probes.md +13 -47
package/src/run/shared.js CHANGED
@@ -17,9 +17,10 @@ import os from 'node:os'
17
17
 
18
18
  // safeGit 收口至 src/git-helper.js:import 建本地词法绑定(本模块内部 L128/130/431 调用)+
19
19
  // re-export 供 run/ 层现有调用方继续从 shared.js 引用(pure re-export 不建本地绑定,会致内部 ReferenceError)。
20
- import { safeGit } from '../git-helper.js'
20
+ // unquoteGitPath 同理:parsePorcelainPath 内部消费 + re-export。
21
+ import { safeGit, unquoteGitPath } from '../git-helper.js'
21
22
  import { createHash } from 'node:crypto'
22
- export { safeGit }
23
+ export { safeGit, unquoteGitPath }
23
24
 
24
25
  const __dirname = dirname(fileURLToPath(import.meta.url))
25
26
 
@@ -133,23 +134,41 @@ export function resolveSpecDir(cwd, opts = {}) {
133
134
  * 被 countAncestorSpecDirs(漂移提醒 warn)与 locateQuickSessionGuard(quick 漂移 fail-fast
134
135
  * 守卫,坑 quick-cwd-drift-splits-specdir)共用 —— 单一真相源,避免两处各自重写祖先枚举漂移。
135
136
  */
137
+ // 祖先链 ceiling(git 主仓根)按 cwd 进程级缓存:每条 stage 命令必经(countAncestorSpecDirs
138
+ // 漂移提醒 + quick 漂移守卫两条路径),此前固定 2 次串行 git spawn(Windows 60-200ms)。
139
+ // 只缓存"git root 已找到"的结果——非 git 仓库不缓存,防测试流程中途 git init 后读到陈旧 null。
140
+ const _ancestorCeilingCache = new Map()
141
+
142
+ function resolveAncestorCeiling(resolved) {
143
+ if (_ancestorCeilingCache.has(resolved)) return _ancestorCeilingCache.get(resolved)
144
+ // 一次 spawn 拿两值(rev-parse 按参数序输出,每值一行)
145
+ const out = safeGit(resolved, ['rev-parse', '--show-toplevel', '--git-common-dir']).value
146
+ let ceiling = null
147
+ if (out) {
148
+ const lines = out.split('\n')
149
+ const topLevel = (lines[0] || '').trim()
150
+ if (topLevel) ceiling = resolve(topLevel)
151
+ const commonDir = (lines[1] || '').trim()
152
+ if (commonDir) {
153
+ const absCommonDir = resolve(resolved, commonDir)
154
+ if (existsSync(absCommonDir) && statSync(absCommonDir).isDirectory()) {
155
+ const mainRoot = dirname(absCommonDir)
156
+ // 主仓根是 worktree 根的祖先时取它(更靠上);monorepo/单仓两者相等不替换,行为不变
157
+ if (!ceiling || resolve(ceiling).startsWith(resolve(mainRoot) + sep)) ceiling = mainRoot
158
+ }
159
+ }
160
+ }
161
+ if (ceiling) _ancestorCeilingCache.set(resolved, ceiling)
162
+ return ceiling
163
+ }
164
+
136
165
  export function ancestorSpecDirs(cwd) {
137
166
  const resolved = resolve(cwd)
138
167
  // 上界 = git root。但 linked worktree 内 --show-toplevel 返回 worktree 根(非主仓根),
139
168
  // 祖先链到不了主仓 .sillyspec → 漂移提醒/quick 守卫全哑(坑 worktree-execute-spec-drift)。
140
169
  // 复刻 worktree.js _resolveMainRepoRoot:--git-common-dir 的 dirname 才是主仓根,取更靠上者作 ceiling。
141
170
  // (git 可能返回相对路径,须 resolve(cwd, commonDir) 绝对化,否则相对 process.cwd 误解析。)
142
- const topLevel = safeGit(resolved, ['rev-parse', '--show-toplevel']).value
143
- let ceiling = topLevel ? resolve(topLevel) : null
144
- const commonDir = safeGit(resolved, ['rev-parse', '--git-common-dir']).value
145
- if (commonDir) {
146
- const absCommonDir = resolve(resolved, commonDir)
147
- if (existsSync(absCommonDir) && statSync(absCommonDir).isDirectory()) {
148
- const mainRoot = dirname(absCommonDir)
149
- // 主仓根是 worktree 根的祖先时取它(更靠上);monorepo/单仓两者相等不替换,行为不变
150
- if (!ceiling || resolve(ceiling).startsWith(resolve(mainRoot) + sep)) ceiling = mainRoot
151
- }
152
- }
171
+ const ceiling = resolveAncestorCeiling(resolved)
153
172
  const dirs = []
154
173
  let dir = resolved
155
174
  while (true) {
@@ -460,8 +479,8 @@ async function raceWithAbort(op, timeoutMs = SYNC_TOTAL_TIMEOUT_MS) {
460
479
 
461
480
  // quick 会话 ID 形态(quick-<hex8>)。与 run/command.js QUICK_SID_RE、progress.js
462
481
  // initChange 的跳过实体目录判断同源;shared.js 不反向 import command.js(重模块),
463
- // 局部复制正则保持轻载——改形态时三处同改。
464
- const QUICK_SID_RE = /^quick-[0-9a-f]{8}$/
482
+ // 局部复制正则保持轻载——改形态时三处同改(prompt.js 经本导出消费,勿再复制一份)。
483
+ export const QUICK_SID_RE = /^quick-[0-9a-f]{8}$/
465
484
 
466
485
  export async function triggerSync(cwd, changeName, platformOpts = {}, opts = {}) {
467
486
  // 平台模式(SillyHub)走自己的回传链路,不走 CLI 内置 sync
@@ -499,6 +518,12 @@ export async function triggerSync(cwd, changeName, platformOpts = {}, opts = {})
499
518
  */
500
519
  const AUTO_PULL_THROTTLE_MS = 10_000
501
520
 
521
+ // 自动 pull 专属熔断(2026-08-21 性能审查 PERF-04):自动注入点(stage 命令分发前 / approve 前)
522
+ // 在打印任何输出前 await,daemon 慢/挂时每 10s 窗口首条命令硬等上限即体感 hang——降为 2s。
523
+ // 手动 `platform pull` 不经此路径(用户显式发起,等待 8s 合理)。超时语义与 8s 版一致:
524
+ // abort 在飞请求(HUB-09),错过窗口的平台更新由下一条 >10s 命令或 push 409 自愈兜底。
525
+ const AUTO_PULL_TIMEOUT_MS = 2_000
526
+
502
527
  function _autoPullThrottlePath(cwd) {
503
528
  return join(cwd, '.sillyspec', '.runtime', 'auto-pull-throttle.json')
504
529
  }
@@ -521,7 +546,7 @@ function _stampAutoPull(cwd) {
521
546
 
522
547
  /**
523
548
  * 触发 pull(下行同步,task-10 / D-009 / FR-04 / FR-06)。
524
- * 复用 triggerSync 的 8s 熔断与 Best Effort 语义;未连接平台静默跳过(与现状一致)。
549
+ * 熔断走 AUTO_PULL_TIMEOUT_MS(2s,见常量注释)与 Best Effort 语义;未连接平台静默跳过(与现状一致)。
525
550
  * 注入时机:stage 命令启动(顶层别名 + case 'run',ql-20260818-008 补齐后者)+ 关键决策点
526
551
  * (approve 前)+ 手动 platform pull。不在每步 pull(避免高频写入与网络压力),仅低频边界点。
527
552
  * 自动注入走 skipIfLocalDirty 保守守卫:本地有未同步改动时 pull 内部跳过 import,
@@ -541,7 +566,7 @@ export async function triggerPull(cwd, changeName, platformOpts = {}, opts = {})
541
566
  if (!sm._getPlatform()) return
542
567
  if (_autoPullRecently(cwd)) return
543
568
  _stampAutoPull(cwd)
544
- await raceWithAbort((sig) => sm.pull(changeName, { skipIfLocalDirty: true, signal: sig.signal }), opts.timeoutMs)
569
+ await raceWithAbort((sig) => sm.pull(changeName, { skipIfLocalDirty: true, signal: sig.signal }), opts.timeoutMs ?? AUTO_PULL_TIMEOUT_MS)
545
570
  } catch (e) {
546
571
  // pull 失败静默跳过(Best Effort,失败不影响正确性)
547
572
  console.warn('⚠️ 拉取失败:', e.message)
@@ -578,10 +603,10 @@ export async function triggerPullActiveChange(cwd, platformOpts = {}) {
578
603
  // progress 不可达则跳过(Best Effort)
579
604
  }
580
605
  if (!cn) return
581
- // 已确认连接 + 单活跃变更,调 pull(复用 8s 熔断 + abort(HUB-09);skipIfLocalDirty 保守守卫
606
+ // 已确认连接 + 单活跃变更,调 pull(自动注入 2s 熔断 + abort(HUB-09);skipIfLocalDirty 保守守卫
582
607
  // 同 triggerPull——本地脏时跳过 import,防平台旧快照覆盖本地领先进度,ql-20260818-008)
583
608
  try {
584
- await raceWithAbort((sig) => sm.pull(cn, { skipIfLocalDirty: true, signal: sig.signal }))
609
+ await raceWithAbort((sig) => sm.pull(cn, { skipIfLocalDirty: true, signal: sig.signal }), AUTO_PULL_TIMEOUT_MS)
585
610
  } catch (e) {
586
611
  console.warn('⚠️ 拉取失败:', e.message)
587
612
  }
@@ -639,7 +664,10 @@ export function parsePorcelainPath(line) {
639
664
  if (!line) return ''
640
665
  let path = line.slice(3).trim()
641
666
  if (path.length >= 2 && path.startsWith('"') && path.endsWith('"')) {
642
- path = path.slice(1, -1).replace(/\\(.)/g, (_, c) => c)
667
+ // unquoteGitPath:\\ \" \n \t 与 \NNN 八进制字节聚合 UTF-8 解码——原 \\(.) 逐字符
668
+ // 解引会把 "\346\226\207.md" 拆成 "346226207.md"。git-helper 已统一 core.quotepath=false
669
+ // (非 ASCII 路径裸输出),此处兜底仍会被引号转义的路径(含控制字符等)。
670
+ path = unquoteGitPath(path.slice(1, -1))
643
671
  }
644
672
  const arrow = path.indexOf(' -> ')
645
673
  if (arrow !== -1) path = path.slice(arrow + 4)
package/src/run/stage.js CHANGED
@@ -17,7 +17,7 @@
17
17
  * - 'child_process'(execSync)裸模块名不变
18
18
  */
19
19
  import { join, dirname } from 'node:path'
20
- import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'
20
+ import { existsSync, readdirSync, readFileSync, mkdirSync } from 'node:fs'
21
21
  import { writeAtomicSync } from '../fs-atomic.js'
22
22
  import { resolveSpecDir, resolveChangeDir, resolveRuntimeRoot, resolveQuickSessionsDir, triggerSync, safeGit, parsePorcelainPath, formatWaitOptions, checkApproval, getStageSteps, warnApprovalUnknown } from './shared.js'
23
23
  import { computeScanProfile, applyScanProfileSteps, executeScanPreflight, executeScanPostcheck } from './scan-profile.js'
@@ -122,7 +122,7 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
122
122
  throw new Error(`execute run 目录创建失败(${join(runtimeRoot, 'execute-runs', currentExecuteRunId)}): ${e.message};` +
123
123
  `请检查该路径是否为普通文件/只读,清理后重跑(sillyspec run execute --change ${changeName} --skip-approval)`)
124
124
  }
125
- writeFileSync(runIdFile, currentExecuteRunId + '\n')
125
+ writeAtomicSync(runIdFile, currentExecuteRunId + '\n')
126
126
  // change 归属戳(坑 worktree-cleanup-marker-chain 根治):run 目录自带变更身份,
127
127
  // marker 断裂(worktree cleanup / 归档清理 / 并行误删)后按戳归属,不再错配其他变更的 run
128
128
  const { stampExecuteRunChange } = await import('../task-review.js')
@@ -130,6 +130,38 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
130
130
  }
131
131
  }
132
132
 
133
+ // ── 跨变更文件冲突预警(坑 cross-change-conflict-no-warning,2026-08-22 实证:并行变更改
134
+ // 同一文件靠人工发现——f578a08e 的「锚点更新」是好实践但工具无预警)。execute 启动时把本变更
135
+ // 的 diff 文件集与其他活跃变更的 diff 文件集求交集,advisory warn 交集与对端变更名 + 锚点
136
+ // 更新提示。best-effort:任何异常静默跳过(预警不阻断 execute 启动)。
137
+ if (stageName === 'execute' && changeName) {
138
+ try {
139
+ const { resolveVerifyChangedFiles } = await import('../verify-postcheck.js')
140
+ const activeChanges = (pm.listChanges(cwd) || []).filter(c => c && c !== changeName)
141
+ if (activeChanges.length > 0) {
142
+ const myFiles = resolveVerifyChangedFiles(cwd, changeName, null, { includeWorkingTree: true }) || []
143
+ const mySet = new Set(myFiles.map(f => f.replace(/\\/g, '/')).filter(f => !f.startsWith('.sillyspec/')))
144
+ if (mySet.size > 0) {
145
+ const conflicts = []
146
+ for (const other of activeChanges) {
147
+ const otherFiles = resolveVerifyChangedFiles(cwd, other, null, { includeWorkingTree: true }) || []
148
+ const overlap = [...new Set(otherFiles.map(f => f.replace(/\\/g, '/')))]
149
+ .filter(f => mySet.has(f))
150
+ if (overlap.length > 0) conflicts.push({ other, overlap })
151
+ }
152
+ if (conflicts.length > 0) {
153
+ console.warn(`⚠️ 跨变更文件冲突预警:本变更(${changeName}})与其他活跃变更改动重叠——`)
154
+ for (const c of conflicts) {
155
+ console.warn(` 与 ${c.other} 重叠 ${c.overlap.length} 个文件:${c.overlap.slice(0, 5).join(', ')}${c.overlap.length > 5 ? ` …(共 ${c.overlap.length})` : ''}`)
156
+ }
157
+ console.warn(` 并行 apply/归档时重叠文件会互相覆盖或冲突。实践:后完成方在其 verify/design 里记录「锚点更新」`)
158
+ console.warn(` (说明基于对方已合入的版本重新锚定),并考虑串行化这两个变更的 apply。`)
159
+ }
160
+ }
161
+ }
162
+ } catch { /* 预警失败不阻断 execute */ }
163
+ }
164
+
133
165
  // 自动探测 currentChange
134
166
  if (autoDetectChange(progress, cwd)) {
135
167
  progress.lastActive = new Date().toLocaleString('zh-CN', { hour12: false })
@@ -207,7 +239,9 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
207
239
  }
208
240
  const guardFile = join(specBase, '.runtime', 'scan-guard.json')
209
241
  mkdirSync(dirname(guardFile), { recursive: true })
210
- writeFileSync(guardFile, JSON.stringify(scanGuard, null, 2) + '\n')
242
+ // 原子写:hook 进程(worktree-guard 的 readScanGuard)并发读,裸 truncate→write 的
243
+ // 半截窗口会被 JSON.parse 吞成 null → 覆盖保护静默失效(fail-open)
244
+ writeAtomicSync(guardFile, JSON.stringify(scanGuard, null, 2) + '\n')
211
245
  if (scanGuard.forceRescan) {
212
246
  console.log('🛡️ scan 覆盖保护已记录: --force-rescan 已开启')
213
247
  } else {
@@ -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 审计,必然漏做。',
@@ -480,7 +480,20 @@ function validateVerifyOutputs(cwd, changeName, context = {}) {
480
480
  warnings.push(`[${changeRiskProfile.level}] 结论 PASS WITH NOTES:design frontmatter 显式声明 risk_level=${changeRiskProfile.level},残留项须为真实集成证据缺口,并在 verify-result.md 如实说明`)
481
481
  }
482
482
  if (requiresEvidence) {
483
- const evidenceCheck = checkIntegrationEvidence(verify, changeRiskProfile.requiredVerification)
483
+ // CLI 回执注入(坑 verify-literal-evidence-mismatch,2026-08-22 实证:证据第一轮就齐
484
+ // 但自然措辞不含字面词被误拦三轮):verify 服务回收器落的回执 = 真实启动+PID 登记
485
+ // 的结构化证据,作为附加匹配文本——有回执不依赖 agent 措辞;无回执维持原字面匹配
486
+ let receiptText = ''
487
+ try {
488
+ const receiptPath = join(cwd, '.sillyspec', '.runtime', 'verify-services.receipt.json')
489
+ if (existsSync(receiptPath)) {
490
+ const receipt = JSON.parse(readFileSync(receiptPath, 'utf8'))
491
+ if (receipt && (receipt.reapedPidCount || 0) > 0 && (!receipt.change || receipt.change === changeName)) {
492
+ receiptText = `CLI 回执:verify 服务进程已回收 ${receipt.reapedPidCount} 个(PID 已登记,真实启动,运行时证据 reapedAt=${receipt.reapedAt})`
493
+ }
494
+ }
495
+ } catch { /* 回执损坏按无回执处理(fail 回原字面匹配) */ }
496
+ const evidenceCheck = checkIntegrationEvidence(verify, changeRiskProfile.requiredVerification, { extraEvidenceText: receiptText })
484
497
  if (!evidenceCheck.ok) {
485
498
  // A: 报错说人话 —— 把「缺哪一项、要写/做什么、判级原因」逐条列出,
486
499
  // 让 agent 不必靠改结论文案撞墙。detail 指明真实启动须是本变更实际改动的
@@ -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) {
@@ -36,7 +36,7 @@ export const definition = {
36
36
  5. module-impact.md 第一行标题必须用中文:# 模块影响分析(Module Impact)— <变更简述>
37
37
 
38
38
  ### 降级(module-impact.md 不存在)
39
- 若缺失(如旧变更未经新 plan 流程,或 small 变更从未生成),按原生成逻辑补一份:读 .sillyspec/workflows/archive-impact.yaml 的 impact-analyzer 角色规则 + git diff + _module-map.yaml,生成模块影响矩阵(模块 × 影响类型 × 相关文件 × 更新摘要 × needs_review,未匹配文件归入 unmapped 表)落盘 module-impact.md。
39
+ 若缺失(如旧变更未经新 plan 流程,或 small 变更从未生成):先跑 \`sillyspec module-impact --change <change-name>\`——CLI 按 _module-map.yaml paths 前缀匹配预填「模块影响矩阵」骨架(文件×模块归属 + 未匹配文件清单,机械部分全代算),你只需逐行填「影响类型」(逻辑变更/数据结构变更/接口变更/调用关系变更/配置变更/新增)与 needs_review 标记,补首行中文标题 \`# 模块影响分析(Module Impact)— <变更简述>\`。骨架无从生成(无 module-map / 无 diff)时才按 archive-impact.yaml 的 impact-analyzer 角色规则全手写。
40
40
 
41
41
  确认完成后,下一步 sync-module-docs 会读 module-impact.md 更新模块卡片文档。`,
42
42
  outputHint: 'module-impact.md 终审结果 + 修正摘要',
@@ -82,12 +82,14 @@ export const definition = {
82
82
  3. 如果标记缺失或重复 → 在 _module-map.yaml 中标记 needs_review: true
83
83
 
84
84
  #### 新建模块卡片模板
85
- 一级标题格式 \`# <中文名>(<module-id>)\`——中文名从模块职责提炼 2-8 字,括号内保留 module-id 原样(sillyhub 平台解析识别用,与 scan 生成约定一致)。
85
+ 一级标题格式 \`# <中文名>(<module-id>)\`——中文名从模块职责提炼 2-8 字,括号内保留 module-id 原样(sillyhub 平台解析识别用,与 scan 生成约定一致)。frontmatter 已带 author/created_at 真值(<git-user>/<now-datetime> 占位符由 CLI 每步替换),照抄即过元数据校验、勿删。
86
86
  \`\`\`markdown
87
87
  ---
88
88
  schema_version: 1
89
89
  doc_type: module-card
90
90
  module_id: <module-id>
91
+ author: <git-user>
92
+ created_at: <now-datetime>
91
93
  ---
92
94
 
93
95
  # <中文名>(<module-id>)
@@ -45,6 +45,7 @@ export const definition = {
45
45
  7. 检查全局模板:\`ls ~/.sillyspec/templates/\`
46
46
  - 有匹配模板 → 询问是否基于模板
47
47
  - 无相关内容 → 跳过,不输出
48
+ 8. 查询知识库:\`cat {SPEC_ROOT}/knowledge/INDEX.md 2>/dev/null\`(无索引则跳过)——按需求关键词命中条目时读取对应知识文件;known-issues/已知坑要在方案期就规避,不要等 execute 的命中报告才发现
48
49
 
49
50
  ### 模块匹配方法
50
51
  读取 _module-map.yaml 后,根据用户描述的需求关键词,匹配相关模块: