sillyspec 3.25.4 → 3.25.6

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 (41) hide show
  1. package/.claude/skills/sillyspec-brainstorm/SKILL.md +27 -1
  2. package/.claude/skills/sillyspec-execute/SKILL.md +56 -0
  3. package/.claude/skills/sillyspec-plan/SKILL.md +26 -0
  4. package/.claude/skills/sillyspec-propose/SKILL.md +26 -0
  5. package/.claude/skills/sillyspec-scan/SKILL.md +12 -7
  6. package/.claude/skills/sillyspec-verify/SKILL.md +2 -2
  7. package/package.json +1 -1
  8. package/src/change-list.js +114 -12
  9. package/src/change-risk-profile.js +36 -1
  10. package/src/constants.js +11 -0
  11. package/src/index.js +4 -1
  12. package/src/machine-interface.js +5 -1
  13. package/src/progress/stage-machine.js +17 -14
  14. package/src/run/command.js +58 -3
  15. package/src/run/complete-handlers.js +9 -3
  16. package/src/run/complete.js +130 -33
  17. package/src/run/gates.js +37 -0
  18. package/src/run/prompt.js +20 -0
  19. package/src/run/quick-audit.js +8 -1
  20. package/src/run/scan-profile.js +29 -10
  21. package/src/run/shared.js +161 -4
  22. package/src/run/stage.js +4 -0
  23. package/src/scan-postcheck.js +25 -11
  24. package/src/stage-contract.js +39 -8
  25. package/src/stage-review.js +16 -5
  26. package/src/stages/archive.js +13 -9
  27. package/src/stages/brainstorm-auto.js +3 -2
  28. package/src/stages/brainstorm.js +13 -16
  29. package/src/stages/execute.js +4 -3
  30. package/src/stages/plan-postcheck.js +1 -1
  31. package/src/stages/plan.js +13 -0
  32. package/src/stages/propose.js +3 -2
  33. package/src/stages/quick.js +31 -31
  34. package/src/stages/scan.js +2 -1
  35. package/src/stages/status.js +2 -2
  36. package/src/stages/verify.js +19 -8
  37. package/src/task-review.js +308 -3
  38. package/src/verify-postcheck.js +353 -38
  39. package/src/worktree-apply.js +134 -67
  40. package/src/worktree.js +106 -32
  41. package/templates/prompts/verify-probes.md +13 -2
package/src/run/shared.js CHANGED
@@ -9,8 +9,8 @@
9
9
  * - triggerSync 的动态 import './sync.js' → '../sync.js'(src/sync.js)
10
10
  * - safeGit 原用顶层 require('child_process'),改静态 import execSync(更 ESM-native)
11
11
  */
12
- import { basename, join, resolve, dirname } from 'node:path'
13
- import { existsSync, readdirSync, readFileSync } from 'node:fs'
12
+ import { basename, join, resolve, dirname, sep } from 'node:path'
13
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
14
14
  import { execSync } from 'node:child_process'
15
15
  import { fileURLToPath } from 'node:url'
16
16
 
@@ -104,6 +104,156 @@ export function resolveSpecDir(cwd, opts = {}) {
104
104
  return join(resolve(cwd), '.sillyspec')
105
105
  }
106
106
 
107
+ /**
108
+ * 枚举 cwd 祖先链(含自身)上所有 .sillyspec 目录的绝对路径,上界 = git root。
109
+ *
110
+ * 返回顺序:从 cwd 最近开始向上到 git root(含两端命中项)。单实例项目任意子目录恒返回 [自身]。
111
+ *
112
+ * 上界 = git root(monorepo 天然边界)。必须有限上界:一路数到文件系统根会撞 home 等
113
+ * 无关祖先的孤立 .sillyspec(home 下任何项目都被误报多实例)。非 git 仓库:不向上数,
114
+ * 只看 cwd 自身(≤1,永不误报)。
115
+ *
116
+ * 被 countAncestorSpecDirs(漂移提醒 warn)与 locateQuickSessionGuard(quick 漂移 fail-fast
117
+ * 守卫,坑 quick-cwd-drift-splits-specdir)共用 —— 单一真相源,避免两处各自重写祖先枚举漂移。
118
+ */
119
+ export function ancestorSpecDirs(cwd) {
120
+ const resolved = resolve(cwd)
121
+ // 上界 = git root。但 linked worktree 内 --show-toplevel 返回 worktree 根(非主仓根),
122
+ // 祖先链到不了主仓 .sillyspec → 漂移提醒/quick 守卫全哑(坑 worktree-execute-spec-drift)。
123
+ // 复刻 worktree.js _resolveMainRepoRoot:--git-common-dir 的 dirname 才是主仓根,取更靠上者作 ceiling。
124
+ // (git 可能返回相对路径,须 resolve(cwd, commonDir) 绝对化,否则相对 process.cwd 误解析。)
125
+ const topLevel = safeGit(resolved, ['rev-parse', '--show-toplevel']).value
126
+ let ceiling = topLevel ? resolve(topLevel) : null
127
+ const commonDir = safeGit(resolved, ['rev-parse', '--git-common-dir']).value
128
+ if (commonDir) {
129
+ const absCommonDir = resolve(resolved, commonDir)
130
+ if (existsSync(absCommonDir) && statSync(absCommonDir).isDirectory()) {
131
+ const mainRoot = dirname(absCommonDir)
132
+ // 主仓根是 worktree 根的祖先时取它(更靠上);monorepo/单仓两者相等不替换,行为不变
133
+ if (!ceiling || resolve(ceiling).startsWith(resolve(mainRoot) + sep)) ceiling = mainRoot
134
+ }
135
+ }
136
+ const dirs = []
137
+ let dir = resolved
138
+ while (true) {
139
+ if (existsSync(join(dir, '.sillyspec'))) dirs.push(join(dir, '.sillyspec'))
140
+ if (!ceiling || resolve(dir) === resolve(ceiling)) break
141
+ const parent = dirname(dir)
142
+ if (parent === dir) break
143
+ dir = parent
144
+ }
145
+ return dirs
146
+ }
147
+
148
+ /**
149
+ * 统计 cwd 祖先链上有多少个 .sillyspec 目录(= ancestorSpecDirs(cwd).length)。
150
+ *
151
+ * ≥2 个 = monorepo 多实例(子项目独立 .sillyspec + 根 .sillyspec 并存),resolveSpecDir 命中的
152
+ * 「最近」实例未必是用户意图的项目(如 cd 进被独立 scan 的子项目跑测试后忘回根)→ 漂移风险。
153
+ * 单实例项目在任意子目录工作恒返回 1,不误报。
154
+ */
155
+ export function countAncestorSpecDirs(cwd) {
156
+ return ancestorSpecDirs(cwd).length
157
+ }
158
+
159
+ /**
160
+ * 在 cwd 祖先链的各 specBase 中查找 quick 会话 guard.json(坑 quick-cwd-drift-splits-specdir)。
161
+ *
162
+ * sessionId(quick-<uuid8>)全局唯一,但 guard.json 按 specBase 分散落盘
163
+ * (<specBase>/.runtime/quick-sessions/<sessionId>/guard.json)。cd 漂移到子项目后,当前 specBase
164
+ * 下读不到本 session 的 guard,但祖先链别处的 specBase(创建 session 的根)仍有它 → 据此定位
165
+ * session 真正归属,供 detectQuickSessionDrift 的 fail-fast 守卫判断。
166
+ *
167
+ * 遍历顺序 = ancestorSpecDirs(cwd 最近 → git root),返回第一个命中。
168
+ *
169
+ * @param {string} cwd
170
+ * @param {string} sessionId - quick-<uuid8>
171
+ * @returns {{ specBase: string, guard: object } | null}
172
+ */
173
+ export function locateQuickSessionGuard(cwd, sessionId) {
174
+ if (!sessionId) return null
175
+ for (const specBase of ancestorSpecDirs(cwd)) {
176
+ const guardFile = join(specBase, '.runtime', 'quick-sessions', sessionId, 'guard.json')
177
+ if (existsSync(guardFile)) {
178
+ try {
179
+ const guard = JSON.parse(readFileSync(guardFile, 'utf8'))
180
+ return { specBase, guard }
181
+ } catch {
182
+ // guard.json 损坏:当作不存在,继续找下一个(不阻断主流程)
183
+ }
184
+ }
185
+ }
186
+ return null
187
+ }
188
+
189
+ /**
190
+ * 检测 quick 会话是否跨 specDir 漂移(坑 quick-cwd-drift-splits-specdir)。
191
+ *
192
+ * quick 被 validateChangeExists 的 sessionId 豁免(quick-<8hex> 不在 changes/ 下),故 quick
193
+ * 漂移除 countAncestorSpecDirs 的 warn 外无硬守卫 → 无声分裂(progress/artifact/QUICKLOG 落
194
+ * 子项目、根 session 停滞)。本函数补这个口:
195
+ * - 当前 specBase 已有本 session guard → null(同一 specDir,无漂移)
196
+ * - 当前 specBase 没有,但祖先链别处有同 sessionId guard → 判漂移,返回引导信息供调用方 fail-fast
197
+ * - 别处也没有 → null(真·新会话首次启动,guard 尚未建,放行 —— 不误伤子项目主动启新 quick)
198
+ *
199
+ * 风格仿 validateChangeExists:null = 通过,对象 = 失败(含 message)。
200
+ *
201
+ * @param {string} cwd
202
+ * @param {string} currentSpecBase - 当前命中的 specBase
203
+ * @param {string} sessionId - quick-<uuid8>
204
+ * @returns {{ realSpecBase: string, message: string } | null}
205
+ */
206
+ export function detectQuickSessionDrift(cwd, currentSpecBase, sessionId) {
207
+ if (!sessionId || !currentSpecBase) return null
208
+ // 当前 specBase 已有本 session guard → 同一 specDir,无漂移
209
+ const currentGuard = join(currentSpecBase, '.runtime', 'quick-sessions', sessionId, 'guard.json')
210
+ if (existsSync(currentGuard)) return null
211
+ // 当前没有 → 看祖先链别处有没有同 sessionId 的 guard
212
+ const located = locateQuickSessionGuard(cwd, sessionId)
213
+ if (!located) return null // 别处也没有 = 真·新会话首次启动,放行
214
+ return {
215
+ realSpecBase: located.specBase,
216
+ message: `quick 会话 "${sessionId}" 创建于 ${located.specBase},当前 cwd 解析到 ${currentSpecBase}(跨 specDir 漂移)。` +
217
+ `继续会把 progress/artifact/QUICKLOG 写到错误的 spec,与原会话分裂。` +
218
+ `排查:① cd 回 ${dirname(located.specBase)}(原 spec 所属项目根)再跑;② 或显式 --spec-dir ${located.specBase} 指定原 spec。`,
219
+ }
220
+ }
221
+
222
+ /**
223
+ * 检测 specBase 是否命中 worktree 内 checkout 出来的 .sillyspec 副本(坑 worktree-execute-spec-drift)。
224
+ *
225
+ * worktree 根 = <mainRepo>/.sillyspec/.runtime/worktrees/<changeName>/(worktree.js WORKTREES_REL)。
226
+ * worktree 是主仓完整 checkout,若 .sillyspec/changes/ 被跟踪 → 副本里 change 目录真实存在,
227
+ * validateChangeExists 被骗放行 → 进度/产出写进副本,与主仓 .sillyspec 分裂(副本随工作树清理丢失)。
228
+ *
229
+ * 判据:specBase 路径含 `.sillyspec/.runtime/worktrees/<seg>/.sillyspec`。主仓 specBase=<mainRepo>/.sillyspec
230
+ * 无此段 → null,零误伤。返回 null=通过;对象=漂移(含 mainSpecBase/changeName/message),风格仿
231
+ * validateChangeExists。覆盖 plan/execute/verify/archive(同 Set)——都要求 change 已存在、都会被副本骗过。
232
+ * 平台模式/--spec-dir 已明确指定,由调用方跳过。
233
+ */
234
+ export function detectWorktreeSpecDrift(specBase) {
235
+ if (!specBase) return null
236
+ const seg = resolve(specBase).split(sep)
237
+ // specBase 恒以 .sillyspec 结尾(command.js specBase=join(cwd,'.sillyspec'))。worktree 副本形如
238
+ // <mainRepo>/.sillyspec/.runtime/worktrees/<change>/.sillyspec —— 尾段也须是 .sillyspec,
239
+ // 否则 worktree 根目录本身(<.../worktrees/<change>)会被误判(它不是 spec,不会作 specBase 传入)。
240
+ if (seg[seg.length - 1] !== '.sillyspec') return null
241
+ for (let i = 0; i + 3 < seg.length; i++) {
242
+ if (seg[i] === '.sillyspec' && seg[i + 1] === '.runtime' && seg[i + 2] === 'worktrees') {
243
+ const changeName = seg[i + 3]
244
+ const mainSpec = seg.slice(0, i + 1).join(sep) // <mainRepo>/.sillyspec
245
+ return {
246
+ changeName,
247
+ mainSpecBase: mainSpec,
248
+ message: `当前 spec 命中 worktree 副本(${specBase})——这是 ${changeName} 隔离工作树内 checkout 出来的 .sillyspec,不是主仓 spec。\n` +
249
+ ` 在此跑 plan/execute/verify/archive 会把进度与产出写到副本,与主仓 .sillyspec 分裂(副本随工作树清理而丢失)。\n` +
250
+ ` 排查:① cd 回 ${dirname(mainSpec)}(主仓根)再跑;② 或 --spec-dir ${mainSpec} 显式指定主仓 spec。`,
251
+ }
252
+ }
253
+ }
254
+ return null
255
+ }
256
+
107
257
  /**
108
258
  * 统一查找变更目录(与 progress.js 的变更检测逻辑一致)。
109
259
  */
@@ -222,12 +372,15 @@ export function isQuickMetadata(p, linkedChanges = []) {
222
372
 
223
373
  /**
224
374
  * quick 完成审计:对比 baseline 与实际变更。
225
- * @returns {{ status: 'safe'|'warning'|'blocked', reasons: string[], changedFiles: string[], newFiles: string[], deletedFiles: string[], baselineHit: string[] }}
375
+ * @returns {{ status: 'safe'|'warning'|'blocked', reasons: string[], changedFiles: string[], newFiles: string[], deletedFiles: string[], baselineHit: string[], stagedTotal: number }}
226
376
  */
227
377
  export async function auditQuickCompletion(cwd, guard, options = {}) {
228
378
  const { baselineFiles, allowedFiles = [], allowNew = false, forceBaseline = false } = guard
229
379
  const { isConfirm } = options
230
- const result = { status: 'safe', reasons: [], changedFiles: [], newFiles: [], deletedFiles: [], baselineHit: [] }
380
+ // stagedTotal:当前所有非 quick 元数据的未提交条目(含前序 baseline 残留)。
381
+ // 与 changedFiles(扣 baseline 后的本轮新增)区分,供审计文案同时展示「本轮新增 vs 累计暂存」,
382
+ // 避免叠加 quick 会话时把前序会话未提交文件误读为「本会话只动了 N 个」。
383
+ const result = { status: 'safe', reasons: [], changedFiles: [], newFiles: [], deletedFiles: [], baselineHit: [], stagedTotal: 0 }
231
384
 
232
385
  try {
233
386
  const gitStatus = execSync('git status --porcelain', { cwd, encoding: 'utf8', timeout: 10000 })
@@ -268,6 +421,10 @@ export async function auditQuickCompletion(cwd, guard, options = {}) {
268
421
  const file = parsePorcelainPath(entry) // 已去引号/处理 rename/归一化,修正首行丢首字符
269
422
  if (!file) continue
270
423
 
424
+ // 累计暂存计数:所有非 quick 元数据的未提交条目(含下方将被 baseline 跳过的前序残留)。
425
+ // 放在 isBaselineFile 跳过之前,确保 baseline 内文件也计入「累计暂存」。
426
+ if (!isQuickMetadata(file, guard.linkedChanges)) result.stagedTotal++
427
+
271
428
  // 预存脏文件:step1 baseline 已记录,非本次 quick 产生,跳过审计(含折叠目录前缀匹配)
272
429
  if (isBaselineFile(file)) continue
273
430
 
package/src/run/stage.js CHANGED
@@ -246,6 +246,10 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
246
246
  })
247
247
  progress.quickGuard = {
248
248
  sessionId: changeName,
249
+ // 锚定创建时的 specBase(坑 quick-cwd-drift-splits-specdir):sessionId 全局复用但
250
+ // guard 按 specDir 落盘,记下归属让漂移可自证(detectQuickSessionDrift 靠目录定位即可,
251
+ // 此字段供诊断/未来一致性校验)。
252
+ specDir: specBase,
249
253
  name_zh: '快速任务守卫',
250
254
  baselineCommit: safeGit(cwd, ['rev-parse', 'HEAD']).value,
251
255
  baselineFiles,
@@ -7,7 +7,7 @@
7
7
 
8
8
  import { existsSync, readdirSync, readFileSync, mkdirSync, writeFileSync } from 'fs'
9
9
  import { join, basename } from 'path'
10
- import { SCAN_STATUS, CHECK_SEVERITY, SCAN_REQUIRED_DOCS } from './constants.js'
10
+ import { SCAN_STATUS, CHECK_SEVERITY, SCAN_REQUIRED_DOCS, SCAN_REQUIRED_DOCS_QUICK } from './constants.js'
11
11
 
12
12
  const REQUIRED_SCAN_DOCS = SCAN_REQUIRED_DOCS
13
13
 
@@ -22,8 +22,12 @@ const REQUIRED_SCAN_DOCS = SCAN_REQUIRED_DOCS
22
22
  * @param {number} [opts.scanMeta.projectCount] - 实际展开的项目数量
23
23
  * @returns {{ status: 'success'|'completed_with_warnings'|'failed_post_check', checks: Array<{name, severity, detail}> }}
24
24
  */
25
- export function runScanPostCheck({ cwd, specDir, outputText = '', scanMeta = {} } ) {
25
+ export function runScanPostCheck({ cwd, specDir, outputText = '', scanMeta = {}, scanProfile = null } ) {
26
26
  const isPlatform = !!specDir
27
+ // profile 感知:quick 档只要求 4 份核心文档,否则要求完整 7 份。
28
+ // 否则 quick 扫描(--quick 显式 或 小项目自动判定)在平台模式会因缺 INTEGRATIONS/TESTING/CONCERNS 直接判 failed。
29
+ const mode = scanProfile?.mode || null
30
+ const requiredDocs = mode === 'quick' ? SCAN_REQUIRED_DOCS_QUICK : SCAN_REQUIRED_DOCS
27
31
  const checks = []
28
32
 
29
33
  if (!isPlatform) {
@@ -31,10 +35,13 @@ export function runScanPostCheck({ cwd, specDir, outputText = '', scanMeta = {}
31
35
  const localSpec = join(cwd, '.sillyspec')
32
36
  const scanDir = join(localSpec, 'docs', basename(cwd), 'scan')
33
37
 
34
- // 检查 7 份文档是否存在
35
- const missing = REQUIRED_SCAN_DOCS.filter(f => !existsSync(join(scanDir, f)))
38
+ // 检查必需文档是否存在(quick profile 按 4 份清单,否则 7 份)
39
+ const missing = requiredDocs.filter(f => !existsSync(join(scanDir, f)))
36
40
  if (missing.length > 0) {
37
- checks.push({ name: 'missing_docs', severity: CHECK_SEVERITY.WARNING, detail: `缺少 ${missing.length} 份 scan 文档: ${missing.join(', ')}` })
41
+ checks.push({ name: 'missing_docs', severity: CHECK_SEVERITY.WARNING, detail: `缺少 ${missing.length} 份 scan 文档(${mode || 'full'} profile): ${missing.join(', ')}` })
42
+ }
43
+ if (mode === 'quick') {
44
+ checks.push({ name: 'quick_profile_notice', severity: CHECK_SEVERITY.WARNING, detail: 'quick scan:仅生成 4 份核心文档(PROJECT/ARCHITECTURE/CONVENTIONS/STRUCTURE),深度扫描待补齐' })
38
45
  }
39
46
 
40
47
  const hasWarning = checks.some(c => c.severity === 'warning')
@@ -74,21 +81,24 @@ export function runScanPostCheck({ cwd, specDir, outputText = '', scanMeta = {}
74
81
  }
75
82
  }
76
83
 
77
- // 2. spec_root 检查 7 份必需文档
84
+ // 2. spec_root 检查必需文档(quick profile 按 4 份清单,否则 7 份)
78
85
  const specScanDir = join(specDir, 'docs', projectName, 'scan')
79
- const missingDocs = REQUIRED_SCAN_DOCS.filter(f => !existsSync(join(specScanDir, f)))
86
+ const missingDocs = requiredDocs.filter(f => !existsSync(join(specScanDir, f)))
80
87
  if (missingDocs.length > 0) {
81
88
  checks.push({
82
- name: missingDocs.length === REQUIRED_SCAN_DOCS.length ? 'all_docs_missing' : 'partial_docs_missing',
89
+ name: missingDocs.length === requiredDocs.length ? 'all_docs_missing' : 'partial_docs_missing',
83
90
  severity: CHECK_SEVERITY.FAILED,
84
- detail: missingDocs.length === REQUIRED_SCAN_DOCS.length
91
+ detail: missingDocs.length === requiredDocs.length
85
92
  ? `spec_root 下无任何 scan 文档(${specScanDir}/),扫描可能未执行`
86
- : `spec_root 缺少必需文档: ${missingDocs.join(', ')}(7 份 scan 文档均为 required)`
93
+ : `spec_root 缺少必需文档: ${missingDocs.join(', ')}(${mode === 'quick' ? 'quick profile 要求 4 份核心文档' : '7 份 scan 文档均为 required'})`
87
94
  })
88
95
  }
96
+ if (mode === 'quick') {
97
+ checks.push({ name: 'quick_profile_notice', severity: CHECK_SEVERITY.WARNING, detail: 'quick scan:仅生成 4 份核心文档(PROJECT/ARCHITECTURE/CONVENTIONS/STRUCTURE),深度扫描待补齐' })
98
+ }
89
99
 
90
100
  // 3. 检查文档 header(author / created_at)— 只看文件头部,避免正文出现同名词被误判
91
- const existingDocs = REQUIRED_SCAN_DOCS.filter(f => existsSync(join(specScanDir, f)))
101
+ const existingDocs = requiredDocs.filter(f => existsSync(join(specScanDir, f)))
92
102
  const docsMissingHeader = []
93
103
  for (const doc of existingDocs) {
94
104
  const content = readFileSync(join(specScanDir, doc), 'utf8')
@@ -310,6 +320,10 @@ export function formatStructuredResult(result, meta = {}) {
310
320
  else if (check.name === 'docs_missing_header') {
311
321
  structured.failure_categories.quality_warnings.push(entry)
312
322
  }
323
+ // quick profile 说明(informational warning,非缺陷)
324
+ else if (check.name === 'quick_profile_notice') {
325
+ structured.failure_categories.quality_warnings.push(entry)
326
+ }
313
327
  // 兜底:归入 violations
314
328
  else {
315
329
  structured.failure_categories.violations.push(entry)
@@ -66,9 +66,14 @@ export function validateChangeExists(specBase, stageName, changeName) {
66
66
  if (stageName === 'archive') {
67
67
  const archiveRoot = join(specBase, 'changes', 'archive')
68
68
  if (existsSync(archiveRoot)) {
69
+ // 归档目录名恒为 <归档日>-<纯描述>(archiveDestDirName 写入时已 strip 源名前导日期)。
70
+ // 校验侧同样 strip changeName 前导日期再比 —— 否则「写 strip、读拿完整源名」语义不一致,
71
+ // 会让带日期前缀的源名(brainstorm 约定,如 2026-07-29-sidebar-menu-restructure)在 step5
72
+ // 被前置校验误拦(坑 archive-step5-leading-date-change-name)。
73
+ const stripped = String(changeName).replace(/^\d{4}-\d{2}-\d{2}-/, '')
69
74
  const archived = readdirSync(archiveRoot).find(d => {
70
75
  const m = d.match(/^\d{4}-\d{2}-\d{2}-(.+)$/)
71
- return m && m[1] === changeName
76
+ return m && m[1] === stripped
72
77
  })
73
78
  if (archived) return null
74
79
  }
@@ -439,9 +444,16 @@ function validateVerifyOutputs(cwd, changeName, context = {}) {
439
444
  designContent: readIfExists(join(changeDir, 'design.md')),
440
445
  planContent: readIfExists(join(changeDir, 'plan.md')),
441
446
  })
447
+ const conclusion = extractVerifyConclusion(verify)
442
448
  if (['integration-critical', 'deployment-critical'].includes(changeRiskProfile.level)) {
443
- const conclusion = extractVerifyConclusion(verify)
444
- if (conclusion === 'PASS WITH NOTES' || conclusion === 'PASS') {
449
+ // 显式 risk_level 声明(design frontmatter,非关键词误判)下,PASS WITH NOTES 视为对残留项的
450
+ // 诚实声明,不强求全量集成证据——豁免本就来自 design 的明确判断。自动关键词判级维持严格:
451
+ // PASS WITH NOTES 仍要求证据齐全,防 agent 用 PASS WITH NOTES 绕证据门控。
452
+ const requiresEvidence = conclusion === 'PASS' || (conclusion === 'PASS WITH NOTES' && !changeRiskProfile.explicit)
453
+ if (conclusion === 'PASS WITH NOTES' && changeRiskProfile.explicit) {
454
+ warnings.push(`[${changeRiskProfile.level}] 结论 PASS WITH NOTES:design frontmatter 显式声明 risk_level=${changeRiskProfile.level},残留项须为真实集成证据缺口,并在 verify-result.md 如实说明`)
455
+ }
456
+ if (requiresEvidence) {
445
457
  const evidenceCheck = checkIntegrationEvidence(verify, changeRiskProfile.requiredVerification)
446
458
  if (!evidenceCheck.ok) {
447
459
  // A: 报错说人话 —— 把「缺哪一项、要写/做什么、判级原因」逐条列出,
@@ -455,7 +467,9 @@ function validateVerifyOutputs(cwd, changeName, context = {}) {
455
467
  return `\n 〔${k}〕${vn.desc}${lit}`
456
468
  })
457
469
  .join('')
458
- const cause = RISK_LEVEL_CAUSES[changeRiskProfile.level] || ''
470
+ const cause = changeRiskProfile.explicit
471
+ ? RISK_LEVEL_CAUSES.explicit
472
+ : (RISK_LEVEL_CAUSES[changeRiskProfile.level] || '')
459
473
  errors.push(
460
474
  `[${changeRiskProfile.level}] 验证结论为 ${conclusion},但缺少真实集成证据。\n` +
461
475
  ` 缺失项(需在 verify-result.md 如实提供并满足):${evidenceCheck.errors.join('; ')}\n` +
@@ -463,8 +477,9 @@ function validateVerifyOutputs(cwd, changeName, context = {}) {
463
477
  ` 风险判级原因:${cause}\n` +
464
478
  ` 命中触发词:${changeRiskProfile.triggers.join(', ')}\n` +
465
479
  ` 出路:① 补全上述缺失的真实集成证据(真实启动 daemon/backend、集成测试、运行日志)后保持 PASS;` +
466
- `或 ② 如实改结论 FAIL(承认端到端未验,留待部署后补)。` +
467
- `仅改结论文案/措辞蹭字面关键词会被对账,PASS WITH NOTES 在此等级下不被允许。`
480
+ `或 ② 如实改结论 FAIL(承认端到端未验,留待部署后补);` +
481
+ `或 ③ 若判级是关键词误伤(实际并未触碰 daemon/session/启动入口),在 design.md frontmatter 加 risk_level: <真实等级>(如 unit-sufficient)显式覆盖后重跑。` +
482
+ `仅改结论文案/措辞蹭字面关键词会被对账。`
468
483
  )
469
484
  }
470
485
  warnings.push(...evidenceCheck.warnings)
@@ -475,13 +490,29 @@ function validateVerifyOutputs(cwd, changeName, context = {}) {
475
490
  return { ok: errors.length === 0, errors, warnings }
476
491
  }
477
492
 
493
+ /**
494
+ * 计算归档目标目录名:保证 archive/ 下目录有且仅有一个日期前缀。
495
+ *
496
+ * 变更名创建时常已带 YYYY-MM-DD- 前缀(brainstorm 强制约定 + CLI 自动占位名),
497
+ * 若归档时再无条件拼一次 date- 前缀,会产生 2026-07-28-2026-07-28-<desc> 双日期。
498
+ * 这里先剥离源名前导日期,再统一拼归档当天日期 → 归档目录名恒为 <归档日期>-<纯描述>。
499
+ * 不带日期的源名(如 quick-<hash>)strip 无效,正常补日期,行为不变。
500
+ *
501
+ * 写(archiveChangeDirectory)与校验(validateArchiveOutputs)共用此函数,
502
+ * 避免「写和校验各自硬编码同公式」的漂移(曾因此导致双日期 bug 写校验都自洽通过)。
503
+ */
504
+ export function archiveDestDirName(date, changeName) {
505
+ const stripped = String(changeName).replace(/^\d{4}-\d{2}-\d{2}-/, '')
506
+ return `${date}-${stripped}`
507
+ }
508
+
478
509
  /**
479
510
  * archive 完成校验:检查归档目录完整性
480
511
  */
481
512
  function validateArchiveOutputs(cwd, changeName) {
482
513
  const archiveDir = join(cwd, '.sillyspec', 'changes', 'archive')
483
514
  const date = new Date().toISOString().slice(0, 10)
484
- const destDir = join(archiveDir, `${date}-${changeName}`)
515
+ const destDir = join(archiveDir, archiveDestDirName(date, changeName))
485
516
 
486
517
  // 归档目录不存在 early return(引擎在存在时才跑)
487
518
  if (!existsSync(destDir)) {
@@ -702,7 +733,7 @@ const contracts = {
702
733
  },
703
734
  status: {
704
735
  stage: 'status',
705
- description: '状态查看',
736
+ description: '项目快照',
706
737
  allowedFrom: [],
707
738
  allowedTo: [],
708
739
  validators: [],
@@ -195,11 +195,22 @@ export function verifyStageReviewDocHash(review, searchDirs) {
195
195
 
196
196
  for (const base of searchDirs || []) {
197
197
  const abs = join(base, primaryRel)
198
- const actual = computeDocHash(abs)
199
- if (actual === null) continue
200
- // 找到主文档,比对 hash
201
- if (review.docHash.toLowerCase() !== actual.toLowerCase()) {
202
- errors.push(`docHash 与主审查文档 ${primaryRel} 的实际内容不匹配 — review.json 疑似伪造(未真正读取文档)`)
198
+ if (!existsSync(abs)) continue
199
+ // 找到主文档,比对 hash。容忍「原始字节」与「LF 规范化」两种口径:Windows 下 CRLF/LF 在
200
+ // git add / eol 规范化前后字节会漂移,同一文件 sha256 偶发不一致(坑 worktree-execute-apply-friction
201
+ // 坑3)——agent 写的 docHash(原始 sha256sum)与 gate 重算只要任一口径匹配即认可,消除 CRLF
202
+ // 漂移导致的反复「疑似伪造」误报。两口径都对不上仍判不匹配(防伪造能力不降级)。
203
+ const content = readFileSync(abs, 'utf8')
204
+ const actualRaw = createHash('sha256').update(content).digest('hex')
205
+ const actualLf = createHash('sha256').update(content.replace(/\r\n/g, '\n')).digest('hex')
206
+ const claimed = String(review.docHash).toLowerCase()
207
+ if (claimed !== actualRaw.toLowerCase() && claimed !== actualLf.toLowerCase()) {
208
+ errors.push(
209
+ `docHash 与主审查文档 ${primaryRel} 的实际内容不匹配。` +
210
+ `若刚改过该文档(如 design 修订后忘重算),重算并更新 review.json 的 docHash 字段:` +
211
+ `在基准目录 ${base} 下运行 sha256sum "${primaryRel}" 取首列 hex 填入;` +
212
+ `若未改文档,则 review.json 疑似伪造(未真正读取文档)。`
213
+ )
203
214
  return { ok: false, errors, warnings }
204
215
  }
205
216
  return { ok: true, errors, warnings }
@@ -5,17 +5,21 @@ export const definition = {
5
5
  steps: [
6
6
  {
7
7
  name: '任务完成度检查',
8
- prompt: `检查 plan.md 中所有任务 checkbox 是否已勾选(plan.md 是任务完成的唯一真相源)。
8
+ prompt: `检查任务完成度,决定是否可归档。
9
9
 
10
- ### 操作
11
- 1. 读取 \`.sillyspec/changes/<change-name>/plan.md\`
12
- 2. 检查所有 \`- [x]\` checkbox 是否已勾选
13
- 3. 如果 plan.md 不存在,回退读取 tasks.md 作为备选
14
- 4. 如有遗漏 → **必须暂停等待用户决定**,不要自行判断"可以归档"
15
- - 调用:\`sillyspec run archive --wait --reason "存在未完成任务,是否继续归档" --options "继续归档,回到execute完成剩余任务" --output "未完成任务列表"\`
10
+ ### 客观完成度报告(CLI 已注入,勿自行数 checkbox)
11
+ {TASK_COMPLETION_REPORT}
12
+
13
+ ### 判定规则
14
+ - **真相源 = review.json verdict**:上方报告由 CLI 从 .runtime/execute-runs/<runId>/tasks/task-NN/review.json 的 specVerdict + qualityVerdict 算出。
15
+ - plan.md 的 - [x] checkbox **仅作显示态参考**:它依赖自动回填,runId marker / review 缺失时会停在未勾态,与客观 verdict 不一致时**以本报告为准**,不要被未勾 checkbox 误导。
16
+ - 报告「未通过 / 缺失」= 0 且 source 为 review.json → 完成度合格,进入下一步。
17
+ - 报告 source 为 plan-checkbox-fallback(客观源不可用)→ 完成度无法客观确认,**必须暂停**让用户交叉核对,不要直接放行。
18
+ - 存在未完成任务 → **必须暂停等待用户决定**,不要自行判断"可以归档"。
19
+ - 调用:sillyspec run archive --wait --reason "存在未完成任务,是否继续归档" --options "继续归档,回到execute完成剩余任务" --output "未完成任务列表"
16
20
 
17
21
  ### 输出
18
- 完成度报告(已勾选/总数 + 未完成任务列表)`,
22
+ 完成度报告(已通过/总数 + 数据源 + 未完成任务列表)`,
19
23
  outputHint: '完成度报告',
20
24
  optional: false
21
25
  },
@@ -137,7 +141,7 @@ module_id: <module-id>
137
141
  2. 确保所有 checkbox 都已勾选
138
142
  3. 让用户确认后,用 \`--confirm\` 完成本步骤:
139
143
  \`sillyspec run archive --done --confirm --output "确认归档"\`
140
- 4. CLI 会创建 \`.sillyspec/changes/archive/\`,并将变更目录移动到 \`.sillyspec/changes/archive/YYYY-MM-DD-<change-name>/\`
144
+ 4. CLI 会创建 \`.sillyspec/changes/archive/\`,并将变更目录移动到 \`.sillyspec/changes/archive/<归档日期>-<纯描述>(源名前导日期去重,避免双日期)/\`
141
145
 
142
146
  ### 输出
143
147
  归档完成 + archive 目录路径`,
@@ -16,8 +16,9 @@ export const definition = {
16
16
 
17
17
  steps: [
18
18
  {
19
- name: '状态检查与上下文加载',
20
- prompt: `检查状态、加载项目上下文、匹配模块。
19
+ name: '进度确认与上下文加载',
20
+ migratedFrom: ['状态检查与上下文加载'],
21
+ prompt: `检查进度、加载项目上下文、匹配模块。用 \`sillyspec progress show\` 查流程进度,不要用 \`sillyspec status\`(项目级快照,不推进流程)。
21
22
 
22
23
  ### 操作
23
24
  1. 运行 \`sillyspec progress show\`,确认 currentStage 为 "brainstorm"
@@ -4,8 +4,10 @@ export const definition = {
4
4
  description: '探索需求、分析技术方案、识别风险',
5
5
  steps: [
6
6
  {
7
- name: '状态检查',
8
- prompt: `检查当前变更的进度状态(sillyspec.db)。
7
+ name: '进度确认',
8
+ // 重命名自「状态检查」(③ status 消歧):老进度里的「状态检查」completed 由迁移逻辑承接,防 currentIdx 回跳
9
+ migratedFrom: ['状态检查'],
10
+ prompt: `检查当前变更的进度状态(sillyspec.db)。用 \`sillyspec progress show\` 查流程进度,不要用 \`sillyspec status\`(项目级快照,不推进流程)。
9
11
 
10
12
  ### 操作
11
13
  1. 运行 \`sillyspec progress show\`
@@ -58,7 +60,7 @@ export const definition = {
58
60
  加载上下文后,用需求描述 + 模块上下文**粗判**本次变更规模:
59
61
  - **明显 small**(满足:预计改动 ≤ 2 个文件、单模块、无 schema/API/状态机/权限变更;或属于改文案/修 bug/样式调整/配置微调等纯执行类)→ 输出:「此变更规模较小,建议直接走 quick 流程」+ 一句依据,给出建议命令 \`sillyspec run quick "<需求>"\`。用户同意则本阶段可在此收尾(\`--done\` 并提示转 quick),不必继续走完整设计流程。
60
62
  - **拿不准或明显 large**(涉及多模块、schema、状态流转、新架构等)→ 不要提示 quick,继续进入下一步「对话式探索与需求澄清」。
61
- - 这是**粗判**,只为让明显的小变更免走完整设计流程;不确定就继续,后续「用户确认并生成规范文件」步骤会基于 design.md 文件清单做精判兜底。
63
+ - 这是**粗判**,只为让明显的小变更免走完整设计流程;不确定就继续,后续「生成规范文件」步骤会基于 design.md 文件清单做精判兜底。
62
64
 
63
65
  ### 输出
64
66
  项目现状理解摘要(3-5 句话,关键约定和架构决策)+ 可能涉及的模块列表 + 本次需求所属子项目 + (如命中)quick 建议
@@ -440,11 +442,10 @@ status: passed | needs-user-input | blocked | skipped
440
442
  optional: false
441
443
  },
442
444
  {
443
- name: '用户确认并生成规范文件',
444
- requiresWait: true,
445
- waitReason: '等待用户最终确认设计方案',
446
- waitOptions: ['确认', '需要修改', '推翻重来'],
447
- prompt: `用户确认设计方案,按变更规模生成规范文件并给出实现路径建议。
445
+ name: '生成规范文件',
446
+ // 重命名自「用户确认并生成规范文件」(去确认门控:设计已在「分段展示设计」步确认过,末步再问纯冗余),老进度 completed 由迁移逻辑承接
447
+ migratedFrom: ['用户确认并生成规范文件'],
448
+ prompt: `按变更规模生成规范文件并给出实现路径建议(设计已在前置步骤确认过,本步不再暂停等确认,生成后展示摘要即可)。
448
449
 
449
450
  ### 规模评估(展示前先做)
450
451
  读取 design.md 的「文件变更清单」,判断本次变更规模:
@@ -454,13 +455,13 @@ status: passed | needs-user-input | blocked | skipped
454
455
  (注:早期「加载项目上下文」步骤已做过一次粗判并可能建议过走 quick;此处基于 design.md 文件清单做精判兜底。)
455
456
 
456
457
  ### 操作
457
- 1. 展示 design.md 摘要 + **规模评估结果(small/large + 一句依据)** 给用户
458
- 2. 暂停等待用户选择:✅ 确认 / ✏️ 修改 / ❌ 推翻重来
459
- 3. 确认后,**按规模生成规范文件**:
458
+ 1. **按规模生成规范文件**:
460
459
  - **scale=large**:在 \`{SPEC_ROOT}/changes/<change-name>/\` 下生成完整四件套(design.md / decisions.md 可选 / proposal.md / requirements.md / tasks.md),实现路径 → \`sillyspec run plan --change <变更名>\`
461
460
  - **scale=small**:只生成/补全 design.md(proposal/requirements/tasks 对 quick 无用,不生成),实现路径 → \`sillyspec run quick --linked-changes <变更名>\`
462
461
  - 两种规模都执行 \`git add .sillyspec/\` — 暂存规范文件(不要 commit,由用户通过统一提交工具处理)。**平台模式跳过 git add**(specRoot 不在 sourceRoot 的 git repo 内)
463
462
 
463
+ 2. 生成完成后展示 design.md 摘要 + **规模评估结果(small/large + 一句依据)** + 实现路径建议,告知用户“如有异议直接说,可修改文件、改 scale 或 --reopen 回退”(不暂停,展示完直接 --done)
464
+
464
465
  所有规范文件头部必须包含 YAML frontmatter:
465
466
  \`\`\`yaml
466
467
  ---
@@ -551,15 +552,11 @@ Then 期望结果
551
552
  4. \`git add .sillyspec/\` — 暂存所有新增文件(不要 commit,由用户通过统一提交工具处理)。**平台模式跳过 git add**(specRoot 不在 sourceRoot 的 git repo 内)
552
553
  5. 后续变更包的骨架文件同样必须包含 \`author: <git-user>\` 和 \`created_at: <now-datetime>\`
553
554
 
554
- ### 铁律 — 必须等待用户最终确认
555
- - 展示 design.md 摘要后暂停等用户确认,**不要自己说"用户已确认"然后生成文件**(命令由 CLI 在下方注入)
556
- - 只有用户明确确认(--answer "确认")后才生成规范文件**
557
-
558
555
  ### 输出
559
556
  所有规范文件路径(含后续变更包目录列表)
560
557
 
561
558
  ### 注意
562
- - 禁止在确认前推进到后续阶段
559
+ - 本步不再等待用户确认,生成完成后直接 --done;用户事后提出修改/推翻时,修改对应文件或用 --reopen 回退重做
563
560
  - 禁止自动 commit
564
561
  - 推翻重来回到「对话式探索与需求澄清」步骤
565
562
  - 表名/字段名/类名必须来自真实代码或标注"新增"
@@ -93,8 +93,9 @@ export const definition = {
93
93
  // 固定前缀步骤定义
94
94
  const fixedPrefix = [
95
95
  {
96
- name: '状态检查',
97
- prompt: `检查当前状态,确认可以执行 execute。
96
+ name: '进度确认',
97
+ migratedFrom: ['状态检查'],
98
+ prompt: `检查当前进度,确认可以执行 execute。用 \`sillyspec progress show\` 查流程进度,不要用 \`sillyspec status\`(项目级快照,不推进流程)。
98
99
 
99
100
  ### 操作
100
101
  1. 运行 \`sillyspec progress show\`
@@ -311,7 +312,7 @@ node -e "import('./src/worktree.js').then(w => { const wm = new w.WorktreeManage
311
312
  2. 系统自动检查:
312
313
  - patch --check 是否通过
313
314
  - 变更是否在 allowed_paths 内
314
- - 主工作区 baseline 是否变化
315
+ - 主工作区是否有未提交 dirty(拦截;已提交推进交 --3way 自动三路合并)
315
316
  - 是否有高风险文件(lockfile/migration/配置/入口)
316
317
  - diff 规模是否异常
317
318
  3. 输出 Apply Decision:
@@ -64,7 +64,7 @@ function parseTaskId(content, filename) {
64
64
  * @param {string} content
65
65
  * @returns {string[]}
66
66
  */
67
- function parseAllowedPaths(content) {
67
+ export function parseAllowedPaths(content) {
68
68
  const fmMatch = content.match(/^---\n([\s\S]*?)\n---/)
69
69
  if (!fmMatch) return []
70
70
  const fm = fmMatch[1]
@@ -322,6 +322,7 @@ tier: {REVIEW_TIER}({REVIEW_TIER_REASON})
322
322
  - [ ] 不存在 P0/P1 unresolved blocker 残留
323
323
  - [ ] 没有实现细节泄漏到 plan.md(接口签名/代码示例应在 tasks/task-NN.md)
324
324
  - [ ] 关键路径与 Wave 依赖合理(无循环依赖、无遗漏前置)
325
+ - [ ] 连带测试归属:改共享/被多 task 依赖源文件的 task 是否在 related_tests 声明了会失效的既有测试、且路径在 allowed_paths 内(或由独立测试 task 覆盖)?(漏声明 = execute 阶段测试债、主代理事后兜底)
325
326
 
326
327
  ### tier=independent 时:启动 plan-review 子代理
327
328
  用 Agent tool 启动子代理(subagent_type: general),prompt 要点:
@@ -401,6 +402,9 @@ verify:
401
402
  constraints:
402
403
  - 边界约束 1(如:不加测试)
403
404
  - 边界约束 2(如:不修改传入参数)
405
+ related_tests: # 可选。仅当本 task 改共享/被多 task 依赖的源文件时填
406
+ - path: frontend/src/lib/errors.test.ts # 因本次改动断言失效的既有测试文件
407
+ reason: 旧断言假设单例,改归属键后需同步
404
408
  ---
405
409
 
406
410
  TaskCard 格式规则(必须严格遵守):
@@ -412,9 +416,17 @@ TaskCard 格式规则(必须严格遵守):
412
416
  - verify: 列表,实际可执行的命令
413
417
  - constraints: 列表,明确边界(含 brownfield 兼容、异常处理)
414
418
  - 不需要:修改文件章节、覆盖来源章节、接口定义章节、TDD 步骤章节、参考章节
419
+ - frontmatter 必须以三减号结尾闭合(开头和结尾各一行 ---)。缺结尾会让 postcheck 提取不到 frontmatter,误报「字段缺失 / allowed_paths 为空」
420
+ - allowed_paths 用块式写法(键名换行后每项一行「 - 路径」),不要用流式方括号 [path]——块式与所有校验器一致最稳
421
+ - implementation / acceptance / constraints / verify 的每条列表项是 YAML plain scalar,避免以下特殊字符触发 ScannerError:
422
+ - 冒号+空格(如代码签名 provider_id:str、queryUsage(id:string))会被当映射键报 mapping values are not allowed here——去掉冒号后空格或改成中文描述
423
+ - 花括号且内部含冒号或引号(如 JS 对象、JS 模板字面量)会报 expected block end——改用不含这些字符的中文描述
424
+ - 引号不成对会报扫描错误
425
+ - 注:goal 用 > 折叠标量可容忍花括号,goal 里写占位符不炸;只有上述 plain scalar 列表项需注意
415
426
  - provides / expects_from 是可选字段:仅当跨 task 契约(一个 task 的接口/DTO/响应被另一个 task 消费)时才填,单 task 或无对外接口场景留空即可
416
427
  - 填写后 plan-postcheck 会做硬对账:consumer 的每个 expects_from[provider].needs 字段必须在对应 provider 的 provides.fields 里,否则 plan 阶段阻断(不进入 execute)
417
428
  - 不要把内部实现字段塞进 provides;只暴露给其他 task 的对外契约形状
429
+ - related_tests 是可选字段:仅当本 task 改共享源文件(被既有测试引用、或被多 task 依赖)时填,列出会因本次改动断言失效的既有测试文件 + 为何失效。关键:这些测试路径必须同时写进本 task 的 allowed_paths(否则子代理被 allowed_paths 铁律锁死、改不了测试 → 退化成主代理事后兜底);若测试由独立的测试 task 负责,则在该测试 task 的 allowed_paths 覆盖、本字段留空
418
430
  - 如果存在 decisions.md,无法覆盖的 D-xxx@vN 在 constraints 中标注
419
431
  - **保存前格式自检**(plan-postcheck 会硬校验,不通过到 Step 4 会批量报错,先在这里逐条自查):
420
432
  - frontmatter 字段齐全:id、title、title_zh、author、created_at、priority、depends_on、blocks、allowed_paths、goal、implementation、acceptance、verify、constraints
@@ -457,6 +469,7 @@ ${subagentPrompts}
457
469
  - allowed_paths 有无冲突
458
470
  - depends_on 与 plan.md Wave 分组是否一致
459
471
  - provides/expects_from 契约自洽:每个 expects_from[provider].needs 字段都在该 provider task 的 provides.fields 里(plan-postcheck 会硬校验,这里提前自查)
472
+ - related_tests(若填)的测试路径是否都在本 task 或某 task 的 allowed_paths 内(否则子代理无权改 → execute 测试债、主代理事后兜底)
460
473
  - 如发现矛盾,列出问题清单,不要自动修复`
461
474
 
462
475
  return {
@@ -9,8 +9,9 @@ export const definition = {
9
9
  description: '生成结构化规范 — proposal + design + tasks',
10
10
  steps: [
11
11
  {
12
- name: '状态检查',
13
- prompt: `检查当前状态,确认可以执行 propose。
12
+ name: '进度确认',
13
+ migratedFrom: ['状态检查'],
14
+ prompt: `检查当前进度,确认可以执行 propose。用 \`sillyspec progress show\` 查流程进度,不要用 \`sillyspec status\`(项目级快照,不推进流程)。
14
15
 
15
16
  ### 操作
16
17
  1. 运行 \`sillyspec progress show\`