sillyspec 3.26.9 → 3.26.11
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 +85 -85
- package/.claude/skills/sillyspec-brainstorm/SKILL.md +114 -114
- package/.claude/skills/sillyspec-commit/SKILL.md +109 -109
- package/.claude/skills/sillyspec-continue/SKILL.md +45 -45
- package/.claude/skills/sillyspec-doctor/SKILL.md +76 -76
- package/.claude/skills/sillyspec-execute/SKILL.md +200 -200
- package/.claude/skills/sillyspec-explore/SKILL.md +95 -95
- package/.claude/skills/sillyspec-knowledge/SKILL.md +269 -269
- package/.claude/skills/sillyspec-plan/SKILL.md +148 -148
- package/.claude/skills/sillyspec-quick/SKILL.md +111 -111
- package/.claude/skills/sillyspec-resume/SKILL.md +68 -68
- package/.claude/skills/sillyspec-scan/SKILL.md +79 -79
- package/.claude/skills/sillyspec-state/SKILL.md +64 -64
- package/.claude/skills/sillyspec-status/SKILL.md +54 -54
- package/.claude/skills/sillyspec-verify/SKILL.md +83 -81
- package/.claude/skills/sillyspec-workspace/SKILL.md +169 -169
- package/.gitattributes +2 -0
- package/.husky/pre-push +20 -20
- package/.npmrc-publish +2 -0
- package/README.md +201 -201
- package/SKILL.md +90 -90
- package/package.json +39 -39
- package/packages/dashboard/dist/assets/index-Bq_Z2hne.js +7446 -7446
- package/packages/dashboard/dist/assets/index-O2W5RV4z.css +1 -1
- package/packages/dashboard/dist/index.html +16 -16
- package/packages/dashboard/package.json +25 -25
- package/packages/dashboard/server/executor.js +86 -86
- package/packages/dashboard/server/index.js +601 -601
- package/packages/dashboard/server/parser.js +526 -526
- package/src/change-risk-profile.js +254 -254
- package/src/classify-change.js +109 -109
- package/src/config-schema.js +310 -310
- package/src/contract-matrix.js +379 -379
- package/src/db.js +350 -350
- package/src/dispatch/backends/local-agent.js +155 -155
- package/src/dispatch/backends/sillyhub-mcp.js +245 -245
- package/src/dispatch/probe.js +210 -210
- package/src/dispatch/strategy.js +121 -121
- package/src/docs-check.js +131 -4
- package/src/doctor-diagnostics.js +711 -711
- package/src/endpoint-extractor.js +401 -401
- package/src/fs-atomic.js +69 -69
- package/src/git-helper.js +79 -79
- package/src/hooks/claude-pre-tool-use.cjs +125 -125
- package/src/hooks/worktree-guard.js +848 -848
- package/src/index.js +90 -8
- package/src/init.js +623 -623
- package/src/knowledge-match.js +130 -130
- package/src/local-detect.js +120 -120
- package/src/machine-interface.js +543 -543
- package/src/migrate.js +127 -127
- package/src/progress/change-registry.js +297 -260
- package/src/progress/consistency-doctor.js +355 -355
- package/src/progress/shared.js +36 -36
- package/src/progress/stage-machine.js +29 -1
- package/src/progress/step-store.js +261 -261
- package/src/progress.js +1131 -1127
- package/src/quicklog.js +693 -675
- package/src/review-tier.js +77 -77
- package/src/run/command.js +1385 -1364
- package/src/run/complete-handlers.js +198 -9
- package/src/run/complete.js +225 -24
- package/src/run/concurrent-detect.js +22 -0
- package/src/run/gates.js +755 -755
- package/src/run/prompt.js +761 -721
- package/src/run/quick-audit.js +155 -146
- package/src/run/shared.js +1268 -1197
- package/src/run/stage.js +487 -479
- package/src/scan-postcheck.js +371 -371
- package/src/setup.js +416 -416
- package/src/sillyhub-mcp/client.js +529 -529
- package/src/spec-sync.js +323 -0
- package/src/stage-contract.js +897 -897
- package/src/stage-review.js +549 -549
- package/src/stages/archive.js +155 -154
- package/src/stages/brainstorm-auto.js +236 -236
- package/src/stages/brainstorm.js +596 -597
- package/src/stages/cmd-existence.js +145 -145
- package/src/stages/doctor.js +371 -371
- package/src/stages/execute.js +1003 -991
- package/src/stages/explore.js +34 -34
- package/src/stages/index.js +29 -29
- package/src/stages/knowledge.js +498 -498
- package/src/stages/plan-postcheck.js +1302 -1281
- package/src/stages/plan.js +625 -604
- package/src/stages/quick.js +151 -143
- package/src/stages/scan.js +606 -605
- package/src/stages/status.js +64 -64
- package/src/stages/verify.js +264 -261
- package/src/sync.js +1223 -1125
- package/src/task-review.js +1019 -1019
- package/src/verify-postcheck.js +1186 -1186
- package/src/workflow.js +600 -600
- package/src/worktree-apply.js +1213 -1136
- package/src/worktree-deps.js +353 -353
- package/src/worktree.js +1481 -1437
- package/templates/workflows/archive-impact.yaml +79 -79
- package/templates/workflows/scan-docs.yaml +132 -132
package/src/task-review.js
CHANGED
|
@@ -1,1019 +1,1019 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* SillySpec Task Review Gate — execute 阶段任务级评审校验
|
|
3
|
-
*
|
|
4
|
-
* execute 阶段每个 task 完成后,controller 必须写入 review.json。
|
|
5
|
-
* execute --done 时 CLI 硬校验:缺失 review 或 verdict 不通过则阻断。
|
|
6
|
-
*
|
|
7
|
-
* 目录结构:
|
|
8
|
-
* .sillyspec/.runtime/execute-runs/<runId>/tasks/<taskId>/review.json
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import { existsSync, readFileSync, mkdirSync, writeFileSync, readdirSync, statSync } from 'fs'
|
|
12
|
-
import { join, resolve } from 'path'
|
|
13
|
-
import { execFileSync } from 'child_process'
|
|
14
|
-
import { pathMatches } from './change-list.js'
|
|
15
|
-
import { parseAllowedPaths, parseRepo, parseBaseCommit, parseHeadCommit } from './stages/plan-postcheck.js'
|
|
16
|
-
import { resolveVerifyChangedFiles } from './verify-postcheck.js'
|
|
17
|
-
import { WorktreeManager } from './worktree.js'
|
|
18
|
-
import { resolveRuntimeRoot } from './run/shared.js'
|
|
19
|
-
|
|
20
|
-
// ── review.json schema version ──
|
|
21
|
-
//
|
|
22
|
-
// design §7.4 / R-07:task-review 的 review.json schemaVersion 1→2(新增可选 repo 字段),
|
|
23
|
-
// 旧 v1(无 repo)向后兼容视 'main',不阻断既有 change archive。
|
|
24
|
-
//
|
|
25
|
-
// 关于常量与"1→2":REVIEW_SCHEMA_VERSION 常量被 stage-review.js(文档型阶段评审)共享——
|
|
26
|
-
// stage-review.test.mjs:358 + register.test.mjs:40 断言其值 === 1,且 stage-review.js 不在本次
|
|
27
|
-
// 变更的 allowed_paths 内。两个 schema(task code-review / stage doc-review)逻辑独立,但历史共享
|
|
28
|
-
// 同一常量。本期保持 REVIEW_SCHEMA_VERSION=1 作 stage-review 写侧默认值(不破坏 stage-review),
|
|
29
|
-
// 同时用 REVIEW_SCHEMA_VERSIONS_ACCEPTED=[1,2] 表达"读侧接受两版"——validateReviewSchema 兼容 v1/v2,
|
|
30
|
-
// 跨仓 task 写 review.json 可用 v2 带 repo 字段,单仓/旧 change 仍用 v1。后续可拆分 stage-review 专用
|
|
31
|
-
// 常量,本期遵循 allowed_paths 约束不扩散到 stage-review.js。
|
|
32
|
-
export const REVIEW_SCHEMA_VERSION = 1
|
|
33
|
-
const REVIEW_SCHEMA_VERSIONS_ACCEPTED = [1, 2]
|
|
34
|
-
// repo 缺省值(review.repo / task 卡 repo 缺省时统一按 'main' 处理)。design §7.2 / §7.4。
|
|
35
|
-
const DEFAULT_REPO_KEY = 'main'
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* 规范化 review.repo / task 卡 repo 字段为有效 repoKey。
|
|
39
|
-
* 缺省 / 非字符串 / 空串 → 'main'(向后兼容旧 v1 review + 无 repo 声明的单仓 task)。
|
|
40
|
-
* @param {any} repo
|
|
41
|
-
* @returns {string}
|
|
42
|
-
*/
|
|
43
|
-
export function normalizeRepoKey(repo) {
|
|
44
|
-
if (typeof repo !== 'string' || repo.trim() === '') return DEFAULT_REPO_KEY
|
|
45
|
-
return repo.trim()
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
// ── 合法 verdict 枚举 ──
|
|
49
|
-
export const VALID_VERDICTS = ['pass', 'fail', 'cannot_verify']
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* 解析 plan.md 中的 task 列表
|
|
53
|
-
* @param {string} planContent - plan.md 文件内容
|
|
54
|
-
* @returns {string[]} task id 列表,如 ['task-01', 'task-02']
|
|
55
|
-
*/
|
|
56
|
-
function parseTaskIdsFromPlan(planContent) {
|
|
57
|
-
if (!planContent) return []
|
|
58
|
-
const ids = new Set()
|
|
59
|
-
const re = /^\s*[-*]\s*\[[ x]\]\s*task-(\d+)/gim
|
|
60
|
-
for (const m of planContent.matchAll(re)) {
|
|
61
|
-
ids.add(`task-${m[1].padStart(2, '0')}`)
|
|
62
|
-
}
|
|
63
|
-
return [...ids].sort()
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* 统计 plan.md/tasks.md 中 task-NN checkbox 的勾选情况(降级口径)。
|
|
68
|
-
* 与 progress.js readPlanCheckboxStatus 同语义,但纯函数、无类依赖,供 summarizeTaskCompletion 降级复用。
|
|
69
|
-
*/
|
|
70
|
-
function countPlanCheckboxes(planContent) {
|
|
71
|
-
const re = /^\s*[-*]\s+\[([ xX])\]\s+task-\d+/gm
|
|
72
|
-
let total = 0
|
|
73
|
-
let checked = 0
|
|
74
|
-
let m
|
|
75
|
-
while ((m = re.exec(planContent)) !== null) {
|
|
76
|
-
total++
|
|
77
|
-
if (m[1] === 'x' || m[1] === 'X') checked++
|
|
78
|
-
}
|
|
79
|
-
return { total, checked }
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* 汇总变更的任务完成度 —— archive Step1「任务完成度检查」的客观真相源。
|
|
84
|
-
*
|
|
85
|
-
* 以 execute 阶段产出的 review.json verdict 为准(task-NN 的 specVerdict + qualityVerdict 均 ≠ fail
|
|
86
|
-
* 视为完成),而非 plan.md checkbox。原因:plan.md checkbox 依赖 autoCheckPlanFromReviews 回填,
|
|
87
|
-
* 而 runId marker / review.json 缺失时回填静默 no-op,checkbox 会停在未勾态,导致完成度失真
|
|
88
|
-
* (archive 误判「全未完成」)。review.json 是 task 级客观 verdict,不受回填断裂影响。
|
|
89
|
-
*
|
|
90
|
-
* runId 解析:优先 current-execute-run-id-<changeName> marker(最新一次 execute run);
|
|
91
|
-
* marker 缺失则扫描 execute-runs/ 下子目录、取 mtime 最新的,尽力定位。
|
|
92
|
-
*
|
|
93
|
-
* fail-safe:无 plan / 无 runId / review 全缺等任何异常,降级到 plan.md checkbox 统计 + 标注 source,
|
|
94
|
-
* 绝不抛错、绝不阻断 archive(最坏退回「数 checkbox」现状,不比修复前差)。
|
|
95
|
-
*
|
|
96
|
-
* @param {{ changeDir: string, runtimeRoot: string, changeName: string }} opts
|
|
97
|
-
* @returns {{ source: string, total: number, completed: number, pending: Array<{id:string,reason:string}>, report: string }}
|
|
98
|
-
* source: 'review.json'(客观源可用)/ 'plan-checkbox-fallback'(降级)/ 'no-plan' / 'no-tasks'
|
|
99
|
-
* report: 人类可读报告,供 prompt {TASK_COMPLETION_REPORT} 注入
|
|
100
|
-
*/
|
|
101
|
-
export function summarizeTaskCompletion({ changeDir, runtimeRoot, changeName }) {
|
|
102
|
-
const noPlan = {
|
|
103
|
-
source: 'no-plan', total: 0, completed: 0, pending: [],
|
|
104
|
-
report: '⚠️ 未找到 plan.md/tasks.md,无法计算任务完成度。请确认变更目录完整。'
|
|
105
|
-
}
|
|
106
|
-
if (!changeDir) return noPlan
|
|
107
|
-
|
|
108
|
-
const planPath = join(changeDir, 'plan.md')
|
|
109
|
-
const tasksPath = join(changeDir, 'tasks.md')
|
|
110
|
-
let planContent = null
|
|
111
|
-
if (existsSync(planPath)) planContent = readFileSync(planPath, 'utf8')
|
|
112
|
-
else if (existsSync(tasksPath)) planContent = readFileSync(tasksPath, 'utf8')
|
|
113
|
-
else return noPlan
|
|
114
|
-
|
|
115
|
-
const taskIds = parseTaskIdsFromPlan(planContent)
|
|
116
|
-
if (taskIds.length === 0) {
|
|
117
|
-
return { source: 'no-tasks', total: 0, completed: 0, pending: [],
|
|
118
|
-
report: '⚠️ plan.md 未解析出任何 task-NN 条目(plan 可能未按规范写 checkbox 列表)。' }
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
// runId 解析:marker → 扫描最新目录(marker 是 agent 可写内容,格式校验防注入/穿越)
|
|
122
|
-
let runId = null
|
|
123
|
-
const marker = join(runtimeRoot, `current-execute-run-id-${changeName}`)
|
|
124
|
-
try {
|
|
125
|
-
if (existsSync(marker)) {
|
|
126
|
-
const c = readFileSync(marker, 'utf8').trim()
|
|
127
|
-
if (c && !isValidExecuteRunId(c)) console.warn(`[sillyspec] execute run marker 内容非法(期望 exec-YYYY-MM-DD-HHMMSS,实得 ${JSON.stringify(c.slice(0, 60))}),忽略并回退目录扫描`)
|
|
128
|
-
if (isValidExecuteRunId(c)) runId = c
|
|
129
|
-
}
|
|
130
|
-
} catch {}
|
|
131
|
-
if (!runId) {
|
|
132
|
-
try {
|
|
133
|
-
const runsDir = join(runtimeRoot, 'execute-runs')
|
|
134
|
-
if (existsSync(runsDir)) {
|
|
135
|
-
const entries = readdirSync(runsDir)
|
|
136
|
-
.map(e => ({ e, p: join(runsDir, e) }))
|
|
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
|
-
}
|
|
142
|
-
} catch {}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
// 无 runId → 降级 plan.md checkbox 统计
|
|
146
|
-
if (!runId) {
|
|
147
|
-
const { total, checked } = countPlanCheckboxes(planContent)
|
|
148
|
-
return {
|
|
149
|
-
source: 'plan-checkbox-fallback', total, completed: checked,
|
|
150
|
-
pending: taskIds.slice(0, Math.max(0, total - checked)).map(id => ({ id, reason: 'checkbox 未勾(降级口径)' })),
|
|
151
|
-
report:
|
|
152
|
-
'⚠️ 客观源不可用(无 execute runId marker,review.json 无法定位)→ 降级用 plan.md checkbox。\n' +
|
|
153
|
-
`- 总任务:${total}\n- plan.md 已勾选:${checked}\n` +
|
|
154
|
-
'⚠️ checkbox 可能因 autoCheckPlanFromReviews 回填断裂而失真,务必交叉核对 .runtime/execute-runs/*/tasks/task-NN/review.json 的 verdict 再下结论。'
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
// 有 runId → 读 review.json verdict
|
|
159
|
-
const completed = []
|
|
160
|
-
const pending = []
|
|
161
|
-
for (const id of taskIds) {
|
|
162
|
-
const reviewPath = join(runtimeRoot, 'execute-runs', runId, 'tasks', id, 'review.json')
|
|
163
|
-
const r = readReview(reviewPath)
|
|
164
|
-
if (r.ok && r.review && r.review.specVerdict !== 'fail' && r.review.qualityVerdict !== 'fail') {
|
|
165
|
-
completed.push(id)
|
|
166
|
-
} else if (r.ok && r.review) {
|
|
167
|
-
pending.push({ id, reason: `verdict 未通过(spec=${r.review.specVerdict}, quality=${r.review.qualityVerdict})` })
|
|
168
|
-
} else {
|
|
169
|
-
pending.push({ id, reason: 'review.json 缺失(task 未走完 execute 评审)' })
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
const pendingLines = pending.length > 0
|
|
174
|
-
? pending.map(p => ` - ${p.id}: ${p.reason}`).join('\n')
|
|
175
|
-
: ' (无)'
|
|
176
|
-
return {
|
|
177
|
-
source: 'review.json', total: taskIds.length, completed: completed.length, pending,
|
|
178
|
-
report:
|
|
179
|
-
`客观任务完成度(真相源 = review.json verdict,runId=${runId}):\n` +
|
|
180
|
-
`- 总任务:${taskIds.length}\n` +
|
|
181
|
-
`- 已通过(spec + quality verdict 均非 fail):${completed.length}\n` +
|
|
182
|
-
`- 未通过 / 缺失:${pending.length}\n` +
|
|
183
|
-
`- 未完成列表:\n${pendingLines}\n` +
|
|
184
|
-
'注:以 review.json verdict 为准;plan.md checkbox 仅作显示态(回填断裂时会与客观 verdict 不一致,以下方客观点为准)。'
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
/**
|
|
189
|
-
* 校验单个 review.json 文件
|
|
190
|
-
*
|
|
191
|
-
* schema 版本(design §7.4 / R-07):接受 v1(无 repo,单仓向后兼容)+ v2(新增可选 repo 字段,
|
|
192
|
-
* 跨仓 task 标记 repo: <key>)。repo 缺省 / 非字符串 → 视 'main'(normalizeRepoKey),不阻断。
|
|
193
|
-
* repo 的真实存在性(local.yaml repos 注册)由 MultiRepoContext 约束② fail-closed 兜底,schema 层
|
|
194
|
-
* 只做"非字符串时不阻断"的宽松类型校验(review.json 是 agent 手写产物,宽松避免误杀)。
|
|
195
|
-
*
|
|
196
|
-
* @param {object} review - 解析后的 JSON 对象
|
|
197
|
-
* @returns {{ ok: boolean, errors: string[] }}
|
|
198
|
-
*/
|
|
199
|
-
export function validateReviewSchema(review) {
|
|
200
|
-
const errors = []
|
|
201
|
-
if (!review || typeof review !== 'object') {
|
|
202
|
-
errors.push('review.json 不是有效 JSON 对象')
|
|
203
|
-
return { ok: false, errors }
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
if (!REVIEW_SCHEMA_VERSIONS_ACCEPTED.includes(review.schemaVersion)) {
|
|
207
|
-
errors.push(`schemaVersion 应为 ${REVIEW_SCHEMA_VERSIONS_ACCEPTED.join(' 或 ')},实际为 ${review.schemaVersion}`)
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
if (!review.task || typeof review.task !== 'string') {
|
|
211
|
-
errors.push('缺少 task 字段(应为 "task-XX" 格式)')
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
// repo(可选,v2 新增):类型宽松校验。非字符串 / 空串 → 视 'main',不阻断 schema。
|
|
215
|
-
// 跨仓 repo 的注册存在性由 MultiRepoContext 构造时 fail-closed 校验(约束②)。
|
|
216
|
-
if (review.repo !== undefined && typeof review.repo !== 'string') {
|
|
217
|
-
errors.push(`repo 字段(若提供)应为字符串,实际类型为 ${typeof review.repo}`)
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
if (!VALID_VERDICTS.includes(review.specVerdict)) {
|
|
221
|
-
errors.push(`specVerdict 无效:${review.specVerdict}(应为 ${VALID_VERDICTS.join('/')})`)
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
if (!VALID_VERDICTS.includes(review.qualityVerdict)) {
|
|
225
|
-
errors.push(`qualityVerdict 无效:${review.qualityVerdict}(应为 ${VALID_VERDICTS.join('/')})`)
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
// cannot_verify 必须提供 requiredEvidence
|
|
229
|
-
if (review.specVerdict === 'cannot_verify' || review.qualityVerdict === 'cannot_verify') {
|
|
230
|
-
if (!Array.isArray(review.requiredEvidence) || review.requiredEvidence.length === 0) {
|
|
231
|
-
errors.push('cannot_verify 的 verdict 必须提供非空的 requiredEvidence 数组')
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
// base/head 非空检查
|
|
236
|
-
if (!review.base || typeof review.base !== 'string') {
|
|
237
|
-
errors.push('缺少 base 字段(git commit hash)')
|
|
238
|
-
}
|
|
239
|
-
if (!review.head || typeof review.head !== 'string') {
|
|
240
|
-
errors.push('缺少 head 字段(git commit hash)')
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
return { ok: errors.length === 0, errors }
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
/**
|
|
247
|
-
* 增强 JSON.parse 失败的定位:从 V8 的 "at position N" 算出 line:col + 出错行上下文,
|
|
248
|
-
* 并对常见非法转义(正则的反斜杠序列误入 JSON 字符串)给针对性修复指引。
|
|
249
|
-
* @param {string} raw 原始文件内容
|
|
250
|
-
* @param {string} message V8 抛出的错误消息
|
|
251
|
-
* @returns {string} 增强后的可读消息
|
|
252
|
-
*/
|
|
253
|
-
export function enrichJsonParseError(raw, message) {
|
|
254
|
-
const m = /at position (\d+)/.exec(message)
|
|
255
|
-
if (!m) return message
|
|
256
|
-
const pos = Math.min(Number(m[1]), raw.length)
|
|
257
|
-
let line = 1, col = 1
|
|
258
|
-
for (let i = 0; i < pos; i++) {
|
|
259
|
-
if (raw[i] === '\n') { line++; col = 1 } else { col++ }
|
|
260
|
-
}
|
|
261
|
-
const lineStart = raw.lastIndexOf('\n', pos - 1) + 1
|
|
262
|
-
const nlAfter = raw.indexOf('\n', pos)
|
|
263
|
-
const lineEnd = nlAfter === -1 ? raw.length : nlAfter
|
|
264
|
-
const snippet = raw.slice(lineStart, lineEnd)
|
|
265
|
-
const caret = ' '.repeat(Math.max(0, col - 1)) + '^'
|
|
266
|
-
// 单行注释内的反斜杠序列是纯文本,不触发块注释终止;正则字面量在函数体内正常解析。
|
|
267
|
-
let out = message + '\n → 第 ' + line + ' 行第 ' + col + ' 列:\n ' + snippet + '\n ' + caret
|
|
268
|
-
// 先移除合法的成对反斜杠(JSON 中 \\ 表字面 \),否则 "\\z" 会被逐字符正则误判为 "\z" 非法转义
|
|
269
|
-
const hasBadEscape = message.includes('Bad escaped character') || /\\[^"\\/bfnrtu]/.test(snippet.replace(/\\\\/g, ''))
|
|
270
|
-
if (hasBadEscape) {
|
|
271
|
-
out += '\n → 疑似正则转义(如 \\s \\d \\w)混入 JSON 字符串。修复:双写反斜杠(\\\\s),或用 JSON.stringify(obj) 重写 review.json。'
|
|
272
|
-
}
|
|
273
|
-
return out
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
/**
|
|
277
|
-
* 读取单个 task 的 review.json
|
|
278
|
-
* @param {string} reviewPath - review.json 文件路径
|
|
279
|
-
* @returns {{ ok: boolean, review: object|null, errors: string[] }}
|
|
280
|
-
*/
|
|
281
|
-
export function readReview(reviewPath) {
|
|
282
|
-
if (!existsSync(reviewPath)) {
|
|
283
|
-
return { ok: false, review: null, errors: ['review.json 不存在'] }
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
let raw
|
|
287
|
-
try {
|
|
288
|
-
raw = readFileSync(reviewPath, 'utf8')
|
|
289
|
-
} catch (e) {
|
|
290
|
-
return { ok: false, review: null, parseError: true, errors: [`review.json 读取失败: ${e.message}`] }
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
let parsed
|
|
294
|
-
try {
|
|
295
|
-
parsed = JSON.parse(raw)
|
|
296
|
-
} catch (e) {
|
|
297
|
-
// 文件存在但 JSON 非法:review 设为 null 但标记 parseError=true,错误消息带行列定位 + 转义修复指引
|
|
298
|
-
return { ok: false, review: null, parseError: true, errors: [`review.json 解析失败: ${enrichJsonParseError(raw, e.message)}`] }
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
const schemaResult = validateReviewSchema(parsed)
|
|
302
|
-
if (!schemaResult.ok) {
|
|
303
|
-
return { ok: false, review: parsed, schemaError: true, errors: schemaResult.errors }
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
return { ok: true, review: parsed, errors: [] }
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
/**
|
|
310
|
-
* 校验 plan.md 里所有「已勾选 [x]」task 的 review.json 是否 schema 完整(坑 review-json-field-gap)。
|
|
311
|
-
*
|
|
312
|
-
* Task Review Gate(validateTaskReviews)只在 execute 整阶段完成时跑,单 task --done 不校验 →
|
|
313
|
-
* 子代理勾了 checkbox 却漏写/漏字段 review.json,要到收尾才暴露,用户被迫事后批量补。本函数供
|
|
314
|
-
* enforceReviewJsonGate 在每次 execute --done 调用:已勾 [x] = 声称完成 = 必有完整 review.json;
|
|
315
|
-
* 未勾 task 不校验(还没做)。readReview 已区分 missing/parseError/schemaError,这里只聚合。
|
|
316
|
-
*
|
|
317
|
-
* @param {{ planContent: string, runtimeRoot: string, executeRunId: string }} opts
|
|
318
|
-
* @returns {{ ok: boolean, failures: Array<{ taskId:string, reviewPath:string, kind:string, errors:string[] }> }}
|
|
319
|
-
* kind ∈ missing(review.json 不存在)/ parseError(JSON 坏)/ schemaError(字段缺)
|
|
320
|
-
*/
|
|
321
|
-
export function validateCheckedTaskReviews({ planContent, runtimeRoot, executeRunId }) {
|
|
322
|
-
const failures = []
|
|
323
|
-
const re = /^\s*[-*]\s*\[x\]\s*task-(\d+)/gim
|
|
324
|
-
let m
|
|
325
|
-
while ((m = re.exec(planContent)) !== null) {
|
|
326
|
-
const taskId = `task-${m[1].padStart(2, '0')}`
|
|
327
|
-
const reviewPath = join(runtimeRoot, 'execute-runs', executeRunId, 'tasks', taskId, 'review.json')
|
|
328
|
-
const r = readReview(reviewPath)
|
|
329
|
-
if (!r.ok) {
|
|
330
|
-
const kind = r.parseError ? 'parseError' : (r.schemaError ? 'schemaError' : 'missing')
|
|
331
|
-
failures.push({ taskId, reviewPath, kind, errors: r.errors })
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
return { ok: failures.length === 0, failures }
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
/**
|
|
338
|
-
* execute --done 时的 task review 总校验
|
|
339
|
-
*
|
|
340
|
-
* 规则:
|
|
341
|
-
* - 每个 plan task 必须有 review.json
|
|
342
|
-
* - specVerdict 或 qualityVerdict 为 fail → 整体 fail
|
|
343
|
-
* - specVerdict 或 qualityVerdict 为 cannot_verify → warning(requiredEvidence 非空)
|
|
344
|
-
* - cannot_verify + requiredEvidence 为空 → fail(agent 逃避判断)
|
|
345
|
-
* - cannot_verify 的 requiredEvidence 汇总到 requiredEvidence 字段,供 verify 阶段消费
|
|
346
|
-
*
|
|
347
|
-
* @param {object} opts
|
|
348
|
-
* @param {string} opts.planContent - plan.md 内容
|
|
349
|
-
* @param {string} opts.runtimeRoot - .sillyspec/.runtime 的绝对路径
|
|
350
|
-
* @param {string} opts.executeRunId - execute run id(如 'exec-2026-06-23-131400')
|
|
351
|
-
* @param {boolean} [opts.allowCannotVerify=true] - 是否允许 cannot_verify(默认允许,给 warning)
|
|
352
|
-
* @param {string} [opts.changeDir] - change 目录(读 task-NN.md frontmatter 判 low_risk)
|
|
353
|
-
* @param {string} [opts.gitDir] - 主仓 git 工作目录(单仓 review 真实性校验 cwd;无 ctx 时用此)
|
|
354
|
-
* @param {object} [opts.ctx] - MultiRepoContext 实例(D-013:缺省/null → 走 opts.gitDir 单仓零回归;
|
|
355
|
-
* 有 ctx 时按 review.repo(缺省 'main')从 ctx.resolve(repo).gitDir 取每条 review 的校验 cwd,
|
|
356
|
-
* 跨仓 gitDir=跨仓仓根,使 base..head diff 在正确仓跑——design §6 task-review A1 行)
|
|
357
|
-
* @returns {{ ok: boolean, errors: string[], warnings: string[], requiredEvidence: Array<{task: string, verdict: string, evidence: string[]}> }}
|
|
358
|
-
*/
|
|
359
|
-
/**
|
|
360
|
-
* 读取 task-XX.md frontmatter,判断是否声明 low_risk: true(type-only/机械迁移等低逻辑风险)。
|
|
361
|
-
* 声明 low_risk 的 task 缺 review.json 时只 warning 不 error(B2:免逐个评审仪式)。
|
|
362
|
-
*/
|
|
363
|
-
function isTaskLowRisk(changeDir, taskId) {
|
|
364
|
-
if (!changeDir) return false
|
|
365
|
-
const taskFile = join(changeDir, 'tasks', `${taskId}.md`)
|
|
366
|
-
if (!existsSync(taskFile)) return false
|
|
367
|
-
try {
|
|
368
|
-
const content = readFileSync(taskFile, 'utf8')
|
|
369
|
-
const fm = content.match(/^---\n([\s\S]*?)\n---/)
|
|
370
|
-
if (!fm) return false
|
|
371
|
-
return /^low_risk:\s*true\s*$/im.test(fm[1])
|
|
372
|
-
} catch {
|
|
373
|
-
return false
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
export function validateTaskReviews(opts) {
|
|
378
|
-
const { planContent, runtimeRoot, executeRunId, allowCannotVerify = true, changeDir = null, gitDir = null, ctx = null } = opts
|
|
379
|
-
|
|
380
|
-
const taskIds = parseTaskIdsFromPlan(planContent)
|
|
381
|
-
|
|
382
|
-
// 如果 plan 里没有 task,跳过校验(向后兼容)
|
|
383
|
-
if (taskIds.length === 0) {
|
|
384
|
-
return { ok: true, errors: [], warnings: [], requiredEvidence: [] }
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
const errors = []
|
|
388
|
-
const warnings = []
|
|
389
|
-
const requiredEvidence = []
|
|
390
|
-
|
|
391
|
-
for (const taskId of taskIds) {
|
|
392
|
-
const reviewDir = join(runtimeRoot, 'execute-runs', executeRunId, 'tasks', taskId)
|
|
393
|
-
const reviewPath = join(reviewDir, 'review.json')
|
|
394
|
-
|
|
395
|
-
const result = readReview(reviewPath)
|
|
396
|
-
|
|
397
|
-
if (!result.ok) {
|
|
398
|
-
if (result.parseError) {
|
|
399
|
-
// review.json 存在但 JSON 非法
|
|
400
|
-
errors.push(`${taskId}: review.json 解析失败 — ${result.errors.join('; ')}`)
|
|
401
|
-
} else if (result.schemaError) {
|
|
402
|
-
// review.json 存在且 JSON 合法,但 schema 校验失败
|
|
403
|
-
errors.push(`${taskId}: review.json 校验失败 — ${result.errors.join('; ')}`)
|
|
404
|
-
} else {
|
|
405
|
-
// review.json 不存在:声明 low_risk 的 task 豁免(B2:type-only/机械迁移免逐个评审)
|
|
406
|
-
if (isTaskLowRisk(changeDir, taskId)) {
|
|
407
|
-
warnings.push(`${taskId}: 缺少 review.json — task 声明 low_risk: true,已豁免评审(type-only/机械迁移)`)
|
|
408
|
-
} else {
|
|
409
|
-
errors.push(`${taskId}: 缺少 review.json — task 未经过评审(期望路径:${reviewPath},execute run ID: ${executeRunId})`)
|
|
410
|
-
}
|
|
411
|
-
}
|
|
412
|
-
continue
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
const review = result.review
|
|
416
|
-
|
|
417
|
-
// 检查 review.task 是否与 plan 中的 taskId 一致
|
|
418
|
-
if (review.task && review.task !== taskId) {
|
|
419
|
-
errors.push(`${taskId}: review.json 中的 task 字段为 "${review.task}",与 plan 不一致(应为 "${taskId}")— agent 可能复制模板未修改`)
|
|
420
|
-
continue
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
// 检查 fail verdict
|
|
424
|
-
if (review.specVerdict === 'fail' || review.qualityVerdict === 'fail') {
|
|
425
|
-
errors.push(`${taskId}: review 未通过 — spec: ${review.specVerdict}, quality: ${review.qualityVerdict}`)
|
|
426
|
-
if (review.reviewerNotes) {
|
|
427
|
-
errors.push(`${taskId}: ${review.reviewerNotes}`)
|
|
428
|
-
}
|
|
429
|
-
continue
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
// ── git 真实性交叉校验:base/head 必须是真实 commit,diff 不能为空 ──
|
|
433
|
-
// D-013 + design §6 A1:有 ctx 时按 review.repo(缺省 'main')切 gitDir——跨仓 review 的
|
|
434
|
-
// base/head 是跨仓仓 commit,必须在跨仓仓根跑 rev-parse/diff,否则判伪造误杀。无 ctx 时退回
|
|
435
|
-
// opts.gitDir(单仓零回归)。reviewGitDir 为 null 则跳过证据校验(与原逻辑一致)。
|
|
436
|
-
let reviewGitDir = gitDir
|
|
437
|
-
if (ctx) {
|
|
438
|
-
const repoKey = normalizeRepoKey(review.repo)
|
|
439
|
-
const entry = ctx.resolve(repoKey)
|
|
440
|
-
if (entry && entry.gitDir) {
|
|
441
|
-
reviewGitDir = entry.gitDir
|
|
442
|
-
} else if (repoKey !== 'main') {
|
|
443
|
-
// 跨仓 repo 在 ctx 中未注册 → 不应发生(MultiRepoContext 构造已 fail-closed 拦截未注册),
|
|
444
|
-
// 防御性降级为 warning 提示,不阻断(review.json schema 已过,仓库解析交给 ctx 上游负责)。
|
|
445
|
-
// 坑7 教训:此分支发生时后续主仓校验必报「疑似伪造」,文案须直接指向真实排查方向。
|
|
446
|
-
warnings.push(`${taskId}: review.repo="${repoKey}" 在 MultiRepoContext 未解析到 entry,退回主仓 gitDir 校验——下方若报 commit 不真实/疑似伪造,先查 repo 声明(plan.md 内联块或 tasks/task-NN.md 的 repo:)与 local.yaml repos: 注册,非 review 伪造`)
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
if (reviewGitDir) {
|
|
450
|
-
const evidence = verifyReviewGitEvidence(review, reviewGitDir)
|
|
451
|
-
for (const w of evidence.warnings) warnings.push(`${taskId}: ${w}`)
|
|
452
|
-
if (!evidence.ok) {
|
|
453
|
-
for (const err of evidence.errors) errors.push(`${taskId}: ${err}`)
|
|
454
|
-
continue
|
|
455
|
-
}
|
|
456
|
-
if (evidence.emptyDiff) {
|
|
457
|
-
if (isTaskLowRisk(changeDir, taskId)) {
|
|
458
|
-
warnings.push(`${taskId}: base..head 无代码变更(task 声明 low_risk: true,不阻断)`)
|
|
459
|
-
} else {
|
|
460
|
-
errors.push(`${taskId}: base..head(${review.base.slice(0, 8)}..${review.head.slice(0, 8)})无任何代码变更 — 评审了一个零改动的任务,review 疑似伪造`)
|
|
461
|
-
continue
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
// 检查 cannot_verify
|
|
467
|
-
if (review.specVerdict === 'cannot_verify' || review.qualityVerdict === 'cannot_verify') {
|
|
468
|
-
if (!allowCannotVerify) {
|
|
469
|
-
errors.push(`${taskId}: cannot_verify 不被允许 — 必须提供评审结果`)
|
|
470
|
-
continue
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
if (review.requiredEvidence && review.requiredEvidence.length > 0) {
|
|
474
|
-
const verdicts = []
|
|
475
|
-
if (review.specVerdict === 'cannot_verify') verdicts.push('spec')
|
|
476
|
-
if (review.qualityVerdict === 'cannot_verify') verdicts.push('quality')
|
|
477
|
-
warnings.push(`${taskId}: ${verdicts.join('+')}=cannot_verify,requiredEvidence 必须在 verify 阶段满足`)
|
|
478
|
-
requiredEvidence.push({
|
|
479
|
-
task: taskId,
|
|
480
|
-
verdict: verdicts.join('+'),
|
|
481
|
-
evidence: review.requiredEvidence,
|
|
482
|
-
})
|
|
483
|
-
} else {
|
|
484
|
-
// cannot_verify + 空 requiredEvidence = agent 逃避判断
|
|
485
|
-
errors.push(`${taskId}: cannot_verify 但 requiredEvidence 为空 — 这是无效评审`)
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
// 额外检查:扫描 execute-runs/<runId>/tasks/ 下是否有 plan 里没有的 task review
|
|
491
|
-
// (agent 可能写错了 task id)
|
|
492
|
-
try {
|
|
493
|
-
const tasksDir = join(runtimeRoot, 'execute-runs', executeRunId, 'tasks')
|
|
494
|
-
if (existsSync(tasksDir)) {
|
|
495
|
-
const taskDirs = readdirSync(tasksDir, { withFileTypes: true })
|
|
496
|
-
.filter(e => e.isDirectory())
|
|
497
|
-
.map(e => e.name)
|
|
498
|
-
const taskIdSet = new Set(taskIds)
|
|
499
|
-
for (const dirName of taskDirs) {
|
|
500
|
-
if (!taskIdSet.has(dirName) && existsSync(join(tasksDir, dirName, 'review.json'))) {
|
|
501
|
-
warnings.push(`${dirName}: 存在 review.json 但不在 plan.md 的 task 列表中(可能是多余文件)`)
|
|
502
|
-
}
|
|
503
|
-
}
|
|
504
|
-
}
|
|
505
|
-
} catch (e) {
|
|
506
|
-
warnings.push(`task review extra-check 异常: ${e.message}`)
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
return {
|
|
510
|
-
ok: errors.length === 0,
|
|
511
|
-
errors,
|
|
512
|
-
warnings,
|
|
513
|
-
requiredEvidence,
|
|
514
|
-
}
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
// ── Git 真实性交叉校验 ──
|
|
518
|
-
|
|
519
|
-
function runGit(gitDir, args) {
|
|
520
|
-
return execFileSync('git', args, {
|
|
521
|
-
cwd: gitDir,
|
|
522
|
-
encoding: 'utf8',
|
|
523
|
-
timeout: 15000,
|
|
524
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
525
|
-
}).trim()
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
// porcelain 状态行 → 变更文件路径(对齐 shared.js:343 parsePorcelainPath:`XY path`、引号包裹、
|
|
529
|
-
// rename `R old -> new` 取箭头后)。未 commit 的 working-tree 改动对账用。
|
|
530
|
-
function parsePorcelainFiles(statusOut) {
|
|
531
|
-
const files = []
|
|
532
|
-
for (const line of String(statusOut).split('\n')) {
|
|
533
|
-
if (line.trim() === '') continue
|
|
534
|
-
let p = line.slice(3).trim()
|
|
535
|
-
if (p.length >= 2 && p.startsWith('"') && p.endsWith('"')) p = p.slice(1, -1).replace(/\\(.)/g, (_, c) => c)
|
|
536
|
-
const arrow = p.indexOf(' -> ')
|
|
537
|
-
if (arrow !== -1) p = p.slice(arrow + 4)
|
|
538
|
-
if (p) files.push(p.replace(/\\/g, '/'))
|
|
539
|
-
}
|
|
540
|
-
return files
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
/**
|
|
544
|
-
* 校验 review.json 的 base/head 是否指向真实 git 提交,且 diff 非空。
|
|
545
|
-
*
|
|
546
|
-
* 历史漏洞:base/head 只做非空字符串检查,agent 可以填任意假 hash 批量伪造
|
|
547
|
-
* review.json 通过 Task Review Gate。此函数用 git 做客观交叉校验:
|
|
548
|
-
* 1. base/head 必须是仓库中可解析的真实 commit
|
|
549
|
-
* 2. base..head 的 diff 不能为空(评审一个零改动的"任务"无意义)
|
|
550
|
-
* 3. review.changedFiles(如提供)必须与实际 diff 文件有交集
|
|
551
|
-
*
|
|
552
|
-
* git 环境不可用(非 git 仓库 / git 缺失)时返回 unavailable,由调用方降级为
|
|
553
|
-
* warning——不因环境问题误杀,但记录在输出中。
|
|
554
|
-
*
|
|
555
|
-
* @param {object} review - 已通过 schema 校验的 review 对象
|
|
556
|
-
* @param {string} gitDir - 执行 git 命令的目录(worktree 优先,回退主仓库)
|
|
557
|
-
* @returns {{ ok: boolean, emptyDiff: boolean, errors: string[], warnings: string[], unavailable: boolean }}
|
|
558
|
-
*/
|
|
559
|
-
export function verifyReviewGitEvidence(review, gitDir) {
|
|
560
|
-
const errors = []
|
|
561
|
-
const warnings = []
|
|
562
|
-
|
|
563
|
-
// git 环境探测:失败即 unavailable,交由调用方降级
|
|
564
|
-
try {
|
|
565
|
-
runGit(gitDir, ['rev-parse', '--git-dir'])
|
|
566
|
-
} catch (e) {
|
|
567
|
-
return {
|
|
568
|
-
ok: true,
|
|
569
|
-
emptyDiff: false,
|
|
570
|
-
errors: [],
|
|
571
|
-
warnings: [`git 环境不可用(${gitDir}),跳过 review 真实性交叉校验: ${e.message?.split('\n')[0] || e.message}`],
|
|
572
|
-
unavailable: true,
|
|
573
|
-
}
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
for (const field of ['base', 'head']) {
|
|
577
|
-
const hash = review[field]
|
|
578
|
-
try {
|
|
579
|
-
runGit(gitDir, ['rev-parse', '--verify', '--quiet', `${hash}^{commit}`])
|
|
580
|
-
} catch {
|
|
581
|
-
errors.push(`${field} "${hash}" 不是仓库中的真实 commit — review.json 疑似伪造`)
|
|
582
|
-
}
|
|
583
|
-
}
|
|
584
|
-
if (errors.length > 0) {
|
|
585
|
-
return { ok: false, emptyDiff: false, errors, warnings, unavailable: false }
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
let diffFiles = []
|
|
589
|
-
try {
|
|
590
|
-
const out = runGit(gitDir, ['diff', '--name-only', `${review.base}..${review.head}`])
|
|
591
|
-
diffFiles = out ? out.split('\n').filter(Boolean) : []
|
|
592
|
-
} catch (e) {
|
|
593
|
-
warnings.push(`git diff ${review.base}..${review.head} 执行失败,跳过 diff 校验: ${e.message?.split('\n')[0] || e.message}`)
|
|
594
|
-
return { ok: true, emptyDiff: false, errors, warnings, unavailable: false }
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
// 并入 working-tree 未提交改动(execute 复盘 a):子代理默认不 commit,base..head 无 commit diff 时
|
|
598
|
-
// diffFiles 留空 → 下方交叉比对拿空集对非空 changedFiles 必判「完全不相交」伪造,逼 agent 强制 commit
|
|
599
|
-
// + 改 7 个 review head。对齐 checkExecuteCodeEvidence(stage-contract.js 同时查 working-tree)语义,
|
|
600
|
-
// 未提交改动也算有效对账源。working-tree 有改动时无条件并入(覆盖「部分 commit + 未提交」)。
|
|
601
|
-
const commitDiffFiles = diffFiles.slice()
|
|
602
|
-
try {
|
|
603
|
-
const wtStatus = runGit(gitDir, ['status', '--porcelain'])
|
|
604
|
-
if (wtStatus && wtStatus.trim().length > 0) {
|
|
605
|
-
const wtFiles = parsePorcelainFiles(wtStatus)
|
|
606
|
-
if (wtFiles.length > 0) diffFiles = diffFiles.concat(wtFiles)
|
|
607
|
-
if (commitDiffFiles.length === 0) {
|
|
608
|
-
warnings.push(`base..head 无 commit diff(${review.base.slice(0, 8)}..${review.head.slice(0, 8)}),交叉对账并入 working-tree 未提交改动 ${wtFiles.length} 个文件 —— 按有效改动处理,不判零改动伪造`)
|
|
609
|
-
}
|
|
610
|
-
}
|
|
611
|
-
} catch {}
|
|
612
|
-
diffFiles = [...new Set(diffFiles)].filter(Boolean)
|
|
613
|
-
|
|
614
|
-
const emptyDiff = diffFiles.length === 0
|
|
615
|
-
|
|
616
|
-
// changedFiles 交叉比对:完全不相交 = review 描述的改动与实际 diff 无关(diffFiles 已含 working-tree)
|
|
617
|
-
if (!emptyDiff && Array.isArray(review.changedFiles) && review.changedFiles.length > 0) {
|
|
618
|
-
const normalize = (p) => String(p).replace(/^\.\//, '')
|
|
619
|
-
const diffSet = new Set(diffFiles.map(normalize))
|
|
620
|
-
const hasOverlap = review.changedFiles.some(f => {
|
|
621
|
-
const nf = normalize(f)
|
|
622
|
-
// 兼容 review 写相对/部分路径:前后缀匹配即可
|
|
623
|
-
return diffSet.has(nf) || diffFiles.some(d => d.endsWith(nf) || nf.endsWith(d))
|
|
624
|
-
})
|
|
625
|
-
if (!hasOverlap) {
|
|
626
|
-
errors.push(`changedFiles 与实际 git diff(${review.base.slice(0, 8)}..${review.head.slice(0, 8)},${diffFiles.length} 个文件)完全不相交 — review 内容与代码变更无关`)
|
|
627
|
-
}
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
return { ok: errors.length === 0, emptyDiff, errors, warnings, unavailable: false }
|
|
631
|
-
}
|
|
632
|
-
|
|
633
|
-
/**
|
|
634
|
-
* 将 cannot_verify 的 requiredEvidence 写入 change 目录
|
|
635
|
-
* 供 verify 阶段消费
|
|
636
|
-
*
|
|
637
|
-
* @param {string} changeDir - 变更目录(.sillyspec/changes/<name>)
|
|
638
|
-
* @param {Array<{task: string, verdict: string, evidence: string[]}>} requiredEvidence
|
|
639
|
-
* @returns {string|null} 写入的文件路径,null 表示无需写入
|
|
640
|
-
*/
|
|
641
|
-
export function writeVerifyRequiredEvidence(changeDir, requiredEvidence) {
|
|
642
|
-
if (!requiredEvidence || requiredEvidence.length === 0) return null
|
|
643
|
-
|
|
644
|
-
const filePath = join(changeDir, 'verify-required-evidence.json')
|
|
645
|
-
const data = {
|
|
646
|
-
generatedAt: new Date().toISOString(),
|
|
647
|
-
schemaVersion: 1,
|
|
648
|
-
items: requiredEvidence,
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
mkdirSync(changeDir, { recursive: true })
|
|
652
|
-
writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n')
|
|
653
|
-
|
|
654
|
-
return filePath
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
/**
|
|
658
|
-
* 生成 execute run id
|
|
659
|
-
* @returns {string} 如 'exec-2026-06-23-131400'
|
|
660
|
-
*/
|
|
661
|
-
export function generateExecuteRunId() {
|
|
662
|
-
const now = new Date()
|
|
663
|
-
const pad = (n) => String(n).padStart(2, '0')
|
|
664
|
-
return `exec-${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
/**
|
|
668
|
-
* execute run id 格式校验:exec-YYYY-MM-DD-HHMMSS。
|
|
669
|
-
* marker 文件是 agent 可写内容,读回后既注入 prompt({EXECUTE_RUN_ID})又拼进
|
|
670
|
-
* join(runtimeRoot, 'execute-runs', runId) 路径——格式校验同时防提示词注入与路径穿越
|
|
671
|
-
* (对齐 stage-review marker 的 review- 前缀校验范式)。
|
|
672
|
-
* @param {string} runId
|
|
673
|
-
* @returns {boolean}
|
|
674
|
-
*/
|
|
675
|
-
export function isValidExecuteRunId(runId) {
|
|
676
|
-
return typeof runId === 'string' && /^exec-\d{4}-\d{2}-\d{2}-\d{6}$/.test(runId)
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
/**
|
|
680
|
-
* 解析最新 execute run id:优先 current-execute-run-id-<changeName> marker;
|
|
681
|
-
* marker 缺失/为空则扫描 execute-runs/ 下子目录、取 mtime 最新的(尽力定位已有 run)。
|
|
682
|
-
*
|
|
683
|
-
* 与 getLatestStageReviewRunId 的目录扫描兜底同语义——避免 marker 丢失(cleanup/并行/手删)
|
|
684
|
-
* 而 agent 已用旧 runId 落了 execute-runs/<旧ID>/ 时,调用方直接 generate 新 ID 找不到旧 review、
|
|
685
|
-
* 误判缺 review.json(gates.js execute 完成门坑)。返回 null 表示既无 marker 也无既有 run 目录。
|
|
686
|
-
*
|
|
687
|
-
* @param {{ runtimeRoot: string, changeName: string }} opts
|
|
688
|
-
* @returns {string|null} runId(exec- 前缀),无则 null
|
|
689
|
-
*/
|
|
690
|
-
/**
|
|
691
|
-
* 扫描 execute-runs/,返回 mtime 最新且真正含 tasks/ 子目录的 runId。
|
|
692
|
-
*
|
|
693
|
-
* 专治 execute run marker 漂移(prompt-control-debt.md gate-atom-a):generateExecuteRunId 只写
|
|
694
|
-
* marker 字符串、run 目录由 ensureTaskReviewDir 在写 review.json 时才建,marker 漂到「尚未建目录 /
|
|
695
|
-
* 未写任何 task review」的新 run 后,旧 run 里齐备的 review.json 全部失联。resolveLatestExecuteRunId
|
|
696
|
-
* 见 marker 非空即原样返回(不校验目录),接不住这种漂移;本函数无视 marker、只看真实目录内容,
|
|
697
|
-
* 供 enforceReviewJsonGate 在 marker 指向的 run 缺 tasks/ 时兜底重定位,避免误报「review.json 不存在」。
|
|
698
|
-
* 全部 run 都无 tasks/(真没写 review)返回 null——调用方维持原阻断,不误放行。
|
|
699
|
-
*
|
|
700
|
-
* @param {{ runtimeRoot: string }} opts
|
|
701
|
-
* @returns {string|null} 含 tasks/ 的最新 runId,无则 null
|
|
702
|
-
*/
|
|
703
|
-
export function resolveLatestExecuteRunIdWithTasks({ runtimeRoot }) {
|
|
704
|
-
try {
|
|
705
|
-
const runsDir = join(runtimeRoot, 'execute-runs')
|
|
706
|
-
if (!existsSync(runsDir)) return null
|
|
707
|
-
const entries = readdirSync(runsDir)
|
|
708
|
-
.map(e => ({ e, p: join(runsDir, e) }))
|
|
709
|
-
.filter(x => { try { return statSync(x.p).isDirectory() } catch { return false } })
|
|
710
|
-
.filter(x => { try { return statSync(join(x.p, 'tasks')).isDirectory() } catch { return false } })
|
|
711
|
-
.map(x => ({ e: x.e, mtime: statSync(x.p).mtimeMs }))
|
|
712
|
-
.sort((a, b) => b.mtime - a.mtime)
|
|
713
|
-
return entries.length > 0 ? entries[0].e : null
|
|
714
|
-
} catch {
|
|
715
|
-
return null
|
|
716
|
-
}
|
|
717
|
-
}
|
|
718
|
-
|
|
719
|
-
export function resolveLatestExecuteRunId({ runtimeRoot, changeName }) {
|
|
720
|
-
const marker = join(runtimeRoot, `current-execute-run-id-${changeName}`)
|
|
721
|
-
try {
|
|
722
|
-
if (existsSync(marker)) {
|
|
723
|
-
const c = readFileSync(marker, 'utf8').trim()
|
|
724
|
-
if (c && !isValidExecuteRunId(c)) console.warn(`[sillyspec] execute run marker 内容非法(期望 exec-YYYY-MM-DD-HHMMSS,实得 ${JSON.stringify(c.slice(0, 60))}),忽略并回退目录扫描`)
|
|
725
|
-
if (isValidExecuteRunId(c)) return c
|
|
726
|
-
}
|
|
727
|
-
} catch {}
|
|
728
|
-
try {
|
|
729
|
-
const runsDir = join(runtimeRoot, 'execute-runs')
|
|
730
|
-
if (existsSync(runsDir)) {
|
|
731
|
-
const entries = readdirSync(runsDir)
|
|
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
|
-
}
|
|
738
|
-
} catch {}
|
|
739
|
-
return null
|
|
740
|
-
}
|
|
741
|
-
|
|
742
|
-
/**
|
|
743
|
-
* worktree execute「主 agent 直接实现」模式收尾兜底:per-task review.json 缺失时,
|
|
744
|
-
* 据 git diff base..head 按 task allowed_paths 归属,自动落盘 cannot_verify 草稿。
|
|
745
|
-
*
|
|
746
|
-
* 根因(坑 worktree-execute-apply-friction 坑2):per-task review.json 无程序化 writer,
|
|
747
|
-
* 全靠子代理手写;主 agent 统一实现强依赖链/机械 task 时不走子代理 review 流程 →
|
|
748
|
-
* review.json 全缺,execute --done 的 Task Review Gate 报「task-XX 缺少 review.json」阻断。
|
|
749
|
-
*
|
|
750
|
-
* 复用(与 Task Review Gate / verify 同源,单一真相):
|
|
751
|
-
* - resolveVerifyChangedFiles(worktree-aware,meta.json 为权威)→ base..head diff 文件集
|
|
752
|
-
* - WorktreeManager.getMeta → baseHash(base)+ worktreePath(决定 head/gitDir,同 gates.js reviewGitDir)
|
|
753
|
-
* - parseAllowedPaths + pathMatches(同 worktree-apply Gate2 口径)→ task 文件归属
|
|
754
|
-
* - exec-id 解析同 gates.js:269 / autoCheckPlanFromReviews:current-execute-run-id-<change> marker,
|
|
755
|
-
* 缺失则 generateExecuteRunId + 落盘(保证草稿与后续 gate 读同一 run 目录)
|
|
756
|
-
*
|
|
757
|
-
* 行为:
|
|
758
|
-
* - 仅 review.json 不存在时写(幂等,已有人工/子代理 verdict 一律跳过,绝不覆盖)
|
|
759
|
-
* - changedFiles 为空的 task 不生成(verifyReviewGitEvidence 判空 diff 伪造,留给 agent)
|
|
760
|
-
* - 草稿 verdict=cannot_verify + 非空 requiredEvidence(过 schema,流转 verify 阶段兑现)
|
|
761
|
-
* - 不属任何 task 的文件累计 unattributed(顺带修复/非源码),不强塞某 task
|
|
762
|
-
*
|
|
763
|
-
* fail-open:任何异常 → 返回统计,仅 console.warn,不阻断 execute 完成(草稿是兜底,
|
|
764
|
-
* 缺了也只是退回原状——gate 报缺 review.json,agent 手补,不比修复前差)。
|
|
765
|
-
*
|
|
766
|
-
* @param {{ changeName: string, cwd: string, platformOpts?: object, ctx?: object|null }} opts
|
|
767
|
-
* @returns {Promise<{ generated: number, skipped: number, unattributed: string[], reason?: string, executeRunId?: string }>}
|
|
768
|
-
*/
|
|
769
|
-
export async function generateTaskReviewDrafts({ changeName, cwd, platformOpts = {}, ctx = null }) {
|
|
770
|
-
const specBase = platformOpts.specRoot || join(cwd, '.sillyspec')
|
|
771
|
-
const runtimeRoot = resolveRuntimeRoot(platformOpts, specBase)
|
|
772
|
-
const changeDir = join(specBase, 'changes', changeName)
|
|
773
|
-
const tasksDir = join(changeDir, 'tasks')
|
|
774
|
-
|
|
775
|
-
if (!changeName) {
|
|
776
|
-
return { generated: 0, skipped: 0, unattributed: [], reason: '无 changeName' }
|
|
777
|
-
}
|
|
778
|
-
// 无 task 卡片目录 → 非多 task 变更,无需补草稿
|
|
779
|
-
if (!existsSync(tasksDir)) {
|
|
780
|
-
return { generated: 0, skipped: 0, unattributed: [], reason: '无 tasks/ 目录' }
|
|
781
|
-
}
|
|
782
|
-
|
|
783
|
-
// exec-id:与 Task Review Gate(gates.js:269)/ autoCheckPlanFromReviews 同源
|
|
784
|
-
const runIdFile = join(runtimeRoot, 'current-execute-run-id-' + changeName)
|
|
785
|
-
let executeRunId = ''
|
|
786
|
-
try {
|
|
787
|
-
if (existsSync(runIdFile)) {
|
|
788
|
-
const c = readFileSync(runIdFile, 'utf8').trim()
|
|
789
|
-
if (c && !isValidExecuteRunId(c)) console.warn(`[sillyspec] execute run marker 内容非法(期望 exec-YYYY-MM-DD-HHMMSS,实得 ${JSON.stringify(c.slice(0, 60))}),视为缺失重新生成`)
|
|
790
|
-
if (isValidExecuteRunId(c)) executeRunId = c
|
|
791
|
-
}
|
|
792
|
-
} catch {}
|
|
793
|
-
if (!executeRunId) {
|
|
794
|
-
executeRunId = generateExecuteRunId()
|
|
795
|
-
// D-001#1 fallback 写入点:mkdir execute-runs/<runId>/tasks 先于 marker(不变量:marker 在则目录在)。
|
|
796
|
-
// 保留 fail-open 契约(调用方 catch 降级)——只去静默:失败 console.error 留痕,不 throw
|
|
797
|
-
//(草稿兜底缺了也只是退回 gate 报缺 review.json,不比修复前差)。
|
|
798
|
-
try {
|
|
799
|
-
mkdirSync(join(runtimeRoot, 'execute-runs', executeRunId, 'tasks'), { recursive: true })
|
|
800
|
-
writeFileSync(runIdFile, executeRunId + '\n')
|
|
801
|
-
} catch (e) {
|
|
802
|
-
console.error(`[sillyspec] execute run marker/目录写入失败(降级继续,草稿落盘仍尝试): ${e.message}`)
|
|
803
|
-
}
|
|
804
|
-
}
|
|
805
|
-
|
|
806
|
-
// base..head diff 文件集(worktree-aware;null=git 不可用,[]=无 commit diff)
|
|
807
|
-
const diffFiles = resolveVerifyChangedFiles(cwd, changeName)
|
|
808
|
-
// 单仓模式(无 ctx):主仓无 diff 即无任何 task 可生成 → 提前返回(原逻辑零回归)。
|
|
809
|
-
// 有 ctx:主仓无 diff 不阻断——跨仓 task 的 diff 在跨仓仓根独立取(per-task),主仓 task 自然跳过(空 changedFiles)。
|
|
810
|
-
if (!ctx && (!diffFiles || diffFiles.length === 0)) {
|
|
811
|
-
return { generated: 0, skipped: 0, unattributed: [], executeRunId, reason: 'base..head 无代码 diff(改动未 commit?)' }
|
|
812
|
-
}
|
|
813
|
-
|
|
814
|
-
// base/head + gitDir:与 gates.js reviewGitDir 同源(worktree 优先,in-place 回退 cwd)
|
|
815
|
-
// 有 ctx 时主仓 base/head 仅供主仓 task 用;ctx 主仓 entry 也可提供 base(meta.baseHash 同源)。
|
|
816
|
-
// 主仓 base/head 解析失败 + 无 ctx → 提前返回(原逻辑);有 ctx → 仅记 null,主仓 task 后续跳过(不阻断跨仓 task)。
|
|
817
|
-
let base = null
|
|
818
|
-
let head = null
|
|
819
|
-
let reviewGitDir = cwd
|
|
820
|
-
try {
|
|
821
|
-
const wm = new WorktreeManager({ cwd })
|
|
822
|
-
const meta = wm.getMeta(changeName)
|
|
823
|
-
base = (meta && (meta.baselineCommit || meta.baseHash)) || null
|
|
824
|
-
if (meta && meta.worktreePath && meta.mode !== 'in-place-fallback' && existsSync(meta.worktreePath)) {
|
|
825
|
-
reviewGitDir = meta.worktreePath
|
|
826
|
-
}
|
|
827
|
-
} catch {}
|
|
828
|
-
if (!base) {
|
|
829
|
-
if (!ctx) {
|
|
830
|
-
return { generated: 0, skipped: 0, unattributed: diffFiles, executeRunId, reason: '无 worktree meta.baseHash(无法定 base/head)' }
|
|
831
|
-
}
|
|
832
|
-
// 有 ctx:主仓 base 缺失(主仓无 worktree / 单纯跨仓 change)→ 主仓 task 后续跳过,跨仓 task 照常
|
|
833
|
-
} else {
|
|
834
|
-
try {
|
|
835
|
-
head = runGit(reviewGitDir, ['rev-parse', 'HEAD'])
|
|
836
|
-
} catch {
|
|
837
|
-
if (!ctx) {
|
|
838
|
-
return { generated: 0, skipped: 0, unattributed: diffFiles, executeRunId, reason: 'git rev-parse HEAD 失败(' + reviewGitDir + ')' }
|
|
839
|
-
}
|
|
840
|
-
// 有 ctx:主仓 HEAD 失败不阻断跨仓 task;head 留 null,主仓 task 后续按空 changedFiles 跳过
|
|
841
|
-
}
|
|
842
|
-
}
|
|
843
|
-
|
|
844
|
-
const taskFiles = readdirSync(tasksDir).filter(f => /^task-\d+\.md$/.test(f)).sort()
|
|
845
|
-
let generated = 0
|
|
846
|
-
let skipped = 0
|
|
847
|
-
const attributed = new Set()
|
|
848
|
-
|
|
849
|
-
for (const tf of taskFiles) {
|
|
850
|
-
const taskId = tf.replace(/\.md$/, '')
|
|
851
|
-
const reviewDir = join(runtimeRoot, 'execute-runs', executeRunId, 'tasks', taskId)
|
|
852
|
-
const reviewPath = join(reviewDir, 'review.json')
|
|
853
|
-
|
|
854
|
-
// 幂等:review.json 已存在(无论合法/解析错/schema 错)一律跳过,绝不覆盖人工/子代理 verdict
|
|
855
|
-
const existing = readReview(reviewPath)
|
|
856
|
-
if (existing.ok || existing.parseError || existing.schemaError) {
|
|
857
|
-
skipped++
|
|
858
|
-
continue
|
|
859
|
-
}
|
|
860
|
-
|
|
861
|
-
const content = readFileSync(join(tasksDir, tf), 'utf8')
|
|
862
|
-
const allowedPaths = parseAllowedPaths(content)
|
|
863
|
-
|
|
864
|
-
// ── 多仓分支(design §6 A2 / D-006 / D-010):跨仓 task 用 task 卡双锡点 + 跨仓仓 diff ──
|
|
865
|
-
const taskRepo = normalizeRepoKey(parseRepo(content))
|
|
866
|
-
const crossEntry = (ctx && taskRepo !== DEFAULT_REPO_KEY) ? ctx.resolve(taskRepo) : null
|
|
867
|
-
let taskBase = base
|
|
868
|
-
let taskHead = head
|
|
869
|
-
let taskChangedFiles
|
|
870
|
-
let draftRepo
|
|
871
|
-
|
|
872
|
-
if (crossEntry) {
|
|
873
|
-
// 跨仓 task:base/head 必须读 task 卡锡点(跨仓仓无 meta.json)。缺任一 → 跳过留给 agent。
|
|
874
|
-
const baseCommit = parseBaseCommit(content)
|
|
875
|
-
const headCommit = parseHeadCommit(content)
|
|
876
|
-
if (!baseCommit || !headCommit) {
|
|
877
|
-
skipped++
|
|
878
|
-
continue
|
|
879
|
-
}
|
|
880
|
-
taskBase = baseCommit
|
|
881
|
-
taskHead = headCommit
|
|
882
|
-
draftRepo = taskRepo
|
|
883
|
-
// 跨仓 diff 在跨仓仓根跑(base..head 锡点锚定,非瞬时 HEAD——D-010 head 精度)
|
|
884
|
-
let crossDiffFiles = []
|
|
885
|
-
try {
|
|
886
|
-
const out = runGit(crossEntry.gitDir, ['diff', '--name-only', `${baseCommit}..${headCommit}`])
|
|
887
|
-
crossDiffFiles = out ? out.split('\n').filter(Boolean).map(p => p.replace(/\\/g, '/')) : []
|
|
888
|
-
} catch {
|
|
889
|
-
// git diff 失败(锡点非真实 commit 等)→ 不生成伪造草稿,留给 agent 手补
|
|
890
|
-
skipped++
|
|
891
|
-
continue
|
|
892
|
-
}
|
|
893
|
-
taskChangedFiles = allowedPaths.length > 0
|
|
894
|
-
? crossDiffFiles.filter(f => allowedPaths.some(p => pathMatches(f, p)))
|
|
895
|
-
: crossDiffFiles
|
|
896
|
-
} else {
|
|
897
|
-
// 主仓 task(taskRepo='main' 或无 ctx 跨仓解析失败 → 退单仓口径)
|
|
898
|
-
// diffFiles 可能为 null(git 不可用)——有 ctx 时主仓 git 异常不阻断跨仓 task,主仓 task 自然跳过。
|
|
899
|
-
const mainDiff = Array.isArray(diffFiles) ? diffFiles : []
|
|
900
|
-
taskChangedFiles = allowedPaths.length > 0
|
|
901
|
-
? mainDiff.filter(f => allowedPaths.some(p => pathMatches(f, p)))
|
|
902
|
-
: []
|
|
903
|
-
}
|
|
904
|
-
|
|
905
|
-
// 空 changedFiles 的 task 不生成(verifyReviewGitEvidence 判空 diff 伪造,留给 agent 手补)
|
|
906
|
-
if (taskChangedFiles.length === 0) {
|
|
907
|
-
skipped++
|
|
908
|
-
continue
|
|
909
|
-
}
|
|
910
|
-
// unattributed 只统计主仓 diff(跨仓 diff 路径相对跨仓仓根,与主仓 unattributed 语义无关)
|
|
911
|
-
if (!crossEntry) {
|
|
912
|
-
taskChangedFiles.forEach(f => attributed.add(f))
|
|
913
|
-
}
|
|
914
|
-
|
|
915
|
-
const draft = {
|
|
916
|
-
schemaVersion: REVIEW_SCHEMA_VERSION,
|
|
917
|
-
task: taskId,
|
|
918
|
-
base: taskBase,
|
|
919
|
-
head: taskHead,
|
|
920
|
-
changedFiles: taskChangedFiles,
|
|
921
|
-
specVerdict: 'cannot_verify',
|
|
922
|
-
qualityVerdict: 'cannot_verify',
|
|
923
|
-
requiredEvidence: ['auto-generated draft: 待 agent 对照 ' + taskId + ' brief + diff 复核后升级为 pass/fail'],
|
|
924
|
-
reviewerNotes: 'auto-generated draft from git diff ' + taskBase.slice(0, 8) + '..' + taskHead.slice(0, 8) + ';verdict=未评审(worktree execute 主 agent 实现模式兜底,坑2)',
|
|
925
|
-
}
|
|
926
|
-
if (draftRepo) draft.repo = draftRepo
|
|
927
|
-
mkdirSync(reviewDir, { recursive: true })
|
|
928
|
-
writeFileSync(reviewPath, JSON.stringify(draft, null, 2) + '\n')
|
|
929
|
-
generated++
|
|
930
|
-
}
|
|
931
|
-
|
|
932
|
-
const unattributed = (Array.isArray(diffFiles) ? diffFiles : []).filter(f => !attributed.has(f))
|
|
933
|
-
return { generated, skipped, unattributed, executeRunId }
|
|
934
|
-
}
|
|
935
|
-
|
|
936
|
-
/**
|
|
937
|
-
* 获取当前(或最新)execute run id
|
|
938
|
-
* 从 runtime 目录下查找 execute-runs/ 子目录
|
|
939
|
-
*
|
|
940
|
-
* @param {string} runtimeRoot - .sillyspec/.runtime 路径
|
|
941
|
-
* @returns {string|null} 最新 run id,null 表示无任何 run
|
|
942
|
-
*/
|
|
943
|
-
/**
|
|
944
|
-
* 获取当前 execute run id
|
|
945
|
-
* 优先从 current-execute-run-id 标记文件读取(execute 阶段启动时写入),
|
|
946
|
-
* fallback 到 execute-runs/ 下最新的 exec- 前缀目录。
|
|
947
|
-
*
|
|
948
|
-
* @param {string} runtimeRoot - .sillyspec/.runtime 路径
|
|
949
|
-
* @returns {string|null} 当前 run id,null 表示无任何 run
|
|
950
|
-
*/
|
|
951
|
-
export function getLatestExecuteRunId(runtimeRoot) {
|
|
952
|
-
// 优先读标记文件(execute 阶段启动时由 run.js 写入,生命周期内不变)
|
|
953
|
-
const markerPath = join(runtimeRoot, 'current-execute-run-id')
|
|
954
|
-
try {
|
|
955
|
-
if (existsSync(markerPath)) {
|
|
956
|
-
const content = readFileSync(markerPath, 'utf8').trim()
|
|
957
|
-
if (content && !isValidExecuteRunId(content)) console.warn(`[sillyspec] execute run marker 内容非法(期望 exec-YYYY-MM-DD-HHMMSS,实得 ${JSON.stringify(content.slice(0, 60))}),忽略并回退目录扫描`)
|
|
958
|
-
if (isValidExecuteRunId(content)) return content
|
|
959
|
-
}
|
|
960
|
-
} catch {}
|
|
961
|
-
|
|
962
|
-
// fallback:扫描 execute-runs/ 目录
|
|
963
|
-
const runsDir = join(runtimeRoot, 'execute-runs')
|
|
964
|
-
if (!existsSync(runsDir)) return null
|
|
965
|
-
|
|
966
|
-
try {
|
|
967
|
-
const entries = readdirSync(runsDir, { withFileTypes: true })
|
|
968
|
-
.filter(e => e.isDirectory() && e.name.startsWith('exec-'))
|
|
969
|
-
.map(e => e.name)
|
|
970
|
-
.sort()
|
|
971
|
-
.reverse()
|
|
972
|
-
return entries[0] || null
|
|
973
|
-
} catch {
|
|
974
|
-
return null
|
|
975
|
-
}
|
|
976
|
-
}
|
|
977
|
-
|
|
978
|
-
/**
|
|
979
|
-
* 确保 task review 目录存在
|
|
980
|
-
* @param {string} runtimeRoot
|
|
981
|
-
* @param {string} executeRunId
|
|
982
|
-
* @param {string} taskId
|
|
983
|
-
* @returns {string} task review 目录路径
|
|
984
|
-
*/
|
|
985
|
-
export function ensureTaskReviewDir(runtimeRoot, executeRunId, taskId) {
|
|
986
|
-
const dir = join(runtimeRoot, 'execute-runs', executeRunId, 'tasks', taskId)
|
|
987
|
-
mkdirSync(dir, { recursive: true })
|
|
988
|
-
return dir
|
|
989
|
-
}
|
|
990
|
-
|
|
991
|
-
/**
|
|
992
|
-
* 打印校验结果
|
|
993
|
-
* @param {{ ok: boolean, errors: string[], warnings: string[], requiredEvidence: Array }} result
|
|
994
|
-
*/
|
|
995
|
-
export function printReviewResult(result, context = {}) {
|
|
996
|
-
if (result.ok && result.warnings.length === 0) {
|
|
997
|
-
console.log('\n✅ Task Review Gate — 所有任务评审通过')
|
|
998
|
-
return
|
|
999
|
-
}
|
|
1000
|
-
|
|
1001
|
-
if (result.errors.length > 0) {
|
|
1002
|
-
console.error('\n🚫 Task Review Gate — FAILED')
|
|
1003
|
-
for (const err of result.errors) {
|
|
1004
|
-
console.error(` - ${err}`)
|
|
1005
|
-
}
|
|
1006
|
-
const hint = context.runtimeRoot && context.executeRunId
|
|
1007
|
-
? `期望路径:${context.runtimeRoot}/execute-runs/${context.executeRunId}/tasks/<task-XX>/review.json`
|
|
1008
|
-
: '为缺失/失败的任务补充 review.json'
|
|
1009
|
-
console.error(`\n 提示:${hint},然后重新 --done`)
|
|
1010
|
-
}
|
|
1011
|
-
|
|
1012
|
-
if (result.warnings.length > 0) {
|
|
1013
|
-
console.warn('\n⚠️ Task Review Gate — WARNING')
|
|
1014
|
-
for (const w of result.warnings) {
|
|
1015
|
-
console.warn(` - ${w}`)
|
|
1016
|
-
}
|
|
1017
|
-
console.warn('\n cannot_verify 的 requiredEvidence 将在 verify 阶段校验')
|
|
1018
|
-
}
|
|
1019
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* SillySpec Task Review Gate — execute 阶段任务级评审校验
|
|
3
|
+
*
|
|
4
|
+
* execute 阶段每个 task 完成后,controller 必须写入 review.json。
|
|
5
|
+
* execute --done 时 CLI 硬校验:缺失 review 或 verdict 不通过则阻断。
|
|
6
|
+
*
|
|
7
|
+
* 目录结构:
|
|
8
|
+
* .sillyspec/.runtime/execute-runs/<runId>/tasks/<taskId>/review.json
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync, readFileSync, mkdirSync, writeFileSync, readdirSync, statSync } from 'fs'
|
|
12
|
+
import { join, resolve } from 'path'
|
|
13
|
+
import { execFileSync } from 'child_process'
|
|
14
|
+
import { pathMatches } from './change-list.js'
|
|
15
|
+
import { parseAllowedPaths, parseRepo, parseBaseCommit, parseHeadCommit } from './stages/plan-postcheck.js'
|
|
16
|
+
import { resolveVerifyChangedFiles } from './verify-postcheck.js'
|
|
17
|
+
import { WorktreeManager } from './worktree.js'
|
|
18
|
+
import { resolveRuntimeRoot } from './run/shared.js'
|
|
19
|
+
|
|
20
|
+
// ── review.json schema version ──
|
|
21
|
+
//
|
|
22
|
+
// design §7.4 / R-07:task-review 的 review.json schemaVersion 1→2(新增可选 repo 字段),
|
|
23
|
+
// 旧 v1(无 repo)向后兼容视 'main',不阻断既有 change archive。
|
|
24
|
+
//
|
|
25
|
+
// 关于常量与"1→2":REVIEW_SCHEMA_VERSION 常量被 stage-review.js(文档型阶段评审)共享——
|
|
26
|
+
// stage-review.test.mjs:358 + register.test.mjs:40 断言其值 === 1,且 stage-review.js 不在本次
|
|
27
|
+
// 变更的 allowed_paths 内。两个 schema(task code-review / stage doc-review)逻辑独立,但历史共享
|
|
28
|
+
// 同一常量。本期保持 REVIEW_SCHEMA_VERSION=1 作 stage-review 写侧默认值(不破坏 stage-review),
|
|
29
|
+
// 同时用 REVIEW_SCHEMA_VERSIONS_ACCEPTED=[1,2] 表达"读侧接受两版"——validateReviewSchema 兼容 v1/v2,
|
|
30
|
+
// 跨仓 task 写 review.json 可用 v2 带 repo 字段,单仓/旧 change 仍用 v1。后续可拆分 stage-review 专用
|
|
31
|
+
// 常量,本期遵循 allowed_paths 约束不扩散到 stage-review.js。
|
|
32
|
+
export const REVIEW_SCHEMA_VERSION = 1
|
|
33
|
+
const REVIEW_SCHEMA_VERSIONS_ACCEPTED = [1, 2]
|
|
34
|
+
// repo 缺省值(review.repo / task 卡 repo 缺省时统一按 'main' 处理)。design §7.2 / §7.4。
|
|
35
|
+
const DEFAULT_REPO_KEY = 'main'
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 规范化 review.repo / task 卡 repo 字段为有效 repoKey。
|
|
39
|
+
* 缺省 / 非字符串 / 空串 → 'main'(向后兼容旧 v1 review + 无 repo 声明的单仓 task)。
|
|
40
|
+
* @param {any} repo
|
|
41
|
+
* @returns {string}
|
|
42
|
+
*/
|
|
43
|
+
export function normalizeRepoKey(repo) {
|
|
44
|
+
if (typeof repo !== 'string' || repo.trim() === '') return DEFAULT_REPO_KEY
|
|
45
|
+
return repo.trim()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── 合法 verdict 枚举 ──
|
|
49
|
+
export const VALID_VERDICTS = ['pass', 'fail', 'cannot_verify']
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 解析 plan.md 中的 task 列表
|
|
53
|
+
* @param {string} planContent - plan.md 文件内容
|
|
54
|
+
* @returns {string[]} task id 列表,如 ['task-01', 'task-02']
|
|
55
|
+
*/
|
|
56
|
+
function parseTaskIdsFromPlan(planContent) {
|
|
57
|
+
if (!planContent) return []
|
|
58
|
+
const ids = new Set()
|
|
59
|
+
const re = /^\s*[-*]\s*\[[ x]\]\s*task-(\d+)/gim
|
|
60
|
+
for (const m of planContent.matchAll(re)) {
|
|
61
|
+
ids.add(`task-${m[1].padStart(2, '0')}`)
|
|
62
|
+
}
|
|
63
|
+
return [...ids].sort()
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 统计 plan.md/tasks.md 中 task-NN checkbox 的勾选情况(降级口径)。
|
|
68
|
+
* 与 progress.js readPlanCheckboxStatus 同语义,但纯函数、无类依赖,供 summarizeTaskCompletion 降级复用。
|
|
69
|
+
*/
|
|
70
|
+
function countPlanCheckboxes(planContent) {
|
|
71
|
+
const re = /^\s*[-*]\s+\[([ xX])\]\s+task-\d+/gm
|
|
72
|
+
let total = 0
|
|
73
|
+
let checked = 0
|
|
74
|
+
let m
|
|
75
|
+
while ((m = re.exec(planContent)) !== null) {
|
|
76
|
+
total++
|
|
77
|
+
if (m[1] === 'x' || m[1] === 'X') checked++
|
|
78
|
+
}
|
|
79
|
+
return { total, checked }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 汇总变更的任务完成度 —— archive Step1「任务完成度检查」的客观真相源。
|
|
84
|
+
*
|
|
85
|
+
* 以 execute 阶段产出的 review.json verdict 为准(task-NN 的 specVerdict + qualityVerdict 均 ≠ fail
|
|
86
|
+
* 视为完成),而非 plan.md checkbox。原因:plan.md checkbox 依赖 autoCheckPlanFromReviews 回填,
|
|
87
|
+
* 而 runId marker / review.json 缺失时回填静默 no-op,checkbox 会停在未勾态,导致完成度失真
|
|
88
|
+
* (archive 误判「全未完成」)。review.json 是 task 级客观 verdict,不受回填断裂影响。
|
|
89
|
+
*
|
|
90
|
+
* runId 解析:优先 current-execute-run-id-<changeName> marker(最新一次 execute run);
|
|
91
|
+
* marker 缺失则扫描 execute-runs/ 下子目录、取 mtime 最新的,尽力定位。
|
|
92
|
+
*
|
|
93
|
+
* fail-safe:无 plan / 无 runId / review 全缺等任何异常,降级到 plan.md checkbox 统计 + 标注 source,
|
|
94
|
+
* 绝不抛错、绝不阻断 archive(最坏退回「数 checkbox」现状,不比修复前差)。
|
|
95
|
+
*
|
|
96
|
+
* @param {{ changeDir: string, runtimeRoot: string, changeName: string }} opts
|
|
97
|
+
* @returns {{ source: string, total: number, completed: number, pending: Array<{id:string,reason:string}>, report: string }}
|
|
98
|
+
* source: 'review.json'(客观源可用)/ 'plan-checkbox-fallback'(降级)/ 'no-plan' / 'no-tasks'
|
|
99
|
+
* report: 人类可读报告,供 prompt {TASK_COMPLETION_REPORT} 注入
|
|
100
|
+
*/
|
|
101
|
+
export function summarizeTaskCompletion({ changeDir, runtimeRoot, changeName }) {
|
|
102
|
+
const noPlan = {
|
|
103
|
+
source: 'no-plan', total: 0, completed: 0, pending: [],
|
|
104
|
+
report: '⚠️ 未找到 plan.md/tasks.md,无法计算任务完成度。请确认变更目录完整。'
|
|
105
|
+
}
|
|
106
|
+
if (!changeDir) return noPlan
|
|
107
|
+
|
|
108
|
+
const planPath = join(changeDir, 'plan.md')
|
|
109
|
+
const tasksPath = join(changeDir, 'tasks.md')
|
|
110
|
+
let planContent = null
|
|
111
|
+
if (existsSync(planPath)) planContent = readFileSync(planPath, 'utf8')
|
|
112
|
+
else if (existsSync(tasksPath)) planContent = readFileSync(tasksPath, 'utf8')
|
|
113
|
+
else return noPlan
|
|
114
|
+
|
|
115
|
+
const taskIds = parseTaskIdsFromPlan(planContent)
|
|
116
|
+
if (taskIds.length === 0) {
|
|
117
|
+
return { source: 'no-tasks', total: 0, completed: 0, pending: [],
|
|
118
|
+
report: '⚠️ plan.md 未解析出任何 task-NN 条目(plan 可能未按规范写 checkbox 列表)。' }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// runId 解析:marker → 扫描最新目录(marker 是 agent 可写内容,格式校验防注入/穿越)
|
|
122
|
+
let runId = null
|
|
123
|
+
const marker = join(runtimeRoot, `current-execute-run-id-${changeName}`)
|
|
124
|
+
try {
|
|
125
|
+
if (existsSync(marker)) {
|
|
126
|
+
const c = readFileSync(marker, 'utf8').trim()
|
|
127
|
+
if (c && !isValidExecuteRunId(c)) console.warn(`[sillyspec] execute run marker 内容非法(期望 exec-YYYY-MM-DD-HHMMSS,实得 ${JSON.stringify(c.slice(0, 60))}),忽略并回退目录扫描`)
|
|
128
|
+
if (isValidExecuteRunId(c)) runId = c
|
|
129
|
+
}
|
|
130
|
+
} catch {}
|
|
131
|
+
if (!runId) {
|
|
132
|
+
try {
|
|
133
|
+
const runsDir = join(runtimeRoot, 'execute-runs')
|
|
134
|
+
if (existsSync(runsDir)) {
|
|
135
|
+
const entries = readdirSync(runsDir)
|
|
136
|
+
.map(e => ({ e, p: join(runsDir, e) }))
|
|
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
|
+
}
|
|
142
|
+
} catch {}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// 无 runId → 降级 plan.md checkbox 统计
|
|
146
|
+
if (!runId) {
|
|
147
|
+
const { total, checked } = countPlanCheckboxes(planContent)
|
|
148
|
+
return {
|
|
149
|
+
source: 'plan-checkbox-fallback', total, completed: checked,
|
|
150
|
+
pending: taskIds.slice(0, Math.max(0, total - checked)).map(id => ({ id, reason: 'checkbox 未勾(降级口径)' })),
|
|
151
|
+
report:
|
|
152
|
+
'⚠️ 客观源不可用(无 execute runId marker,review.json 无法定位)→ 降级用 plan.md checkbox。\n' +
|
|
153
|
+
`- 总任务:${total}\n- plan.md 已勾选:${checked}\n` +
|
|
154
|
+
'⚠️ checkbox 可能因 autoCheckPlanFromReviews 回填断裂而失真,务必交叉核对 .runtime/execute-runs/*/tasks/task-NN/review.json 的 verdict 再下结论。'
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// 有 runId → 读 review.json verdict
|
|
159
|
+
const completed = []
|
|
160
|
+
const pending = []
|
|
161
|
+
for (const id of taskIds) {
|
|
162
|
+
const reviewPath = join(runtimeRoot, 'execute-runs', runId, 'tasks', id, 'review.json')
|
|
163
|
+
const r = readReview(reviewPath)
|
|
164
|
+
if (r.ok && r.review && r.review.specVerdict !== 'fail' && r.review.qualityVerdict !== 'fail') {
|
|
165
|
+
completed.push(id)
|
|
166
|
+
} else if (r.ok && r.review) {
|
|
167
|
+
pending.push({ id, reason: `verdict 未通过(spec=${r.review.specVerdict}, quality=${r.review.qualityVerdict})` })
|
|
168
|
+
} else {
|
|
169
|
+
pending.push({ id, reason: 'review.json 缺失(task 未走完 execute 评审)' })
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const pendingLines = pending.length > 0
|
|
174
|
+
? pending.map(p => ` - ${p.id}: ${p.reason}`).join('\n')
|
|
175
|
+
: ' (无)'
|
|
176
|
+
return {
|
|
177
|
+
source: 'review.json', total: taskIds.length, completed: completed.length, pending,
|
|
178
|
+
report:
|
|
179
|
+
`客观任务完成度(真相源 = review.json verdict,runId=${runId}):\n` +
|
|
180
|
+
`- 总任务:${taskIds.length}\n` +
|
|
181
|
+
`- 已通过(spec + quality verdict 均非 fail):${completed.length}\n` +
|
|
182
|
+
`- 未通过 / 缺失:${pending.length}\n` +
|
|
183
|
+
`- 未完成列表:\n${pendingLines}\n` +
|
|
184
|
+
'注:以 review.json verdict 为准;plan.md checkbox 仅作显示态(回填断裂时会与客观 verdict 不一致,以下方客观点为准)。'
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* 校验单个 review.json 文件
|
|
190
|
+
*
|
|
191
|
+
* schema 版本(design §7.4 / R-07):接受 v1(无 repo,单仓向后兼容)+ v2(新增可选 repo 字段,
|
|
192
|
+
* 跨仓 task 标记 repo: <key>)。repo 缺省 / 非字符串 → 视 'main'(normalizeRepoKey),不阻断。
|
|
193
|
+
* repo 的真实存在性(local.yaml repos 注册)由 MultiRepoContext 约束② fail-closed 兜底,schema 层
|
|
194
|
+
* 只做"非字符串时不阻断"的宽松类型校验(review.json 是 agent 手写产物,宽松避免误杀)。
|
|
195
|
+
*
|
|
196
|
+
* @param {object} review - 解析后的 JSON 对象
|
|
197
|
+
* @returns {{ ok: boolean, errors: string[] }}
|
|
198
|
+
*/
|
|
199
|
+
export function validateReviewSchema(review) {
|
|
200
|
+
const errors = []
|
|
201
|
+
if (!review || typeof review !== 'object') {
|
|
202
|
+
errors.push('review.json 不是有效 JSON 对象')
|
|
203
|
+
return { ok: false, errors }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (!REVIEW_SCHEMA_VERSIONS_ACCEPTED.includes(review.schemaVersion)) {
|
|
207
|
+
errors.push(`schemaVersion 应为 ${REVIEW_SCHEMA_VERSIONS_ACCEPTED.join(' 或 ')},实际为 ${review.schemaVersion}`)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (!review.task || typeof review.task !== 'string') {
|
|
211
|
+
errors.push('缺少 task 字段(应为 "task-XX" 格式)')
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// repo(可选,v2 新增):类型宽松校验。非字符串 / 空串 → 视 'main',不阻断 schema。
|
|
215
|
+
// 跨仓 repo 的注册存在性由 MultiRepoContext 构造时 fail-closed 校验(约束②)。
|
|
216
|
+
if (review.repo !== undefined && typeof review.repo !== 'string') {
|
|
217
|
+
errors.push(`repo 字段(若提供)应为字符串,实际类型为 ${typeof review.repo}`)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (!VALID_VERDICTS.includes(review.specVerdict)) {
|
|
221
|
+
errors.push(`specVerdict 无效:${review.specVerdict}(应为 ${VALID_VERDICTS.join('/')})`)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (!VALID_VERDICTS.includes(review.qualityVerdict)) {
|
|
225
|
+
errors.push(`qualityVerdict 无效:${review.qualityVerdict}(应为 ${VALID_VERDICTS.join('/')})`)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// cannot_verify 必须提供 requiredEvidence
|
|
229
|
+
if (review.specVerdict === 'cannot_verify' || review.qualityVerdict === 'cannot_verify') {
|
|
230
|
+
if (!Array.isArray(review.requiredEvidence) || review.requiredEvidence.length === 0) {
|
|
231
|
+
errors.push('cannot_verify 的 verdict 必须提供非空的 requiredEvidence 数组')
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// base/head 非空检查
|
|
236
|
+
if (!review.base || typeof review.base !== 'string') {
|
|
237
|
+
errors.push('缺少 base 字段(git commit hash)')
|
|
238
|
+
}
|
|
239
|
+
if (!review.head || typeof review.head !== 'string') {
|
|
240
|
+
errors.push('缺少 head 字段(git commit hash)')
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return { ok: errors.length === 0, errors }
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* 增强 JSON.parse 失败的定位:从 V8 的 "at position N" 算出 line:col + 出错行上下文,
|
|
248
|
+
* 并对常见非法转义(正则的反斜杠序列误入 JSON 字符串)给针对性修复指引。
|
|
249
|
+
* @param {string} raw 原始文件内容
|
|
250
|
+
* @param {string} message V8 抛出的错误消息
|
|
251
|
+
* @returns {string} 增强后的可读消息
|
|
252
|
+
*/
|
|
253
|
+
export function enrichJsonParseError(raw, message) {
|
|
254
|
+
const m = /at position (\d+)/.exec(message)
|
|
255
|
+
if (!m) return message
|
|
256
|
+
const pos = Math.min(Number(m[1]), raw.length)
|
|
257
|
+
let line = 1, col = 1
|
|
258
|
+
for (let i = 0; i < pos; i++) {
|
|
259
|
+
if (raw[i] === '\n') { line++; col = 1 } else { col++ }
|
|
260
|
+
}
|
|
261
|
+
const lineStart = raw.lastIndexOf('\n', pos - 1) + 1
|
|
262
|
+
const nlAfter = raw.indexOf('\n', pos)
|
|
263
|
+
const lineEnd = nlAfter === -1 ? raw.length : nlAfter
|
|
264
|
+
const snippet = raw.slice(lineStart, lineEnd)
|
|
265
|
+
const caret = ' '.repeat(Math.max(0, col - 1)) + '^'
|
|
266
|
+
// 单行注释内的反斜杠序列是纯文本,不触发块注释终止;正则字面量在函数体内正常解析。
|
|
267
|
+
let out = message + '\n → 第 ' + line + ' 行第 ' + col + ' 列:\n ' + snippet + '\n ' + caret
|
|
268
|
+
// 先移除合法的成对反斜杠(JSON 中 \\ 表字面 \),否则 "\\z" 会被逐字符正则误判为 "\z" 非法转义
|
|
269
|
+
const hasBadEscape = message.includes('Bad escaped character') || /\\[^"\\/bfnrtu]/.test(snippet.replace(/\\\\/g, ''))
|
|
270
|
+
if (hasBadEscape) {
|
|
271
|
+
out += '\n → 疑似正则转义(如 \\s \\d \\w)混入 JSON 字符串。修复:双写反斜杠(\\\\s),或用 JSON.stringify(obj) 重写 review.json。'
|
|
272
|
+
}
|
|
273
|
+
return out
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* 读取单个 task 的 review.json
|
|
278
|
+
* @param {string} reviewPath - review.json 文件路径
|
|
279
|
+
* @returns {{ ok: boolean, review: object|null, errors: string[] }}
|
|
280
|
+
*/
|
|
281
|
+
export function readReview(reviewPath) {
|
|
282
|
+
if (!existsSync(reviewPath)) {
|
|
283
|
+
return { ok: false, review: null, errors: ['review.json 不存在'] }
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
let raw
|
|
287
|
+
try {
|
|
288
|
+
raw = readFileSync(reviewPath, 'utf8')
|
|
289
|
+
} catch (e) {
|
|
290
|
+
return { ok: false, review: null, parseError: true, errors: [`review.json 读取失败: ${e.message}`] }
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
let parsed
|
|
294
|
+
try {
|
|
295
|
+
parsed = JSON.parse(raw)
|
|
296
|
+
} catch (e) {
|
|
297
|
+
// 文件存在但 JSON 非法:review 设为 null 但标记 parseError=true,错误消息带行列定位 + 转义修复指引
|
|
298
|
+
return { ok: false, review: null, parseError: true, errors: [`review.json 解析失败: ${enrichJsonParseError(raw, e.message)}`] }
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const schemaResult = validateReviewSchema(parsed)
|
|
302
|
+
if (!schemaResult.ok) {
|
|
303
|
+
return { ok: false, review: parsed, schemaError: true, errors: schemaResult.errors }
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return { ok: true, review: parsed, errors: [] }
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* 校验 plan.md 里所有「已勾选 [x]」task 的 review.json 是否 schema 完整(坑 review-json-field-gap)。
|
|
311
|
+
*
|
|
312
|
+
* Task Review Gate(validateTaskReviews)只在 execute 整阶段完成时跑,单 task --done 不校验 →
|
|
313
|
+
* 子代理勾了 checkbox 却漏写/漏字段 review.json,要到收尾才暴露,用户被迫事后批量补。本函数供
|
|
314
|
+
* enforceReviewJsonGate 在每次 execute --done 调用:已勾 [x] = 声称完成 = 必有完整 review.json;
|
|
315
|
+
* 未勾 task 不校验(还没做)。readReview 已区分 missing/parseError/schemaError,这里只聚合。
|
|
316
|
+
*
|
|
317
|
+
* @param {{ planContent: string, runtimeRoot: string, executeRunId: string }} opts
|
|
318
|
+
* @returns {{ ok: boolean, failures: Array<{ taskId:string, reviewPath:string, kind:string, errors:string[] }> }}
|
|
319
|
+
* kind ∈ missing(review.json 不存在)/ parseError(JSON 坏)/ schemaError(字段缺)
|
|
320
|
+
*/
|
|
321
|
+
export function validateCheckedTaskReviews({ planContent, runtimeRoot, executeRunId }) {
|
|
322
|
+
const failures = []
|
|
323
|
+
const re = /^\s*[-*]\s*\[x\]\s*task-(\d+)/gim
|
|
324
|
+
let m
|
|
325
|
+
while ((m = re.exec(planContent)) !== null) {
|
|
326
|
+
const taskId = `task-${m[1].padStart(2, '0')}`
|
|
327
|
+
const reviewPath = join(runtimeRoot, 'execute-runs', executeRunId, 'tasks', taskId, 'review.json')
|
|
328
|
+
const r = readReview(reviewPath)
|
|
329
|
+
if (!r.ok) {
|
|
330
|
+
const kind = r.parseError ? 'parseError' : (r.schemaError ? 'schemaError' : 'missing')
|
|
331
|
+
failures.push({ taskId, reviewPath, kind, errors: r.errors })
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return { ok: failures.length === 0, failures }
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* execute --done 时的 task review 总校验
|
|
339
|
+
*
|
|
340
|
+
* 规则:
|
|
341
|
+
* - 每个 plan task 必须有 review.json
|
|
342
|
+
* - specVerdict 或 qualityVerdict 为 fail → 整体 fail
|
|
343
|
+
* - specVerdict 或 qualityVerdict 为 cannot_verify → warning(requiredEvidence 非空)
|
|
344
|
+
* - cannot_verify + requiredEvidence 为空 → fail(agent 逃避判断)
|
|
345
|
+
* - cannot_verify 的 requiredEvidence 汇总到 requiredEvidence 字段,供 verify 阶段消费
|
|
346
|
+
*
|
|
347
|
+
* @param {object} opts
|
|
348
|
+
* @param {string} opts.planContent - plan.md 内容
|
|
349
|
+
* @param {string} opts.runtimeRoot - .sillyspec/.runtime 的绝对路径
|
|
350
|
+
* @param {string} opts.executeRunId - execute run id(如 'exec-2026-06-23-131400')
|
|
351
|
+
* @param {boolean} [opts.allowCannotVerify=true] - 是否允许 cannot_verify(默认允许,给 warning)
|
|
352
|
+
* @param {string} [opts.changeDir] - change 目录(读 task-NN.md frontmatter 判 low_risk)
|
|
353
|
+
* @param {string} [opts.gitDir] - 主仓 git 工作目录(单仓 review 真实性校验 cwd;无 ctx 时用此)
|
|
354
|
+
* @param {object} [opts.ctx] - MultiRepoContext 实例(D-013:缺省/null → 走 opts.gitDir 单仓零回归;
|
|
355
|
+
* 有 ctx 时按 review.repo(缺省 'main')从 ctx.resolve(repo).gitDir 取每条 review 的校验 cwd,
|
|
356
|
+
* 跨仓 gitDir=跨仓仓根,使 base..head diff 在正确仓跑——design §6 task-review A1 行)
|
|
357
|
+
* @returns {{ ok: boolean, errors: string[], warnings: string[], requiredEvidence: Array<{task: string, verdict: string, evidence: string[]}> }}
|
|
358
|
+
*/
|
|
359
|
+
/**
|
|
360
|
+
* 读取 task-XX.md frontmatter,判断是否声明 low_risk: true(type-only/机械迁移等低逻辑风险)。
|
|
361
|
+
* 声明 low_risk 的 task 缺 review.json 时只 warning 不 error(B2:免逐个评审仪式)。
|
|
362
|
+
*/
|
|
363
|
+
function isTaskLowRisk(changeDir, taskId) {
|
|
364
|
+
if (!changeDir) return false
|
|
365
|
+
const taskFile = join(changeDir, 'tasks', `${taskId}.md`)
|
|
366
|
+
if (!existsSync(taskFile)) return false
|
|
367
|
+
try {
|
|
368
|
+
const content = readFileSync(taskFile, 'utf8')
|
|
369
|
+
const fm = content.match(/^---\n([\s\S]*?)\n---/)
|
|
370
|
+
if (!fm) return false
|
|
371
|
+
return /^low_risk:\s*true\s*$/im.test(fm[1])
|
|
372
|
+
} catch {
|
|
373
|
+
return false
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export function validateTaskReviews(opts) {
|
|
378
|
+
const { planContent, runtimeRoot, executeRunId, allowCannotVerify = true, changeDir = null, gitDir = null, ctx = null } = opts
|
|
379
|
+
|
|
380
|
+
const taskIds = parseTaskIdsFromPlan(planContent)
|
|
381
|
+
|
|
382
|
+
// 如果 plan 里没有 task,跳过校验(向后兼容)
|
|
383
|
+
if (taskIds.length === 0) {
|
|
384
|
+
return { ok: true, errors: [], warnings: [], requiredEvidence: [] }
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const errors = []
|
|
388
|
+
const warnings = []
|
|
389
|
+
const requiredEvidence = []
|
|
390
|
+
|
|
391
|
+
for (const taskId of taskIds) {
|
|
392
|
+
const reviewDir = join(runtimeRoot, 'execute-runs', executeRunId, 'tasks', taskId)
|
|
393
|
+
const reviewPath = join(reviewDir, 'review.json')
|
|
394
|
+
|
|
395
|
+
const result = readReview(reviewPath)
|
|
396
|
+
|
|
397
|
+
if (!result.ok) {
|
|
398
|
+
if (result.parseError) {
|
|
399
|
+
// review.json 存在但 JSON 非法
|
|
400
|
+
errors.push(`${taskId}: review.json 解析失败 — ${result.errors.join('; ')}`)
|
|
401
|
+
} else if (result.schemaError) {
|
|
402
|
+
// review.json 存在且 JSON 合法,但 schema 校验失败
|
|
403
|
+
errors.push(`${taskId}: review.json 校验失败 — ${result.errors.join('; ')}`)
|
|
404
|
+
} else {
|
|
405
|
+
// review.json 不存在:声明 low_risk 的 task 豁免(B2:type-only/机械迁移免逐个评审)
|
|
406
|
+
if (isTaskLowRisk(changeDir, taskId)) {
|
|
407
|
+
warnings.push(`${taskId}: 缺少 review.json — task 声明 low_risk: true,已豁免评审(type-only/机械迁移)`)
|
|
408
|
+
} else {
|
|
409
|
+
errors.push(`${taskId}: 缺少 review.json — task 未经过评审(期望路径:${reviewPath},execute run ID: ${executeRunId})`)
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
continue
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const review = result.review
|
|
416
|
+
|
|
417
|
+
// 检查 review.task 是否与 plan 中的 taskId 一致
|
|
418
|
+
if (review.task && review.task !== taskId) {
|
|
419
|
+
errors.push(`${taskId}: review.json 中的 task 字段为 "${review.task}",与 plan 不一致(应为 "${taskId}")— agent 可能复制模板未修改`)
|
|
420
|
+
continue
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// 检查 fail verdict
|
|
424
|
+
if (review.specVerdict === 'fail' || review.qualityVerdict === 'fail') {
|
|
425
|
+
errors.push(`${taskId}: review 未通过 — spec: ${review.specVerdict}, quality: ${review.qualityVerdict}`)
|
|
426
|
+
if (review.reviewerNotes) {
|
|
427
|
+
errors.push(`${taskId}: ${review.reviewerNotes}`)
|
|
428
|
+
}
|
|
429
|
+
continue
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// ── git 真实性交叉校验:base/head 必须是真实 commit,diff 不能为空 ──
|
|
433
|
+
// D-013 + design §6 A1:有 ctx 时按 review.repo(缺省 'main')切 gitDir——跨仓 review 的
|
|
434
|
+
// base/head 是跨仓仓 commit,必须在跨仓仓根跑 rev-parse/diff,否则判伪造误杀。无 ctx 时退回
|
|
435
|
+
// opts.gitDir(单仓零回归)。reviewGitDir 为 null 则跳过证据校验(与原逻辑一致)。
|
|
436
|
+
let reviewGitDir = gitDir
|
|
437
|
+
if (ctx) {
|
|
438
|
+
const repoKey = normalizeRepoKey(review.repo)
|
|
439
|
+
const entry = ctx.resolve(repoKey)
|
|
440
|
+
if (entry && entry.gitDir) {
|
|
441
|
+
reviewGitDir = entry.gitDir
|
|
442
|
+
} else if (repoKey !== 'main') {
|
|
443
|
+
// 跨仓 repo 在 ctx 中未注册 → 不应发生(MultiRepoContext 构造已 fail-closed 拦截未注册),
|
|
444
|
+
// 防御性降级为 warning 提示,不阻断(review.json schema 已过,仓库解析交给 ctx 上游负责)。
|
|
445
|
+
// 坑7 教训:此分支发生时后续主仓校验必报「疑似伪造」,文案须直接指向真实排查方向。
|
|
446
|
+
warnings.push(`${taskId}: review.repo="${repoKey}" 在 MultiRepoContext 未解析到 entry,退回主仓 gitDir 校验——下方若报 commit 不真实/疑似伪造,先查 repo 声明(plan.md 内联块或 tasks/task-NN.md 的 repo:)与 local.yaml repos: 注册,非 review 伪造`)
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
if (reviewGitDir) {
|
|
450
|
+
const evidence = verifyReviewGitEvidence(review, reviewGitDir)
|
|
451
|
+
for (const w of evidence.warnings) warnings.push(`${taskId}: ${w}`)
|
|
452
|
+
if (!evidence.ok) {
|
|
453
|
+
for (const err of evidence.errors) errors.push(`${taskId}: ${err}`)
|
|
454
|
+
continue
|
|
455
|
+
}
|
|
456
|
+
if (evidence.emptyDiff) {
|
|
457
|
+
if (isTaskLowRisk(changeDir, taskId)) {
|
|
458
|
+
warnings.push(`${taskId}: base..head 无代码变更(task 声明 low_risk: true,不阻断)`)
|
|
459
|
+
} else {
|
|
460
|
+
errors.push(`${taskId}: base..head(${review.base.slice(0, 8)}..${review.head.slice(0, 8)})无任何代码变更 — 评审了一个零改动的任务,review 疑似伪造`)
|
|
461
|
+
continue
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// 检查 cannot_verify
|
|
467
|
+
if (review.specVerdict === 'cannot_verify' || review.qualityVerdict === 'cannot_verify') {
|
|
468
|
+
if (!allowCannotVerify) {
|
|
469
|
+
errors.push(`${taskId}: cannot_verify 不被允许 — 必须提供评审结果`)
|
|
470
|
+
continue
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
if (review.requiredEvidence && review.requiredEvidence.length > 0) {
|
|
474
|
+
const verdicts = []
|
|
475
|
+
if (review.specVerdict === 'cannot_verify') verdicts.push('spec')
|
|
476
|
+
if (review.qualityVerdict === 'cannot_verify') verdicts.push('quality')
|
|
477
|
+
warnings.push(`${taskId}: ${verdicts.join('+')}=cannot_verify,requiredEvidence 必须在 verify 阶段满足`)
|
|
478
|
+
requiredEvidence.push({
|
|
479
|
+
task: taskId,
|
|
480
|
+
verdict: verdicts.join('+'),
|
|
481
|
+
evidence: review.requiredEvidence,
|
|
482
|
+
})
|
|
483
|
+
} else {
|
|
484
|
+
// cannot_verify + 空 requiredEvidence = agent 逃避判断
|
|
485
|
+
errors.push(`${taskId}: cannot_verify 但 requiredEvidence 为空 — 这是无效评审`)
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// 额外检查:扫描 execute-runs/<runId>/tasks/ 下是否有 plan 里没有的 task review
|
|
491
|
+
// (agent 可能写错了 task id)
|
|
492
|
+
try {
|
|
493
|
+
const tasksDir = join(runtimeRoot, 'execute-runs', executeRunId, 'tasks')
|
|
494
|
+
if (existsSync(tasksDir)) {
|
|
495
|
+
const taskDirs = readdirSync(tasksDir, { withFileTypes: true })
|
|
496
|
+
.filter(e => e.isDirectory())
|
|
497
|
+
.map(e => e.name)
|
|
498
|
+
const taskIdSet = new Set(taskIds)
|
|
499
|
+
for (const dirName of taskDirs) {
|
|
500
|
+
if (!taskIdSet.has(dirName) && existsSync(join(tasksDir, dirName, 'review.json'))) {
|
|
501
|
+
warnings.push(`${dirName}: 存在 review.json 但不在 plan.md 的 task 列表中(可能是多余文件)`)
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
} catch (e) {
|
|
506
|
+
warnings.push(`task review extra-check 异常: ${e.message}`)
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
return {
|
|
510
|
+
ok: errors.length === 0,
|
|
511
|
+
errors,
|
|
512
|
+
warnings,
|
|
513
|
+
requiredEvidence,
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// ── Git 真实性交叉校验 ──
|
|
518
|
+
|
|
519
|
+
function runGit(gitDir, args) {
|
|
520
|
+
return execFileSync('git', args, {
|
|
521
|
+
cwd: gitDir,
|
|
522
|
+
encoding: 'utf8',
|
|
523
|
+
timeout: 15000,
|
|
524
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
525
|
+
}).trim()
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// porcelain 状态行 → 变更文件路径(对齐 shared.js:343 parsePorcelainPath:`XY path`、引号包裹、
|
|
529
|
+
// rename `R old -> new` 取箭头后)。未 commit 的 working-tree 改动对账用。
|
|
530
|
+
function parsePorcelainFiles(statusOut) {
|
|
531
|
+
const files = []
|
|
532
|
+
for (const line of String(statusOut).split('\n')) {
|
|
533
|
+
if (line.trim() === '') continue
|
|
534
|
+
let p = line.slice(3).trim()
|
|
535
|
+
if (p.length >= 2 && p.startsWith('"') && p.endsWith('"')) p = p.slice(1, -1).replace(/\\(.)/g, (_, c) => c)
|
|
536
|
+
const arrow = p.indexOf(' -> ')
|
|
537
|
+
if (arrow !== -1) p = p.slice(arrow + 4)
|
|
538
|
+
if (p) files.push(p.replace(/\\/g, '/'))
|
|
539
|
+
}
|
|
540
|
+
return files
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* 校验 review.json 的 base/head 是否指向真实 git 提交,且 diff 非空。
|
|
545
|
+
*
|
|
546
|
+
* 历史漏洞:base/head 只做非空字符串检查,agent 可以填任意假 hash 批量伪造
|
|
547
|
+
* review.json 通过 Task Review Gate。此函数用 git 做客观交叉校验:
|
|
548
|
+
* 1. base/head 必须是仓库中可解析的真实 commit
|
|
549
|
+
* 2. base..head 的 diff 不能为空(评审一个零改动的"任务"无意义)
|
|
550
|
+
* 3. review.changedFiles(如提供)必须与实际 diff 文件有交集
|
|
551
|
+
*
|
|
552
|
+
* git 环境不可用(非 git 仓库 / git 缺失)时返回 unavailable,由调用方降级为
|
|
553
|
+
* warning——不因环境问题误杀,但记录在输出中。
|
|
554
|
+
*
|
|
555
|
+
* @param {object} review - 已通过 schema 校验的 review 对象
|
|
556
|
+
* @param {string} gitDir - 执行 git 命令的目录(worktree 优先,回退主仓库)
|
|
557
|
+
* @returns {{ ok: boolean, emptyDiff: boolean, errors: string[], warnings: string[], unavailable: boolean }}
|
|
558
|
+
*/
|
|
559
|
+
export function verifyReviewGitEvidence(review, gitDir) {
|
|
560
|
+
const errors = []
|
|
561
|
+
const warnings = []
|
|
562
|
+
|
|
563
|
+
// git 环境探测:失败即 unavailable,交由调用方降级
|
|
564
|
+
try {
|
|
565
|
+
runGit(gitDir, ['rev-parse', '--git-dir'])
|
|
566
|
+
} catch (e) {
|
|
567
|
+
return {
|
|
568
|
+
ok: true,
|
|
569
|
+
emptyDiff: false,
|
|
570
|
+
errors: [],
|
|
571
|
+
warnings: [`git 环境不可用(${gitDir}),跳过 review 真实性交叉校验: ${e.message?.split('\n')[0] || e.message}`],
|
|
572
|
+
unavailable: true,
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
for (const field of ['base', 'head']) {
|
|
577
|
+
const hash = review[field]
|
|
578
|
+
try {
|
|
579
|
+
runGit(gitDir, ['rev-parse', '--verify', '--quiet', `${hash}^{commit}`])
|
|
580
|
+
} catch {
|
|
581
|
+
errors.push(`${field} "${hash}" 不是仓库中的真实 commit — review.json 疑似伪造`)
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
if (errors.length > 0) {
|
|
585
|
+
return { ok: false, emptyDiff: false, errors, warnings, unavailable: false }
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
let diffFiles = []
|
|
589
|
+
try {
|
|
590
|
+
const out = runGit(gitDir, ['diff', '--name-only', `${review.base}..${review.head}`])
|
|
591
|
+
diffFiles = out ? out.split('\n').filter(Boolean) : []
|
|
592
|
+
} catch (e) {
|
|
593
|
+
warnings.push(`git diff ${review.base}..${review.head} 执行失败,跳过 diff 校验: ${e.message?.split('\n')[0] || e.message}`)
|
|
594
|
+
return { ok: true, emptyDiff: false, errors, warnings, unavailable: false }
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// 并入 working-tree 未提交改动(execute 复盘 a):子代理默认不 commit,base..head 无 commit diff 时
|
|
598
|
+
// diffFiles 留空 → 下方交叉比对拿空集对非空 changedFiles 必判「完全不相交」伪造,逼 agent 强制 commit
|
|
599
|
+
// + 改 7 个 review head。对齐 checkExecuteCodeEvidence(stage-contract.js 同时查 working-tree)语义,
|
|
600
|
+
// 未提交改动也算有效对账源。working-tree 有改动时无条件并入(覆盖「部分 commit + 未提交」)。
|
|
601
|
+
const commitDiffFiles = diffFiles.slice()
|
|
602
|
+
try {
|
|
603
|
+
const wtStatus = runGit(gitDir, ['status', '--porcelain'])
|
|
604
|
+
if (wtStatus && wtStatus.trim().length > 0) {
|
|
605
|
+
const wtFiles = parsePorcelainFiles(wtStatus)
|
|
606
|
+
if (wtFiles.length > 0) diffFiles = diffFiles.concat(wtFiles)
|
|
607
|
+
if (commitDiffFiles.length === 0) {
|
|
608
|
+
warnings.push(`base..head 无 commit diff(${review.base.slice(0, 8)}..${review.head.slice(0, 8)}),交叉对账并入 working-tree 未提交改动 ${wtFiles.length} 个文件 —— 按有效改动处理,不判零改动伪造`)
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
} catch {}
|
|
612
|
+
diffFiles = [...new Set(diffFiles)].filter(Boolean)
|
|
613
|
+
|
|
614
|
+
const emptyDiff = diffFiles.length === 0
|
|
615
|
+
|
|
616
|
+
// changedFiles 交叉比对:完全不相交 = review 描述的改动与实际 diff 无关(diffFiles 已含 working-tree)
|
|
617
|
+
if (!emptyDiff && Array.isArray(review.changedFiles) && review.changedFiles.length > 0) {
|
|
618
|
+
const normalize = (p) => String(p).replace(/^\.\//, '')
|
|
619
|
+
const diffSet = new Set(diffFiles.map(normalize))
|
|
620
|
+
const hasOverlap = review.changedFiles.some(f => {
|
|
621
|
+
const nf = normalize(f)
|
|
622
|
+
// 兼容 review 写相对/部分路径:前后缀匹配即可
|
|
623
|
+
return diffSet.has(nf) || diffFiles.some(d => d.endsWith(nf) || nf.endsWith(d))
|
|
624
|
+
})
|
|
625
|
+
if (!hasOverlap) {
|
|
626
|
+
errors.push(`changedFiles 与实际 git diff(${review.base.slice(0, 8)}..${review.head.slice(0, 8)},${diffFiles.length} 个文件)完全不相交 — review 内容与代码变更无关`)
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
return { ok: errors.length === 0, emptyDiff, errors, warnings, unavailable: false }
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* 将 cannot_verify 的 requiredEvidence 写入 change 目录
|
|
635
|
+
* 供 verify 阶段消费
|
|
636
|
+
*
|
|
637
|
+
* @param {string} changeDir - 变更目录(.sillyspec/changes/<name>)
|
|
638
|
+
* @param {Array<{task: string, verdict: string, evidence: string[]}>} requiredEvidence
|
|
639
|
+
* @returns {string|null} 写入的文件路径,null 表示无需写入
|
|
640
|
+
*/
|
|
641
|
+
export function writeVerifyRequiredEvidence(changeDir, requiredEvidence) {
|
|
642
|
+
if (!requiredEvidence || requiredEvidence.length === 0) return null
|
|
643
|
+
|
|
644
|
+
const filePath = join(changeDir, 'verify-required-evidence.json')
|
|
645
|
+
const data = {
|
|
646
|
+
generatedAt: new Date().toISOString(),
|
|
647
|
+
schemaVersion: 1,
|
|
648
|
+
items: requiredEvidence,
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
mkdirSync(changeDir, { recursive: true })
|
|
652
|
+
writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n')
|
|
653
|
+
|
|
654
|
+
return filePath
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* 生成 execute run id
|
|
659
|
+
* @returns {string} 如 'exec-2026-06-23-131400'
|
|
660
|
+
*/
|
|
661
|
+
export function generateExecuteRunId() {
|
|
662
|
+
const now = new Date()
|
|
663
|
+
const pad = (n) => String(n).padStart(2, '0')
|
|
664
|
+
return `exec-${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* execute run id 格式校验:exec-YYYY-MM-DD-HHMMSS。
|
|
669
|
+
* marker 文件是 agent 可写内容,读回后既注入 prompt({EXECUTE_RUN_ID})又拼进
|
|
670
|
+
* join(runtimeRoot, 'execute-runs', runId) 路径——格式校验同时防提示词注入与路径穿越
|
|
671
|
+
* (对齐 stage-review marker 的 review- 前缀校验范式)。
|
|
672
|
+
* @param {string} runId
|
|
673
|
+
* @returns {boolean}
|
|
674
|
+
*/
|
|
675
|
+
export function isValidExecuteRunId(runId) {
|
|
676
|
+
return typeof runId === 'string' && /^exec-\d{4}-\d{2}-\d{2}-\d{6}$/.test(runId)
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* 解析最新 execute run id:优先 current-execute-run-id-<changeName> marker;
|
|
681
|
+
* marker 缺失/为空则扫描 execute-runs/ 下子目录、取 mtime 最新的(尽力定位已有 run)。
|
|
682
|
+
*
|
|
683
|
+
* 与 getLatestStageReviewRunId 的目录扫描兜底同语义——避免 marker 丢失(cleanup/并行/手删)
|
|
684
|
+
* 而 agent 已用旧 runId 落了 execute-runs/<旧ID>/ 时,调用方直接 generate 新 ID 找不到旧 review、
|
|
685
|
+
* 误判缺 review.json(gates.js execute 完成门坑)。返回 null 表示既无 marker 也无既有 run 目录。
|
|
686
|
+
*
|
|
687
|
+
* @param {{ runtimeRoot: string, changeName: string }} opts
|
|
688
|
+
* @returns {string|null} runId(exec- 前缀),无则 null
|
|
689
|
+
*/
|
|
690
|
+
/**
|
|
691
|
+
* 扫描 execute-runs/,返回 mtime 最新且真正含 tasks/ 子目录的 runId。
|
|
692
|
+
*
|
|
693
|
+
* 专治 execute run marker 漂移(prompt-control-debt.md gate-atom-a):generateExecuteRunId 只写
|
|
694
|
+
* marker 字符串、run 目录由 ensureTaskReviewDir 在写 review.json 时才建,marker 漂到「尚未建目录 /
|
|
695
|
+
* 未写任何 task review」的新 run 后,旧 run 里齐备的 review.json 全部失联。resolveLatestExecuteRunId
|
|
696
|
+
* 见 marker 非空即原样返回(不校验目录),接不住这种漂移;本函数无视 marker、只看真实目录内容,
|
|
697
|
+
* 供 enforceReviewJsonGate 在 marker 指向的 run 缺 tasks/ 时兜底重定位,避免误报「review.json 不存在」。
|
|
698
|
+
* 全部 run 都无 tasks/(真没写 review)返回 null——调用方维持原阻断,不误放行。
|
|
699
|
+
*
|
|
700
|
+
* @param {{ runtimeRoot: string }} opts
|
|
701
|
+
* @returns {string|null} 含 tasks/ 的最新 runId,无则 null
|
|
702
|
+
*/
|
|
703
|
+
export function resolveLatestExecuteRunIdWithTasks({ runtimeRoot }) {
|
|
704
|
+
try {
|
|
705
|
+
const runsDir = join(runtimeRoot, 'execute-runs')
|
|
706
|
+
if (!existsSync(runsDir)) return null
|
|
707
|
+
const entries = readdirSync(runsDir)
|
|
708
|
+
.map(e => ({ e, p: join(runsDir, e) }))
|
|
709
|
+
.filter(x => { try { return statSync(x.p).isDirectory() } catch { return false } })
|
|
710
|
+
.filter(x => { try { return statSync(join(x.p, 'tasks')).isDirectory() } catch { return false } })
|
|
711
|
+
.map(x => ({ e: x.e, mtime: statSync(x.p).mtimeMs }))
|
|
712
|
+
.sort((a, b) => b.mtime - a.mtime)
|
|
713
|
+
return entries.length > 0 ? entries[0].e : null
|
|
714
|
+
} catch {
|
|
715
|
+
return null
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
export function resolveLatestExecuteRunId({ runtimeRoot, changeName }) {
|
|
720
|
+
const marker = join(runtimeRoot, `current-execute-run-id-${changeName}`)
|
|
721
|
+
try {
|
|
722
|
+
if (existsSync(marker)) {
|
|
723
|
+
const c = readFileSync(marker, 'utf8').trim()
|
|
724
|
+
if (c && !isValidExecuteRunId(c)) console.warn(`[sillyspec] execute run marker 内容非法(期望 exec-YYYY-MM-DD-HHMMSS,实得 ${JSON.stringify(c.slice(0, 60))}),忽略并回退目录扫描`)
|
|
725
|
+
if (isValidExecuteRunId(c)) return c
|
|
726
|
+
}
|
|
727
|
+
} catch {}
|
|
728
|
+
try {
|
|
729
|
+
const runsDir = join(runtimeRoot, 'execute-runs')
|
|
730
|
+
if (existsSync(runsDir)) {
|
|
731
|
+
const entries = readdirSync(runsDir)
|
|
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
|
+
}
|
|
738
|
+
} catch {}
|
|
739
|
+
return null
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* worktree execute「主 agent 直接实现」模式收尾兜底:per-task review.json 缺失时,
|
|
744
|
+
* 据 git diff base..head 按 task allowed_paths 归属,自动落盘 cannot_verify 草稿。
|
|
745
|
+
*
|
|
746
|
+
* 根因(坑 worktree-execute-apply-friction 坑2):per-task review.json 无程序化 writer,
|
|
747
|
+
* 全靠子代理手写;主 agent 统一实现强依赖链/机械 task 时不走子代理 review 流程 →
|
|
748
|
+
* review.json 全缺,execute --done 的 Task Review Gate 报「task-XX 缺少 review.json」阻断。
|
|
749
|
+
*
|
|
750
|
+
* 复用(与 Task Review Gate / verify 同源,单一真相):
|
|
751
|
+
* - resolveVerifyChangedFiles(worktree-aware,meta.json 为权威)→ base..head diff 文件集
|
|
752
|
+
* - WorktreeManager.getMeta → baseHash(base)+ worktreePath(决定 head/gitDir,同 gates.js reviewGitDir)
|
|
753
|
+
* - parseAllowedPaths + pathMatches(同 worktree-apply Gate2 口径)→ task 文件归属
|
|
754
|
+
* - exec-id 解析同 gates.js:269 / autoCheckPlanFromReviews:current-execute-run-id-<change> marker,
|
|
755
|
+
* 缺失则 generateExecuteRunId + 落盘(保证草稿与后续 gate 读同一 run 目录)
|
|
756
|
+
*
|
|
757
|
+
* 行为:
|
|
758
|
+
* - 仅 review.json 不存在时写(幂等,已有人工/子代理 verdict 一律跳过,绝不覆盖)
|
|
759
|
+
* - changedFiles 为空的 task 不生成(verifyReviewGitEvidence 判空 diff 伪造,留给 agent)
|
|
760
|
+
* - 草稿 verdict=cannot_verify + 非空 requiredEvidence(过 schema,流转 verify 阶段兑现)
|
|
761
|
+
* - 不属任何 task 的文件累计 unattributed(顺带修复/非源码),不强塞某 task
|
|
762
|
+
*
|
|
763
|
+
* fail-open:任何异常 → 返回统计,仅 console.warn,不阻断 execute 完成(草稿是兜底,
|
|
764
|
+
* 缺了也只是退回原状——gate 报缺 review.json,agent 手补,不比修复前差)。
|
|
765
|
+
*
|
|
766
|
+
* @param {{ changeName: string, cwd: string, platformOpts?: object, ctx?: object|null }} opts
|
|
767
|
+
* @returns {Promise<{ generated: number, skipped: number, unattributed: string[], reason?: string, executeRunId?: string }>}
|
|
768
|
+
*/
|
|
769
|
+
export async function generateTaskReviewDrafts({ changeName, cwd, platformOpts = {}, ctx = null }) {
|
|
770
|
+
const specBase = platformOpts.specRoot || join(cwd, '.sillyspec')
|
|
771
|
+
const runtimeRoot = resolveRuntimeRoot(platformOpts, specBase)
|
|
772
|
+
const changeDir = join(specBase, 'changes', changeName)
|
|
773
|
+
const tasksDir = join(changeDir, 'tasks')
|
|
774
|
+
|
|
775
|
+
if (!changeName) {
|
|
776
|
+
return { generated: 0, skipped: 0, unattributed: [], reason: '无 changeName' }
|
|
777
|
+
}
|
|
778
|
+
// 无 task 卡片目录 → 非多 task 变更,无需补草稿
|
|
779
|
+
if (!existsSync(tasksDir)) {
|
|
780
|
+
return { generated: 0, skipped: 0, unattributed: [], reason: '无 tasks/ 目录' }
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// exec-id:与 Task Review Gate(gates.js:269)/ autoCheckPlanFromReviews 同源
|
|
784
|
+
const runIdFile = join(runtimeRoot, 'current-execute-run-id-' + changeName)
|
|
785
|
+
let executeRunId = ''
|
|
786
|
+
try {
|
|
787
|
+
if (existsSync(runIdFile)) {
|
|
788
|
+
const c = readFileSync(runIdFile, 'utf8').trim()
|
|
789
|
+
if (c && !isValidExecuteRunId(c)) console.warn(`[sillyspec] execute run marker 内容非法(期望 exec-YYYY-MM-DD-HHMMSS,实得 ${JSON.stringify(c.slice(0, 60))}),视为缺失重新生成`)
|
|
790
|
+
if (isValidExecuteRunId(c)) executeRunId = c
|
|
791
|
+
}
|
|
792
|
+
} catch {}
|
|
793
|
+
if (!executeRunId) {
|
|
794
|
+
executeRunId = generateExecuteRunId()
|
|
795
|
+
// D-001#1 fallback 写入点:mkdir execute-runs/<runId>/tasks 先于 marker(不变量:marker 在则目录在)。
|
|
796
|
+
// 保留 fail-open 契约(调用方 catch 降级)——只去静默:失败 console.error 留痕,不 throw
|
|
797
|
+
//(草稿兜底缺了也只是退回 gate 报缺 review.json,不比修复前差)。
|
|
798
|
+
try {
|
|
799
|
+
mkdirSync(join(runtimeRoot, 'execute-runs', executeRunId, 'tasks'), { recursive: true })
|
|
800
|
+
writeFileSync(runIdFile, executeRunId + '\n')
|
|
801
|
+
} catch (e) {
|
|
802
|
+
console.error(`[sillyspec] execute run marker/目录写入失败(降级继续,草稿落盘仍尝试): ${e.message}`)
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// base..head diff 文件集(worktree-aware;null=git 不可用,[]=无 commit diff)
|
|
807
|
+
const diffFiles = resolveVerifyChangedFiles(cwd, changeName)
|
|
808
|
+
// 单仓模式(无 ctx):主仓无 diff 即无任何 task 可生成 → 提前返回(原逻辑零回归)。
|
|
809
|
+
// 有 ctx:主仓无 diff 不阻断——跨仓 task 的 diff 在跨仓仓根独立取(per-task),主仓 task 自然跳过(空 changedFiles)。
|
|
810
|
+
if (!ctx && (!diffFiles || diffFiles.length === 0)) {
|
|
811
|
+
return { generated: 0, skipped: 0, unattributed: [], executeRunId, reason: 'base..head 无代码 diff(改动未 commit?)' }
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// base/head + gitDir:与 gates.js reviewGitDir 同源(worktree 优先,in-place 回退 cwd)
|
|
815
|
+
// 有 ctx 时主仓 base/head 仅供主仓 task 用;ctx 主仓 entry 也可提供 base(meta.baseHash 同源)。
|
|
816
|
+
// 主仓 base/head 解析失败 + 无 ctx → 提前返回(原逻辑);有 ctx → 仅记 null,主仓 task 后续跳过(不阻断跨仓 task)。
|
|
817
|
+
let base = null
|
|
818
|
+
let head = null
|
|
819
|
+
let reviewGitDir = cwd
|
|
820
|
+
try {
|
|
821
|
+
const wm = new WorktreeManager({ cwd })
|
|
822
|
+
const meta = wm.getMeta(changeName)
|
|
823
|
+
base = (meta && (meta.baselineCommit || meta.baseHash)) || null
|
|
824
|
+
if (meta && meta.worktreePath && meta.mode !== 'in-place-fallback' && existsSync(meta.worktreePath)) {
|
|
825
|
+
reviewGitDir = meta.worktreePath
|
|
826
|
+
}
|
|
827
|
+
} catch {}
|
|
828
|
+
if (!base) {
|
|
829
|
+
if (!ctx) {
|
|
830
|
+
return { generated: 0, skipped: 0, unattributed: diffFiles, executeRunId, reason: '无 worktree meta.baseHash(无法定 base/head)' }
|
|
831
|
+
}
|
|
832
|
+
// 有 ctx:主仓 base 缺失(主仓无 worktree / 单纯跨仓 change)→ 主仓 task 后续跳过,跨仓 task 照常
|
|
833
|
+
} else {
|
|
834
|
+
try {
|
|
835
|
+
head = runGit(reviewGitDir, ['rev-parse', 'HEAD'])
|
|
836
|
+
} catch {
|
|
837
|
+
if (!ctx) {
|
|
838
|
+
return { generated: 0, skipped: 0, unattributed: diffFiles, executeRunId, reason: 'git rev-parse HEAD 失败(' + reviewGitDir + ')' }
|
|
839
|
+
}
|
|
840
|
+
// 有 ctx:主仓 HEAD 失败不阻断跨仓 task;head 留 null,主仓 task 后续按空 changedFiles 跳过
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
const taskFiles = readdirSync(tasksDir).filter(f => /^task-\d+\.md$/.test(f)).sort()
|
|
845
|
+
let generated = 0
|
|
846
|
+
let skipped = 0
|
|
847
|
+
const attributed = new Set()
|
|
848
|
+
|
|
849
|
+
for (const tf of taskFiles) {
|
|
850
|
+
const taskId = tf.replace(/\.md$/, '')
|
|
851
|
+
const reviewDir = join(runtimeRoot, 'execute-runs', executeRunId, 'tasks', taskId)
|
|
852
|
+
const reviewPath = join(reviewDir, 'review.json')
|
|
853
|
+
|
|
854
|
+
// 幂等:review.json 已存在(无论合法/解析错/schema 错)一律跳过,绝不覆盖人工/子代理 verdict
|
|
855
|
+
const existing = readReview(reviewPath)
|
|
856
|
+
if (existing.ok || existing.parseError || existing.schemaError) {
|
|
857
|
+
skipped++
|
|
858
|
+
continue
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
const content = readFileSync(join(tasksDir, tf), 'utf8')
|
|
862
|
+
const allowedPaths = parseAllowedPaths(content)
|
|
863
|
+
|
|
864
|
+
// ── 多仓分支(design §6 A2 / D-006 / D-010):跨仓 task 用 task 卡双锡点 + 跨仓仓 diff ──
|
|
865
|
+
const taskRepo = normalizeRepoKey(parseRepo(content))
|
|
866
|
+
const crossEntry = (ctx && taskRepo !== DEFAULT_REPO_KEY) ? ctx.resolve(taskRepo) : null
|
|
867
|
+
let taskBase = base
|
|
868
|
+
let taskHead = head
|
|
869
|
+
let taskChangedFiles
|
|
870
|
+
let draftRepo
|
|
871
|
+
|
|
872
|
+
if (crossEntry) {
|
|
873
|
+
// 跨仓 task:base/head 必须读 task 卡锡点(跨仓仓无 meta.json)。缺任一 → 跳过留给 agent。
|
|
874
|
+
const baseCommit = parseBaseCommit(content)
|
|
875
|
+
const headCommit = parseHeadCommit(content)
|
|
876
|
+
if (!baseCommit || !headCommit) {
|
|
877
|
+
skipped++
|
|
878
|
+
continue
|
|
879
|
+
}
|
|
880
|
+
taskBase = baseCommit
|
|
881
|
+
taskHead = headCommit
|
|
882
|
+
draftRepo = taskRepo
|
|
883
|
+
// 跨仓 diff 在跨仓仓根跑(base..head 锡点锚定,非瞬时 HEAD——D-010 head 精度)
|
|
884
|
+
let crossDiffFiles = []
|
|
885
|
+
try {
|
|
886
|
+
const out = runGit(crossEntry.gitDir, ['diff', '--name-only', `${baseCommit}..${headCommit}`])
|
|
887
|
+
crossDiffFiles = out ? out.split('\n').filter(Boolean).map(p => p.replace(/\\/g, '/')) : []
|
|
888
|
+
} catch {
|
|
889
|
+
// git diff 失败(锡点非真实 commit 等)→ 不生成伪造草稿,留给 agent 手补
|
|
890
|
+
skipped++
|
|
891
|
+
continue
|
|
892
|
+
}
|
|
893
|
+
taskChangedFiles = allowedPaths.length > 0
|
|
894
|
+
? crossDiffFiles.filter(f => allowedPaths.some(p => pathMatches(f, p)))
|
|
895
|
+
: crossDiffFiles
|
|
896
|
+
} else {
|
|
897
|
+
// 主仓 task(taskRepo='main' 或无 ctx 跨仓解析失败 → 退单仓口径)
|
|
898
|
+
// diffFiles 可能为 null(git 不可用)——有 ctx 时主仓 git 异常不阻断跨仓 task,主仓 task 自然跳过。
|
|
899
|
+
const mainDiff = Array.isArray(diffFiles) ? diffFiles : []
|
|
900
|
+
taskChangedFiles = allowedPaths.length > 0
|
|
901
|
+
? mainDiff.filter(f => allowedPaths.some(p => pathMatches(f, p)))
|
|
902
|
+
: []
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// 空 changedFiles 的 task 不生成(verifyReviewGitEvidence 判空 diff 伪造,留给 agent 手补)
|
|
906
|
+
if (taskChangedFiles.length === 0) {
|
|
907
|
+
skipped++
|
|
908
|
+
continue
|
|
909
|
+
}
|
|
910
|
+
// unattributed 只统计主仓 diff(跨仓 diff 路径相对跨仓仓根,与主仓 unattributed 语义无关)
|
|
911
|
+
if (!crossEntry) {
|
|
912
|
+
taskChangedFiles.forEach(f => attributed.add(f))
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
const draft = {
|
|
916
|
+
schemaVersion: REVIEW_SCHEMA_VERSION,
|
|
917
|
+
task: taskId,
|
|
918
|
+
base: taskBase,
|
|
919
|
+
head: taskHead,
|
|
920
|
+
changedFiles: taskChangedFiles,
|
|
921
|
+
specVerdict: 'cannot_verify',
|
|
922
|
+
qualityVerdict: 'cannot_verify',
|
|
923
|
+
requiredEvidence: ['auto-generated draft: 待 agent 对照 ' + taskId + ' brief + diff 复核后升级为 pass/fail'],
|
|
924
|
+
reviewerNotes: 'auto-generated draft from git diff ' + taskBase.slice(0, 8) + '..' + taskHead.slice(0, 8) + ';verdict=未评审(worktree execute 主 agent 实现模式兜底,坑2)',
|
|
925
|
+
}
|
|
926
|
+
if (draftRepo) draft.repo = draftRepo
|
|
927
|
+
mkdirSync(reviewDir, { recursive: true })
|
|
928
|
+
writeFileSync(reviewPath, JSON.stringify(draft, null, 2) + '\n')
|
|
929
|
+
generated++
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
const unattributed = (Array.isArray(diffFiles) ? diffFiles : []).filter(f => !attributed.has(f))
|
|
933
|
+
return { generated, skipped, unattributed, executeRunId }
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
/**
|
|
937
|
+
* 获取当前(或最新)execute run id
|
|
938
|
+
* 从 runtime 目录下查找 execute-runs/ 子目录
|
|
939
|
+
*
|
|
940
|
+
* @param {string} runtimeRoot - .sillyspec/.runtime 路径
|
|
941
|
+
* @returns {string|null} 最新 run id,null 表示无任何 run
|
|
942
|
+
*/
|
|
943
|
+
/**
|
|
944
|
+
* 获取当前 execute run id
|
|
945
|
+
* 优先从 current-execute-run-id 标记文件读取(execute 阶段启动时写入),
|
|
946
|
+
* fallback 到 execute-runs/ 下最新的 exec- 前缀目录。
|
|
947
|
+
*
|
|
948
|
+
* @param {string} runtimeRoot - .sillyspec/.runtime 路径
|
|
949
|
+
* @returns {string|null} 当前 run id,null 表示无任何 run
|
|
950
|
+
*/
|
|
951
|
+
export function getLatestExecuteRunId(runtimeRoot) {
|
|
952
|
+
// 优先读标记文件(execute 阶段启动时由 run.js 写入,生命周期内不变)
|
|
953
|
+
const markerPath = join(runtimeRoot, 'current-execute-run-id')
|
|
954
|
+
try {
|
|
955
|
+
if (existsSync(markerPath)) {
|
|
956
|
+
const content = readFileSync(markerPath, 'utf8').trim()
|
|
957
|
+
if (content && !isValidExecuteRunId(content)) console.warn(`[sillyspec] execute run marker 内容非法(期望 exec-YYYY-MM-DD-HHMMSS,实得 ${JSON.stringify(content.slice(0, 60))}),忽略并回退目录扫描`)
|
|
958
|
+
if (isValidExecuteRunId(content)) return content
|
|
959
|
+
}
|
|
960
|
+
} catch {}
|
|
961
|
+
|
|
962
|
+
// fallback:扫描 execute-runs/ 目录
|
|
963
|
+
const runsDir = join(runtimeRoot, 'execute-runs')
|
|
964
|
+
if (!existsSync(runsDir)) return null
|
|
965
|
+
|
|
966
|
+
try {
|
|
967
|
+
const entries = readdirSync(runsDir, { withFileTypes: true })
|
|
968
|
+
.filter(e => e.isDirectory() && e.name.startsWith('exec-'))
|
|
969
|
+
.map(e => e.name)
|
|
970
|
+
.sort()
|
|
971
|
+
.reverse()
|
|
972
|
+
return entries[0] || null
|
|
973
|
+
} catch {
|
|
974
|
+
return null
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/**
|
|
979
|
+
* 确保 task review 目录存在
|
|
980
|
+
* @param {string} runtimeRoot
|
|
981
|
+
* @param {string} executeRunId
|
|
982
|
+
* @param {string} taskId
|
|
983
|
+
* @returns {string} task review 目录路径
|
|
984
|
+
*/
|
|
985
|
+
export function ensureTaskReviewDir(runtimeRoot, executeRunId, taskId) {
|
|
986
|
+
const dir = join(runtimeRoot, 'execute-runs', executeRunId, 'tasks', taskId)
|
|
987
|
+
mkdirSync(dir, { recursive: true })
|
|
988
|
+
return dir
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
/**
|
|
992
|
+
* 打印校验结果
|
|
993
|
+
* @param {{ ok: boolean, errors: string[], warnings: string[], requiredEvidence: Array }} result
|
|
994
|
+
*/
|
|
995
|
+
export function printReviewResult(result, context = {}) {
|
|
996
|
+
if (result.ok && result.warnings.length === 0) {
|
|
997
|
+
console.log('\n✅ Task Review Gate — 所有任务评审通过')
|
|
998
|
+
return
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
if (result.errors.length > 0) {
|
|
1002
|
+
console.error('\n🚫 Task Review Gate — FAILED')
|
|
1003
|
+
for (const err of result.errors) {
|
|
1004
|
+
console.error(` - ${err}`)
|
|
1005
|
+
}
|
|
1006
|
+
const hint = context.runtimeRoot && context.executeRunId
|
|
1007
|
+
? `期望路径:${context.runtimeRoot}/execute-runs/${context.executeRunId}/tasks/<task-XX>/review.json`
|
|
1008
|
+
: '为缺失/失败的任务补充 review.json'
|
|
1009
|
+
console.error(`\n 提示:${hint},然后重新 --done`)
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
if (result.warnings.length > 0) {
|
|
1013
|
+
console.warn('\n⚠️ Task Review Gate — WARNING')
|
|
1014
|
+
for (const w of result.warnings) {
|
|
1015
|
+
console.warn(` - ${w}`)
|
|
1016
|
+
}
|
|
1017
|
+
console.warn('\n cannot_verify 的 requiredEvidence 将在 verify 阶段校验')
|
|
1018
|
+
}
|
|
1019
|
+
}
|