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/package.json +1 -1
- package/src/hooks/worktree-guard.js +86 -31
- package/src/index.js +141 -5
- package/src/local-detect.js +110 -0
- package/src/machine-interface.js +471 -0
- package/src/progress.js +110 -5
- package/src/run.js +174 -46
- package/src/stage-contract.js +107 -1
- package/src/stages/quick.js +11 -0
- package/src/stages/scan.js +7 -16
- package/src/stages/verify.js +3 -1
- package/src/sync.js +73 -5
- package/src/task-review.js +104 -1
- package/src/verify-postcheck.js +433 -0
- package/src/worktree-apply.js +19 -4
- package/src/worktree.js +12 -0
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
|
-
// 替换 <
|
|
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
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
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
|
|
1383
|
-
//
|
|
1384
|
-
//
|
|
1385
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
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 +
|
|
1586
|
-
//
|
|
1587
|
-
//
|
|
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'),
|
|
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
|
-
|
|
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
|
|
2966
|
+
console.error(` 请先创建 quicklog 记录再 --done。`)
|
|
2891
2967
|
return { stageCompleted: false, currentIdx, nextPendingIdx: -1 }
|
|
2892
2968
|
}
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
|
|
2901
|
-
|
|
2902
|
-
|
|
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
|
-
|
|
2907
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
package/src/stage-contract.js
CHANGED
|
@@ -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',
|
package/src/stages/quick.js
CHANGED
|
@@ -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,由用户通过统一提交工具处理)
|
package/src/stages/scan.js
CHANGED
|
@@ -182,19 +182,15 @@ export const definition = {
|
|
|
182
182
|
|
|
183
183
|
### 操作
|
|
184
184
|
1. 检查 {SPEC_ROOT}/local.yaml 是否已存在,已存在则跳过(提示"local.yaml 已存在,跳过生成")
|
|
185
|
-
2.
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
},
|
package/src/stages/verify.js
CHANGED
|
@@ -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
|
}
|