sillyspec 3.25.9 → 3.25.11

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 (71) 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-verify/SKILL.md +1 -1
  8. package/README.md +9 -0
  9. package/package.json +2 -2
  10. package/src/change-list.js +11 -8
  11. package/src/change-risk-profile.js +22 -0
  12. package/src/db.js +160 -159
  13. package/src/dispatch/backends/local-agent.js +152 -0
  14. package/src/dispatch/backends/sillyhub-mcp.js +242 -0
  15. package/src/dispatch/probe.js +208 -0
  16. package/src/dispatch/strategy.js +121 -0
  17. package/src/doctor-diagnostics.js +19 -25
  18. package/src/fs-atomic.js +69 -69
  19. package/src/git-helper.js +66 -0
  20. package/src/hooks/worktree-guard.js +64 -55
  21. package/src/index.js +176 -34
  22. package/src/init.js +7 -14
  23. package/src/machine-interface.js +54 -19
  24. package/src/migrate.js +14 -4
  25. package/src/modules.js +16 -5
  26. package/src/progress/change-registry.js +72 -70
  27. package/src/progress/consistency-doctor.js +71 -14
  28. package/src/progress/shared.js +6 -3
  29. package/src/progress/stage-machine.js +61 -65
  30. package/src/progress/step-store.js +62 -67
  31. package/src/progress.js +155 -233
  32. package/src/quicklog.js +13 -5
  33. package/src/run/command.js +65 -37
  34. package/src/run/complete-handlers.js +1005 -889
  35. package/src/run/complete.js +115 -208
  36. package/src/run/concurrent-detect.js +132 -0
  37. package/src/run/gates.js +289 -17
  38. package/src/run/prompt.js +22 -68
  39. package/src/run/quick-audit.js +10 -9
  40. package/src/run/shared.js +648 -600
  41. package/src/run/stage.js +43 -24
  42. package/src/scan-postcheck.js +9 -31
  43. package/src/sillyhub-mcp/client.js +389 -0
  44. package/src/spec-dir-typo.js +71 -0
  45. package/src/stage-contract-engine.js +22 -1
  46. package/src/stage-contract-spec.js +11 -4
  47. package/src/stage-contract.js +26 -3
  48. package/src/stage-review.js +13 -9
  49. package/src/stages/archive.js +4 -6
  50. package/src/stages/brainstorm-auto.js +26 -20
  51. package/src/stages/brainstorm.js +29 -5
  52. package/src/stages/cmd-existence.js +145 -0
  53. package/src/stages/doctor.js +9 -6
  54. package/src/stages/execute.js +61 -20
  55. package/src/stages/plan-postcheck.js +152 -0
  56. package/src/stages/plan.js +16 -31
  57. package/src/stages/quick.js +1 -1
  58. package/src/stages/scan.js +9 -3
  59. package/src/stages/status.js +0 -1
  60. package/src/stages/verify.js +12 -7
  61. package/src/sync.js +4 -4
  62. package/src/task-review.js +30 -1
  63. package/src/verify-postcheck.js +933 -862
  64. package/src/version.js +19 -0
  65. package/src/worktree-apply.js +85 -68
  66. package/src/worktree-deps.js +89 -5
  67. package/src/worktree.js +213 -111
  68. package/templates/prompts/taskcard-rules.md +27 -0
  69. package/templates/prompts/verify-probes.md +3 -3
  70. package/.claude/skills/sillyspec-propose/SKILL.md +0 -87
  71. package/src/stages/propose.js +0 -181
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,7 +111,7 @@ 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
@@ -158,10 +161,10 @@ function rollbackStageCompletion(stageData, steps, currentIdx) {
158
161
  * 漏写 triggerSync / 写错 return 结构导致行为分裂。返回 nextPendingIdx=currentIdx,
159
162
  * 让上层走「完成但不推进」分支,--done 被拒、agent 修复产物后重跑。
160
163
  */
161
- async function rollbackCompletionAndReturn(pm, progress, stageData, steps, currentIdx, cwd, changeName, platformOpts) {
164
+ function rollbackCompletionAndReturn(pm, progress, stageData, steps, currentIdx, cwd, changeName, platformOpts) {
162
165
  rollbackStageCompletion(stageData, steps, currentIdx)
163
166
  progress.lastActive = new Date().toLocaleString('zh-CN', { hour12: false })
164
- await pm._write(cwd, progress, changeName)
167
+ pm._write(cwd, progress, changeName)
165
168
  triggerSync(cwd, changeName, platformOpts)
166
169
  return { stageCompleted: false, currentIdx, nextPendingIdx: currentIdx }
167
170
  }
@@ -216,12 +219,19 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
216
219
  // 契约 parity 对账:扫前端 API 调用 vs execute 提取的 provider endpoint artifact。
217
220
  // 接线自 contract-matrix pipeline(verifyApiParity 的 CLI 入口)。
218
221
  const { runVerifyParityCheck, printVerifyParityCheck } = await import('../verify-postcheck.js')
219
- const parityCheck = runVerifyParityCheck({ cwd, specBase, changeName, runtimeRoot: platformOpts?.runtimeRoot })
222
+ const parityRuntimeRoot = resolveRuntimeRoot(platformOpts, specBase)
223
+ const parityCheck = runVerifyParityCheck({ cwd, specBase, changeName, runtimeRoot: parityRuntimeRoot })
220
224
  printVerifyParityCheck(parityCheck)
221
225
  // ── 删除探针(advisory,不阻断):切斯特顿栅栏护栏,用 git 事实对账静默删除代码 ──
222
226
  const { runVerifyDeletionCheck, printVerifyDeletionCheck } = await import('../verify-postcheck.js')
223
227
  const deletionCheck = runVerifyDeletionCheck({ cwd, specBase, changeName })
224
228
  printVerifyDeletionCheck(deletionCheck)
229
+ // ── required-evidence 对账(advisory,不阻断):闭合 execute→verify evidence 死链 ──
230
+ // execute Task Review Gate 把 cannot_verify 任务的 evidence 落盘 verify-required-evidence.json,
231
+ // 本探针查每个 cannot_verify 任务是否在 verify-result.md 体现(CLI 只查提及,满足度 agent 自报告)。
232
+ const { runVerifyRequiredEvidenceCheck, printVerifyRequiredEvidenceCheck } = await import('../verify-postcheck.js')
233
+ const evidenceCheck = runVerifyRequiredEvidenceCheck({ cwd, specBase, changeName })
234
+ printVerifyRequiredEvidenceCheck(evidenceCheck)
225
235
  console.log('\n✅ 验证通过,下一步:sillyspec run archive')
226
236
  }
227
237
 
@@ -250,12 +260,12 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
250
260
  }
251
261
  }
252
262
 
253
- // ── Stage Review Gate:brainstorm/plan/propose/execute 独立审查(按 tier 分级)──
263
+ // ── Stage Review Gate:brainstorm/plan/execute 独立审查(按 tier 分级)──
254
264
  // tier=self 放行+审计打印;tier=independent 必须 review.json 且 verdict 非 fail,fail-closed
255
- if (['brainstorm', 'plan', 'propose', 'execute'].includes(stageName)) {
265
+ if (['brainstorm', 'plan', 'execute'].includes(stageName)) {
256
266
  try {
257
267
  const { classifyReviewTier } = await import('../review-tier.js')
258
- const { validateStageReview, getLatestStageReviewRunId, printStageReviewResult } = await import('../stage-review.js')
268
+ const { validateStageReview, getLatestStageReviewRunId, printStageReviewResult, generateStageReviewRunId, stageReviewMarkerPath } = await import('../stage-review.js')
259
269
  const effectiveSpecBase = platformOpts?.specRoot || specBase
260
270
  const reviewChangeDir = resolveChangeDir(cwd, progress, platformOpts?.specRoot)
261
271
  const designPath = reviewChangeDir ? join(reviewChangeDir, 'design.md') : null
@@ -268,15 +278,23 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
268
278
  }
269
279
  }
270
280
  const tier = classifyReviewTier({ planLevel, designPath })
271
- const runtimeRoot = platformOpts?.runtimeRoot || join(effectiveSpecBase, '.runtime')
281
+ const runtimeRoot = resolveRuntimeRoot(platformOpts, effectiveSpecBase)
272
282
 
273
283
  if (tier.tier === 'self') {
274
284
  console.log('\nℹ️ Stage Review: ' + stageName + ' tier=self(' + tier.reason + '),已降级为当前 agent 自审,不强制独立子代理。')
275
285
  } else {
276
- const reviewRunId = getLatestStageReviewRunId(runtimeRoot, stageName, changeName)
286
+ let reviewRunId = getLatestStageReviewRunId(runtimeRoot, stageName, changeName)
287
+ if (!reviewRunId) {
288
+ // marker 缺失(execute 批量完成跳过 prompt 渲染等场景)→ 自生 + 写盘
289
+ // 让 gate 读到确定 ID,错误从 execute-null 变 execute-review-<id>(可执行)
290
+ reviewRunId = generateStageReviewRunId()
291
+ try {
292
+ mkdirSync(runtimeRoot, { recursive: true })
293
+ writeFileSync(stageReviewMarkerPath(runtimeRoot, stageName, changeName), reviewRunId + '\n')
294
+ } catch {}
295
+ }
277
296
  const reviewType = stageName === 'brainstorm' ? 'design'
278
297
  : stageName === 'plan' ? 'plan'
279
- : stageName === 'propose' ? 'proposal'
280
298
  : 'acceptance'
281
299
  const searchDirs = [effectiveSpecBase, reviewChangeDir, cwd].filter(Boolean)
282
300
  const reviewResult = validateStageReview({ stage: stageName, reviewType, runtimeRoot, reviewRunId, searchDirs })
@@ -302,7 +320,7 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
302
320
 
303
321
  if (planPath && existsSync(planPath)) {
304
322
  const planContent = readFileSync(planPath, 'utf8')
305
- const runtimeRoot = platformOpts?.runtimeRoot || join(effectiveSpecBase, '.runtime')
323
+ const runtimeRoot = resolveRuntimeRoot(platformOpts, effectiveSpecBase)
306
324
 
307
325
  // execute run id:从变更专属标记文件读取
308
326
  const runIdFile = join(runtimeRoot, `current-execute-run-id-${changeName}`)
@@ -313,10 +331,16 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
313
331
  }
314
332
  } catch {}
315
333
  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 {}
334
+ // marker 缺失:先扫描 execute-runs/ 既有目录找回真实 runId(与 getLatestStageReviewRunId
335
+ // 目录扫描兜底同语义),避免 marker 丢失而 agent 已用旧 runId 落盘时,直接 generate 新 ID
336
+ // 找不到旧 review、误判缺 review.json。仅当确实无既有 run 才 generate 新 ID 并落盘。
337
+ const { generateExecuteRunId, resolveLatestExecuteRunId } = await import('../task-review.js')
338
+ executeRunId = resolveLatestExecuteRunId({ runtimeRoot, changeName }) || ''
339
+ if (!executeRunId) {
340
+ executeRunId = generateExecuteRunId()
341
+ // 落盘(marker 缺失时 fallback 生成后写盘,保证后续 checkbox/gate 读到同一 ID)
342
+ try { mkdirSync(runtimeRoot, { recursive: true }); writeFileSync(runIdFile, executeRunId + '\n') } catch {}
343
+ }
320
344
  }
321
345
 
322
346
  // git 真实性校验目录:worktree 存在则用 worktree(base/head commit 在其中),否则主仓库
@@ -363,3 +387,251 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
363
387
  return null
364
388
  }
365
389
 
390
+ /**
391
+ * 读取 design.md frontmatter 的 scale 字段(brainstorm 末步写入 'small'|'large')。
392
+ * completeStep 的下一步提示据此分叉:small→quick,large/读不到→plan(fail-safe 走重流程)。
393
+ * 只解析首个 YAML frontmatter 块,避免误读正文里的 "scale:"。
394
+ * 从 complete.js 迁入(completeStageGates 共享收尾管线 + completeStep brainstorm 提示消费)。
395
+ */
396
+ export function readDesignScale(specBase, changeName) {
397
+ if (!changeName) return null
398
+ const designPath = join(specBase, 'changes', changeName, 'design.md')
399
+ if (!existsSync(designPath)) return null
400
+ const text = readFileSync(designPath, 'utf8')
401
+ const fm = text.match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*\r?\n/)
402
+ if (!fm) return null
403
+ const m = fm[1].match(/^scale:[ \t]*['"]?(\w+)/m)
404
+ return m ? m[1] : null
405
+ }
406
+
407
+ /**
408
+ * 校验变更目录下近 10 分钟新增的 .md/.yaml/.yml 文件含 author/created_at 元数据(advisory 打印)。
409
+ * 从 complete.js 迁入(completeStageGates 共享收尾管线消费)。
410
+ */
411
+ export function validateMetadata(cwd, stageName, specBase) {
412
+ const changesDir = join(specBase, 'changes')
413
+ if (!existsSync(changesDir)) return
414
+
415
+ const cutoff = Date.now() - 10 * 60 * 1000
416
+ const missing = []
417
+
418
+ function walk(dir) {
419
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
420
+ const full = join(dir, entry.name)
421
+ try {
422
+ if (entry.isDirectory()) { walk(full); continue }
423
+ if (!/\.(md|yaml|yml)$/.test(entry.name)) continue
424
+ const mtime = statSync(full).mtimeMs
425
+ if (mtime < cutoff) continue
426
+ const content = readFileSync(full, 'utf-8')
427
+ if (!content.includes('author:') && !content.includes('author:')) missing.push(full)
428
+ if (!content.includes('created_at:') && !content.includes('created_at:')) missing.push(full)
429
+ } catch (e) { /* skip unreadable files */ }
430
+ }
431
+ }
432
+
433
+ walk(changesDir)
434
+ const unique = [...new Set(missing)]
435
+ if (unique.length > 0) {
436
+ console.log(`\n⚠️ 以下文件缺少 author 或 created_at 元数据:`)
437
+ unique.forEach(f => console.log(` - ${f.replace(cwd + '/', '')}`))
438
+ console.log('请在文件头部添加 author(git 用户名)和 created_at(精确到秒)')
439
+ }
440
+ }
441
+
442
+ /**
443
+ * 验证关键文件是否存在于正确的变更目录下(advisory 打印,防 AI 写错路径)。
444
+ * 从 complete.js 迁入(completeStageGates 共享收尾管线消费)。
445
+ */
446
+ export function validateFileLocations(cwd, stageName, progress, changeName, specBase) {
447
+ const effectiveChange = changeName || progress.currentChange
448
+ if (!effectiveChange) return
449
+
450
+ const changeDir = join(specBase, 'changes', effectiveChange)
451
+ if (!existsSync(changeDir)) return
452
+
453
+ // 每个阶段完成后预期存在的文件
454
+ // brainstorm:scale=small(小变更)只必产 design.md;large/未标 scale → 四件套全。
455
+ // 与 validateBrainstormOutputs 的 BRAINSTORM_RULES condition(scale≠small)同源,避免对合法 small 变更
456
+ // 误报"⬜ proposal/requirements/tasks 未找到"(本检查仅 advisory 打印不 gate,但误导输出仍要消除)。
457
+ const brainstormExpected = readDesignScale(specBase, effectiveChange) === 'small'
458
+ ? ['design.md']
459
+ : ['design.md', 'proposal.md', 'requirements.md', 'tasks.md']
460
+ const expectedFiles = {
461
+ brainstorm: brainstormExpected,
462
+ plan: ['plan.md'],
463
+ archive: ['module-impact.md'],
464
+ }
465
+
466
+ const expected = expectedFiles[stageName]
467
+ if (!expected) return
468
+
469
+ const missing = []
470
+ for (const file of expected) {
471
+ if (!existsSync(join(changeDir, file))) {
472
+ missing.push(file)
473
+ }
474
+ }
475
+
476
+ if (missing.length > 0) {
477
+ console.log(`\n⚠️ 文件位置验证:以下文件未在变更目录中找到`)
478
+ console.log(` 变更目录:${changeDir.replace(cwd + '/', '')}/`)
479
+ for (const f of missing) {
480
+ // 检查是否写到了错误的位置
481
+ const wrongPath = join(specBase, 'changes', 'change', effectiveChange, f)
482
+ if (existsSync(wrongPath)) {
483
+ console.log(` ❌ ${f} — 不存在,但发现了错误路径:${wrongPath.replace(cwd + '/', '')}`)
484
+ console.log(` 提示:应该写入 ${changeDir.replace(cwd + '/', '')}/${f}`)
485
+ } else {
486
+ console.log(` ⬜ ${f} — 未找到(该阶段可能未产出此文件)`)
487
+ }
488
+ }
489
+ } else {
490
+ console.log(`\n✅ 文件位置验证:所有 ${expected.length} 个预期文件均在变更目录中`)
491
+ }
492
+ }
493
+
494
+ /**
495
+ * 读本变更 design.md §6「文件变更清单」表格,提取声明交付的文件路径(concurrent 预检 ownFiles 源,D-002)。
496
+ *
497
+ * 用途:execute --done 并发预检在 in-place-fallback 模式下,需把本变更交付文件从 foreignFiles 排除
498
+ * (否则自己产出的脏文件会被当他者)。worktree 模式下交付文件不在主仓 git status,ownFiles 用空数组即可。
499
+ *
500
+ * 解析:定位 `## 6.` 段(到下一个 `## N.` 前),逐行扫表格行第 2 列的反引号文件路径,跳过表头/分隔行。
501
+ * 设计容错(design §9 / R-02):design.md 缺失 / 无 §6 / 解析空 → 返回 [],foreignFiles 退化为保守噪音,
502
+ * otherActiveChanges 仍可靠。
503
+ *
504
+ * @param {string} specBase 规范根(.sillyspec/ 所在)
505
+ * @param {string} changeName 当前变更名
506
+ * @returns {string[]} 本变更声明交付的文件路径(git 路径,正斜杠)
507
+ */
508
+ function readDesignOwnFiles(specBase, changeName) {
509
+ if (!specBase || !changeName) return []
510
+ let content
511
+ try {
512
+ content = readFileSync(join(specBase, 'changes', changeName, 'design.md'), 'utf8')
513
+ } catch {
514
+ return []
515
+ }
516
+ const files = []
517
+ let inSection6 = false
518
+ for (const line of content.split('\n')) {
519
+ const heading = line.match(/^##\s+(\d+)\./)
520
+ if (heading) {
521
+ inSection6 = heading[1] === '6'
522
+ continue
523
+ }
524
+ if (!inSection6 || !line.startsWith('|')) continue
525
+ // 跳过表头分隔行(|---|---|)
526
+ if (/^\|[\s:|-]+\|?$/.test(line.trim())) continue
527
+ // 表格第 2 列反引号文件路径:`| 新增 | \`src/.../x.js\` | 说明 |`
528
+ const m = line.match(/^\|[^|]+\|\s*`([^`]+)`/)
529
+ if (m) files.push(m[1].trim())
530
+ }
531
+ return files
532
+ }
533
+
534
+ /**
535
+ * 阶段完成收尾共享管线(从 completeStep 抽出,消除 noAI 末步 / continueStep 完成分支绕过 gate 的
536
+ * S1/S2/S3 三处不对称)。调用方已自行标记 stageData.status='completed' 并 pm._write 落盘后调用本函数。
537
+ *
538
+ * 序列:handleScanStageCompleted → validateMetadata → validateFileLocations[completed‖skipped 守卫] →
539
+ * auxiliary 重置 → runStageCompletionGates[同守卫] → handleExecuteWorktreeCleanup。
540
+ *
541
+ * 关键陷阱(design §5.4):auxiliary 重置把 stageData.steps 换成 freshSteps(全 pending);下方
542
+ * runStageCompletionGates 守卫与 rollbackStageCompletion 必须用入参 steps(pre-reset 原数组),
543
+ * 不得重读 stageData.steps(否则计数恒 0 → gate 永跳过)。本函数全程用入参 steps,未重读。
544
+ *
545
+ * @returns {Promise<{stageCompleted:false,currentIdx,nextPendingIdx:number}|null>}
546
+ * null = 全部通过,调用方继续自管收尾(如下一步提示);
547
+ * 非 null = gate/handler 失败已 rollback,调用方直接 return。
548
+ */
549
+ export async function completeStageGates({ stageName, cwd, changeName, platformOpts, specBase, progress, pm, stageData, steps, currentIdx, outputText }) {
550
+ // 整体 try/catch(task-04 / review-2026-08-09 #2):收尾段(execute 预检 + scan handler + validate* +
551
+ // auxiliary 重置 + runStageCompletionGates)任一段抛非结构化异常 → rollbackCompletionAndReturn
552
+ // (回滚 in-progress + 落盘 + 返回未完成对象),不冒顶 exit 1。正常的 _scanResult/_gateEarlyReturn
553
+ // early-return 是 return 非 throw,不被 catch 拦;下方 handleExecuteWorktreeCleanup 在 try 外(副作用独立,失败不 rollback stage 状态)。
554
+ try {
555
+ // ── execute --done 并发他者改动预检(FR-06/FR-07,非阻断 advisory)──
556
+ // 仅 execute 触发,不影响 scan/plan/verify/archive 等 stage 的 completeStageGates。
557
+ // 纯副作用:console.warn 后照常推进,不改 stageData / 不阻断级联(FR-07)。
558
+ // 整个钩子 try/catch 兜底——任何异常吞掉,绝不影响 gate 级联(FR-07 不阻断铁律)。
559
+ if (stageName === 'execute') {
560
+ try {
561
+ // ownFiles 源(D-002 + B-002,钉死):动态 import WorktreeManager 取 meta.mode(复用上方
562
+ // Task Review Gate 的 ../worktree.js 动态 import 先例)。WorktreeManager 无 appliedFiles
563
+ // 字段(B-002 已证),不用模糊的「worktree applied 文件」。
564
+ // - worktree 模式(meta.mode 非 in-place-fallback):主仓 git status 看不见本变更交付文件
565
+ // → ownFiles=[](无害,dogfood 当前实际模式)
566
+ // - in-place-fallback 模式:交付文件就在主仓 dirty → ownFiles 读 design.md §6 文件清单排除
567
+ let ownFiles = []
568
+ // linkedChanges:execute 通常无(quick 管线概念,存 quick guard 文件);stageData 取不到则 []。
569
+ const linkedChanges = Array.isArray(stageData?.linkedChanges) ? stageData.linkedChanges : []
570
+ try {
571
+ const { WorktreeManager } = await import('../worktree.js')
572
+ const wm = new WorktreeManager({ cwd })
573
+ const meta = wm.getMeta(changeName)
574
+ if (meta?.mode === 'in-place-fallback') {
575
+ ownFiles = readDesignOwnFiles(specBase, changeName)
576
+ }
577
+ } catch {
578
+ // getMeta 抛(meta.json 缺 / worktree 损坏)→ ownFiles=[] 兜底,不崩(FR-07)。
579
+ ownFiles = []
580
+ }
581
+ const detected = detectConcurrentChanges(cwd, { changeName, linkedChanges, ownFiles })
582
+ const warn = formatConcurrentWarning(detected)
583
+ if (warn) console.warn(warn)
584
+ } catch {
585
+ // FR-07 不阻断:钩子任何意外异常静默吞掉,gate 级联照常推进。
586
+ }
587
+ }
588
+
589
+ // scan 平台 manifest + post-check(S1 平台受害者:noAI scanPostcheck 末步 / continueStep 现也走这里)
590
+ const _scanResult = await handleScanStageCompleted({ stageName, currentIdx, cwd, progress, pm, stageData, changeName, outputText, platformOpts })
591
+ if (_scanResult) return _scanResult
592
+
593
+ // 守卫计数:completed‖skipped === total(修 S3);用入参 steps(pre-reset 原数组,design §5.4)
594
+ const settledCount = steps.filter(s => s.status === 'completed' || s.status === 'skipped').length
595
+ const total = steps.length
596
+
597
+ validateMetadata(cwd, stageName, specBase)
598
+
599
+ // 验证关键文件位置(仅当所有步骤已结案 completed‖skipped 时才校验)
600
+ if (settledCount === total && total > 0) {
601
+ validateFileLocations(cwd, stageName, progress, changeName, specBase)
602
+ }
603
+
604
+ // 辅助阶段完成后重置步骤(scan 等 auxiliary 阶段重置回 pending 可重跑)
605
+ const stageDef = stageRegistry[stageName]
606
+ if (stageDef?.auxiliary) {
607
+ const freshSteps = (stageDef.steps || []).map(s => ({
608
+ name: s.name,
609
+ status: 'pending',
610
+ output: null,
611
+ completedAt: null
612
+ }))
613
+ stageData.steps = freshSteps
614
+ stageData.status = 'pending'
615
+ stageData.completedAt = null
616
+ if (progress.currentStage === stageName) progress.currentStage = ''
617
+ pm._write(cwd, progress, changeName)
618
+ }
619
+
620
+ // 阶段完成校验 gate 级联(runValidators → verify-test → Plan→Execute → Stage Review → Task Review)
621
+ if (settledCount === total && total > 0) {
622
+ const _gateEarlyReturn = await runStageCompletionGates({ stageName, cwd, changeName, platformOpts, specBase, progress, pm, stageData, steps, currentIdx })
623
+ if (_gateEarlyReturn) return _gateEarlyReturn
624
+ } else if (settledCount < total) {
625
+ console.log(`\n⚠️ 阶段校验跳过:${total} 步中仅 ${settledCount} 步标记为已结案(completed‖skipped),可能存在状态不同步。如确认阶段已完成,请运行 --status 确认。`)
626
+ }
627
+ } catch (e) {
628
+ console.error(`\n❌ ${stageName} 阶段完成收尾异常(已 rollback 为 in-progress,请修复后重新 --done):${e?.message || e}`)
629
+ return await rollbackCompletionAndReturn(pm, progress, stageData, steps, currentIdx, cwd, changeName, platformOpts)
630
+ }
631
+
632
+ // execute worktree cleanup(completeStep / continueStep 完成分支统一调用,避免双清理)
633
+ await handleExecuteWorktreeCleanup({ stageName, changeName, cwd })
634
+
635
+ return null
636
+ }
637
+
package/src/run/prompt.js CHANGED
@@ -20,8 +20,9 @@
20
20
  import { basename, join } from 'node:path'
21
21
  import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs'
22
22
  import { stageRegistry } from '../stages/index.js'
23
- import { resolvePromptIncludes, safeGit, WAIT_MARKER_RE } from './shared.js'
23
+ import { resolvePromptIncludes, resolveRuntimeRoot, safeGit, WAIT_MARKER_RE } from './shared.js'
24
24
  import { renderStageContract } from '../stage-contract-spec.js'
25
+ import { parseModuleMapSimple } from '../modules.js'
25
26
 
26
27
  /**
27
28
  * 从 _module-map.yaml 读取模块上下文索引
@@ -36,6 +37,14 @@ function loadModuleContextIndex(specBase, projectName) {
36
37
  const mapPath = join(specBase, 'docs', projectName, 'modules', '_module-map.yaml')
37
38
  if (!existsSync(mapPath)) return null
38
39
  const content = readFileSync(mapPath, 'utf8')
40
+ // schema_version 校验(advisory):modules.js 写 schema_version: 2;缺失或非 2 → warn,
41
+ // 提示 v1/v2 格式混用风险(旧格式字段解析会静默错位)。正常生成的文件不触发。
42
+ const sv = content.match(/^schema_version:\s*(\d+)/m)
43
+ if (!sv) {
44
+ console.warn(`⚠️ _module-map.yaml 缺少 schema_version 声明(期望 2),模块解析可能错位:${mapPath}`)
45
+ } else if (sv[1] !== '2') {
46
+ console.warn(`⚠️ _module-map.yaml schema_version=${sv[1]}(期望 2),可能为旧格式,模块解析可能错位:${mapPath}`)
47
+ }
39
48
  return parseModuleMapSimple(content)
40
49
  } catch {
41
50
  return null
@@ -104,63 +113,8 @@ function buildModuleContextInjection(taskDescription, moduleIndex, specBase, pro
104
113
  return injection
105
114
  }
106
115
 
107
- // 复用 modules.js 的简单 YAML 解析(避免循环依赖)
108
- function parseModuleMapSimple(content) {
109
- const modules = {}
110
- let currentModule = null
111
- let currentKey = null
112
- let currentArray = null
113
-
114
- for (const line of content.split('\n')) {
115
- const moduleMatch = line.match(/^ ([a-zA-Z0-9_-]+):$/)
116
- if (moduleMatch) {
117
- if (currentArray && currentModule && currentKey) {
118
- modules[currentModule][currentKey] = currentArray
119
- }
120
- currentModule = moduleMatch[1]
121
- modules[currentModule] = {}
122
- currentKey = null
123
- currentArray = null
124
- continue
125
- }
126
- if (!currentModule) continue
127
-
128
- const arrayFieldMatch = line.match(/^ (depends_on|used_by|paths|tags|aliases|entrypoints|main_symbols|review_reasons|core_files|test_files|related_docs|verify_commands):$/)
129
- if (arrayFieldMatch) {
130
- if (currentArray && currentKey) modules[currentModule][currentKey] = currentArray
131
- currentKey = arrayFieldMatch[1]
132
- currentArray = []
133
- continue
134
- }
135
-
136
- const inlineArrayMatch = line.match(/^ (depends_on|used_by|paths|tags|aliases|entrypoints|main_symbols|review_reasons|core_files|test_files|related_docs|verify_commands): \[(.*)\]$/)
137
- if (inlineArrayMatch) {
138
- if (currentArray && currentKey) modules[currentModule][currentKey] = currentArray
139
- const vals = inlineArrayMatch[2].split(',').map(v => v.trim()).filter(Boolean)
140
- modules[currentModule][inlineArrayMatch[1]] = vals
141
- currentKey = null
142
- currentArray = null
143
- continue
144
- }
145
-
146
- const scalarMatch = line.match(/^ (status|doc|needs_review|role|risk_level): (.+)$/)
147
- if (scalarMatch) {
148
- if (currentArray && currentKey) { modules[currentModule][currentKey] = currentArray; currentArray = null; currentKey = null }
149
- modules[currentModule][scalarMatch[1]] = scalarMatch[2]
150
- continue
151
- }
152
-
153
- const itemMatch = line.match(/^ - (.+)$/)
154
- if (itemMatch && currentArray !== null) {
155
- currentArray.push(itemMatch[1].trim())
156
- continue
157
- }
158
- }
159
- if (currentArray && currentModule && currentKey) {
160
- modules[currentModule][currentKey] = currentArray
161
- }
162
- return modules
163
- }
116
+ // parseModuleMapSimple 复用 modules.js 的 canonical 实现(合并历史 copy-paste 副本,2026-08-07;
117
+ // 无循环依赖:modules.js 仅 import fs/path/db.js,prompt.js → modules.js 单向)。
164
118
  /**
165
119
  * 输出当前步骤的 prompt
166
120
  */
@@ -450,7 +404,7 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
450
404
  if (stageName === 'execute' && promptText.includes('{EXECUTE_RUN_ID}')) {
451
405
  let runId = ''
452
406
  const execSpecBase = platformOpts?.specRoot || join(cwd, '.sillyspec')
453
- const runtimeRoot = platformOpts?.runtimeRoot || join(execSpecBase, '.runtime')
407
+ const runtimeRoot = resolveRuntimeRoot(platformOpts, execSpecBase)
454
408
  const runIdFile = join(runtimeRoot, `current-execute-run-id-${changeName}`)
455
409
  try {
456
410
  if (existsSync(runIdFile)) {
@@ -466,10 +420,10 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
466
420
  promptText = promptText.replace(/\{EXECUTE_RUN_ID\}/g, runId)
467
421
  }
468
422
 
469
- // Stage Review Tier:brainstorm/plan/propose/execute 阶段注入审查分级占位符
423
+ // Stage Review Tier:brainstorm/plan/execute 阶段注入审查分级占位符
470
424
  // (scanProfile 只在 scan 生效、change-risk-profile 只管 apply/verify,都不约束这些阶段的审查方式,
471
425
  // 故按 plan_level / 变更文件数分级:self 当前 agent 自审,independent 强制独立子代理 + review.json)
472
- if (['brainstorm', 'plan', 'propose', 'execute'].includes(stageName) && promptText.includes('{REVIEW_TIER}')) {
426
+ if (['brainstorm', 'plan', 'execute'].includes(stageName) && promptText.includes('{REVIEW_TIER}')) {
473
427
  try {
474
428
  const { classifyReviewTier } = await import('../review-tier.js')
475
429
  const { generateStageReviewRunId, renderReviewJsonContract, stageReviewMarkerPath } = await import('../stage-review.js')
@@ -488,7 +442,7 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
488
442
  // reviewRunId:优先读 marker 复用(保证多次渲染 prompt 注入同一 ID == gate 读取的 ID,
489
443
  // 修复「prompt 多次渲染 / 多次 review 时 gate 取错 ID 读错 review.json」),marker 缺失才
490
444
  // generate + 落盘。对齐 execute {EXECUTE_RUN_ID} 段(prompt.js:449-467)。
491
- const tierRuntimeRoot = platformOpts?.runtimeRoot || join(tierSpecBase, '.runtime')
445
+ const tierRuntimeRoot = resolveRuntimeRoot(platformOpts, tierSpecBase)
492
446
  const reviewRunIdMarker = stageReviewMarkerPath(tierRuntimeRoot, stageName, changeName)
493
447
  let reviewRunId = ''
494
448
  try {
@@ -526,7 +480,7 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
526
480
  try {
527
481
  const { summarizeTaskCompletion } = await import('../task-review.js')
528
482
  const tcrSpecBase = platformOpts?.specRoot || join(cwd, '.sillyspec')
529
- const tcrRuntimeRoot = platformOpts?.runtimeRoot || join(tcrSpecBase, '.runtime')
483
+ const tcrRuntimeRoot = resolveRuntimeRoot(platformOpts, tcrSpecBase)
530
484
  const tcrChangeDir = changeName ? join(tcrSpecBase, 'changes', changeName) : null
531
485
  const summary = tcrChangeDir
532
486
  ? summarizeTaskCompletion({ changeDir: tcrChangeDir, runtimeRoot: tcrRuntimeRoot, changeName })
@@ -585,11 +539,11 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
585
539
  if (stepIndex === 0) {
586
540
  console.log(`\n### ⚠️ 铁律`)
587
541
  console.log('- 文档优先:代码产出必须先有对应的设计/规范文档支撑。')
588
- console.log('- 只做本步骤描述的操作,不得自行扩展或跳过')
589
- console.log('- 不要回头修改已完成的步骤')
590
- console.log('- 不要编造不存在的 CLI 子命令')
591
- console.log('- 完成后立即执行 --done 命令,不得跳过')
592
- console.log('- 不要用 mv/rename 重命名变更目录,必须用 `sillyspec change-rename <旧名> <新名>`')
542
+ console.log('- 聚焦本步骤:只执行本步骤描述的操作并完整做完;自行扩展或跳步会破坏状态机推进,后续步骤再做后续事。')
543
+ console.log('- 已完成步骤视为只读:需修订用 `sillyspec run <stage> --reopen --from-step N` 回退重做,直接回头改会让进度记录与产物脱节。')
544
+ console.log('- CLI 子命令以本 prompt 或上一条 --done 输出的字面为准;不确定时停下问用户,猜测命令会让状态机推进到错误位置。')
545
+ console.log('- 本步骤产物落盘后立即执行 prompt 末尾的 --done:CLI 据此校验产出并推进状态机;不跑则进度永远停在本步。')
546
+ console.log('- 变更目录改名用 `sillyspec change-rename <旧名> <新名>`:mv/rename 会漏改进度库引用,导致变更失联。')
593
547
  console.log('- 文档类型文件(.md/.yaml/.json 等)头部必须包含 author(git 用户名)和 created_at(精确到秒)')
594
548
  console.log('- 执行构建/测试前必须先读 local.yaml,优先使用其中配置的命令、路径和环境变量;未配置时才使用默认值')
595
549
  }
@@ -6,12 +6,11 @@
6
6
  *
7
7
  * 路径修正(相对 src/run/):
8
8
  * - resolveQuickLinkedChanges 的 quick-recommend.js 动态 import './quick-recommend.js' → '../quick-recommend.js'
9
- * - 原动态 import('child_process') 删除,改顶部静态 execSync
9
+ * - 脏文件信号改用 safeGit(带 safe.directory,Q3);不再 import execSync
10
10
  * - parsePorcelainPath 从 ./shared.js 重 import;checkbox 顶部 import
11
11
  */
12
- import { execSync } from 'node:child_process'
13
12
  import { checkbox } from '@inquirer/prompts'
14
- import { parsePorcelainPath } from './shared.js'
13
+ import { parsePorcelainPath, safeGit } from './shared.js'
15
14
 
16
15
  /**
17
16
  * 打印 quick 完成审计结论(按 review.status 输出 SAFE/WARNING/BLOCKED)。
@@ -55,7 +54,7 @@ export function printQuickAuditReview(review) {
55
54
  export async function resolveQuickLinkedChanges({ pm, cwd, specDir, quickFiles, taskDescription, nonInteractive }) {
56
55
  let activeChanges = []
57
56
  try {
58
- activeChanges = await pm.listChanges(cwd)
57
+ activeChanges = pm.listChanges(cwd)
59
58
  } catch {
60
59
  activeChanges = []
61
60
  }
@@ -67,15 +66,17 @@ export async function resolveQuickLinkedChanges({ pm, cwd, specDir, quickFiles,
67
66
  return []
68
67
  }
69
68
 
70
- // 脏文件(推荐信号之一)
69
+ // 脏文件(推荐信号之一)。safeGit 带 safe.directory,避免 linked worktree/容器/挂载点下裸
70
+ // `git status` 抛错致推荐信号静默丢失(multi-agent-review Q3 同类)。推荐非关键路径,失败回退 []。
71
+ // trim:false:porcelain 首行前导空格是状态码,trim 会削掉致 parsePorcelainPath 丢首字符。
71
72
  let baselineFiles = []
72
- try {
73
- const gs = execSync('git status --porcelain', { cwd, encoding: 'utf8', timeout: 10000 })
74
- baselineFiles = gs.split('\n').filter(Boolean)
73
+ const statusResult = safeGit(cwd, ['status', '--porcelain'], { trim: false })
74
+ if (!statusResult.error) {
75
+ baselineFiles = (statusResult.value || '').split('\n').filter(Boolean)
75
76
  .map(l => parsePorcelainPath(l))
76
77
  .filter(Boolean)
77
78
  .filter(f => !f.startsWith('.sillyspec/'))
78
- } catch {}
79
+ }
79
80
 
80
81
  // 推荐打分(脏文件 + 任务描述双信号)
81
82
  let recommendations = []