sillyspec 3.22.8 → 3.23.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.
package/src/run.js CHANGED
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { basename, join, resolve, dirname, relative, isAbsolute } from 'path'
8
8
  import { existsSync, readdirSync, mkdirSync, writeFileSync, appendFileSync, readFileSync, rmSync, statSync } from 'fs'
9
+ import { randomBytes, randomUUID } from 'crypto'
9
10
  import { createRequire } from 'module'
10
11
  const require = createRequire(import.meta.url)
11
12
  import { ProgressManager } from './progress.js'
@@ -705,16 +706,23 @@ async function outputStep(stageName, stepIndex, steps, cwd, changeName, dbProjec
705
706
  if (changeName && promptText.includes('<change-name>')) {
706
707
  promptText = promptText.replace(/<change-name>/g, changeName)
707
708
  }
708
- // 替换 <linked-changes> 占位符(quick 阶段:从 .runtime/quick-guard.json 读关联变更)
709
+ // 替换 <quick-session-id> 占位符(quick 阶段专用:sessionId == changeName == quick-<uuid8>,
710
+ // 见 runStage 参数解析 quickSessionId 生成。告知 agent 本会话 id + --done 需带 --change)
711
+ if (changeName && promptText.includes('<quick-session-id>')) {
712
+ promptText = promptText.replace(/<quick-session-id>/g, changeName)
713
+ }
714
+ // 替换 <linked-changes> 占位符(quick 阶段:从 .runtime/quick-sessions/<sessionId>/guard.json 读关联变更)
709
715
  if (promptText.includes('<linked-changes>')) {
710
716
  const specBaseLc = platformOpts?.specRoot || join(cwd, '.sillyspec')
711
717
  let linkedChanges = []
712
718
  try {
713
- const guardFile = join(specBaseLc, '.runtime', 'quick-guard.json')
714
- if (existsSync(guardFile)) {
715
- const guard = JSON.parse(readFileSync(guardFile, 'utf8'))
716
- linkedChanges = Array.isArray(guard.linkedChanges) ? guard.linkedChanges : []
717
- }
719
+ // D-002:guard session 存。changeName == quick-<uuid8>(见 runStage 参数解析)。回退读旧单文件(兼容 task-03 前)
720
+ const sessionGuardFile = join(specBaseLc, '.runtime', 'quick-sessions', changeName, 'guard.json')
721
+ const legacyGuardFile = join(specBaseLc, '.runtime', 'quick-guard.json')
722
+ const guard = existsSync(sessionGuardFile)
723
+ ? JSON.parse(readFileSync(sessionGuardFile, 'utf8'))
724
+ : (existsSync(legacyGuardFile) ? JSON.parse(readFileSync(legacyGuardFile, 'utf8')) : null)
725
+ if (guard) linkedChanges = Array.isArray(guard.linkedChanges) ? guard.linkedChanges : []
718
726
  } catch {}
719
727
  const display = linkedChanges.length > 0 ? linkedChanges.join(', ') : '(无)'
720
728
  promptText = promptText.replace(/<linked-changes>/g, display)
@@ -776,6 +784,16 @@ async function outputStep(stageName, stepIndex, steps, cwd, changeName, dbProjec
776
784
  platformDirectives.push(`workspace_id: ${platformOpts.workspaceId}`)
777
785
  }
778
786
  promptText = platformDirectives.join('\n') + '\n\n' + promptText
787
+ } else {
788
+ // 常规模式(无平台 specRoot):占位符替换为 cwd/.sillyspec 下对应路径
789
+ // 让用 {SPEC_ROOT}/{DOCS_ROOT} 等占位符的 prompt(如 quick/scan)在常规模式也写到正确位置
790
+ const projectName = dbProjectName || basename(cwd)
791
+ const specSillyspec = join(cwd, '.sillyspec')
792
+ promptText = promptText.replace(/\{SPEC_ROOT\}/g, specSillyspec)
793
+ promptText = promptText.replace(/\{DOCS_ROOT\}/g, join(specSillyspec, 'docs', projectName))
794
+ promptText = promptText.replace(/\{PROJECTS_ROOT\}/g, join(specSillyspec, 'projects'))
795
+ promptText = promptText.replace(/\{WORKFLOWS_ROOT\}/g, join(specSillyspec, 'workflows'))
796
+ promptText = promptText.replace(/\{KNOWLEDGE_ROOT\}/g, join(specSillyspec, 'knowledge'))
779
797
  }
780
798
 
781
799
  // 注入 scanProfile 硬约束指令
@@ -1368,12 +1386,52 @@ export async function runCommand(args, cwd, specDir = null) {
1368
1386
  if (explicitLinked !== null) {
1369
1387
  linkedChanges = explicitLinked
1370
1388
  }
1371
- // quick progress 键固定走 'default':归属语义已由 linkedChanges 承担(→ quick-guard.json
1372
- // <linked-changes> 占位符注入 prompt)。progress 不能走 pm.read(cwd, null) 的自动检测——
1373
- // 多活跃 change 项目里 quick 步骤进度会随活跃 change 增减在各 change/default 间漂移,
1374
- // 表现为 --done 时「回退」到更早步骤。固定 default 后无论是否带 --change 都稳定。
1389
+ // quick 会话隔离(D-001@v1 + D-003@v1 + §4.4 跨进程传递):每会话用 sessionId 作 changeName,
1390
+ // DB 分行 progress.quick-<uuid8>,避免并行 quick 会话共享单行 progress.default.quick 互相覆盖。
1391
+ // sessionId = crypto.randomUUID 8 hex(摒弃旧 quick-YYYYMMDD-HHMMSS 时间戳,同秒并发撞)。
1392
+ // crypto.randomUUID node:crypto import(兼容 engines node>=18,不依赖 Node 19+ 全局)。
1393
+ //
1394
+ // --done 跨进程恢复 sessionId 的优先级(§4.4):
1395
+ // 1. --change quick-<uuid8>(单值且匹配 sessionId 形态)→ 精确指定本会话 sessionId
1396
+ // (quick 的 --change 历史被复用为 linkedChanges 见上 1370-1377;此处对「恰好一个 quick-<8hex>」
1397
+ // 特例识别为 sessionId,多值/不匹配仍走 linkedChanges 语义,向后兼容)
1398
+ // 2. 未识别出 sessionId 且为 --done → fallback 读 .runtime/current-quick-run-id(单会话兼容;
1399
+ // 多会话时可能拿到他者,文档声明建议带 --change)
1400
+ // 3. 仍读不到 → 生成新 UUID(兼容旧行为;进度可能命中空行,由后续 pm.read 兜底)
1401
+ const QUICK_SID_RE = /^quick-[0-9a-f]{8}$/
1402
+ let quickSessionId = null
1375
1403
  if (stageName === 'quick') {
1376
- changeName = 'default'
1404
+ // 1373-1376 已把 quick 的 --change 值清进 linkedChanges、changeName null。
1405
+ // 此处回看 --change 原始值:若恰好是单个 quick-<8hex> → 识别为本会话 sessionId(精确恢复),
1406
+ // 并撤销把它当 linkedChanges 的误判。多值或不匹配 → 维持 linkedChanges 语义(旧兼容)。
1407
+ const rawChange = changeIdx !== -1 && flags[changeIdx + 1] ? flags[changeIdx + 1].trim() : null
1408
+ const rawIsSingleSid = rawChange && rawChange.indexOf(',') === -1 && QUICK_SID_RE.test(rawChange)
1409
+ if (rawIsSingleSid) {
1410
+ // --done --change quick-<uuid8>:精确恢复(撤销 1373-1376 把它误当 linkedChanges)
1411
+ quickSessionId = rawChange
1412
+ changeName = rawChange
1413
+ linkedChanges = []
1414
+ } else if (!changeName) {
1415
+ // 未精确指定:非 --done 必生成新 sessionId;--done 先 fallback current-quick-run-id,读不到再生成
1416
+ const isDoneLike = isDone || isStatus || isSkip || isReset || isReopen
1417
+ if (isDoneLike) {
1418
+ try {
1419
+ const runtimeRoot = platformOpts.runtimeRoot || join(specRoot, '.runtime')
1420
+ const idFile = join(runtimeRoot, 'current-quick-run-id')
1421
+ if (existsSync(idFile)) {
1422
+ const v = readFileSync(idFile, 'utf8').trim()
1423
+ if (QUICK_SID_RE.test(v)) quickSessionId = v
1424
+ }
1425
+ } catch {}
1426
+ }
1427
+ if (!quickSessionId) {
1428
+ quickSessionId = 'quick-' + randomUUID().slice(0, 8)
1429
+ }
1430
+ changeName = quickSessionId
1431
+ } else {
1432
+ // 用户显式传了非 sessionId 形态的变更名 → 尊重,不生成 UUID(旧兼容路径)
1433
+ quickSessionId = changeName
1434
+ }
1377
1435
  }
1378
1436
 
1379
1437
  // 解析 --files a.js,b.js(quick 专用:显式声明 allowedFiles)
@@ -1421,13 +1479,16 @@ export async function runCommand(args, cwd, specDir = null) {
1421
1479
  // 关键:--done 收尾时复用首次 run 持久化的 linkedChanges,不在管道/CI 下重复弹交互 prompt。
1422
1480
  // explicitLinked === null 且未传 --change 时才进入此分支(显式 --linked-changes none 不应触发交互)。
1423
1481
  if (stageName === 'quick' && explicitLinked === null && linkedChanges.length === 0) {
1424
- const guardFile = join(specRoot, '.runtime', 'quick-guard.json')
1482
+ // D-002:guard session 存(.runtime/quick-sessions/<sessionId>/guard.json)。
1483
+ // sessionId == changeName == quick-<uuid8>(上面参数解析已确定)。回退读旧单文件 quick-guard.json(task-03 前兼容)。
1425
1484
  let persistedLinked = null
1426
1485
  try {
1427
- if (existsSync(guardFile)) {
1428
- const g = JSON.parse(readFileSync(guardFile, 'utf8'))
1429
- if (Array.isArray(g.linkedChanges)) persistedLinked = g.linkedChanges
1430
- }
1486
+ const sessionGuardFile = join(specRoot, '.runtime', 'quick-sessions', changeName, 'guard.json')
1487
+ const legacyGuardFile = join(specRoot, '.runtime', 'quick-guard.json')
1488
+ const g = existsSync(sessionGuardFile)
1489
+ ? JSON.parse(readFileSync(sessionGuardFile, 'utf8'))
1490
+ : (existsSync(legacyGuardFile) ? JSON.parse(readFileSync(legacyGuardFile, 'utf8')) : null)
1491
+ if (g && Array.isArray(g.linkedChanges)) persistedLinked = g.linkedChanges
1431
1492
  } catch {}
1432
1493
  if (persistedLinked) {
1433
1494
  linkedChanges = persistedLinked
@@ -1466,7 +1527,7 @@ export async function runCommand(args, cwd, specDir = null) {
1466
1527
  process.exit(1)
1467
1528
  }
1468
1529
  const date = new Date().toISOString().slice(0, 10)
1469
- const autoName = `${date}-new-change`
1530
+ const autoName = `${date}-new-change-${randomBytes(4).toString('hex')}`
1470
1531
  console.log(`🔄 自动创建变更:${autoName}`)
1471
1532
  console.log(` 提示:可以用 --change <名称> 指定自定义变更名`)
1472
1533
  console.log(` 或事后重命名:sillyspec change-rename ${autoName} <新名称>`)
@@ -1571,9 +1632,10 @@ export async function runCommand(args, cwd, specDir = null) {
1571
1632
  }
1572
1633
  }
1573
1634
 
1574
- // quick 启动(非 --done):reset steps + 生成 quickRunId,避免多会话共享 progress.default.quick
1575
- // 继承并行会话的 step1(会话 B 复用会话 A 的 ql,本会话改动无 ql 记录)。C-实用方案:
1576
- // 每次 quick 启动 reset steps(逻辑隔离,本会话从 step1 重跑建独立 ql)+ 写 current-quick-run-id
1635
+ // quick 启动(非 --done):reset steps + current-quick-run-id(本会话 sessionId,作 --done fallback)。
1636
+ // sessionId 已在参数解析阶段生成(quickSessionId == changeName == quick-<uuid8>),
1637
+ // 这里复用同一值写 current-quick-run-id,保证两处一致。
1638
+ // 旧 quick-YYYYMMDD-HHMMSS 时间戳已摒弃(D-003@v1:同秒并发撞)。
1577
1639
  if (stageName === 'quick' && !isDone && !isStatus && !isSkip && !isReset && !isReopen) {
1578
1640
  const qStage = progress.stages?.quick
1579
1641
  if (qStage?.steps?.length) {
@@ -1581,13 +1643,14 @@ export async function runCommand(args, cwd, specDir = null) {
1581
1643
  qStage.status = 'in-progress'
1582
1644
  }
1583
1645
  try {
1584
- const now = new Date()
1585
- const pad = (n) => String(n).padStart(2, '0')
1586
- const quickRunId = `quick-${now.getFullYear()}-${pad(now.getMonth()+1)}-${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
1587
1646
  const runtimeRoot = platformOpts.runtimeRoot || join(specRoot, '.runtime')
1588
1647
  mkdirSync(runtimeRoot, { recursive: true })
1589
- writeFileSync(join(runtimeRoot, 'current-quick-run-id'), quickRunId)
1648
+ writeFileSync(join(runtimeRoot, 'current-quick-run-id'), quickSessionId + '\n')
1590
1649
  } catch {}
1650
+ // 显式告知 agent 本会话 sessionId + --done 需带 --change(CLI 短进程,run/done 独立进程,
1651
+ // sessionId 靠 --change 跨进程传递;不带 --change 时 fallback 读 current-quick-run-id,多会话不可靠)
1652
+ console.log(`📌 本 quick 会话 sessionId: ${quickSessionId}`)
1653
+ console.log(` 完成时用: sillyspec run quick --done --change ${quickSessionId} --output "..."`)
1591
1654
  }
1592
1655
 
1593
1656
  // 确保步骤已初始化
@@ -1898,6 +1961,7 @@ async function runStage(pm, progress, stageName, cwd, changeName, skipApproval =
1898
1961
  const allowNew = quickOpts?.isAllowNew || false
1899
1962
  const forceBaseline = quickOpts?.isForceBaseline || false
1900
1963
  progress.quickGuard = {
1964
+ sessionId: changeName,
1901
1965
  name_zh: '快速任务守卫',
1902
1966
  baselineCommit: safeGit(cwd, ['rev-parse', 'HEAD']).value,
1903
1967
  baselineFiles,
@@ -1907,8 +1971,11 @@ async function runStage(pm, progress, stageName, cwd, changeName, skipApproval =
1907
1971
  linkedChanges: Array.isArray(quickOpts?.linkedChanges) ? quickOpts.linkedChanges : [],
1908
1972
  startedAt: new Date().toISOString(),
1909
1973
  }
1910
- // 写入 quick-guard.json 供 worktree-guard hook 读取
1911
- const guardFile = join(specBase, '.runtime', 'quick-guard.json')
1974
+ // 写入 .runtime/quick-sessions/<sessionId>/guard.json 供 worktree-guard hook 读取
1975
+ // (D-002:按 session 存,多会话各自 guard 不互覆盖。runStage 作用域内 sessionId == changeName == quick-<uuid8>,见 §4.4/4.5)
1976
+ const sessionGuardDir = join(specBase, '.runtime', 'quick-sessions', changeName)
1977
+ mkdirSync(sessionGuardDir, { recursive: true })
1978
+ const guardFile = join(sessionGuardDir, 'guard.json')
1912
1979
  writeFileSync(guardFile, JSON.stringify(progress.quickGuard, null, 2))
1913
1980
  const parts = [`${baselineFiles.length} 个已有脏文件`]
1914
1981
  if (allowedFiles.length > 0) parts.push(`${allowedFiles.length} 个 allowedFiles`)
@@ -2355,6 +2422,48 @@ async function continueStep(pm, progress, stageName, cwd, answer, options = {})
2355
2422
  console.warn(`🔗 Worktree: check failed — ${e.message}`);
2356
2423
  }
2357
2424
  }
2425
+ // 阶段完成后明确下一步(agent 常卡:stageData completed 但不知要 run <下一阶段> 推进 currentStage)
2426
+ const nextStageHint = { brainstorm: 'plan', plan: 'execute', execute: 'verify', verify: 'archive' }[stageName]
2427
+ if (nextStageHint) {
2428
+ console.log(`\n👉 ${stageName} 已完成。下一步:sillyspec run ${nextStageHint}${changeName ? ` --change ${changeName}` : ''}`)
2429
+ if (stageName === 'execute') {
2430
+ console.log(` ⚠️ 若 worktree 改动还没 apply 到主工作区,先:sillyspec worktree apply ${changeName}`)
2431
+ console.log(` (apply 不需要先 commit,支持 working tree 未提交改动)`)
2432
+ // plan.md checkbox auto-check:execute 完成 + review.json pass → 自动勾选(治本,比警告可靠)
2433
+ try {
2434
+ const specBaseLc = platformOpts.specRoot || join(cwd, '.sillyspec')
2435
+ const changeDir = join(specBaseLc, 'changes', changeName)
2436
+ const planPath = join(changeDir, 'plan.md')
2437
+ const runtimeRoot = platformOpts.runtimeRoot || join(specBaseLc, '.runtime')
2438
+ const runIdFile = join(runtimeRoot, `current-execute-run-id-${changeName}`)
2439
+ if (existsSync(planPath) && existsSync(runIdFile)) {
2440
+ const executeRunId = readFileSync(runIdFile, 'utf8').trim()
2441
+ const planContent = readFileSync(planPath, 'utf8')
2442
+ const { readReview } = await import('./task-review.js')
2443
+ let checkedCount = 0
2444
+ let skippedCount = 0
2445
+ const updated = planContent.replace(/^(\s*[-*]\s*\[)\s(\]\s*task-\d+)/gim, (match, p1, p2) => {
2446
+ const taskNum = match.match(/task-(\d+)/)[1].padStart(2, '0')
2447
+ const reviewPath = join(runtimeRoot, 'execute-runs', executeRunId, 'tasks', `task-${taskNum}`, 'review.json')
2448
+ const r = readReview(reviewPath)
2449
+ if (r.ok && r.review?.specVerdict !== 'fail' && r.review?.qualityVerdict !== 'fail') {
2450
+ checkedCount++
2451
+ return `${p1}x${p2}` // 勾选
2452
+ }
2453
+ skippedCount++
2454
+ return match // 不勾
2455
+ })
2456
+ if (checkedCount > 0) {
2457
+ writeFileSync(planPath, updated)
2458
+ console.log(` ✅ 自动勾选 ${checkedCount} 个 task checkbox(基于 review.json pass)`)
2459
+ }
2460
+ if (skippedCount > 0) {
2461
+ console.warn(` ⚠️ ${skippedCount} 个 task 未勾(review.json 缺失/fail)→ archive 会拦。补 review 后重跑 execute --done 触发自动勾`)
2462
+ }
2463
+ }
2464
+ } catch {}
2465
+ }
2466
+ }
2358
2467
  return { stageCompleted: true, currentIdx, nextPendingIdx: -1 }
2359
2468
  }
2360
2469
 
@@ -2476,6 +2585,26 @@ async function ensureDepsFreshness(cwd, changeName, specBase, worktreeMeta) {
2476
2585
  }
2477
2586
  }
2478
2587
 
2588
+ /**
2589
+ * 阶段完成校验失败时回滚状态。
2590
+ *
2591
+ * completeStep 在跑 validator 之前就把 stageData.status 写成 'completed',
2592
+ * 若校验失败不回滚,DB 会与真实产物不一致(hook/doctor/下游阶段全部误判),
2593
+ * 且所有步骤都是 completed 时 agent 无法重新 --done("没有待完成的步骤")。
2594
+ * 此处将 stage 回滚为 in-progress,最后一步重置为 pending,供修复产物后重做。
2595
+ */
2596
+ function rollbackStageCompletion(stageData, steps, currentIdx) {
2597
+ // 辅助阶段在 validator 前已被重置为 pending(steps 也换成了新数组),不要覆盖
2598
+ if (stageData.status === 'completed') {
2599
+ stageData.status = 'in-progress'
2600
+ stageData.completedAt = null
2601
+ }
2602
+ if (steps[currentIdx] && steps[currentIdx].status === 'completed') {
2603
+ steps[currentIdx].status = 'pending'
2604
+ steps[currentIdx].completedAt = null
2605
+ }
2606
+ }
2607
+
2479
2608
  async function completeStep(pm, progress, stageName, cwd, outputText, inputText = null, options = {}) {
2480
2609
  const { printNext = true, confirm = false, changeName, platformOpts = {}, nonInteractive = false, confirmMode = null } = options
2481
2610
  const specBase = platformOpts.specRoot || join(cwd, '.sillyspec')
@@ -2834,27 +2963,45 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
2834
2963
  if (!hasQuicklog) {
2835
2964
  console.error(`\n❌ quick 阶段完成校验失败:未检测到 QUICKLOG 记录文件。`)
2836
2965
  console.error(` step 2 要求创建 quicklog 记录,但文件不存在。`)
2837
- console.error(` 请先创建 quicklog 记录再 --done,或使用 --skip-approval 跳过此校验。`)
2966
+ console.error(` 请先创建 quicklog 记录再 --done。`)
2838
2967
  return { stageCompleted: false, currentIdx, nextPendingIdx: -1 }
2839
2968
  }
2840
- if (progress.quickGuard) {
2841
- const review = await auditQuickCompletion(cwd, progress.quickGuard, { isConfirm })
2842
- progress.quickGuard.review = review
2843
- progress.quickGuard.completedAt = new Date().toISOString()
2844
- printQuickAuditReview(review)
2845
- if (review.status === 'blocked') {
2846
- steps[currentIdx].status = 'pending'
2847
- steps[currentIdx].completedAt = null
2848
- if (outputText) steps[currentIdx].output = null
2849
- process.exit(1)
2969
+ // §4.6 quick 收尾:从 session guard.json 读 guard(不依赖 progress.quickGuard)。
2970
+ // D-003@v1:progress._write 不持久化顶层 quickGuard,跨进程 --done 时读出的 progress quickGuard,
2971
+ // 若仍用 if (progress.quickGuard) 驱动收尾会整体跳过,导致 .runtime/quick-sessions/<sessionId>/ 残留僵尸。
2972
+ // 改为从文件读 guard:优先 session 目录 guard.json,回退旧单文件 quick-guard.json(task-03 前兼容)。
2973
+ // sessionId == changeName == quick-<uuid8>(completeStep 作用域内 changeName 已解构自 options)。
2974
+ {
2975
+ const runtimeBase = platformOpts.runtimeRoot || join(specBase, '.runtime')
2976
+ const sessionGuardFile = join(runtimeBase, 'quick-sessions', changeName, 'guard.json')
2977
+ const legacyGuardFile = join(specBase, '.runtime', 'quick-guard.json')
2978
+ let guard = null
2979
+ try {
2980
+ guard = existsSync(sessionGuardFile)
2981
+ ? JSON.parse(readFileSync(sessionGuardFile, 'utf8'))
2982
+ : (existsSync(legacyGuardFile) ? JSON.parse(readFileSync(legacyGuardFile, 'utf8')) : null)
2983
+ } catch {}
2984
+ if (guard) {
2985
+ const review = await auditQuickCompletion(cwd, guard, { isConfirm: confirm })
2986
+ printQuickAuditReview(review)
2987
+ if (review.status === 'blocked') {
2988
+ steps[currentIdx].status = 'pending'
2989
+ steps[currentIdx].completedAt = null
2990
+ if (outputText) steps[currentIdx].output = null
2991
+ process.exit(1)
2992
+ }
2993
+ progress.lastQuickReview = review
2850
2994
  }
2995
+ // 清理:guard 命中与缺失两种情况都执行(guard 缺失 → 跳过审计,仅清理不抛错)。
2996
+ // rmSync {recursive,force} / unlinkSync 都容忍文件不存在(brownfield)。
2851
2997
  try {
2852
2998
  const { unlinkSync } = await import('fs')
2853
- const guardFile = join(specBase, '.runtime', 'quick-guard.json')
2854
- unlinkSync(guardFile)
2999
+ if (changeName) {
3000
+ const sessionDir = join(runtimeBase, 'quick-sessions', changeName)
3001
+ rmSync(sessionDir, { recursive: true, force: true })
3002
+ }
3003
+ if (existsSync(legacyGuardFile)) unlinkSync(legacyGuardFile)
2855
3004
  } catch {}
2856
- progress.lastQuickReview = review
2857
- delete progress.quickGuard
2858
3005
  }
2859
3006
  }
2860
3007
 
@@ -3072,10 +3219,11 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
3072
3219
  for (const err of contractResult.errors) {
3073
3220
  console.error(` - ${err}`)
3074
3221
  }
3075
- console.error(`\n 提示:修复缺失产物后重新运行此步骤,或使用 --skip-approval 跳过校验`)
3222
+ console.error(`\n 提示:修复缺失产物后重新完成此步骤(--skip-approval 只跳过阶段转换/审批检查,不能跳过产物校验)`)
3076
3223
  // 产物校验失败必须阻断完成 —— 否则 validator 形同虚设,
3077
3224
  // verify 会带着 FAIL/缺 verify-result.md 被 ✅ 标记完成(历史教训)。
3078
3225
  // plan/execute 的专项契约校验(下方)在产物齐全后才需要继续跑,故此处先 return。
3226
+ rollbackStageCompletion(stageData, steps, currentIdx)
3079
3227
  progress.lastActive = new Date().toLocaleString('zh-CN',{hour12:false})
3080
3228
  await pm._write(cwd, progress, changeName)
3081
3229
  triggerSync(cwd, changeName, platformOpts)
@@ -3088,8 +3236,22 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
3088
3236
  }
3089
3237
  }
3090
3238
 
3091
- // verify 产物校验通过 + 结论非 FAIL(否则上面已阻断),此时才能声称"验证通过"
3239
+ // verify 产物校验通过 + 结论非 FAIL(否则上面已阻断)。
3240
+ // 再由 CLI 亲自执行 local.yaml 的测试命令,与 verify-result.md 的自报告对账:
3241
+ // 自报告 PASS 但实测失败 → 阻断(防止"文案通过"绕过验证)。
3092
3242
  if (stageName === 'verify') {
3243
+ const { runVerifyTestCheck, printVerifyTestCheck } = await import('./verify-postcheck.js')
3244
+ const testCheck = runVerifyTestCheck({ cwd, specBase, changeName })
3245
+ printVerifyTestCheck(testCheck)
3246
+ if (testCheck.status === 'failed') {
3247
+ console.error('\n❌ verify 阶段被阻断:verify-result.md 自报告通过,但 CLI 实测测试失败。')
3248
+ console.error(' 请修复失败的测试并更新 verify-result.md 后重新完成此步骤。')
3249
+ rollbackStageCompletion(stageData, steps, currentIdx)
3250
+ progress.lastActive = new Date().toLocaleString('zh-CN',{hour12:false})
3251
+ await pm._write(cwd, progress, changeName)
3252
+ triggerSync(cwd, changeName, platformOpts)
3253
+ return { stageCompleted: false, currentIdx, nextPendingIdx: currentIdx }
3254
+ }
3093
3255
  console.log('\n✅ 验证通过,下一步:sillyspec run archive')
3094
3256
  }
3095
3257
 
@@ -3106,6 +3268,7 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
3106
3268
  for (const err of planValidation.errors) console.error(` - ${err}`)
3107
3269
  console.error(`\n plan.md 不满足 execute 契约,请修复后重新完成此步骤。`)
3108
3270
  // 阻断 completed
3271
+ rollbackStageCompletion(stageData, steps, currentIdx)
3109
3272
  progress.lastActive = new Date().toLocaleString('zh-CN',{hour12:false})
3110
3273
  await pm._write(cwd, progress, changeName)
3111
3274
  triggerSync(cwd, changeName, platformOpts)
@@ -3146,7 +3309,18 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
3146
3309
  executeRunId = generateExecuteRunId()
3147
3310
  }
3148
3311
 
3149
- const reviewResult = validateTaskReviews({ planContent, runtimeRoot, executeRunId, changeDir: planFile })
3312
+ // git 真实性校验目录:worktree 存在则用 worktree(base/head commit 在其中),否则主仓库
3313
+ let reviewGitDir = cwd
3314
+ try {
3315
+ const { WorktreeManager } = await import('./worktree.js')
3316
+ const wm = new WorktreeManager({ cwd })
3317
+ const meta = wm.getMeta(changeName)
3318
+ if (meta?.worktreePath && meta.mode !== 'in-place-fallback' && existsSync(meta.worktreePath)) {
3319
+ reviewGitDir = meta.worktreePath
3320
+ }
3321
+ } catch {}
3322
+
3323
+ const reviewResult = validateTaskReviews({ planContent, runtimeRoot, executeRunId, changeDir: planFile, gitDir: reviewGitDir })
3150
3324
  printReviewResult(reviewResult)
3151
3325
 
3152
3326
  if (!reviewResult.ok) {
@@ -3157,6 +3331,7 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
3157
3331
  console.error('\n⚠️ 部分任务已在 plan.md 中勾选,但 review.json 不存在。')
3158
3332
  console.error(' 请取消勾选这些任务的 checkbox,或补充对应的 review.json。')
3159
3333
  }
3334
+ rollbackStageCompletion(stageData, steps, currentIdx)
3160
3335
  progress.lastActive = new Date().toLocaleString('zh-CN',{hour12:false})
3161
3336
  await pm._write(cwd, progress, changeName)
3162
3337
  triggerSync(cwd, changeName, platformOpts)
@@ -3173,8 +3348,14 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
3173
3348
  }
3174
3349
  }
3175
3350
  } catch (e) {
3176
- console.warn(`⚠️ Task Review Gate 异常: ${e.message}`)
3177
- // 不阻断,但记录异常
3351
+ // fail-closed:Gate 自身异常时不能默认放行,否则异常成了绕过评审的通道
3352
+ console.error(`❌ Task Review Gate 异常,阻断 execute 完成: ${e.message}`)
3353
+ console.error(' 请检查 review.json / plan.md 是否可读,修复后重新完成此步骤。')
3354
+ rollbackStageCompletion(stageData, steps, currentIdx)
3355
+ progress.lastActive = new Date().toLocaleString('zh-CN',{hour12:false})
3356
+ await pm._write(cwd, progress, changeName)
3357
+ triggerSync(cwd, changeName, platformOpts)
3358
+ return { stageCompleted: false, currentIdx, nextPendingIdx: currentIdx }
3178
3359
  }
3179
3360
  }
3180
3361
  } else if (actualCompleted < actualTotal) {
@@ -7,6 +7,7 @@
7
7
 
8
8
  import { existsSync, readdirSync, readFileSync } from 'fs'
9
9
  import { join, basename } from 'path'
10
+ import { execFileSync } from 'child_process'
10
11
  import { detectChangeRisk, checkIntegrationEvidence } from './change-risk-profile.js'
11
12
 
12
13
  /**
@@ -528,6 +529,111 @@ function validateChangeClosed(cwd, changeName) {
528
529
  return { ok: errors.length === 0, errors, warnings }
529
530
  }
530
531
 
532
+ // ============ Execute 代码变更客观核验 ============
533
+
534
+ function gitTry(dir, args) {
535
+ try {
536
+ const out = execFileSync('git', args, {
537
+ cwd: dir,
538
+ encoding: 'utf8',
539
+ timeout: 15000,
540
+ stdio: ['ignore', 'pipe', 'pipe'],
541
+ }).trim()
542
+ return { ok: true, out }
543
+ } catch (e) {
544
+ return { ok: false, out: '', error: e.message?.split('\n')[0] || String(e) }
545
+ }
546
+ }
547
+
548
+ /**
549
+ * 客观核验 execute 阶段是否产生了真实代码变更。
550
+ *
551
+ * 历史漏洞:execute 无 stage-level validator,agent 勾选 plan.md 全部 checkbox
552
+ * 即可让 execute 被标 completed,代码完成度与真实变更完全脱钩。
553
+ *
554
+ * 判定顺序(fail-open on uncertainty,避免环境差异误杀):
555
+ * 1. worktree meta 存在且有 baseHash → 在 worktree 内查 baseHash..HEAD diff + 未提交改动
556
+ * 2. sillyspec/<change> 分支存在 → 查 merge-base..branch diff
557
+ * 3. 主工作区存在未提交改动(apply 后未 commit 的常见形态)→ changed
558
+ * 4. 均无法判定 → unknown(由调用方降级为 warning)
559
+ *
560
+ * @returns {{ status: 'changed'|'unchanged'|'unknown', detail: string }}
561
+ */
562
+ export function checkExecuteCodeEvidence(cwd, changeName) {
563
+ const metaPath = join(cwd, '.sillyspec', '.runtime', 'worktrees', changeName, 'meta.json')
564
+ let meta = null
565
+ if (existsSync(metaPath)) {
566
+ try { meta = JSON.parse(readFileSync(metaPath, 'utf8')) } catch {}
567
+ }
568
+
569
+ // 1. worktree(或 in-place)meta 有 baseHash:最权威的对账基准
570
+ if (meta?.baseHash) {
571
+ const gitDir = (meta.worktreePath && meta.mode !== 'in-place-fallback' && existsSync(meta.worktreePath))
572
+ ? meta.worktreePath
573
+ : cwd
574
+ const diff = gitTry(gitDir, ['diff', '--name-only', `${meta.baseHash}..HEAD`])
575
+ const status = gitTry(gitDir, ['status', '--porcelain'])
576
+ if (diff.ok && status.ok) {
577
+ const committedFiles = diff.out ? diff.out.split('\n').filter(Boolean).length : 0
578
+ const hasUncommitted = status.out.trim().length > 0
579
+ if (committedFiles > 0 || hasUncommitted) {
580
+ return { status: 'changed', detail: `${committedFiles} 个已提交变更文件${hasUncommitted ? ' + 未提交改动' : ''}(base ${meta.baseHash.slice(0, 8)})` }
581
+ }
582
+ return { status: 'unchanged', detail: `${gitDir} 相对 base ${meta.baseHash.slice(0, 8)} 无任何提交或未提交改动` }
583
+ }
584
+ return { status: 'unknown', detail: `git 核验失败: ${diff.error || status.error}` }
585
+ }
586
+
587
+ // 2. worktree 已清理但分支残留:diff merge-base..branch
588
+ const branch = `sillyspec/${changeName}`
589
+ const branchHash = gitTry(cwd, ['rev-parse', '--verify', '--quiet', branch])
590
+ if (branchHash.ok && branchHash.out) {
591
+ const mergeBase = gitTry(cwd, ['merge-base', 'HEAD', branch])
592
+ if (mergeBase.ok && mergeBase.out) {
593
+ const diff = gitTry(cwd, ['diff', '--name-only', `${mergeBase.out}..${branch}`])
594
+ if (diff.ok && diff.out.split('\n').filter(Boolean).length > 0) {
595
+ return { status: 'changed', detail: `分支 ${branch} 相对 merge-base 有变更` }
596
+ }
597
+ }
598
+ }
599
+
600
+ // 3. 主工作区未提交改动(worktree apply 后的常见形态)
601
+ const status = gitTry(cwd, ['status', '--porcelain'])
602
+ if (status.ok && status.out.trim()) {
603
+ return { status: 'changed', detail: '主工作区存在未提交改动' }
604
+ }
605
+
606
+ return { status: 'unknown', detail: '无 worktree meta 且无可对账的分支/未提交改动(变更可能已 apply 并提交),无法客观判定' }
607
+ }
608
+
609
+ /**
610
+ * execute 完成校验:plan.md 声明了任务时,必须存在真实代码变更。
611
+ * 防止"勾选 checkbox = 完成 execute"的谎报路径。
612
+ */
613
+ function validateExecuteOutputs(cwd, changeName, context = {}) {
614
+ const { specRoot } = context
615
+ const errors = []
616
+ const warnings = []
617
+
618
+ const changeDir = resolveChangeDir(cwd, changeName, specRoot)
619
+ const planPath = join(changeDir, 'plan.md')
620
+ // 无 plan.md 的 execute(旧流程/quick 混用)不在此核验范围
621
+ if (!existsSync(planPath)) return { ok: true, errors, warnings }
622
+
623
+ const planContent = readFileSync(planPath, 'utf8')
624
+ const hasTasks = /^\s*[-*]\s*\[[ xX]\]\s*task-\d+/m.test(planContent)
625
+ if (!hasTasks) return { ok: true, errors, warnings }
626
+
627
+ const evidence = checkExecuteCodeEvidence(cwd, changeName)
628
+ if (evidence.status === 'unchanged') {
629
+ errors.push(`execute 代码变更核验失败:plan.md 声明了任务,但 ${evidence.detail} — 勾选 checkbox 不等于完成实现`)
630
+ } else if (evidence.status === 'unknown') {
631
+ warnings.push(`execute 代码变更无法客观核验:${evidence.detail}`)
632
+ }
633
+
634
+ return { ok: errors.length === 0, errors, warnings }
635
+ }
636
+
531
637
  // ============ Contract Registry ============
532
638
 
533
639
  /**
@@ -564,7 +670,7 @@ const contracts = {
564
670
  description: '代码实现',
565
671
  allowedFrom: ['plan'],
566
672
  allowedTo: ['verify'],
567
- validators: [],
673
+ validators: [validateExecuteOutputs],
568
674
  },
569
675
  verify: {
570
676
  stage: 'verify',
@@ -22,7 +22,7 @@ export const definition = {
22
22
  ### 操作
23
23
  1. 运行 \`sillyspec progress show\`,确认 currentStage 为 "brainstorm"
24
24
  2. 如果未初始化,提示先运行 sillyspec init
25
- 3. **检查变更名称**:如果当前变更名是自动生成的(如 \`2026-06-02-new-change\`),直接重命名为有意义名称,然后运行 \`sillyspec change-rename <旧名> <新名>\`
25
+ 3. **检查变更名称**:如果当前变更名是自动生成的(如 \`2026-06-02-new-change-a3f2b7c1\`),直接重命名为有意义名称,然后运行 \`sillyspec change-rename <旧名> <新名>\`
26
26
 
27
27
  ### 加载上下文
28
28
  4. 读取 CODEBASE-OVERVIEW.md + 共享规范 + 子项目上下文
@@ -12,7 +12,7 @@ export const definition = {
12
12
  2. 确认 currentStage 为 "brainstorm"
13
13
  3. 如果有进行中的 brainstorm,提示选择继续或重新开始
14
14
  4. 如果未初始化,提示先运行 sillyspec init
15
- 5. **检查变更名称是否有意义**:如果当前变更名是自动生成的(如 \`2026-06-02-new-change\`),询问用户确认实际变更名,然后运行 \`sillyspec change-rename <旧名> <新名>\` 重命名
15
+ 5. **检查变更名称是否有意义**:如果当前变更名是自动生成的(如 \`2026-06-02-new-change-a3f2b7c1\`),询问用户确认实际变更名,然后运行 \`sillyspec change-rename <旧名> <新名>\` 重命名
16
16
 
17
17
  ### 输出
18
18
  当前状态摘要(1-2 句话)