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/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 +227 -46
- package/src/stage-contract.js +107 -1
- package/src/stages/brainstorm-auto.js +1 -1
- package/src/stages/brainstorm.js +1 -1
- package/src/stages/quick.js +25 -14
- 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,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
|
-
// 替换 <
|
|
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
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
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
|
|
1372
|
-
//
|
|
1373
|
-
//
|
|
1374
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
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 +
|
|
1575
|
-
//
|
|
1576
|
-
//
|
|
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'),
|
|
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
|
-
|
|
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
|
|
2966
|
+
console.error(` 请先创建 quicklog 记录再 --done。`)
|
|
2838
2967
|
return { stageCompleted: false, currentIdx, nextPendingIdx: -1 }
|
|
2839
2968
|
}
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
|
|
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
|
-
|
|
2854
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
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',
|
|
@@ -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 + 共享规范 + 子项目上下文
|
package/src/stages/brainstorm.js
CHANGED
|
@@ -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 句话)
|