sillyspec 3.26.11 → 3.26.14
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/.claude/skills/sillyspec-archive/SKILL.md +1 -1
- package/.claude/skills/sillyspec-execute/SKILL.md +1 -1
- package/.claude/skills/sillyspec-knowledge/SKILL.md +1 -1
- package/.claude/skills/sillyspec-plan/SKILL.md +5 -5
- package/.claude/skills/sillyspec-quick/SKILL.md +5 -1
- package/README.md +7 -5
- package/SKILL.md +5 -4
- package/package.json +1 -1
- package/packages/dashboard/server/executor.js +10 -1
- package/packages/dashboard/server/index.js +108 -46
- package/src/config-schema.js +3 -0
- package/src/contract-matrix.js +22 -13
- package/src/db.js +24 -4
- package/src/dispatch/probe.js +99 -63
- package/src/docs-check.js +58 -13
- package/src/docs-debt.js +6 -2
- package/src/docs-gate.js +1 -0
- package/src/doctor-diagnostics.js +208 -21
- package/src/fs-atomic.js +3 -1
- package/src/git-helper.js +7 -4
- package/src/hooks/worktree-guard.js +8 -2
- package/src/index.js +200 -27
- package/src/init.js +3 -2
- package/src/local-detect.js +9 -1
- package/src/modules.js +7 -5
- package/src/progress/stage-machine.js +589 -578
- package/src/progress/step-store.js +1 -1
- package/src/progress.js +187 -30
- package/src/quicklog.js +47 -16
- package/src/run/command.js +88 -46
- package/src/run/complete-handlers.js +27 -15
- package/src/run/complete.js +1186 -1121
- package/src/run/concurrent-detect.js +3 -1
- package/src/run/gates.js +172 -25
- package/src/run/prompt.js +17 -9
- package/src/run/scan-profile.js +35 -23
- package/src/run/shared.js +164 -63
- package/src/run/stage.js +41 -1
- package/src/setup.js +8 -10
- package/src/sillyhub-mcp/client.js +74 -6
- package/src/spec-dir-typo.js +3 -24
- package/src/spec-sync.js +43 -8
- package/src/stage-contract.js +12 -38
- package/src/stage-review.js +40 -1
- package/src/stages/archive.js +4 -3
- package/src/stages/brainstorm-auto.js +1 -1
- package/src/stages/brainstorm.js +27 -13
- package/src/stages/doctor.js +29 -16
- package/src/stages/execute.js +170 -154
- package/src/stages/knowledge.js +18 -3
- package/src/stages/plan-postcheck.js +72 -37
- package/src/stages/plan.js +70 -48
- package/src/stages/quick.js +3 -1
- package/src/stages/verify.js +1 -1
- package/src/sync.js +339 -225
- package/src/task-review.js +281 -64
- package/src/taskcard.js +178 -0
- package/src/verify-postcheck.js +48 -35
- package/src/worktree-apply.js +58 -34
- package/src/worktree-deps.js +120 -20
- package/src/worktree.js +55 -9
- package/.npmrc-publish +0 -2
package/src/task-review.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { existsSync, readFileSync, mkdirSync, writeFileSync, readdirSync, statSync } from 'fs'
|
|
12
12
|
import { join, resolve } from 'path'
|
|
13
|
-
import {
|
|
13
|
+
import { git } from './git-helper.js'
|
|
14
14
|
import { pathMatches } from './change-list.js'
|
|
15
15
|
import { parseAllowedPaths, parseRepo, parseBaseCommit, parseHeadCommit } from './stages/plan-postcheck.js'
|
|
16
16
|
import { resolveVerifyChangedFiles } from './verify-postcheck.js'
|
|
@@ -105,64 +105,63 @@ export function summarizeTaskCompletion({ changeDir, runtimeRoot, changeName })
|
|
|
105
105
|
}
|
|
106
106
|
if (!changeDir) return noPlan
|
|
107
107
|
|
|
108
|
+
// 2026-08-20-task-truth-unify:任务清单唯一真相在 tasks.md,优先读注册表;plan.md 回退
|
|
109
|
+
// (旧归档变更兼容读侧——新契约 plan.md 为纯 ID 引用行,无 checkbox 可解析)
|
|
108
110
|
const planPath = join(changeDir, 'plan.md')
|
|
109
111
|
const tasksPath = join(changeDir, 'tasks.md')
|
|
110
112
|
let planContent = null
|
|
111
|
-
if (existsSync(
|
|
112
|
-
else if (existsSync(
|
|
113
|
+
if (existsSync(tasksPath)) planContent = readFileSync(tasksPath, 'utf8')
|
|
114
|
+
else if (existsSync(planPath)) planContent = readFileSync(planPath, 'utf8')
|
|
113
115
|
else return noPlan
|
|
114
116
|
|
|
115
117
|
const taskIds = parseTaskIdsFromPlan(planContent)
|
|
116
118
|
if (taskIds.length === 0) {
|
|
117
119
|
return { source: 'no-tasks', total: 0, completed: 0, pending: [],
|
|
118
|
-
report: '⚠️
|
|
120
|
+
report: '⚠️ tasks.md 未解析出任何 task-NN 条目(任务注册表可能未按规范写 checkbox 列表)。' }
|
|
119
121
|
}
|
|
120
122
|
|
|
121
|
-
// runId
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
123
|
+
// runId 解析(坑 worktree-cleanup-marker-chain 根治):归属化解析 resolveExecuteRunForChange——
|
|
124
|
+
// marker 优先但校验覆盖度;marker 断裂(worktree cleanup / 归档清理 / 并行误删)时按 change 戳
|
|
125
|
+
// 精确归属,旧 run 退覆盖度启发。不再盲目 mtime 最新(曾把其他变更的 run 错配给本变更 →
|
|
126
|
+
// 全部 task 误报「review.json 缺失」→ 手工回填 7 份实际存在于正确 run 里的 review)。
|
|
127
|
+
const resolved = resolveExecuteRunForChange({ runtimeRoot, changeName, taskIds })
|
|
128
|
+
const runId = resolved ? resolved.runId : null
|
|
129
|
+
if (resolved && resolved.relocated) {
|
|
130
|
+
console.warn(`[sillyspec] execute run 归属修正:marker 指向的 run 对变更 ${changeName} 零覆盖,改用 ${resolved.origin === 'stamp' ? 'change 戳' : '覆盖度'}命中的 run ${runId}(真实 review 所在)`)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// 无归属 run → 降级 plan.md checkbox 统计;报告区分「无法归属」与「没跑过 execute」,
|
|
134
|
+
// 避免把定位失败误导成「review 全缺、需手工回填」
|
|
131
135
|
if (!runId) {
|
|
136
|
+
const { total, checked } = countPlanCheckboxes(planContent)
|
|
137
|
+
let runsNote = '无 execute-runs 目录(本变更未跑过 execute?)'
|
|
132
138
|
try {
|
|
133
139
|
const runsDir = join(runtimeRoot, 'execute-runs')
|
|
134
140
|
if (existsSync(runsDir)) {
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
.filter(x => { try { return statSync(x.p).isDirectory() } catch { return false } })
|
|
138
|
-
.map(x => ({ e: x.e, mtime: statSync(x.p).mtimeMs }))
|
|
139
|
-
.sort((a, b) => b.mtime - a.mtime)
|
|
140
|
-
if (entries.length > 0) runId = entries[0].e
|
|
141
|
+
const n = readdirSync(runsDir).filter(e => { try { return statSync(join(runsDir, e)).isDirectory() } catch { return false } }).length
|
|
142
|
+
if (n > 0) runsNote = `execute-runs/ 有 ${n} 个 run 但无一能归属到本变更(无 change 戳且零覆盖——marker 链断裂且 review 可能从未生成)`
|
|
141
143
|
}
|
|
142
144
|
} catch {}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
// 无 runId → 降级 plan.md checkbox 统计
|
|
146
|
-
if (!runId) {
|
|
147
|
-
const { total, checked } = countPlanCheckboxes(planContent)
|
|
148
145
|
return {
|
|
149
146
|
source: 'plan-checkbox-fallback', total, completed: checked,
|
|
150
147
|
pending: taskIds.slice(0, Math.max(0, total - checked)).map(id => ({ id, reason: 'checkbox 未勾(降级口径)' })),
|
|
151
148
|
report:
|
|
152
|
-
'⚠️
|
|
149
|
+
'⚠️ 客观源不可用(execute run 无法归属到本变更:' + runsNote + ')→ 降级用 plan.md checkbox。\n' +
|
|
153
150
|
`- 总任务:${total}\n- plan.md 已勾选:${checked}\n` +
|
|
154
151
|
'⚠️ checkbox 可能因 autoCheckPlanFromReviews 回填断裂而失真,务必交叉核对 .runtime/execute-runs/*/tasks/task-NN/review.json 的 verdict 再下结论。'
|
|
155
152
|
}
|
|
156
153
|
}
|
|
157
154
|
|
|
158
|
-
// 有 runId → 读 review.json verdict
|
|
155
|
+
// 有 runId → 读 review.json verdict;cannot_verify 草稿单列(不是真正复核,归档前应可见)
|
|
159
156
|
const completed = []
|
|
160
157
|
const pending = []
|
|
158
|
+
let cannotVerifyCount = 0
|
|
161
159
|
for (const id of taskIds) {
|
|
162
160
|
const reviewPath = join(runtimeRoot, 'execute-runs', runId, 'tasks', id, 'review.json')
|
|
163
161
|
const r = readReview(reviewPath)
|
|
164
162
|
if (r.ok && r.review && r.review.specVerdict !== 'fail' && r.review.qualityVerdict !== 'fail') {
|
|
165
163
|
completed.push(id)
|
|
164
|
+
if (r.review.specVerdict === 'cannot_verify' || r.review.qualityVerdict === 'cannot_verify') cannotVerifyCount++
|
|
166
165
|
} else if (r.ok && r.review) {
|
|
167
166
|
pending.push({ id, reason: `verdict 未通过(spec=${r.review.specVerdict}, quality=${r.review.qualityVerdict})` })
|
|
168
167
|
} else {
|
|
@@ -173,12 +172,17 @@ export function summarizeTaskCompletion({ changeDir, runtimeRoot, changeName })
|
|
|
173
172
|
const pendingLines = pending.length > 0
|
|
174
173
|
? pending.map(p => ` - ${p.id}: ${p.reason}`).join('\n')
|
|
175
174
|
: ' (无)'
|
|
175
|
+
const draftLines = cannotVerifyCount > 0
|
|
176
|
+
? `- cannot_verify 草稿(未真正复核): ${cannotVerifyCount}\n` +
|
|
177
|
+
' ⚠️ 这些 task 的 review 是 cannot_verify(含自动草稿兜底)而非 pass——归档前应确认 verify 阶段已兑现其 requiredEvidence,否则派独立子代理对照 task brief + git diff 补真实复核,勿静默放行\n'
|
|
178
|
+
: ''
|
|
176
179
|
return {
|
|
177
|
-
source: 'review.json', total: taskIds.length, completed: completed.length, pending,
|
|
180
|
+
source: 'review.json', total: taskIds.length, completed: completed.length, pending, cannotVerify: cannotVerifyCount, runId,
|
|
178
181
|
report:
|
|
179
|
-
`客观任务完成度(真相源 = review.json verdict,runId=${runId}):\n` +
|
|
182
|
+
`客观任务完成度(真相源 = review.json verdict,runId=${runId}${resolved.origin !== 'marker' ? `,归属=${resolved.origin}` : ''}):\n` +
|
|
180
183
|
`- 总任务:${taskIds.length}\n` +
|
|
181
184
|
`- 已通过(spec + quality verdict 均非 fail):${completed.length}\n` +
|
|
185
|
+
draftLines +
|
|
182
186
|
`- 未通过 / 缺失:${pending.length}\n` +
|
|
183
187
|
`- 未完成列表:\n${pendingLines}\n` +
|
|
184
188
|
'注:以 review.json verdict 为准;plan.md checkbox 仅作显示态(回填断裂时会与客观 verdict 不一致,以下方客观点为准)。'
|
|
@@ -387,6 +391,9 @@ export function validateTaskReviews(opts) {
|
|
|
387
391
|
const errors = []
|
|
388
392
|
const warnings = []
|
|
389
393
|
const requiredEvidence = []
|
|
394
|
+
// PERF-01:整个循环共享一份 git 结果缓存(同 gitDir 的探测/status、同 (base,head) 的
|
|
395
|
+
// diff 在多 task 间高度重复;N task 从 5N 个 git 子进程降到 ~2+去重后数量)
|
|
396
|
+
const evidenceCache = {}
|
|
390
397
|
|
|
391
398
|
for (const taskId of taskIds) {
|
|
392
399
|
const reviewDir = join(runtimeRoot, 'execute-runs', executeRunId, 'tasks', taskId)
|
|
@@ -447,7 +454,7 @@ export function validateTaskReviews(opts) {
|
|
|
447
454
|
}
|
|
448
455
|
}
|
|
449
456
|
if (reviewGitDir) {
|
|
450
|
-
const evidence = verifyReviewGitEvidence(review, reviewGitDir)
|
|
457
|
+
const evidence = verifyReviewGitEvidence(review, reviewGitDir, evidenceCache)
|
|
451
458
|
for (const w of evidence.warnings) warnings.push(`${taskId}: ${w}`)
|
|
452
459
|
if (!evidence.ok) {
|
|
453
460
|
for (const err of evidence.errors) errors.push(`${taskId}: ${err}`)
|
|
@@ -516,13 +523,10 @@ export function validateTaskReviews(opts) {
|
|
|
516
523
|
|
|
517
524
|
// ── Git 真实性交叉校验 ──
|
|
518
525
|
|
|
526
|
+
// QUAL-01 收口:原为本地 execFileSync('git')(无 safe.directory、与 git-helper 口径分裂),
|
|
527
|
+
// 现走统一入口;抛错/trim 语义不变,timeout 15000 保留(rev-parse/diff 大仓余量)
|
|
519
528
|
function runGit(gitDir, args) {
|
|
520
|
-
return
|
|
521
|
-
cwd: gitDir,
|
|
522
|
-
encoding: 'utf8',
|
|
523
|
-
timeout: 15000,
|
|
524
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
525
|
-
}).trim()
|
|
529
|
+
return git(gitDir, args, { timeout: 15000 })
|
|
526
530
|
}
|
|
527
531
|
|
|
528
532
|
// porcelain 状态行 → 变更文件路径(对齐 shared.js:343 parsePorcelainPath:`XY path`、引号包裹、
|
|
@@ -552,31 +556,57 @@ function parsePorcelainFiles(statusOut) {
|
|
|
552
556
|
* git 环境不可用(非 git 仓库 / git 缺失)时返回 unavailable,由调用方降级为
|
|
553
557
|
* warning——不因环境问题误杀,但记录在输出中。
|
|
554
558
|
*
|
|
559
|
+
/**
|
|
555
560
|
* @param {object} review - 已通过 schema 校验的 review 对象
|
|
556
561
|
* @param {string} gitDir - 执行 git 命令的目录(worktree 优先,回退主仓库)
|
|
562
|
+
* @param {object} [cache] - 进程内单次校验批的共享缓存(体检 PERF-01:N task × 5 个 git
|
|
563
|
+
* 子进程去重——gitDir 探测与 status --porcelain 结果按 gitDir 恒同,commit 校验按
|
|
564
|
+
* (gitDir,hash)、diff 按 (gitDir,base,head) 在多 task 间高度重复。validateTaskReviews
|
|
565
|
+
* 整个循环传同一份;不传则退化为逐次执行(单测/独立调用零回归)。缓存生命周期 =
|
|
566
|
+
* 一次校验批:working-tree status 是快照语义,跨批复用会读到陈旧状态。
|
|
557
567
|
* @returns {{ ok: boolean, emptyDiff: boolean, errors: string[], warnings: string[], unavailable: boolean }}
|
|
558
568
|
*/
|
|
559
|
-
export function verifyReviewGitEvidence(review, gitDir) {
|
|
569
|
+
export function verifyReviewGitEvidence(review, gitDir, cache = null) {
|
|
560
570
|
const errors = []
|
|
561
571
|
const warnings = []
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
572
|
+
const c = cache || (cache = {})
|
|
573
|
+
c.gitOk = c.gitOk || new Map()
|
|
574
|
+
c.verified = c.verified || new Set()
|
|
575
|
+
c.diffs = c.diffs || new Map()
|
|
576
|
+
c.wtStatus = c.wtStatus || new Map()
|
|
577
|
+
|
|
578
|
+
// git 环境探测:失败即 unavailable,交由调用方降级(同 gitDir 结果恒同,批内缓存)
|
|
579
|
+
if (!c.gitOk.has(gitDir)) {
|
|
580
|
+
try {
|
|
581
|
+
runGit(gitDir, ['rev-parse', '--git-dir'])
|
|
582
|
+
c.gitOk.set(gitDir, true)
|
|
583
|
+
} catch (e) {
|
|
584
|
+
c.gitOk.set(gitDir, false)
|
|
585
|
+
return {
|
|
586
|
+
ok: true,
|
|
587
|
+
emptyDiff: false,
|
|
588
|
+
errors: [],
|
|
589
|
+
warnings: [`git 环境不可用(${gitDir}),跳过 review 真实性交叉校验: ${e.message?.split('\n')[0] || e.message}`],
|
|
590
|
+
unavailable: true,
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
} else if (c.gitOk.get(gitDir) === false) {
|
|
567
594
|
return {
|
|
568
595
|
ok: true,
|
|
569
596
|
emptyDiff: false,
|
|
570
597
|
errors: [],
|
|
571
|
-
warnings: [`git 环境不可用(${gitDir}),跳过 review
|
|
598
|
+
warnings: [`git 环境不可用(${gitDir}),跳过 review 真实性交叉校验(批内缓存判定)`],
|
|
572
599
|
unavailable: true,
|
|
573
600
|
}
|
|
574
601
|
}
|
|
575
602
|
|
|
576
603
|
for (const field of ['base', 'head']) {
|
|
577
604
|
const hash = review[field]
|
|
605
|
+
const vkey = `${gitDir}\u0000${hash}`
|
|
606
|
+
if (c.verified.has(vkey)) continue
|
|
578
607
|
try {
|
|
579
608
|
runGit(gitDir, ['rev-parse', '--verify', '--quiet', `${hash}^{commit}`])
|
|
609
|
+
c.verified.add(vkey)
|
|
580
610
|
} catch {
|
|
581
611
|
errors.push(`${field} "${hash}" 不是仓库中的真实 commit — review.json 疑似伪造`)
|
|
582
612
|
}
|
|
@@ -586,21 +616,32 @@ export function verifyReviewGitEvidence(review, gitDir) {
|
|
|
586
616
|
}
|
|
587
617
|
|
|
588
618
|
let diffFiles = []
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
diffFiles =
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
|
|
619
|
+
const dkey = `${gitDir}\u0000${review.base}\u0000${review.head}`
|
|
620
|
+
if (c.diffs.has(dkey)) {
|
|
621
|
+
diffFiles = c.diffs.get(dkey)
|
|
622
|
+
} else {
|
|
623
|
+
try {
|
|
624
|
+
const out = runGit(gitDir, ['diff', '--name-only', `${review.base}..${review.head}`])
|
|
625
|
+
diffFiles = out ? out.split('\n').filter(Boolean) : []
|
|
626
|
+
c.diffs.set(dkey, diffFiles)
|
|
627
|
+
} catch (e) {
|
|
628
|
+
warnings.push(`git diff ${review.base}..${review.head} 执行失败,跳过 diff 校验: ${e.message?.split('\n')[0] || e.message}`)
|
|
629
|
+
return { ok: true, emptyDiff: false, errors, warnings, unavailable: false }
|
|
630
|
+
}
|
|
595
631
|
}
|
|
596
632
|
|
|
597
633
|
// 并入 working-tree 未提交改动(execute 复盘 a):子代理默认不 commit,base..head 无 commit diff 时
|
|
598
634
|
// diffFiles 留空 → 下方交叉比对拿空集对非空 changedFiles 必判「完全不相交」伪造,逼 agent 强制 commit
|
|
599
635
|
// + 改 7 个 review head。对齐 checkExecuteCodeEvidence(stage-contract.js 同时查 working-tree)语义,
|
|
600
636
|
// 未提交改动也算有效对账源。working-tree 有改动时无条件并入(覆盖「部分 commit + 未提交」)。
|
|
637
|
+
// PERF-01:同 gitDir 的 status 快照按批缓存(单次校验批内恒同,跨批复用会读到陈旧状态)
|
|
601
638
|
const commitDiffFiles = diffFiles.slice()
|
|
602
639
|
try {
|
|
603
|
-
|
|
640
|
+
let wtStatus = c.wtStatus.get(gitDir)
|
|
641
|
+
if (wtStatus === undefined) {
|
|
642
|
+
wtStatus = runGit(gitDir, ['status', '--porcelain'])
|
|
643
|
+
c.wtStatus.set(gitDir, wtStatus)
|
|
644
|
+
}
|
|
604
645
|
if (wtStatus && wtStatus.trim().length > 0) {
|
|
605
646
|
const wtFiles = parsePorcelainFiles(wtStatus)
|
|
606
647
|
if (wtFiles.length > 0) diffFiles = diffFiles.concat(wtFiles)
|
|
@@ -700,7 +741,7 @@ export function isValidExecuteRunId(runId) {
|
|
|
700
741
|
* @param {{ runtimeRoot: string }} opts
|
|
701
742
|
* @returns {string|null} 含 tasks/ 的最新 runId,无则 null
|
|
702
743
|
*/
|
|
703
|
-
export function resolveLatestExecuteRunIdWithTasks({ runtimeRoot }) {
|
|
744
|
+
export function resolveLatestExecuteRunIdWithTasks({ runtimeRoot, changeName = null }) {
|
|
704
745
|
try {
|
|
705
746
|
const runsDir = join(runtimeRoot, 'execute-runs')
|
|
706
747
|
if (!existsSync(runsDir)) return null
|
|
@@ -710,7 +751,19 @@ export function resolveLatestExecuteRunIdWithTasks({ runtimeRoot }) {
|
|
|
710
751
|
.filter(x => { try { return statSync(join(x.p, 'tasks')).isDirectory() } catch { return false } })
|
|
711
752
|
.map(x => ({ e: x.e, mtime: statSync(x.p).mtimeMs }))
|
|
712
753
|
.sort((a, b) => b.mtime - a.mtime)
|
|
713
|
-
|
|
754
|
+
if (entries.length === 0) return null
|
|
755
|
+
// changeName 给定时优先 change 戳等值的 run(坑 worktree-cleanup-marker-chain:mtime 最新
|
|
756
|
+
// 会错拿其他变更的 run);无戳命中退回 mtime 最新(向后兼容,调用方多为 marker 漂移兜底,
|
|
757
|
+
// 宁可拿最新 run 也不空手而归——覆盖度由调用方 validateTaskReviews 复校验)
|
|
758
|
+
if (changeName) {
|
|
759
|
+
for (const x of entries) {
|
|
760
|
+
try {
|
|
761
|
+
const c = readFileSync(join(runsDir, x.e, 'change'), 'utf8').trim()
|
|
762
|
+
if (c === changeName) return x.e
|
|
763
|
+
} catch {}
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
return entries[0].e
|
|
714
767
|
} catch {
|
|
715
768
|
return null
|
|
716
769
|
}
|
|
@@ -725,20 +778,145 @@ export function resolveLatestExecuteRunId({ runtimeRoot, changeName }) {
|
|
|
725
778
|
if (isValidExecuteRunId(c)) return c
|
|
726
779
|
}
|
|
727
780
|
} catch {}
|
|
781
|
+
// marker 缺失(worktree cleanup / 归档清理 / 并行会话误删——坑 worktree-cleanup-marker-chain)
|
|
782
|
+
// 时不再盲目取 mtime 最新(会拿到其他变更的 run):先按 change 归属戳过滤,命中才返回;
|
|
783
|
+
// 无戳(旧 run)退回 mtime 最新保持向后兼容。
|
|
728
784
|
try {
|
|
729
|
-
const
|
|
730
|
-
if (
|
|
731
|
-
|
|
732
|
-
.map(e => ({ e, p: join(runsDir, e) }))
|
|
733
|
-
.filter(x => { try { return statSync(x.p).isDirectory() } catch { return false } })
|
|
734
|
-
.map(x => ({ e: x.e, mtime: statSync(x.p).mtimeMs }))
|
|
735
|
-
.sort((a, b) => b.mtime - a.mtime)
|
|
736
|
-
if (entries.length > 0) return entries[0].e
|
|
737
|
-
}
|
|
785
|
+
const candidates = listExecuteRunCandidates(runtimeRoot, changeName)
|
|
786
|
+
if (candidates.stamped.length > 0) return candidates.stamped[0]
|
|
787
|
+
if (candidates.all.length > 0) return candidates.all[0]
|
|
738
788
|
} catch {}
|
|
739
789
|
return null
|
|
740
790
|
}
|
|
741
791
|
|
|
792
|
+
// ── execute run 的 change 归属戳(坑 worktree-cleanup-marker-chain 根治)──
|
|
793
|
+
//
|
|
794
|
+
// 背景:run 目录 execute-runs/<runId>/ 本身不带变更身份,change→run 的唯一链接是
|
|
795
|
+
// current-execute-run-id-<change> marker;worktree cleanup / 归档 marker 清理 / 并行会话
|
|
796
|
+
// 误删后链即断,各 fallback(mtime 最新)会错拿其他变更的 run → archive 完成度报告把
|
|
797
|
+
// 已实现 task 全报「review.json 缺失」,只能手工回填。
|
|
798
|
+
// 修法:marker 写入点同步在 run 目录落 `change` 戳;marker 断裂后按戳精确归属,
|
|
799
|
+
// 旧 run(无戳)退回覆盖度启发式(run 的 tasks/ 含本变更 task-NN review 才算命中)。
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* 在 run 目录写入 change 归属戳(execute-runs/<runId>/change,内容为 changeName)。
|
|
803
|
+
* 与 marker 写入点同步调用(stage.js 主点 + gates.js/task-review.js 补写点)。
|
|
804
|
+
* best-effort:失败仅 warn 不抛(戳是归属优化的载体,缺了退回覆盖度启发式,不阻断主流程)。
|
|
805
|
+
* @param {string} runtimeRoot
|
|
806
|
+
* @param {string} runId
|
|
807
|
+
* @param {string} changeName
|
|
808
|
+
*/
|
|
809
|
+
export function stampExecuteRunChange(runtimeRoot, runId, changeName) {
|
|
810
|
+
if (!runtimeRoot || !runId || !changeName) return
|
|
811
|
+
try {
|
|
812
|
+
// run 目录可能尚未创建(本 helper 先于 review 落盘调用的路径)——mkdir 保证自洽
|
|
813
|
+
mkdirSync(join(runtimeRoot, 'execute-runs', runId), { recursive: true })
|
|
814
|
+
writeFileSync(join(runtimeRoot, 'execute-runs', runId, 'change'), changeName + '\n')
|
|
815
|
+
} catch (e) {
|
|
816
|
+
console.warn(`[sillyspec] execute run change 戳写入失败(归属 fallback 退回覆盖度启发式,不阻断): ${e.message}`)
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* 读 run 目录的 change 归属戳。
|
|
822
|
+
* @returns {string|null} 戳内容(trim 后非空),无戳/读失败返回 null
|
|
823
|
+
*/
|
|
824
|
+
function readExecuteRunChangeStamp(runtimeRoot, runId) {
|
|
825
|
+
try {
|
|
826
|
+
const c = readFileSync(join(runtimeRoot, 'execute-runs', runId, 'change'), 'utf8').trim()
|
|
827
|
+
return c || null
|
|
828
|
+
} catch { return null }
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* 扫描 execute-runs/ 候选 run(mtime 最新优先),按 change 归属戳分组。
|
|
833
|
+
* @returns {{ stamped: string[], all: string[] }} stamped=戳等 changeName 的 runId(新→旧);
|
|
834
|
+
* all=全部 runId(新→旧,含无戳旧 run,供覆盖度启发式与向后兼容 fallback)
|
|
835
|
+
*/
|
|
836
|
+
function listExecuteRunCandidates(runtimeRoot, changeName) {
|
|
837
|
+
const runsDir = join(runtimeRoot, 'execute-runs')
|
|
838
|
+
const out = { stamped: [], all: [] }
|
|
839
|
+
if (!existsSync(runsDir)) return out
|
|
840
|
+
const entries = readdirSync(runsDir)
|
|
841
|
+
.map(e => ({ e, p: join(runsDir, e) }))
|
|
842
|
+
.filter(x => { try { return statSync(x.p).isDirectory() } catch { return false } })
|
|
843
|
+
.map(x => ({ e: x.e, mtime: statSync(x.p).mtimeMs }))
|
|
844
|
+
.sort((a, b) => b.mtime - a.mtime)
|
|
845
|
+
for (const x of entries) {
|
|
846
|
+
out.all.push(x.e)
|
|
847
|
+
if (changeName && readExecuteRunChangeStamp(runtimeRoot, x.e) === changeName) out.stamped.push(x.e)
|
|
848
|
+
}
|
|
849
|
+
return out
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/**
|
|
853
|
+
* 判定 run 对本变更 taskIds 的覆盖度:tasks/ 下存在多少个 task-NN/review.json ∈ taskIds。
|
|
854
|
+
* taskIds 为空时退化为「tasks/ 下存在任意 task-NN/review.json」(保守启发)。
|
|
855
|
+
* @returns {number} 命中的 review 数
|
|
856
|
+
*/
|
|
857
|
+
function executeRunCoverage(runtimeRoot, runId, taskIds) {
|
|
858
|
+
try {
|
|
859
|
+
const tasksDir = join(runtimeRoot, 'execute-runs', runId, 'tasks')
|
|
860
|
+
if (!existsSync(tasksDir)) return 0
|
|
861
|
+
const entries = readdirSync(tasksDir).filter(e => /^task-\d+$/.test(e))
|
|
862
|
+
if (taskIds.length > 0) return entries.filter(e => taskIds.includes(e) && existsSync(join(tasksDir, e, 'review.json'))).length
|
|
863
|
+
return entries.filter(e => existsSync(join(tasksDir, e, 'review.json'))).length
|
|
864
|
+
} catch { return 0 }
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/**
|
|
868
|
+
* 归属化解析本变更的 execute run(marker 链断裂的根治入口)。
|
|
869
|
+
*
|
|
870
|
+
* 解析顺序:
|
|
871
|
+
* 1. marker(格式校验)指向的 run,若其对本变更有覆盖(tasks/ 有本变更 review)或戳匹配 → 用它;
|
|
872
|
+
* 2. marker 缺失/非法/零覆盖(坑10 型漂移:marker 指向空 run)→ 扫描候选:
|
|
873
|
+
* a. change 戳等值的最新的 run;
|
|
874
|
+
* b.(旧 run 无戳)覆盖度 ≥1 的最新 run;
|
|
875
|
+
* 3. 都不命中 → null(调用方降级,报告明确「run 无法归属」而非「review 全缺」)。
|
|
876
|
+
*
|
|
877
|
+
* fail-safe:任何异常 → null,绝不抛。
|
|
878
|
+
* @param {{ runtimeRoot: string, changeName: string, taskIds?: string[] }} opts
|
|
879
|
+
* @returns {{ runId: string, origin: 'marker'|'stamp'|'coverage', relocated?: boolean }|null}
|
|
880
|
+
*/
|
|
881
|
+
export function resolveExecuteRunForChange({ runtimeRoot, changeName, taskIds = [] }) {
|
|
882
|
+
try {
|
|
883
|
+
// 1. marker
|
|
884
|
+
let markerRunId = null
|
|
885
|
+
try {
|
|
886
|
+
const marker = join(runtimeRoot, `current-execute-run-id-${changeName}`)
|
|
887
|
+
if (existsSync(marker)) {
|
|
888
|
+
const c = readFileSync(marker, 'utf8').trim()
|
|
889
|
+
if (c && !isValidExecuteRunId(c)) console.warn(`[sillyspec] execute run marker 内容非法(期望 exec-YYYY-MM-DD-HHMMSS,实得 ${JSON.stringify(c.slice(0, 60))}),忽略并回退归属扫描`)
|
|
890
|
+
if (isValidExecuteRunId(c)) markerRunId = c
|
|
891
|
+
}
|
|
892
|
+
} catch {}
|
|
893
|
+
if (markerRunId) {
|
|
894
|
+
const stamped = readExecuteRunChangeStamp(runtimeRoot, markerRunId) === changeName
|
|
895
|
+
const covered = executeRunCoverage(runtimeRoot, markerRunId, taskIds)
|
|
896
|
+
if (stamped || covered > 0) return { runId: markerRunId, origin: 'marker' }
|
|
897
|
+
// marker 零覆盖且无戳:疑似漂移(坑10:marker 在、真实 review 在别的 run),下探扫描
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// 2. 扫描:戳优先 → 覆盖度启发(旧 run)。
|
|
901
|
+
// 覆盖度启发跳过「戳属他变更」的 run:task-NN 命名跨变更同构(都从 task-01 起),只看覆盖度
|
|
902
|
+
// 会把他变更 run 的 task-01 review 误配给本变更;戳存在且不等值 = 该 run 有明确主人,排除。
|
|
903
|
+
const candidates = listExecuteRunCandidates(runtimeRoot, changeName)
|
|
904
|
+
if (candidates.stamped.length > 0) {
|
|
905
|
+
return { runId: candidates.stamped[0], origin: 'stamp', relocated: markerRunId ? markerRunId !== candidates.stamped[0] : true }
|
|
906
|
+
}
|
|
907
|
+
for (const runId of candidates.all) {
|
|
908
|
+
const stamp = readExecuteRunChangeStamp(runtimeRoot, runId)
|
|
909
|
+
if (stamp && stamp !== changeName) continue
|
|
910
|
+
if (executeRunCoverage(runtimeRoot, runId, taskIds) > 0) {
|
|
911
|
+
return { runId, origin: 'coverage', relocated: markerRunId ? markerRunId !== runId : true }
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
return null
|
|
915
|
+
} catch {
|
|
916
|
+
return null
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
|
|
742
920
|
/**
|
|
743
921
|
* worktree execute「主 agent 直接实现」模式收尾兜底:per-task review.json 缺失时,
|
|
744
922
|
* 据 git diff base..head 按 task allowed_paths 归属,自动落盘 cannot_verify 草稿。
|
|
@@ -798,6 +976,7 @@ export async function generateTaskReviewDrafts({ changeName, cwd, platformOpts =
|
|
|
798
976
|
try {
|
|
799
977
|
mkdirSync(join(runtimeRoot, 'execute-runs', executeRunId, 'tasks'), { recursive: true })
|
|
800
978
|
writeFileSync(runIdFile, executeRunId + '\n')
|
|
979
|
+
stampExecuteRunChange(runtimeRoot, executeRunId, changeName)
|
|
801
980
|
} catch (e) {
|
|
802
981
|
console.error(`[sillyspec] execute run marker/目录写入失败(降级继续,草稿落盘仍尝试): ${e.message}`)
|
|
803
982
|
}
|
|
@@ -844,6 +1023,7 @@ export async function generateTaskReviewDrafts({ changeName, cwd, platformOpts =
|
|
|
844
1023
|
const taskFiles = readdirSync(tasksDir).filter(f => /^task-\d+\.md$/.test(f)).sort()
|
|
845
1024
|
let generated = 0
|
|
846
1025
|
let skipped = 0
|
|
1026
|
+
let noAttributionCount = 0
|
|
847
1027
|
const attributed = new Set()
|
|
848
1028
|
|
|
849
1029
|
for (const tf of taskFiles) {
|
|
@@ -902,8 +1082,9 @@ export async function generateTaskReviewDrafts({ changeName, cwd, platformOpts =
|
|
|
902
1082
|
: []
|
|
903
1083
|
}
|
|
904
1084
|
|
|
905
|
-
//
|
|
906
|
-
|
|
1085
|
+
// 主仓 task 缺 base/head(meta 缺失 / HEAD 解析失败,见上方 :968-981 注释承诺的"后续跳过"):
|
|
1086
|
+
// 跳过留给 agent 手补。此前 null.slice(0,8) 直接 TypeError 崩掉整个草稿生成(体检 BUG-06)
|
|
1087
|
+
if (!crossEntry && (!taskBase || !taskHead)) {
|
|
907
1088
|
skipped++
|
|
908
1089
|
continue
|
|
909
1090
|
}
|
|
@@ -912,6 +1093,42 @@ export async function generateTaskReviewDrafts({ changeName, cwd, platformOpts =
|
|
|
912
1093
|
taskChangedFiles.forEach(f => attributed.add(f))
|
|
913
1094
|
}
|
|
914
1095
|
|
|
1096
|
+
// 空 changedFiles 的 task 也生成「无归属」草稿(坑 task-review-draft-skip-leak,2026-08-21
|
|
1097
|
+
// 实证:task-08/task-10 两次都因 allowed_paths 未命中 diff 被静默跳过,--done gate 拦截后
|
|
1098
|
+
// agent 只能从零手写 review.json)。cannot_verify 草稿本就待复核(verify 阶段兑现
|
|
1099
|
+
// requiredEvidence),空 changedFiles 不触发 emptyDiff 伪造误判(该判定看真实 git diff 而非
|
|
1100
|
+
// review.changedFiles),shouldAutoCheckTask 零 diff 守卫也保证不会被自动勾选——草稿从
|
|
1101
|
+
// 「漏了让 agent 从零写」变成「给了骨架让 agent 升级 verdict」。
|
|
1102
|
+
if (taskChangedFiles.length === 0) {
|
|
1103
|
+
const draft = {
|
|
1104
|
+
schemaVersion: REVIEW_SCHEMA_VERSION,
|
|
1105
|
+
task: taskId,
|
|
1106
|
+
base: taskBase,
|
|
1107
|
+
head: taskHead,
|
|
1108
|
+
changedFiles: [],
|
|
1109
|
+
specVerdict: 'cannot_verify',
|
|
1110
|
+
qualityVerdict: 'cannot_verify',
|
|
1111
|
+
requiredEvidence: ['auto-generated draft (no-attributed-diff): ' + taskId +
|
|
1112
|
+
' 的 allowed_paths 未命中本次 diff 任何文件(task 未实现 / 改动与他人同文件 / 路径不匹配)——' +
|
|
1113
|
+
'需人工确认实际改动后升级 verdict,确属未实现则回 fail'],
|
|
1114
|
+
reviewerNotes: 'auto-generated draft from git diff ' + taskBase.slice(0, 8) + '..' + taskHead.slice(0, 8) +
|
|
1115
|
+
';verdict=未评审(无归属 diff 兜底草稿,坑 task-review-draft-skip-leak)',
|
|
1116
|
+
}
|
|
1117
|
+
if (draftRepo) draft.repo = draftRepo
|
|
1118
|
+
// fail-open(对齐函数头契约 + execute-run-dir-fail-loud 测试锁定的「写失败返回统计不抛」):
|
|
1119
|
+
// 无归属草稿写入失败(如 tasks/ 被普通文件占位 ENOTDIR)降级 skipped,不崩整个草稿生成
|
|
1120
|
+
try {
|
|
1121
|
+
mkdirSync(reviewDir, { recursive: true })
|
|
1122
|
+
writeFileSync(reviewPath, JSON.stringify(draft, null, 2) + '\n')
|
|
1123
|
+
generated++
|
|
1124
|
+
noAttributionCount++
|
|
1125
|
+
} catch (e) {
|
|
1126
|
+
console.error(`[sillyspec] task ${taskId} 无归属草稿落盘失败(降级留给 agent 手补): ${e.message}`)
|
|
1127
|
+
skipped++
|
|
1128
|
+
}
|
|
1129
|
+
continue
|
|
1130
|
+
}
|
|
1131
|
+
|
|
915
1132
|
const draft = {
|
|
916
1133
|
schemaVersion: REVIEW_SCHEMA_VERSION,
|
|
917
1134
|
task: taskId,
|
|
@@ -930,7 +1147,7 @@ export async function generateTaskReviewDrafts({ changeName, cwd, platformOpts =
|
|
|
930
1147
|
}
|
|
931
1148
|
|
|
932
1149
|
const unattributed = (Array.isArray(diffFiles) ? diffFiles : []).filter(f => !attributed.has(f))
|
|
933
|
-
return { generated, skipped, unattributed, executeRunId }
|
|
1150
|
+
return { generated, skipped, noAttribution: noAttributionCount, unattributed, executeRunId }
|
|
934
1151
|
}
|
|
935
1152
|
|
|
936
1153
|
/**
|