sillyspec 3.25.9 → 3.26.0

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 (76) hide show
  1. package/.claude/skills/sillyspec-auto/SKILL.md +8 -13
  2. package/.claude/skills/sillyspec-brainstorm/SKILL.md +8 -6
  3. package/.claude/skills/sillyspec-continue/SKILL.md +3 -3
  4. package/.claude/skills/sillyspec-doctor/SKILL.md +1 -1
  5. package/.claude/skills/sillyspec-execute/SKILL.md +10 -4
  6. package/.claude/skills/sillyspec-plan/SKILL.md +1 -1
  7. package/.claude/skills/sillyspec-quick/SKILL.md +3 -1
  8. package/.claude/skills/sillyspec-verify/SKILL.md +1 -1
  9. package/.sillyspec-platform-cleaned +1 -0
  10. package/.sillyspec-platform.json +7 -0
  11. package/README.md +9 -0
  12. package/package.json +2 -2
  13. package/src/change-list.js +11 -8
  14. package/src/change-risk-profile.js +22 -0
  15. package/src/db.js +170 -161
  16. package/src/dispatch/backends/local-agent.js +152 -0
  17. package/src/dispatch/backends/sillyhub-mcp.js +242 -0
  18. package/src/dispatch/probe.js +210 -0
  19. package/src/dispatch/strategy.js +121 -0
  20. package/src/doctor-diagnostics.js +19 -25
  21. package/src/fs-atomic.js +69 -69
  22. package/src/git-helper.js +66 -0
  23. package/src/hooks/worktree-guard.js +64 -55
  24. package/src/index.js +326 -36
  25. package/src/init.js +7 -14
  26. package/src/local-detect.js +120 -110
  27. package/src/machine-interface.js +54 -19
  28. package/src/migrate.js +14 -4
  29. package/src/modules.js +16 -5
  30. package/src/progress/change-registry.js +85 -70
  31. package/src/progress/consistency-doctor.js +71 -14
  32. package/src/progress/shared.js +8 -5
  33. package/src/progress/stage-machine.js +67 -65
  34. package/src/progress/step-store.js +72 -67
  35. package/src/progress.js +448 -236
  36. package/src/quicklog.js +59 -12
  37. package/src/run/command.js +78 -39
  38. package/src/run/complete-handlers.js +1028 -889
  39. package/src/run/complete.js +132 -212
  40. package/src/run/concurrent-detect.js +132 -0
  41. package/src/run/gates.js +302 -19
  42. package/src/run/prompt.js +22 -68
  43. package/src/run/quick-audit.js +10 -9
  44. package/src/run/shared.js +722 -600
  45. package/src/run/stage.js +43 -24
  46. package/src/scan-postcheck.js +9 -31
  47. package/src/sillyhub-mcp/client.js +394 -0
  48. package/src/sillyhub-mcp/config.js +62 -0
  49. package/src/spec-dir-typo.js +71 -0
  50. package/src/stage-contract-engine.js +22 -1
  51. package/src/stage-contract-spec.js +11 -4
  52. package/src/stage-contract.js +26 -3
  53. package/src/stage-review.js +118 -10
  54. package/src/stages/archive.js +4 -6
  55. package/src/stages/brainstorm-auto.js +26 -20
  56. package/src/stages/brainstorm.js +29 -5
  57. package/src/stages/cmd-existence.js +145 -0
  58. package/src/stages/doctor.js +10 -7
  59. package/src/stages/execute.js +64 -22
  60. package/src/stages/plan-postcheck.js +152 -0
  61. package/src/stages/plan.js +16 -31
  62. package/src/stages/quick.js +25 -9
  63. package/src/stages/scan.js +53 -8
  64. package/src/stages/status.js +0 -1
  65. package/src/stages/verify.js +14 -9
  66. package/src/sync.js +433 -16
  67. package/src/task-review.js +59 -1
  68. package/src/verify-postcheck.js +938 -862
  69. package/src/version.js +19 -0
  70. package/src/worktree-apply.js +218 -93
  71. package/src/worktree-deps.js +89 -5
  72. package/src/worktree.js +213 -111
  73. package/templates/prompts/taskcard-rules.md +27 -0
  74. package/templates/prompts/verify-probes.md +3 -3
  75. package/.claude/skills/sillyspec-propose/SKILL.md +0 -87
  76. package/src/stages/propose.js +0 -181
@@ -0,0 +1,132 @@
1
+ /**
2
+ * concurrent-detect — 多 agent 并发写预检的纯函数检测核心(design §7)。
3
+ *
4
+ * 单次 git status --porcelain 扫描,复用 isQuickMetadata 的「关联 vs 他者」分类口径,
5
+ * 产出两类并发信号:
6
+ * - foreignFiles: 脏文件里非 metadata、不在 ownFiles 的真实业务文件(他者未提交改动)
7
+ * - otherActiveChanges: 脏文件落在 .sillyspec/changes/<他者变更>/ 下去重成的变更名集合
8
+ *
9
+ * 非阻塞 advisory:检测结果仅在调用点本地消费(→ formatConcurrentWarning → console.warn),
10
+ * 不改 audit status / gate 通过性 / isQuickMetadata 语义(design §2/§9)。
11
+ *
12
+ * 纯函数无副作用:不写盘、不 console(console 留给调用点 task-02/03)。
13
+ */
14
+ import { safeGit, parsePorcelainPath, isQuickMetadata } from './shared.js'
15
+
16
+ /**
17
+ * 内联解析 .sillyspec/changes/<dir>/ 路径取 <dir>。
18
+ * 与 shared.js isQuickMetadata 内部 regex(^\.sillyspec\/changes\/([^/]+)(\/|$))同源——
19
+ * D-008 deferred:暂不抽 shared.js 公共 helper,改其一改其二。
20
+ * @param {string} file 已归一化(正斜杠)的 git 路径
21
+ * @returns {string|null} 变更目录名,不在 changes/ 下返回 null
22
+ */
23
+ function extractChangeDir(file) {
24
+ const m = file.match(/^\.sillyspec\/changes\/([^/]+)(\/|$)/)
25
+ return m ? m[1] : null
26
+ }
27
+
28
+ /** git 路径反斜杠归一(跨平台 Windows 兼容)。 */
29
+ function normalizeGitPath(p) {
30
+ return String(p).replace(/\\/g, '/')
31
+ }
32
+
33
+ /**
34
+ * 检测工作树里的并发他者改动(非阻塞 advisory 用)。
35
+ *
36
+ * @param {string} cwd 主仓库根
37
+ * @param {{ changeName: string, linkedChanges?: string[], ownFiles?: string[], specDir?: string }} opts
38
+ * - changeName: 当前变更名(排除自身变更目录)
39
+ * - linkedChanges: 关联变更(透传给 isQuickMetadata 的关联归类,同时从 otherActiveChanges 排除)
40
+ * - ownFiles: 本 --done 负责的文件(从 foreignFiles 排除,避免把自己当他者)
41
+ * - specDir: 规范目录(保留参数,当前实现用 .sillyspec/ 前缀 regex 定位,暂未使用)
42
+ * @returns {{ hasForeign: boolean, foreignFiles: string[], otherActiveChanges: string[], gitError: string|null }}
43
+ * - foreignFiles: 脏文件里非 metadata、不在 ownFiles 的真实业务文件
44
+ * - otherActiveChanges: 脏文件落在 .sillyspec/changes/<他者变更>/ 下,去重成的变更名集合
45
+ * - gitError: git status 读失败时填错误串,hasForeign=false(FR-04 fail-open,不抛异常)
46
+ */
47
+ export function detectConcurrentChanges(cwd, { changeName, linkedChanges = [], ownFiles = [], specDir } = {}) {
48
+ // safeGit 必传 trim:false(D-004):porcelain 首行前导空格是状态码一部分,trim 会削掉致
49
+ // parsePorcelainPath 丢首字符(坑见 shared.js auditQuickCompletion :448 注释)。
50
+ const statusResult = safeGit(cwd, ['status', '--porcelain'], { trim: false })
51
+
52
+ // FR-04 fail-open:git status 读不到不崩、不阻断、不误报(advisory 漏报可接受,最坏漏报而非误阻)。
53
+ if (statusResult.error) {
54
+ return { hasForeign: false, foreignFiles: [], otherActiveChanges: [], gitError: statusResult.error }
55
+ }
56
+
57
+ const gitStatus = statusResult.value || ''
58
+ // 不整段 trim:同 auditQuickCompletion :455 注释,会削首行前导空格致首文件路径丢首字符。
59
+ const entries = gitStatus.split('\n').filter(Boolean)
60
+
61
+ // 本变更 + 关联变更目录集合:rule1 据此把「自己/关联」从他者变更中排除。
62
+ const ownChangeSet = new Set(
63
+ (Array.isArray(linkedChanges) ? linkedChanges : [])
64
+ .map(normalizeGitPath)
65
+ .filter(Boolean)
66
+ )
67
+ if (changeName) ownChangeSet.add(normalizeGitPath(changeName))
68
+
69
+ // ownFiles 归一化集合:rule3 据此把本会话产出从他者业务文件中排除。
70
+ const ownFileSet = new Set(
71
+ (Array.isArray(ownFiles) ? ownFiles : [])
72
+ .map(normalizeGitPath)
73
+ .filter(Boolean)
74
+ )
75
+
76
+ const foreignFiles = []
77
+ const otherActiveSet = new Set()
78
+
79
+ for (const line of entries) {
80
+ // parsePorcelainPath:去引号 / 处理 rename / 归一化反斜杠(跨平台)。
81
+ const file = parsePorcelainPath(line)
82
+ if (!file) continue
83
+
84
+ // rule1:落 .sillyspec/changes/<dir>/ 且 <dir> 非本变更非关联 → otherActiveChanges 去重。
85
+ const dir = extractChangeDir(file)
86
+ if (dir && !ownChangeSet.has(normalizeGitPath(dir))) {
87
+ otherActiveSet.add(dir)
88
+ continue
89
+ }
90
+
91
+ // rule2:isQuickMetadata 为 true(quick 元数据 / 关联变更目录 / quicklog/.runtime/modules 等)→ 跳过。
92
+ // 注意:当前变更自己的 changes/<changeName>/ 也走这里——changeName 通常不在 linkedChanges,
93
+ // isQuickMetadata 视为元数据放行,与本变更自身目录不该当他者的语义一致。
94
+ if (isQuickMetadata(file, linkedChanges)) continue
95
+
96
+ // rule3:真实业务文件 → 不在 ownFiles 则归入他者 foreignFiles。
97
+ if (!ownFileSet.has(file)) {
98
+ foreignFiles.push(file)
99
+ }
100
+ }
101
+
102
+ const otherActiveChanges = [...otherActiveSet]
103
+ const hasForeign = foreignFiles.length > 0 || otherActiveChanges.length > 0
104
+ return { hasForeign, foreignFiles, otherActiveChanges, gitError: null }
105
+ }
106
+
107
+ /**
108
+ * 把检测结果格式化为多行 ⚠️ 警告串。
109
+ * @param {{ hasForeign: boolean, foreignFiles?: string[], otherActiveChanges?: string[], gitError?: string|null }|null} detected
110
+ * @returns {string|null} 无他者并发(hasForeign 为 false / null 输入)返回 null,调用点据此跳过 console.warn
111
+ */
112
+ export function formatConcurrentWarning(detected) {
113
+ if (!detected || !detected.hasForeign) return null
114
+ const foreignFiles = Array.isArray(detected.foreignFiles) ? detected.foreignFiles : []
115
+ const otherActiveChanges = Array.isArray(detected.otherActiveChanges) ? detected.otherActiveChanges : []
116
+
117
+ const lines = ['⚠️ 检测到工作树存在并发他者改动(非阻断 advisory,请人工确认):']
118
+
119
+ if (foreignFiles.length > 0) {
120
+ lines.push('他者业务文件(git-dirty,可能非本变更产出):')
121
+ for (const f of foreignFiles) lines.push(` - ${f}`)
122
+ }
123
+
124
+ if (otherActiveChanges.length > 0) {
125
+ // D-005:文案用「脏变更目录」+ git-dirty 标注,勿用「活跃」防与 DB active 状态混淆。
126
+ lines.push('他者脏变更目录(git-dirty,非 DB active):')
127
+ for (const c of otherActiveChanges) lines.push(` - ${c}`)
128
+ }
129
+
130
+ lines.push('提交请用显式 pathspec 隔离本变更文件,勿 git add . 扫入他者工作。')
131
+ return lines.join('\n')
132
+ }
package/src/run/gates.js CHANGED
@@ -20,9 +20,12 @@
20
20
  * 不搬:ensureDepsFreshness(execute 入口 deps 自检,调用方 runStage 非 completeStep,归属未来 execute-handler)
21
21
  */
22
22
  import { basename, join } from 'node:path'
23
- import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs'
24
- import { triggerSync, resolveChangeDir } from './shared.js'
23
+ import { existsSync, readFileSync, readdirSync, statSync, mkdirSync, writeFileSync } from 'node:fs'
24
+ import { triggerSync, resolveChangeDir, resolveRuntimeRoot } from './shared.js'
25
25
  import { runValidators } from '../stage-contract.js'
26
+ import { handleScanStageCompleted, handleExecuteWorktreeCleanup } from './complete-handlers.js'
27
+ import { detectConcurrentChanges, formatConcurrentWarning } from './concurrent-detect.js'
28
+ import { stageRegistry } from '../stages/index.js'
26
29
 
27
30
  /**
28
31
  * 判断当前 execute step 所在 wave 是否全部 task 都声明 no_deps_verify: true(D-006@v2)。
@@ -108,13 +111,24 @@ export async function enforceDepsGate(stageName, cwd, changeName, step, steps, c
108
111
  */
109
112
  export async function enforceReviewJsonGate(stageName, cwd, changeName, step, steps, currentIdx, specBase, platformOpts) {
110
113
  if (stageName !== 'execute' || !changeName) return true
111
- const runtimeRoot = platformOpts?.runtimeRoot || join(specBase, '.runtime')
114
+ const runtimeRoot = resolveRuntimeRoot(platformOpts, specBase)
112
115
  const runIdFile = join(runtimeRoot, `current-execute-run-id-${changeName}`)
113
116
  const planPath = join(specBase, 'changes', changeName, 'plan.md')
114
117
  if (!existsSync(runIdFile) || !existsSync(planPath)) return true
115
- const executeRunId = readFileSync(runIdFile, 'utf8').trim()
118
+ let executeRunId = readFileSync(runIdFile, 'utf8').trim()
116
119
  const planContent = readFileSync(planPath, 'utf8')
117
- const { validateCheckedTaskReviews } = await import('../task-review.js')
120
+ const { validateCheckedTaskReviews, resolveLatestExecuteRunIdWithTasks } = await import('../task-review.js')
121
+ // marker 漂移兜底(gate-atom-a 正确修法):marker 指向的 run 缺 tasks/(generateExecuteRunId 只写
122
+ // marker 不建目录,漂移后新 run 不继承旧 review)时,无视 marker 改扫 execute-runs/ 取 mtime 最新
123
+ // 且真正含 tasks/ 的 run,用其齐备的 review.json 校验,避免误报「review.json 不存在」。注意不能用
124
+ // resolveLatestExecuteRunId——它见 marker 非空即原样返回(不校验目录),恰是本场景要绕开的值。
125
+ if (executeRunId && !existsSync(join(runtimeRoot, 'execute-runs', executeRunId, 'tasks'))) {
126
+ const relocated = resolveLatestExecuteRunIdWithTasks({ runtimeRoot })
127
+ if (relocated && relocated !== executeRunId) {
128
+ console.warn(`⚠️ execute run marker 漂移:${executeRunId} 无 tasks/,改用真实含 review 的 run ${relocated}`)
129
+ executeRunId = relocated
130
+ }
131
+ }
118
132
  const result = validateCheckedTaskReviews({ planContent, runtimeRoot, executeRunId })
119
133
  if (result.ok) return true
120
134
  if (steps && steps[currentIdx]) steps[currentIdx].status = 'blocked'
@@ -158,10 +172,10 @@ function rollbackStageCompletion(stageData, steps, currentIdx) {
158
172
  * 漏写 triggerSync / 写错 return 结构导致行为分裂。返回 nextPendingIdx=currentIdx,
159
173
  * 让上层走「完成但不推进」分支,--done 被拒、agent 修复产物后重跑。
160
174
  */
161
- async function rollbackCompletionAndReturn(pm, progress, stageData, steps, currentIdx, cwd, changeName, platformOpts) {
175
+ function rollbackCompletionAndReturn(pm, progress, stageData, steps, currentIdx, cwd, changeName, platformOpts) {
162
176
  rollbackStageCompletion(stageData, steps, currentIdx)
163
177
  progress.lastActive = new Date().toLocaleString('zh-CN', { hour12: false })
164
- await pm._write(cwd, progress, changeName)
178
+ pm._write(cwd, progress, changeName)
165
179
  triggerSync(cwd, changeName, platformOpts)
166
180
  return { stageCompleted: false, currentIdx, nextPendingIdx: currentIdx }
167
181
  }
@@ -216,12 +230,19 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
216
230
  // 契约 parity 对账:扫前端 API 调用 vs execute 提取的 provider endpoint artifact。
217
231
  // 接线自 contract-matrix pipeline(verifyApiParity 的 CLI 入口)。
218
232
  const { runVerifyParityCheck, printVerifyParityCheck } = await import('../verify-postcheck.js')
219
- const parityCheck = runVerifyParityCheck({ cwd, specBase, changeName, runtimeRoot: platformOpts?.runtimeRoot })
233
+ const parityRuntimeRoot = resolveRuntimeRoot(platformOpts, specBase)
234
+ const parityCheck = runVerifyParityCheck({ cwd, specBase, changeName, runtimeRoot: parityRuntimeRoot })
220
235
  printVerifyParityCheck(parityCheck)
221
236
  // ── 删除探针(advisory,不阻断):切斯特顿栅栏护栏,用 git 事实对账静默删除代码 ──
222
237
  const { runVerifyDeletionCheck, printVerifyDeletionCheck } = await import('../verify-postcheck.js')
223
238
  const deletionCheck = runVerifyDeletionCheck({ cwd, specBase, changeName })
224
239
  printVerifyDeletionCheck(deletionCheck)
240
+ // ── required-evidence 对账(advisory,不阻断):闭合 execute→verify evidence 死链 ──
241
+ // execute Task Review Gate 把 cannot_verify 任务的 evidence 落盘 verify-required-evidence.json,
242
+ // 本探针查每个 cannot_verify 任务是否在 verify-result.md 体现(CLI 只查提及,满足度 agent 自报告)。
243
+ const { runVerifyRequiredEvidenceCheck, printVerifyRequiredEvidenceCheck } = await import('../verify-postcheck.js')
244
+ const evidenceCheck = runVerifyRequiredEvidenceCheck({ cwd, specBase, changeName })
245
+ printVerifyRequiredEvidenceCheck(evidenceCheck)
225
246
  console.log('\n✅ 验证通过,下一步:sillyspec run archive')
226
247
  }
227
248
 
@@ -250,12 +271,12 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
250
271
  }
251
272
  }
252
273
 
253
- // ── Stage Review Gate:brainstorm/plan/propose/execute 独立审查(按 tier 分级)──
274
+ // ── Stage Review Gate:brainstorm/plan/execute 独立审查(按 tier 分级)──
254
275
  // tier=self 放行+审计打印;tier=independent 必须 review.json 且 verdict 非 fail,fail-closed
255
- if (['brainstorm', 'plan', 'propose', 'execute'].includes(stageName)) {
276
+ if (['brainstorm', 'plan', 'execute'].includes(stageName)) {
256
277
  try {
257
278
  const { classifyReviewTier } = await import('../review-tier.js')
258
- const { validateStageReview, getLatestStageReviewRunId, printStageReviewResult } = await import('../stage-review.js')
279
+ const { validateStageReview, getLatestStageReviewRunId, printStageReviewResult, generateStageReviewRunId, stageReviewMarkerPath } = await import('../stage-review.js')
259
280
  const effectiveSpecBase = platformOpts?.specRoot || specBase
260
281
  const reviewChangeDir = resolveChangeDir(cwd, progress, platformOpts?.specRoot)
261
282
  const designPath = reviewChangeDir ? join(reviewChangeDir, 'design.md') : null
@@ -268,15 +289,23 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
268
289
  }
269
290
  }
270
291
  const tier = classifyReviewTier({ planLevel, designPath })
271
- const runtimeRoot = platformOpts?.runtimeRoot || join(effectiveSpecBase, '.runtime')
292
+ const runtimeRoot = resolveRuntimeRoot(platformOpts, effectiveSpecBase)
272
293
 
273
294
  if (tier.tier === 'self') {
274
295
  console.log('\nℹ️ Stage Review: ' + stageName + ' tier=self(' + tier.reason + '),已降级为当前 agent 自审,不强制独立子代理。')
275
296
  } else {
276
- const reviewRunId = getLatestStageReviewRunId(runtimeRoot, stageName, changeName)
297
+ let reviewRunId = getLatestStageReviewRunId(runtimeRoot, stageName, changeName)
298
+ if (!reviewRunId) {
299
+ // marker 缺失(execute 批量完成跳过 prompt 渲染等场景)→ 自生 + 写盘
300
+ // 让 gate 读到确定 ID,错误从 execute-null 变 execute-review-<id>(可执行)
301
+ reviewRunId = generateStageReviewRunId()
302
+ try {
303
+ mkdirSync(runtimeRoot, { recursive: true })
304
+ writeFileSync(stageReviewMarkerPath(runtimeRoot, stageName, changeName), reviewRunId + '\n')
305
+ } catch {}
306
+ }
277
307
  const reviewType = stageName === 'brainstorm' ? 'design'
278
308
  : stageName === 'plan' ? 'plan'
279
- : stageName === 'propose' ? 'proposal'
280
309
  : 'acceptance'
281
310
  const searchDirs = [effectiveSpecBase, reviewChangeDir, cwd].filter(Boolean)
282
311
  const reviewResult = validateStageReview({ stage: stageName, reviewType, runtimeRoot, reviewRunId, searchDirs })
@@ -302,7 +331,7 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
302
331
 
303
332
  if (planPath && existsSync(planPath)) {
304
333
  const planContent = readFileSync(planPath, 'utf8')
305
- const runtimeRoot = platformOpts?.runtimeRoot || join(effectiveSpecBase, '.runtime')
334
+ const runtimeRoot = resolveRuntimeRoot(platformOpts, effectiveSpecBase)
306
335
 
307
336
  // execute run id:从变更专属标记文件读取
308
337
  const runIdFile = join(runtimeRoot, `current-execute-run-id-${changeName}`)
@@ -313,10 +342,16 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
313
342
  }
314
343
  } catch {}
315
344
  if (!executeRunId) {
316
- const { generateExecuteRunId } = await import('../task-review.js')
317
- executeRunId = generateExecuteRunId()
318
- // 落盘(marker 缺失时 fallback 生成后写盘,保证后续 checkbox/gate 读到同一 ID)
319
- try { mkdirSync(runtimeRoot, { recursive: true }); writeFileSync(runIdFile, executeRunId + '\n') } catch {}
345
+ // marker 缺失:先扫描 execute-runs/ 既有目录找回真实 runId(与 getLatestStageReviewRunId
346
+ // 目录扫描兜底同语义),避免 marker 丢失而 agent 已用旧 runId 落盘时,直接 generate 新 ID
347
+ // 找不到旧 review、误判缺 review.json。仅当确实无既有 run 才 generate 新 ID 并落盘。
348
+ const { generateExecuteRunId, resolveLatestExecuteRunId } = await import('../task-review.js')
349
+ executeRunId = resolveLatestExecuteRunId({ runtimeRoot, changeName }) || ''
350
+ if (!executeRunId) {
351
+ executeRunId = generateExecuteRunId()
352
+ // 落盘(marker 缺失时 fallback 生成后写盘,保证后续 checkbox/gate 读到同一 ID)
353
+ try { mkdirSync(runtimeRoot, { recursive: true }); writeFileSync(runIdFile, executeRunId + '\n') } catch {}
354
+ }
320
355
  }
321
356
 
322
357
  // git 真实性校验目录:worktree 存在则用 worktree(base/head commit 在其中),否则主仓库
@@ -363,3 +398,251 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
363
398
  return null
364
399
  }
365
400
 
401
+ /**
402
+ * 读取 design.md frontmatter 的 scale 字段(brainstorm 末步写入 'small'|'large')。
403
+ * completeStep 的下一步提示据此分叉:small→quick,large/读不到→plan(fail-safe 走重流程)。
404
+ * 只解析首个 YAML frontmatter 块,避免误读正文里的 "scale:"。
405
+ * 从 complete.js 迁入(completeStageGates 共享收尾管线 + completeStep brainstorm 提示消费)。
406
+ */
407
+ export function readDesignScale(specBase, changeName) {
408
+ if (!changeName) return null
409
+ const designPath = join(specBase, 'changes', changeName, 'design.md')
410
+ if (!existsSync(designPath)) return null
411
+ const text = readFileSync(designPath, 'utf8')
412
+ const fm = text.match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*\r?\n/)
413
+ if (!fm) return null
414
+ const m = fm[1].match(/^scale:[ \t]*['"]?(\w+)/m)
415
+ return m ? m[1] : null
416
+ }
417
+
418
+ /**
419
+ * 校验变更目录下近 10 分钟新增的 .md/.yaml/.yml 文件含 author/created_at 元数据(advisory 打印)。
420
+ * 从 complete.js 迁入(completeStageGates 共享收尾管线消费)。
421
+ */
422
+ export function validateMetadata(cwd, stageName, specBase) {
423
+ const changesDir = join(specBase, 'changes')
424
+ if (!existsSync(changesDir)) return
425
+
426
+ const cutoff = Date.now() - 10 * 60 * 1000
427
+ const missing = []
428
+
429
+ function walk(dir) {
430
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
431
+ const full = join(dir, entry.name)
432
+ try {
433
+ if (entry.isDirectory()) { walk(full); continue }
434
+ if (!/\.(md|yaml|yml)$/.test(entry.name)) continue
435
+ const mtime = statSync(full).mtimeMs
436
+ if (mtime < cutoff) continue
437
+ const content = readFileSync(full, 'utf-8')
438
+ if (!content.includes('author:') && !content.includes('author:')) missing.push(full)
439
+ if (!content.includes('created_at:') && !content.includes('created_at:')) missing.push(full)
440
+ } catch (e) { /* skip unreadable files */ }
441
+ }
442
+ }
443
+
444
+ walk(changesDir)
445
+ const unique = [...new Set(missing)]
446
+ if (unique.length > 0) {
447
+ console.log(`\n⚠️ 以下文件缺少 author 或 created_at 元数据:`)
448
+ unique.forEach(f => console.log(` - ${f.replace(cwd + '/', '')}`))
449
+ console.log('请在文件头部添加 author(git 用户名)和 created_at(精确到秒)')
450
+ }
451
+ }
452
+
453
+ /**
454
+ * 验证关键文件是否存在于正确的变更目录下(advisory 打印,防 AI 写错路径)。
455
+ * 从 complete.js 迁入(completeStageGates 共享收尾管线消费)。
456
+ */
457
+ export function validateFileLocations(cwd, stageName, progress, changeName, specBase) {
458
+ const effectiveChange = changeName || progress.currentChange
459
+ if (!effectiveChange) return
460
+
461
+ const changeDir = join(specBase, 'changes', effectiveChange)
462
+ if (!existsSync(changeDir)) return
463
+
464
+ // 每个阶段完成后预期存在的文件
465
+ // brainstorm:scale=small(小变更)只必产 design.md;large/未标 scale → 四件套全。
466
+ // 与 validateBrainstormOutputs 的 BRAINSTORM_RULES condition(scale≠small)同源,避免对合法 small 变更
467
+ // 误报"⬜ proposal/requirements/tasks 未找到"(本检查仅 advisory 打印不 gate,但误导输出仍要消除)。
468
+ const brainstormExpected = readDesignScale(specBase, effectiveChange) === 'small'
469
+ ? ['design.md']
470
+ : ['design.md', 'proposal.md', 'requirements.md', 'tasks.md']
471
+ const expectedFiles = {
472
+ brainstorm: brainstormExpected,
473
+ plan: ['plan.md'],
474
+ archive: ['module-impact.md'],
475
+ }
476
+
477
+ const expected = expectedFiles[stageName]
478
+ if (!expected) return
479
+
480
+ const missing = []
481
+ for (const file of expected) {
482
+ if (!existsSync(join(changeDir, file))) {
483
+ missing.push(file)
484
+ }
485
+ }
486
+
487
+ if (missing.length > 0) {
488
+ console.log(`\n⚠️ 文件位置验证:以下文件未在变更目录中找到`)
489
+ console.log(` 变更目录:${changeDir.replace(cwd + '/', '')}/`)
490
+ for (const f of missing) {
491
+ // 检查是否写到了错误的位置
492
+ const wrongPath = join(specBase, 'changes', 'change', effectiveChange, f)
493
+ if (existsSync(wrongPath)) {
494
+ console.log(` ❌ ${f} — 不存在,但发现了错误路径:${wrongPath.replace(cwd + '/', '')}`)
495
+ console.log(` 提示:应该写入 ${changeDir.replace(cwd + '/', '')}/${f}`)
496
+ } else {
497
+ console.log(` ⬜ ${f} — 未找到(该阶段可能未产出此文件)`)
498
+ }
499
+ }
500
+ } else {
501
+ console.log(`\n✅ 文件位置验证:所有 ${expected.length} 个预期文件均在变更目录中`)
502
+ }
503
+ }
504
+
505
+ /**
506
+ * 读本变更 design.md §6「文件变更清单」表格,提取声明交付的文件路径(concurrent 预检 ownFiles 源,D-002)。
507
+ *
508
+ * 用途:execute --done 并发预检在 in-place-fallback 模式下,需把本变更交付文件从 foreignFiles 排除
509
+ * (否则自己产出的脏文件会被当他者)。worktree 模式下交付文件不在主仓 git status,ownFiles 用空数组即可。
510
+ *
511
+ * 解析:定位 `## 6.` 段(到下一个 `## N.` 前),逐行扫表格行第 2 列的反引号文件路径,跳过表头/分隔行。
512
+ * 设计容错(design §9 / R-02):design.md 缺失 / 无 §6 / 解析空 → 返回 [],foreignFiles 退化为保守噪音,
513
+ * otherActiveChanges 仍可靠。
514
+ *
515
+ * @param {string} specBase 规范根(.sillyspec/ 所在)
516
+ * @param {string} changeName 当前变更名
517
+ * @returns {string[]} 本变更声明交付的文件路径(git 路径,正斜杠)
518
+ */
519
+ function readDesignOwnFiles(specBase, changeName) {
520
+ if (!specBase || !changeName) return []
521
+ let content
522
+ try {
523
+ content = readFileSync(join(specBase, 'changes', changeName, 'design.md'), 'utf8')
524
+ } catch {
525
+ return []
526
+ }
527
+ const files = []
528
+ let inSection6 = false
529
+ for (const line of content.split('\n')) {
530
+ const heading = line.match(/^##\s+(\d+)\./)
531
+ if (heading) {
532
+ inSection6 = heading[1] === '6'
533
+ continue
534
+ }
535
+ if (!inSection6 || !line.startsWith('|')) continue
536
+ // 跳过表头分隔行(|---|---|)
537
+ if (/^\|[\s:|-]+\|?$/.test(line.trim())) continue
538
+ // 表格第 2 列反引号文件路径:`| 新增 | \`src/.../x.js\` | 说明 |`
539
+ const m = line.match(/^\|[^|]+\|\s*`([^`]+)`/)
540
+ if (m) files.push(m[1].trim())
541
+ }
542
+ return files
543
+ }
544
+
545
+ /**
546
+ * 阶段完成收尾共享管线(从 completeStep 抽出,消除 noAI 末步 / continueStep 完成分支绕过 gate 的
547
+ * S1/S2/S3 三处不对称)。调用方已自行标记 stageData.status='completed' 并 pm._write 落盘后调用本函数。
548
+ *
549
+ * 序列:handleScanStageCompleted → validateMetadata → validateFileLocations[completed‖skipped 守卫] →
550
+ * auxiliary 重置 → runStageCompletionGates[同守卫] → handleExecuteWorktreeCleanup。
551
+ *
552
+ * 关键陷阱(design §5.4):auxiliary 重置把 stageData.steps 换成 freshSteps(全 pending);下方
553
+ * runStageCompletionGates 守卫与 rollbackStageCompletion 必须用入参 steps(pre-reset 原数组),
554
+ * 不得重读 stageData.steps(否则计数恒 0 → gate 永跳过)。本函数全程用入参 steps,未重读。
555
+ *
556
+ * @returns {Promise<{stageCompleted:false,currentIdx,nextPendingIdx:number}|null>}
557
+ * null = 全部通过,调用方继续自管收尾(如下一步提示);
558
+ * 非 null = gate/handler 失败已 rollback,调用方直接 return。
559
+ */
560
+ export async function completeStageGates({ stageName, cwd, changeName, platformOpts, specBase, progress, pm, stageData, steps, currentIdx, outputText }) {
561
+ // 整体 try/catch(task-04 / review-2026-08-09 #2):收尾段(execute 预检 + scan handler + validate* +
562
+ // auxiliary 重置 + runStageCompletionGates)任一段抛非结构化异常 → rollbackCompletionAndReturn
563
+ // (回滚 in-progress + 落盘 + 返回未完成对象),不冒顶 exit 1。正常的 _scanResult/_gateEarlyReturn
564
+ // early-return 是 return 非 throw,不被 catch 拦;下方 handleExecuteWorktreeCleanup 在 try 外(副作用独立,失败不 rollback stage 状态)。
565
+ try {
566
+ // ── execute --done 并发他者改动预检(FR-06/FR-07,非阻断 advisory)──
567
+ // 仅 execute 触发,不影响 scan/plan/verify/archive 等 stage 的 completeStageGates。
568
+ // 纯副作用:console.warn 后照常推进,不改 stageData / 不阻断级联(FR-07)。
569
+ // 整个钩子 try/catch 兜底——任何异常吞掉,绝不影响 gate 级联(FR-07 不阻断铁律)。
570
+ if (stageName === 'execute') {
571
+ try {
572
+ // ownFiles 源(D-002 + B-002,钉死):动态 import WorktreeManager 取 meta.mode(复用上方
573
+ // Task Review Gate 的 ../worktree.js 动态 import 先例)。WorktreeManager 无 appliedFiles
574
+ // 字段(B-002 已证),不用模糊的「worktree applied 文件」。
575
+ // - worktree 模式(meta.mode 非 in-place-fallback):主仓 git status 看不见本变更交付文件
576
+ // → ownFiles=[](无害,dogfood 当前实际模式)
577
+ // - in-place-fallback 模式:交付文件就在主仓 dirty → ownFiles 读 design.md §6 文件清单排除
578
+ let ownFiles = []
579
+ // linkedChanges:execute 通常无(quick 管线概念,存 quick guard 文件);stageData 取不到则 []。
580
+ const linkedChanges = Array.isArray(stageData?.linkedChanges) ? stageData.linkedChanges : []
581
+ try {
582
+ const { WorktreeManager } = await import('../worktree.js')
583
+ const wm = new WorktreeManager({ cwd })
584
+ const meta = wm.getMeta(changeName)
585
+ if (meta?.mode === 'in-place-fallback') {
586
+ ownFiles = readDesignOwnFiles(specBase, changeName)
587
+ }
588
+ } catch {
589
+ // getMeta 抛(meta.json 缺 / worktree 损坏)→ ownFiles=[] 兜底,不崩(FR-07)。
590
+ ownFiles = []
591
+ }
592
+ const detected = detectConcurrentChanges(cwd, { changeName, linkedChanges, ownFiles })
593
+ const warn = formatConcurrentWarning(detected)
594
+ if (warn) console.warn(warn)
595
+ } catch {
596
+ // FR-07 不阻断:钩子任何意外异常静默吞掉,gate 级联照常推进。
597
+ }
598
+ }
599
+
600
+ // scan 平台 manifest + post-check(S1 平台受害者:noAI scanPostcheck 末步 / continueStep 现也走这里)
601
+ const _scanResult = await handleScanStageCompleted({ stageName, currentIdx, cwd, progress, pm, stageData, changeName, outputText, platformOpts })
602
+ if (_scanResult) return _scanResult
603
+
604
+ // 守卫计数:completed‖skipped === total(修 S3);用入参 steps(pre-reset 原数组,design §5.4)
605
+ const settledCount = steps.filter(s => s.status === 'completed' || s.status === 'skipped').length
606
+ const total = steps.length
607
+
608
+ validateMetadata(cwd, stageName, specBase)
609
+
610
+ // 验证关键文件位置(仅当所有步骤已结案 completed‖skipped 时才校验)
611
+ if (settledCount === total && total > 0) {
612
+ validateFileLocations(cwd, stageName, progress, changeName, specBase)
613
+ }
614
+
615
+ // 辅助阶段完成后重置步骤(scan 等 auxiliary 阶段重置回 pending 可重跑)
616
+ const stageDef = stageRegistry[stageName]
617
+ if (stageDef?.auxiliary) {
618
+ const freshSteps = (stageDef.steps || []).map(s => ({
619
+ name: s.name,
620
+ status: 'pending',
621
+ output: null,
622
+ completedAt: null
623
+ }))
624
+ stageData.steps = freshSteps
625
+ stageData.status = 'pending'
626
+ stageData.completedAt = null
627
+ if (progress.currentStage === stageName) progress.currentStage = ''
628
+ pm._write(cwd, progress, changeName)
629
+ }
630
+
631
+ // 阶段完成校验 gate 级联(runValidators → verify-test → Plan→Execute → Stage Review → Task Review)
632
+ if (settledCount === total && total > 0) {
633
+ const _gateEarlyReturn = await runStageCompletionGates({ stageName, cwd, changeName, platformOpts, specBase, progress, pm, stageData, steps, currentIdx })
634
+ if (_gateEarlyReturn) return _gateEarlyReturn
635
+ } else if (settledCount < total) {
636
+ console.log(`\n⚠️ 阶段校验跳过:${total} 步中仅 ${settledCount} 步标记为已结案(completed‖skipped),可能存在状态不同步。如确认阶段已完成,请运行 --status 确认。`)
637
+ }
638
+ } catch (e) {
639
+ console.error(`\n❌ ${stageName} 阶段完成收尾异常(已 rollback 为 in-progress,请修复后重新 --done):${e?.message || e}`)
640
+ return await rollbackCompletionAndReturn(pm, progress, stageData, steps, currentIdx, cwd, changeName, platformOpts)
641
+ }
642
+
643
+ // execute worktree cleanup(completeStep / continueStep 完成分支统一调用,避免双清理)
644
+ await handleExecuteWorktreeCleanup({ stageName, changeName, cwd })
645
+
646
+ return null
647
+ }
648
+