sillyspec 3.26.10 → 3.26.13

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