sillyspec 3.22.9 → 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,7 +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 } from 'crypto'
9
+ import { randomBytes, randomUUID } from 'crypto'
10
10
  import { createRequire } from 'module'
11
11
  const require = createRequire(import.meta.url)
12
12
  import { ProgressManager } from './progress.js'
@@ -706,16 +706,23 @@ async function outputStep(stageName, stepIndex, steps, cwd, changeName, dbProjec
706
706
  if (changeName && promptText.includes('<change-name>')) {
707
707
  promptText = promptText.replace(/<change-name>/g, changeName)
708
708
  }
709
- // 替换 <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 读关联变更)
710
715
  if (promptText.includes('<linked-changes>')) {
711
716
  const specBaseLc = platformOpts?.specRoot || join(cwd, '.sillyspec')
712
717
  let linkedChanges = []
713
718
  try {
714
- const guardFile = join(specBaseLc, '.runtime', 'quick-guard.json')
715
- if (existsSync(guardFile)) {
716
- const guard = JSON.parse(readFileSync(guardFile, 'utf8'))
717
- linkedChanges = Array.isArray(guard.linkedChanges) ? guard.linkedChanges : []
718
- }
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 : []
719
726
  } catch {}
720
727
  const display = linkedChanges.length > 0 ? linkedChanges.join(', ') : '(无)'
721
728
  promptText = promptText.replace(/<linked-changes>/g, display)
@@ -1379,12 +1386,52 @@ export async function runCommand(args, cwd, specDir = null) {
1379
1386
  if (explicitLinked !== null) {
1380
1387
  linkedChanges = explicitLinked
1381
1388
  }
1382
- // quick progress 键固定走 'default':归属语义已由 linkedChanges 承担(→ quick-guard.json
1383
- // <linked-changes> 占位符注入 prompt)。progress 不能走 pm.read(cwd, null) 的自动检测——
1384
- // 多活跃 change 项目里 quick 步骤进度会随活跃 change 增减在各 change/default 间漂移,
1385
- // 表现为 --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
1386
1403
  if (stageName === 'quick') {
1387
- 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
+ }
1388
1435
  }
1389
1436
 
1390
1437
  // 解析 --files a.js,b.js(quick 专用:显式声明 allowedFiles)
@@ -1432,13 +1479,16 @@ export async function runCommand(args, cwd, specDir = null) {
1432
1479
  // 关键:--done 收尾时复用首次 run 持久化的 linkedChanges,不在管道/CI 下重复弹交互 prompt。
1433
1480
  // explicitLinked === null 且未传 --change 时才进入此分支(显式 --linked-changes none 不应触发交互)。
1434
1481
  if (stageName === 'quick' && explicitLinked === null && linkedChanges.length === 0) {
1435
- 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 前兼容)。
1436
1484
  let persistedLinked = null
1437
1485
  try {
1438
- if (existsSync(guardFile)) {
1439
- const g = JSON.parse(readFileSync(guardFile, 'utf8'))
1440
- if (Array.isArray(g.linkedChanges)) persistedLinked = g.linkedChanges
1441
- }
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
1442
1492
  } catch {}
1443
1493
  if (persistedLinked) {
1444
1494
  linkedChanges = persistedLinked
@@ -1582,9 +1632,10 @@ export async function runCommand(args, cwd, specDir = null) {
1582
1632
  }
1583
1633
  }
1584
1634
 
1585
- // quick 启动(非 --done):reset steps + 生成 quickRunId,避免多会话共享 progress.default.quick
1586
- // 继承并行会话的 step1(会话 B 复用会话 A 的 ql,本会话改动无 ql 记录)。C-实用方案:
1587
- // 每次 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:同秒并发撞)。
1588
1639
  if (stageName === 'quick' && !isDone && !isStatus && !isSkip && !isReset && !isReopen) {
1589
1640
  const qStage = progress.stages?.quick
1590
1641
  if (qStage?.steps?.length) {
@@ -1592,13 +1643,14 @@ export async function runCommand(args, cwd, specDir = null) {
1592
1643
  qStage.status = 'in-progress'
1593
1644
  }
1594
1645
  try {
1595
- const now = new Date()
1596
- const pad = (n) => String(n).padStart(2, '0')
1597
- const quickRunId = `quick-${now.getFullYear()}-${pad(now.getMonth()+1)}-${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
1598
1646
  const runtimeRoot = platformOpts.runtimeRoot || join(specRoot, '.runtime')
1599
1647
  mkdirSync(runtimeRoot, { recursive: true })
1600
- writeFileSync(join(runtimeRoot, 'current-quick-run-id'), quickRunId)
1648
+ writeFileSync(join(runtimeRoot, 'current-quick-run-id'), quickSessionId + '\n')
1601
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 "..."`)
1602
1654
  }
1603
1655
 
1604
1656
  // 确保步骤已初始化
@@ -1909,6 +1961,7 @@ async function runStage(pm, progress, stageName, cwd, changeName, skipApproval =
1909
1961
  const allowNew = quickOpts?.isAllowNew || false
1910
1962
  const forceBaseline = quickOpts?.isForceBaseline || false
1911
1963
  progress.quickGuard = {
1964
+ sessionId: changeName,
1912
1965
  name_zh: '快速任务守卫',
1913
1966
  baselineCommit: safeGit(cwd, ['rev-parse', 'HEAD']).value,
1914
1967
  baselineFiles,
@@ -1918,8 +1971,11 @@ async function runStage(pm, progress, stageName, cwd, changeName, skipApproval =
1918
1971
  linkedChanges: Array.isArray(quickOpts?.linkedChanges) ? quickOpts.linkedChanges : [],
1919
1972
  startedAt: new Date().toISOString(),
1920
1973
  }
1921
- // 写入 quick-guard.json 供 worktree-guard hook 读取
1922
- 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')
1923
1979
  writeFileSync(guardFile, JSON.stringify(progress.quickGuard, null, 2))
1924
1980
  const parts = [`${baselineFiles.length} 个已有脏文件`]
1925
1981
  if (allowedFiles.length > 0) parts.push(`${allowedFiles.length} 个 allowedFiles`)
@@ -2529,6 +2585,26 @@ async function ensureDepsFreshness(cwd, changeName, specBase, worktreeMeta) {
2529
2585
  }
2530
2586
  }
2531
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
+
2532
2608
  async function completeStep(pm, progress, stageName, cwd, outputText, inputText = null, options = {}) {
2533
2609
  const { printNext = true, confirm = false, changeName, platformOpts = {}, nonInteractive = false, confirmMode = null } = options
2534
2610
  const specBase = platformOpts.specRoot || join(cwd, '.sillyspec')
@@ -2887,27 +2963,45 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
2887
2963
  if (!hasQuicklog) {
2888
2964
  console.error(`\n❌ quick 阶段完成校验失败:未检测到 QUICKLOG 记录文件。`)
2889
2965
  console.error(` step 2 要求创建 quicklog 记录,但文件不存在。`)
2890
- console.error(` 请先创建 quicklog 记录再 --done,或使用 --skip-approval 跳过此校验。`)
2966
+ console.error(` 请先创建 quicklog 记录再 --done。`)
2891
2967
  return { stageCompleted: false, currentIdx, nextPendingIdx: -1 }
2892
2968
  }
2893
- if (progress.quickGuard) {
2894
- const review = await auditQuickCompletion(cwd, progress.quickGuard, { isConfirm })
2895
- progress.quickGuard.review = review
2896
- progress.quickGuard.completedAt = new Date().toISOString()
2897
- printQuickAuditReview(review)
2898
- if (review.status === 'blocked') {
2899
- steps[currentIdx].status = 'pending'
2900
- steps[currentIdx].completedAt = null
2901
- if (outputText) steps[currentIdx].output = null
2902
- 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
2903
2994
  }
2995
+ // 清理:guard 命中与缺失两种情况都执行(guard 缺失 → 跳过审计,仅清理不抛错)。
2996
+ // rmSync {recursive,force} / unlinkSync 都容忍文件不存在(brownfield)。
2904
2997
  try {
2905
2998
  const { unlinkSync } = await import('fs')
2906
- const guardFile = join(specBase, '.runtime', 'quick-guard.json')
2907
- 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)
2908
3004
  } catch {}
2909
- progress.lastQuickReview = review
2910
- delete progress.quickGuard
2911
3005
  }
2912
3006
  }
2913
3007
 
@@ -3125,10 +3219,11 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
3125
3219
  for (const err of contractResult.errors) {
3126
3220
  console.error(` - ${err}`)
3127
3221
  }
3128
- console.error(`\n 提示:修复缺失产物后重新运行此步骤,或使用 --skip-approval 跳过校验`)
3222
+ console.error(`\n 提示:修复缺失产物后重新完成此步骤(--skip-approval 只跳过阶段转换/审批检查,不能跳过产物校验)`)
3129
3223
  // 产物校验失败必须阻断完成 —— 否则 validator 形同虚设,
3130
3224
  // verify 会带着 FAIL/缺 verify-result.md 被 ✅ 标记完成(历史教训)。
3131
3225
  // plan/execute 的专项契约校验(下方)在产物齐全后才需要继续跑,故此处先 return。
3226
+ rollbackStageCompletion(stageData, steps, currentIdx)
3132
3227
  progress.lastActive = new Date().toLocaleString('zh-CN',{hour12:false})
3133
3228
  await pm._write(cwd, progress, changeName)
3134
3229
  triggerSync(cwd, changeName, platformOpts)
@@ -3141,8 +3236,22 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
3141
3236
  }
3142
3237
  }
3143
3238
 
3144
- // verify 产物校验通过 + 结论非 FAIL(否则上面已阻断),此时才能声称"验证通过"
3239
+ // verify 产物校验通过 + 结论非 FAIL(否则上面已阻断)。
3240
+ // 再由 CLI 亲自执行 local.yaml 的测试命令,与 verify-result.md 的自报告对账:
3241
+ // 自报告 PASS 但实测失败 → 阻断(防止"文案通过"绕过验证)。
3145
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
+ }
3146
3255
  console.log('\n✅ 验证通过,下一步:sillyspec run archive')
3147
3256
  }
3148
3257
 
@@ -3159,6 +3268,7 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
3159
3268
  for (const err of planValidation.errors) console.error(` - ${err}`)
3160
3269
  console.error(`\n plan.md 不满足 execute 契约,请修复后重新完成此步骤。`)
3161
3270
  // 阻断 completed
3271
+ rollbackStageCompletion(stageData, steps, currentIdx)
3162
3272
  progress.lastActive = new Date().toLocaleString('zh-CN',{hour12:false})
3163
3273
  await pm._write(cwd, progress, changeName)
3164
3274
  triggerSync(cwd, changeName, platformOpts)
@@ -3199,7 +3309,18 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
3199
3309
  executeRunId = generateExecuteRunId()
3200
3310
  }
3201
3311
 
3202
- 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 })
3203
3324
  printReviewResult(reviewResult)
3204
3325
 
3205
3326
  if (!reviewResult.ok) {
@@ -3210,6 +3331,7 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
3210
3331
  console.error('\n⚠️ 部分任务已在 plan.md 中勾选,但 review.json 不存在。')
3211
3332
  console.error(' 请取消勾选这些任务的 checkbox,或补充对应的 review.json。')
3212
3333
  }
3334
+ rollbackStageCompletion(stageData, steps, currentIdx)
3213
3335
  progress.lastActive = new Date().toLocaleString('zh-CN',{hour12:false})
3214
3336
  await pm._write(cwd, progress, changeName)
3215
3337
  triggerSync(cwd, changeName, platformOpts)
@@ -3226,8 +3348,14 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
3226
3348
  }
3227
3349
  }
3228
3350
  } catch (e) {
3229
- console.warn(`⚠️ Task Review Gate 异常: ${e.message}`)
3230
- // 不阻断,但记录异常
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 }
3231
3359
  }
3232
3360
  }
3233
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',
@@ -8,6 +8,12 @@ export const definition = {
8
8
  name: '理解任务',
9
9
  prompt: `解析任务参数,加载项目上下文。
10
10
 
11
+ ### 📌 本 quick 会话 sessionId: \`<quick-session-id>\`
12
+ - CLI 是短进程,run 与 done 是独立进程,sessionId 靠 \`--change\` 跨进程传递
13
+ - **完成每个 step 用**:\`sillyspec run quick --done --change <quick-session-id> --output "..."\`
14
+ - 多会话并发时**必须带 \`--change <quick-session-id>\`**,否则可能命中他者会话的状态
15
+ - 不带 \`--change\` 时 fallback 读 \`current-quick-run-id\`(单会话兼容;多会话不可靠)
16
+
11
17
  ### 操作
12
18
  1. 检查关联变更(\`<linked-changes>\`,逗号分隔的变更名列表;显示「(无)」= 不关联变更),确定记录方式
13
19
  2. 理解任务:模糊则问一个问题确认
@@ -81,6 +87,11 @@ quicklog 已创建(必须放在输出的第一行确认)+ 任务理解 + 上
81
87
  name: '暂存和更新记录',
82
88
  prompt: `Git 暂存并更新任务记录。
83
89
 
90
+ ### 📌 收尾确认 — sessionId: \`<quick-session-id>\`
91
+ - 本步骤是最后一步,完成后 quick 会话即结束
92
+ - **完成本 step 用**:\`sillyspec run quick --done --change <quick-session-id> --output "暂存和记录确认"\`
93
+ - 多会话并发**必须带 \`--change <quick-session-id>\`**,不带会 fallback 读 \`current-quick-run-id\` 可能命中他者会话
94
+
84
95
  ### 操作
85
96
  1. 查看 \`git status --porcelain\`,确认只包含本次 quick 相关文件
86
97
  2. 使用 \`git add -- <file...>\` 暂存本次 quick 实际修改的文件(不要 commit,由用户通过统一提交工具处理)
@@ -182,19 +182,15 @@ export const definition = {
182
182
 
183
183
  ### 操作
184
184
  1. 检查 {SPEC_ROOT}/local.yaml 是否已存在,已存在则跳过(提示"local.yaml 已存在,跳过生成")
185
- 2. 根据项目类型生成默认配置:
186
- - **Node.js**(有 package.json):build: "npm run build", test: "npm test", lint: "npm run lint", type: nodejs
187
- - **Maven**(有 pom.xml):build: "mvn compile", test: "mvn test", lint: "mvn checkstyle:check", type: maven
188
- - **Gradle**(有 build.gradle):build: "./gradlew build", test: "./gradlew test", type: gradle
189
- - **通用项目**:只写注释模板, type: generic
190
- 3. 确保目录存在:mkdir -p {SPEC_ROOT}
191
- 4. 原子写入(先写 tmp 文件再 rename)
192
-
193
- ### 文件格式
185
+ 2. 调用 \`sillyspec local detect\` 命令生成 local.yaml(该命令封装了纯 fs 项目类型嗅探:package.json→nodejs / pom.xml→maven / build.gradle→gradle / Makefile→make / 否则 generic,对应默认 commands)。**不要自行嗅探 package.json/pom.xml/build.gradle 等文件**——探测逻辑唯一归属 local-detect.js,本步骤只负责调用命令并报告结果。
186
+ 3. 该命令内部已处理:确保目录存在(mkdir -p)、已存在则跳过、原子写入(先写 tmp rename)。
187
+ 4. 报告生成的 project.type local.yaml 路径。
188
+
189
+ ### 输出格式参考(由 \`sillyspec local detect\` 自动产出)
194
190
  \`\`\`yaml
195
191
  # SillySpec 本地配置(自动生成,可手动修改)
196
192
  project:
197
- type: nodejs # nodejs/maven/gradle/generic
193
+ type: nodejs # nodejs/maven/gradle/make/generic
198
194
 
199
195
  commands:
200
196
  build: "npm run build"
@@ -203,15 +199,10 @@ commands:
203
199
 
204
200
  # 测试策略:full=全量测试, module=只测变更模块, skip=跳过测试
205
201
  test_strategy: module
206
-
207
- # 模块测试路径映射(可选)
208
- # module_paths:
209
- # user-service: "user/"
210
- # order-service: "order/"
211
202
  \`\`\`
212
203
 
213
204
  ### 输出
214
- local.yaml 生成结果(已存在/已生成)`,
205
+ local.yaml 生成结果(已存在/已生成 + project.type)`,
215
206
  outputHint: 'local.yaml 生成状态',
216
207
  optional: false
217
208
  },
@@ -223,6 +223,7 @@ grep -rl "<关键词>" <源码目录>/ --include="*.java" --include="*.js" --inc
223
223
  ### 注意
224
224
  - 不要全量编译/测试整个项目,只测变更涉及的模块
225
225
  - 如果变更模块不确定,优先使用 local.yaml 中的命令
226
+ - **CLI 对账机制**:verify 阶段最终 --done 时,CLI 会亲自执行 local.yaml 的 commands.test 并与你的报告对账;实测失败会直接阻断 verify 完成,谎报测试结果没有意义
226
227
 
227
228
  ### 输出
228
229
  测试结果 + 技术债务标记`,
@@ -314,7 +315,8 @@ verify-result.md 路径 + 验证报告摘要 + 下一步命令
314
315
  ### 注意
315
316
  - PASS → 运行 \`sillyspec run archive\` 归档
316
317
  - FAIL → 修复后运行 \`sillyspec run verify\` 重新验证
317
- - verify-result.md 是变更包的正式验收记录,归档后保留`,
318
+ - verify-result.md 是变更包的正式验收记录,归档后保留
319
+ - **CLI 对账机制**:本步骤 --done 时 CLI 会亲自执行 local.yaml 的 commands.test;结论写 PASS 但实测失败 → verify 完成被阻断`,
318
320
  outputHint: '验证报告',
319
321
  optional: false
320
322
  }