@peterxiaoyang/superspec 0.1.34 → 0.1.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/record.js CHANGED
@@ -1,14 +1,16 @@
1
1
  // SuperSpec 流程引擎 — record:工作项结果登记
2
2
  import { readFileSync, existsSync } from "node:fs";
3
- import { join } from "node:path";
3
+ import { isAbsolute, join, relative, resolve } from "node:path";
4
4
  import { ensureChangeLayout, readEvents, appendEvent, makeEvent, sha256File, sha256Text, withLock, appendRawRecord, } from "./store.js";
5
5
  import { reviewEvidenceDigest, reviewVerifierStaleReason } from "./review.js";
6
+ import { CODE_REVIEW_DECISION_ANSWER_LABELS, CODE_REVIEW_DECISION_SCOPE_PREFIX, codeReviewDecisionAnswerLabel, codeReviewJobStaleReason, normalizeCodeReviewDecisionAnswer, scanCodeChanges, } from "./code_review.js";
6
7
  import { jobSubmitArgv } from "./job_action.js";
7
8
  const REVIEW_REPORT_REQUIRED_FIELDS = ["role", "verdict", "findings"];
8
9
  const REVIEW_REPORT_OPTIONAL_FIELDS = ["summary", "evidence_refs", "risks", "open_questions"];
9
10
  const REVIEWER_KINDS = new Set(["codex-subagent", "human", "external-agent"]);
11
+ const CODE_REVIEW_FINDING_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/;
10
12
  function requiresReviewer(role) {
11
- return role === "critic" || role === "architect" || role === "test-engineer";
13
+ return role === "critic" || role === "architect" || role === "test-engineer" || role === "code-reviewer";
12
14
  }
13
15
  function recommendedAgentForRole(role) {
14
16
  switch (role) {
@@ -16,6 +18,7 @@ function recommendedAgentForRole(role) {
16
18
  case "architect": return "architect";
17
19
  case "test-engineer": return "test-engineer";
18
20
  case "verifier": return "verifier";
21
+ case "code-reviewer": return "code-reviewer";
19
22
  case "executor": return "executor";
20
23
  case "test-run": return "test-runner";
21
24
  }
@@ -30,12 +33,209 @@ function roleDescription(role) {
30
33
  return "审查测试契约、覆盖策略、RED/GREEN 可信度和验收场景映射";
31
34
  case "verifier":
32
35
  return "验证 proposal、实现状态、任务完成、测试契约和 SuperSpec 证据是否足以支撑完成结论";
36
+ case "code-reviewer":
37
+ return "审查 apply 后的代码实现质量、健壮性、性能、安全、兼容、边界条件、是否偏离 proposal/design/tasks/test-contract 以及关键测试缺口";
33
38
  case "executor":
34
39
  return "执行受限实现工作项";
35
40
  case "test-run":
36
41
  return "执行受限测试工作项";
37
42
  }
38
43
  }
44
+ function asObject(value) {
45
+ return value && typeof value === "object" && !Array.isArray(value) ? value : null;
46
+ }
47
+ function nonEmptyString(value) {
48
+ return typeof value === "string" && value.trim() !== "";
49
+ }
50
+ function stringArray(value) {
51
+ return Array.isArray(value) && value.every(item => typeof item === "string");
52
+ }
53
+ function safeCodeReviewFindingId(value) {
54
+ return CODE_REVIEW_FINDING_ID_RE.test(value);
55
+ }
56
+ function uncheckedCodeReviewPaths(value, checks) {
57
+ const paths = new Set();
58
+ if (!Array.isArray(value))
59
+ return paths;
60
+ for (const item of value) {
61
+ const obj = asObject(item);
62
+ if (!obj) {
63
+ checks.push("代码审查覆盖范围里的未检查项必须是包含 path/reason 的对象");
64
+ continue;
65
+ }
66
+ if (!nonEmptyString(obj.path)) {
67
+ checks.push("代码审查覆盖范围里的未检查项缺少 path");
68
+ continue;
69
+ }
70
+ if (!nonEmptyString(obj.reason)) {
71
+ checks.push(`代码审查覆盖范围里的未检查项 ${obj.path} 缺少 reason`);
72
+ continue;
73
+ }
74
+ paths.add(obj.path);
75
+ }
76
+ return paths;
77
+ }
78
+ function codeReviewResultKind(value) {
79
+ return value === "invalid_report" || value === "non_actionable_report" || value === "review_failed"
80
+ ? value
81
+ : null;
82
+ }
83
+ function validateReviewer(obj, checks) {
84
+ if (!("reviewer" in obj))
85
+ checks.push("报告缺少必填字段 reviewer");
86
+ const reviewer = obj.reviewer;
87
+ if (!reviewer || typeof reviewer !== "object" || Array.isArray(reviewer)) {
88
+ checks.push("报告 reviewer 必须是包含 kind/id 的对象");
89
+ }
90
+ else {
91
+ if (typeof reviewer.kind !== "string" || !REVIEWER_KINDS.has(reviewer.kind)) {
92
+ checks.push(`报告 reviewer.kind 必须是 ${[...REVIEWER_KINDS].join("|")} 之一`);
93
+ }
94
+ if (typeof reviewer.id !== "string" || reviewer.id.trim() === "") {
95
+ checks.push("报告 reviewer.id 必须是非空字符串");
96
+ }
97
+ }
98
+ }
99
+ function validateCodeReviewScope(obj, job, checks) {
100
+ const scope = asObject(obj.review_scope);
101
+ if (!scope) {
102
+ checks.push("代码审查报告缺少覆盖范围字段 review_scope");
103
+ return;
104
+ }
105
+ if (scope.job_id !== job.job_id) {
106
+ checks.push(`代码审查覆盖范围里的 job_id ${String(scope.job_id)} 与工作项 ${job.job_id} 不匹配`);
107
+ }
108
+ if (scope.packet_digest !== job.packet_digest) {
109
+ checks.push("代码审查覆盖范围里的 packet_digest 与工作项不匹配,请使用当前工作项说明重新生成报告");
110
+ }
111
+ if (!stringArray(scope.checked_paths))
112
+ checks.push("代码审查覆盖范围里的 checked_paths 必须是字符串数组");
113
+ if (!stringArray(scope.checked_docs))
114
+ checks.push("代码审查覆盖范围里的 checked_docs 必须是字符串数组");
115
+ if (!Array.isArray(scope.unchecked))
116
+ checks.push("代码审查覆盖范围里的 unchecked 必须是数组");
117
+ if (stringArray(scope.checked_paths) && Array.isArray(scope.unchecked)) {
118
+ const checkedPaths = new Set(scope.checked_paths);
119
+ const uncheckedPaths = uncheckedCodeReviewPaths(scope.unchecked, checks);
120
+ for (const bound of job.boundFiles) {
121
+ if (!checkedPaths.has(bound.path) && !uncheckedPaths.has(bound.path)) {
122
+ checks.push(`代码审查报告未说明是否检查了 ${bound.path}`);
123
+ }
124
+ }
125
+ }
126
+ }
127
+ function actionableCodeReviewFindings(findings) {
128
+ if (!Array.isArray(findings))
129
+ return { actionable: [], reasons: ["报告字段 findings 必须是数组"] };
130
+ const actionable = [];
131
+ const reasons = [];
132
+ const ids = new Set();
133
+ for (const raw of findings) {
134
+ const finding = asObject(raw);
135
+ if (!finding) {
136
+ reasons.push("问题项必须是对象");
137
+ continue;
138
+ }
139
+ if (finding.blocking !== true)
140
+ continue;
141
+ const id = finding.id;
142
+ if (!nonEmptyString(id)) {
143
+ reasons.push("阻塞问题项缺少 id");
144
+ continue;
145
+ }
146
+ if (!safeCodeReviewFindingId(id)) {
147
+ reasons.push(`阻塞问题 ${id} 的 id 只能包含字母、数字、点、下划线、冒号或短横线,且不能包含空格或 #`);
148
+ continue;
149
+ }
150
+ if (ids.has(id)) {
151
+ reasons.push(`阻塞问题项 id 重复:${id}`);
152
+ continue;
153
+ }
154
+ ids.add(id);
155
+ const findingReasons = [];
156
+ const type = finding.type;
157
+ if (type !== "implementation" && type !== "spec" && type !== "mixed") {
158
+ findingReasons.push(`阻塞问题 ${id} 的分类 type 必须是 implementation|spec|mixed`);
159
+ reasons.push(...findingReasons);
160
+ continue;
161
+ }
162
+ for (const field of ["description", "evidence", "impact", "suggested_action"]) {
163
+ if (!nonEmptyString(finding[field]))
164
+ findingReasons.push(`阻塞问题 ${id} 缺少 ${field}`);
165
+ }
166
+ if (!stringArray(finding.source_refs) || finding.source_refs.length === 0) {
167
+ findingReasons.push(`阻塞问题 ${id} 缺少 source_refs`);
168
+ }
169
+ if (finding.suggested_action !== "apply" && finding.suggested_action !== "propose") {
170
+ findingReasons.push(`阻塞问题 ${id} 的 suggested_action 必须是 apply|propose`);
171
+ }
172
+ if (type === "implementation" && finding.suggested_action !== "apply") {
173
+ findingReasons.push(`纯代码实现问题 ${id} 的 suggested_action 必须是 apply`);
174
+ }
175
+ if (findingReasons.length > 0) {
176
+ reasons.push(...findingReasons);
177
+ continue;
178
+ }
179
+ actionable.push(finding);
180
+ }
181
+ return { actionable, reasons };
182
+ }
183
+ function codeReviewRejectEventPayload(input) {
184
+ const payload = {
185
+ job_id: input.jobId,
186
+ role: input.job.role,
187
+ report_digest: input.reportDigest,
188
+ result_kind: input.resultKind,
189
+ reason: input.reason,
190
+ ...(input.rawRef ?? {}),
191
+ };
192
+ if (input.resultKind === "review_failed" && input.parsedReport) {
193
+ payload.review_scope = input.parsedReport.review_scope ?? {};
194
+ payload.findings = Array.isArray(input.parsedReport.findings) ? input.parsedReport.findings : [];
195
+ payload.reviewer = input.parsedReport.reviewer ?? null;
196
+ if (typeof input.parsedReport.summary === "string")
197
+ payload.summary = input.parsedReport.summary;
198
+ }
199
+ return payload;
200
+ }
201
+ function failedReviewReportRejectEventPayload(input) {
202
+ const payload = {
203
+ job_id: input.jobId,
204
+ role: input.job.role,
205
+ report_digest: input.reportDigest,
206
+ reason: "报告结论为 fail,工作项未通过",
207
+ findings: Array.isArray(input.parsedReport.findings) ? input.parsedReport.findings : [],
208
+ ...input.rawRef,
209
+ };
210
+ for (const field of REVIEW_REPORT_OPTIONAL_FIELDS) {
211
+ if (field in input.parsedReport)
212
+ payload[field] = input.parsedReport[field];
213
+ }
214
+ return payload;
215
+ }
216
+ function projectRelativePath(projectRoot, path) {
217
+ const absolute = isAbsolute(path) ? path : resolve(projectRoot, path);
218
+ const rel = relative(projectRoot, absolute).replace(/\\/g, "/");
219
+ if (!rel || rel === ".." || rel.startsWith("../") || isAbsolute(rel))
220
+ return null;
221
+ return rel;
222
+ }
223
+ function staleBoundFilesChecks(job, projectRoot, changeRoot, ignoredCodePaths = []) {
224
+ const checks = [];
225
+ if (job.role === "code-reviewer") {
226
+ const ignored = new Set(ignoredCodePaths);
227
+ const currentPaths = scanCodeChanges(projectRoot).paths.filter(path => !ignored.has(path));
228
+ const reason = codeReviewJobStaleReason(projectRoot, job, currentPaths);
229
+ return reason ? [reason] : [];
230
+ }
231
+ for (const bf of job.boundFiles) {
232
+ const currentSha = sha256File(join(changeRoot, bf.path)) ?? "sha256:missing";
233
+ if (currentSha !== bf.sha) {
234
+ checks.push(`绑定文件 ${bf.path} 已变化(${bf.sha} → ${currentSha})`);
235
+ }
236
+ }
237
+ return checks;
238
+ }
39
239
  /** 从 events 中查找 job(H4 修复:job 只在 transition_commit 的 new_jobs payload 里) */
40
240
  function findJob(events, jobId) {
41
241
  for (const ev of events) {
@@ -66,23 +266,23 @@ function terminalJobSubmitResult(events, jobId, terminal, reportDigest) {
66
266
  return {
67
267
  event_type: existing.event_type,
68
268
  accepted: existing.event_type === "job_accepted",
69
- message: "幂等返回:同 report 已提交",
269
+ message: "幂等返回:同一报告已提交",
70
270
  job_state: existing.event_type === "job_accepted" ? "accepted" : "rejected",
71
271
  };
72
272
  }
73
273
  return {
74
274
  event_type: "job_rejected",
75
275
  accepted: false,
76
- message: `工作项 ${jobId} 已终态(${terminal}),不接受新报告。需要新工作项请重跑 transition。`,
276
+ message: `工作项 ${jobId} 已结束(${terminal}),不接受新报告。需要新工作项请重新执行对应的 transition。`,
77
277
  };
78
278
  }
79
- function recordJobSubmitLoaded(projectRoot, change, changeRoot, jobId, job, events, reportContent, reportDigest) {
279
+ function recordJobSubmitLoaded(projectRoot, change, changeRoot, jobId, job, events, reportContent, reportDigest, reportFile) {
80
280
  const checks = [];
81
281
  let parsedReport = null;
82
282
  try {
83
283
  const report = JSON.parse(reportContent);
84
284
  if (!report || typeof report !== "object" || Array.isArray(report)) {
85
- checks.push("报告必须是 JSON object");
285
+ checks.push("报告必须是 JSON 对象");
86
286
  }
87
287
  else {
88
288
  parsedReport = report;
@@ -95,42 +295,27 @@ function recordJobSubmitLoaded(projectRoot, change, changeRoot, jobId, job, even
95
295
  checks.push(`报告角色 ${String(obj.role)} 与工作项角色 ${job.role} 不匹配`);
96
296
  }
97
297
  if (obj.verdict !== "pass" && obj.verdict !== "fail") {
98
- checks.push("报告 verdict 必须是 pass 或 fail");
298
+ checks.push("报告结论字段 verdict 必须是 pass 或 fail");
99
299
  }
100
300
  if (!Array.isArray(obj.findings)) {
101
- checks.push("报告 findings 必须是数组");
102
- }
103
- if (obj.verdict === "fail") {
104
- checks.push("报告 verdict=fail,工作项未通过");
301
+ checks.push("报告问题列表 findings 必须是数组");
105
302
  }
106
303
  if (requiresReviewer(job.role)) {
107
- if (!("reviewer" in obj))
108
- checks.push("报告缺少必填字段 reviewer");
109
- const reviewer = obj.reviewer;
110
- if (!reviewer || typeof reviewer !== "object" || Array.isArray(reviewer)) {
111
- checks.push("报告 reviewer 必须是包含 kind/id 的对象");
112
- }
113
- else {
114
- if (typeof reviewer.kind !== "string" || !REVIEWER_KINDS.has(reviewer.kind)) {
115
- checks.push(`报告 reviewer.kind 必须是 ${[...REVIEWER_KINDS].join("|")} 之一`);
116
- }
117
- if (typeof reviewer.id !== "string" || reviewer.id.trim() === "") {
118
- checks.push("报告 reviewer.id 必须是非空字符串");
119
- }
120
- }
304
+ validateReviewer(obj, checks);
305
+ }
306
+ if (job.role === "code-reviewer") {
307
+ validateCodeReviewScope(parsedReport, job, checks);
121
308
  }
122
309
  }
123
310
  }
124
311
  catch {
125
312
  checks.push("报告必须是有效 JSON");
126
313
  }
127
- for (const bf of job.boundFiles) {
128
- const currentSha = sha256File(join(changeRoot, bf.path)) ?? "sha256:missing";
129
- if (currentSha !== bf.sha) {
130
- checks.push(`绑定文件 ${bf.path} 已变化(${bf.sha} → ${currentSha})`);
131
- }
132
- }
133
- const reviewStaleReason = reviewVerifierStaleReason(job, changeRoot, reviewEvidenceDigest(events));
314
+ const reportPath = reportFile ? projectRelativePath(projectRoot, reportFile) : null;
315
+ checks.push(...staleBoundFilesChecks(job, projectRoot, changeRoot, reportPath ? [reportPath] : []));
316
+ const reviewStaleReason = job.role === "code-reviewer"
317
+ ? null
318
+ : reviewVerifierStaleReason(job, changeRoot, reviewEvidenceDigest(events));
134
319
  if (reviewStaleReason && !checks.includes(reviewStaleReason)) {
135
320
  checks.push(reviewStaleReason);
136
321
  }
@@ -138,11 +323,27 @@ function recordJobSubmitLoaded(projectRoot, change, changeRoot, jobId, job, even
138
323
  checks.push("报告内容为空");
139
324
  }
140
325
  if (checks.length > 0) {
326
+ const resultKind = job.role === "code-reviewer" ? "invalid_report" : undefined;
327
+ const rawRef = resultKind && parsedReport
328
+ ? appendRawRecord(projectRoot, change, "review-reports", parsedReport)
329
+ : null;
141
330
  const rejectEvent = makeEvent(change, "job_rejected", {
142
- job_id: jobId,
143
- role: job.role,
144
- report_digest: reportDigest,
145
- reason: checks.join("; "),
331
+ ...(resultKind
332
+ ? codeReviewRejectEventPayload({
333
+ jobId,
334
+ job,
335
+ reportDigest,
336
+ resultKind,
337
+ reason: checks.join("; "),
338
+ parsedReport,
339
+ rawRef,
340
+ })
341
+ : {
342
+ job_id: jobId,
343
+ role: job.role,
344
+ report_digest: reportDigest,
345
+ reason: checks.join("; "),
346
+ }),
146
347
  });
147
348
  appendEvent(projectRoot, change, rejectEvent);
148
349
  return {
@@ -152,6 +353,77 @@ function recordJobSubmitLoaded(projectRoot, change, changeRoot, jobId, job, even
152
353
  job_state: "rejected",
153
354
  };
154
355
  }
356
+ if (job.role !== "code-reviewer" && parsedReport?.verdict === "fail") {
357
+ const rawRef = appendRawRecord(projectRoot, change, "review-reports", parsedReport);
358
+ const rejectEvent = makeEvent(change, "job_rejected", failedReviewReportRejectEventPayload({
359
+ jobId,
360
+ job,
361
+ reportDigest,
362
+ parsedReport,
363
+ rawRef,
364
+ }));
365
+ appendEvent(projectRoot, change, rejectEvent);
366
+ return {
367
+ event_type: "job_rejected",
368
+ accepted: false,
369
+ message: `工作项 ${jobId} 被拒绝:报告结论为 fail,工作项未通过`,
370
+ job_state: "rejected",
371
+ };
372
+ }
373
+ if (job.role === "code-reviewer" && parsedReport) {
374
+ const verdict = parsedReport.verdict;
375
+ const findings = parsedReport.findings;
376
+ const blockingCount = Array.isArray(findings)
377
+ ? findings.filter(item => asObject(item)?.blocking === true).length
378
+ : 0;
379
+ const { actionable, reasons } = actionableCodeReviewFindings(findings);
380
+ if (verdict === "pass") {
381
+ if (blockingCount > 0) {
382
+ const reason = "报告结论为 pass 时不能包含 blocking:true 的阻塞问题";
383
+ const rawRef = appendRawRecord(projectRoot, change, "review-reports", parsedReport);
384
+ appendEvent(projectRoot, change, makeEvent(change, "job_rejected", codeReviewRejectEventPayload({
385
+ jobId,
386
+ job,
387
+ reportDigest,
388
+ resultKind: "invalid_report",
389
+ reason,
390
+ parsedReport,
391
+ rawRef,
392
+ })));
393
+ return {
394
+ event_type: "job_rejected",
395
+ accepted: false,
396
+ message: `工作项 ${jobId} 被拒绝:${reason}`,
397
+ job_state: "rejected",
398
+ };
399
+ }
400
+ }
401
+ else if (verdict === "fail") {
402
+ const hasOnlyActionableBlockingFindings = actionable.length > 0 && reasons.length === 0;
403
+ const resultKind = hasOnlyActionableBlockingFindings ? "review_failed" : "non_actionable_report";
404
+ const reason = hasOnlyActionableBlockingFindings
405
+ ? `代码审查发现 ${actionable.length} 个需要处理的阻塞问题`
406
+ : actionable.length > 0
407
+ ? `代码审查报告包含无法处理的阻塞问题:${reasons.join("; ")}`
408
+ : `代码审查报告没有给出可处理的阻塞问题${reasons.length > 0 ? `:${reasons.join("; ")}` : ""}`;
409
+ const rawRef = appendRawRecord(projectRoot, change, "review-reports", parsedReport);
410
+ appendEvent(projectRoot, change, makeEvent(change, "job_rejected", codeReviewRejectEventPayload({
411
+ jobId,
412
+ job,
413
+ reportDigest,
414
+ resultKind,
415
+ reason,
416
+ parsedReport,
417
+ rawRef,
418
+ })));
419
+ return {
420
+ event_type: "job_rejected",
421
+ accepted: false,
422
+ message: `工作项 ${jobId} 被拒绝:${reason}`,
423
+ job_state: "rejected",
424
+ };
425
+ }
426
+ }
155
427
  const rawRef = appendRawRecord(projectRoot, change, "review-reports", parsedReport);
156
428
  const acceptEvent = makeEvent(change, "job_accepted", {
157
429
  job_id: jobId,
@@ -187,7 +459,7 @@ export function recordJobSubmit(projectRoot, change, changeRoot, jobId, reportFi
187
459
  }
188
460
  const reportContent = readFileSync(reportFile, "utf8");
189
461
  const reportDigest = sha256File(reportFile) ?? "sha256:unknown";
190
- return recordJobSubmitLoaded(projectRoot, change, changeRoot, jobId, job, events, reportContent, reportDigest);
462
+ return recordJobSubmitLoaded(projectRoot, change, changeRoot, jobId, job, events, reportContent, reportDigest, reportFile);
191
463
  });
192
464
  }
193
465
  /** record job-submit:从 JSON 内容登记工作项结果 */
@@ -208,35 +480,80 @@ export function recordJobSubmitContent(projectRoot, change, changeRoot, jobId, r
208
480
  });
209
481
  }
210
482
  function recordUserDecisionLoaded(projectRoot, change, events, content, inputDigest) {
483
+ const existing = events.find(e => e.event_type === "user_decision_recorded"
484
+ && e.payload.input_digest === inputDigest);
485
+ if (existing) {
486
+ const accepted = existing.payload.accepted !== false;
487
+ return {
488
+ event_type: "user_decision_recorded",
489
+ accepted,
490
+ message: accepted ? "幂等返回:同一用户决策已登记" : "幂等返回:同一无效用户决策已登记",
491
+ };
492
+ }
211
493
  let decision;
212
494
  try {
213
495
  decision = JSON.parse(content);
214
496
  }
215
497
  catch {
216
- appendEvent(projectRoot, change, makeEvent(change, "user_decision_recorded", { accepted: false, reason: "invalid_json" }));
498
+ appendEvent(projectRoot, change, makeEvent(change, "user_decision_recorded", {
499
+ accepted: false,
500
+ reason: "invalid_json",
501
+ input_digest: inputDigest,
502
+ }));
217
503
  return { event_type: "user_decision_recorded", accepted: false, message: "决策文件不是有效 JSON" };
218
504
  }
219
- if (!decision.scope || !decision.answer) {
220
- appendEvent(projectRoot, change, makeEvent(change, "user_decision_recorded", { accepted: false, reason: "missing_scope_or_answer" }));
505
+ if (!nonEmptyString(decision.scope) || !nonEmptyString(decision.answer)) {
506
+ appendEvent(projectRoot, change, makeEvent(change, "user_decision_recorded", {
507
+ accepted: false,
508
+ reason: "missing_scope_or_answer",
509
+ input_digest: inputDigest,
510
+ }));
221
511
  return { event_type: "user_decision_recorded", accepted: false, message: "决策文件缺少 scope 或 answer" };
222
512
  }
223
- const existing = events.find(e => e.event_type === "user_decision_recorded"
224
- && e.payload.input_digest === inputDigest);
225
- if (existing) {
226
- return {
227
- event_type: "user_decision_recorded",
228
- accepted: true,
229
- message: "幂等返回:同 user decision 已登记",
230
- };
513
+ if (decision.scope.startsWith(CODE_REVIEW_DECISION_SCOPE_PREFIX)) {
514
+ const normalizedAnswer = normalizeCodeReviewDecisionAnswer(decision.answer);
515
+ if (!normalizedAnswer) {
516
+ appendEvent(projectRoot, change, makeEvent(change, "user_decision_recorded", {
517
+ accepted: false,
518
+ scope: decision.scope,
519
+ answer: decision.answer,
520
+ reason: "invalid_code_review_decision_answer",
521
+ input_digest: inputDigest,
522
+ }));
523
+ return {
524
+ event_type: "user_decision_recorded",
525
+ accepted: false,
526
+ message: `代码审查决策必须是 ${CODE_REVIEW_DECISION_ANSWER_LABELS.reopen_propose}、${CODE_REVIEW_DECISION_ANSWER_LABELS.reopen_apply} 或 ${CODE_REVIEW_DECISION_ANSWER_LABELS.dismiss}`,
527
+ };
528
+ }
529
+ if (!nonEmptyString(decision.reason)) {
530
+ appendEvent(projectRoot, change, makeEvent(change, "user_decision_recorded", {
531
+ accepted: false,
532
+ scope: decision.scope,
533
+ answer: decision.answer,
534
+ reason: "missing_reason",
535
+ input_digest: inputDigest,
536
+ }));
537
+ return {
538
+ event_type: "user_decision_recorded",
539
+ accepted: false,
540
+ message: "代码审查决策必须写明原因",
541
+ };
542
+ }
231
543
  }
544
+ const normalizedAnswer = decision.scope.startsWith(CODE_REVIEW_DECISION_SCOPE_PREFIX)
545
+ ? normalizeCodeReviewDecisionAnswer(decision.answer)
546
+ : null;
232
547
  const normalizedDecision = {
233
548
  scope: decision.scope,
234
- question: decision.question ?? "",
235
- answer: decision.answer,
549
+ question: typeof decision.question === "string" ? decision.question : "",
550
+ answer: normalizedAnswer ? codeReviewDecisionAnswerLabel(normalizedAnswer) : decision.answer,
551
+ ...(typeof decision.reason === "string" ? { reason: decision.reason.trim() } : {}),
236
552
  };
237
553
  const rawRef = appendRawRecord(projectRoot, change, "user-decisions", normalizedDecision);
238
554
  const event = makeEvent(change, "user_decision_recorded", {
239
555
  ...normalizedDecision,
556
+ accepted: true,
240
557
  input_digest: inputDigest,
241
558
  ...rawRef,
242
559
  });
@@ -244,7 +561,7 @@ function recordUserDecisionLoaded(projectRoot, change, events, content, inputDig
244
561
  return {
245
562
  event_type: "user_decision_recorded",
246
563
  accepted: true,
247
- message: `用户决策已登记:scope=${decision.scope}`,
564
+ message: `用户决策已登记:${decision.scope}`,
248
565
  };
249
566
  }
250
567
  /** record user-decision:登记用户决策 */
@@ -310,6 +627,7 @@ export function jobsPacket(projectRoot, change, jobId) {
310
627
  if (!job) {
311
628
  return { found: false, message: `工作项 ${jobId} 不存在` };
312
629
  }
630
+ const isCodeReviewer = job.role === "code-reviewer";
313
631
  return {
314
632
  found: true,
315
633
  packet: {
@@ -318,21 +636,30 @@ export function jobsPacket(projectRoot, change, jobId) {
318
636
  recommended_agent: recommendedAgentForRole(job.role),
319
637
  boundFiles: job.boundFiles,
320
638
  ...(job.review_evidence_digest ? { review_evidence_digest: job.review_evidence_digest } : {}),
639
+ ...(job.previous_rejection ? { previous_rejection: job.previous_rejection } : {}),
321
640
  packet_digest: job.packet_digest,
322
641
  required_output_kind: "job_report_json",
323
642
  preferred_input_mode: "stdin",
324
643
  submission_command: `superspec record job-submit --change "${change}" --job "${job.job_id}" --report -`,
325
644
  submission_argv: jobSubmitArgv(change, job.job_id),
326
645
  file_fallback: true,
327
- output_contract_fields: requiresReviewer(job.role) ? [...REVIEW_REPORT_REQUIRED_FIELDS, "reviewer"] : [...REVIEW_REPORT_REQUIRED_FIELDS],
646
+ output_contract_fields: isCodeReviewer
647
+ ? [...REVIEW_REPORT_REQUIRED_FIELDS, "reviewer", "review_scope"]
648
+ : requiresReviewer(job.role) ? [...REVIEW_REPORT_REQUIRED_FIELDS, "reviewer"] : [...REVIEW_REPORT_REQUIRED_FIELDS],
328
649
  output_contract_optional_fields: [...REVIEW_REPORT_OPTIONAL_FIELDS],
329
650
  output_instructions: `${roleDescription(job.role)}。请审查 ${job.boundFiles.map(f => f.path).join(", ")},` +
330
- (job.review_evidence_digest ? `本工作项绑定的执行证据版本为 ${job.review_evidence_digest},` : "") +
331
- (requiresReviewer(job.role) ? `必须由独立 ${recommendedAgentForRole(job.role)} reviewer 执行并在 reviewer.kind/id 中记录来源,` : "") +
332
- `产出 JSON 报告内容并优先通过 --report - stdin 登记;文件路径模式仍可作为 fallback。` +
333
- (requiresReviewer(job.role)
334
- ? `最小格式:{"role":"${job.role}","verdict":"pass|fail","findings":[],"reviewer":{"kind":"codex-subagent","id":"<thread-or-agent-id>"}}`
335
- : `最小格式:{"role":"${job.role}","verdict":"pass|fail","findings":[]}`),
651
+ (job.review_evidence_digest ? `本工作项对应的执行证据版本为 ${job.review_evidence_digest},` : "") +
652
+ (job.previous_rejection ? `上一次代码审查没有形成可推进结论,原因:${job.previous_rejection.reason}。本次请根据该原因重新审查,` : "") +
653
+ (requiresReviewer(job.role) ? `必须由独立 ${recommendedAgentForRole(job.role)} 审查角色执行,并在 reviewer.kind/id 中记录来源,` : "") +
654
+ `产出 JSON 报告内容并优先通过 --report - 从 stdin 登记;文件路径模式仍可作为 fallback。以下 JSON 合约给审查代理使用,普通对话不要原样复述。` +
655
+ (isCodeReviewer
656
+ ? `最小格式:{"role":"code-reviewer","verdict":"pass|fail","review_scope":{"job_id":"${job.job_id}","packet_digest":"${job.packet_digest}","checked_paths":${JSON.stringify(job.boundFiles.map(f => f.path))},"checked_docs":["proposal.md","design.md","tasks.md",".superspec/artifacts/test-contract.md"],"unchecked":[]},"findings":[],"reviewer":{"kind":"codex-subagent","id":"<thread-or-agent-id>"}};review_scope 用来说明本次审查覆盖了哪些文件和文档,checked_paths 与 unchecked 必须合起来覆盖全部 boundFiles,unchecked 条目格式为 {"path":"<path>","reason":"<reason>"}。`
657
+ + `报告结论为 fail 时,findings 至少包含一个可处理、可追溯的阻塞问题,字段为 {"id":"<stable-id>","blocking":true,"type":"implementation|spec|mixed","description":"<what>","evidence":"<why>","source_refs":["<path:line>"],"impact":"<impact>","suggested_action":"apply|propose"}。type 中 implementation 表示纯代码实现问题,spec 表示方案/需求文档问题,mixed 表示需要使用者判断的混合问题。`
658
+ : job.role === "verifier"
659
+ ? `最小格式:{"role":"verifier","verdict":"pass|fail","findings":[]}。核对代码审查记录 code_review_gate:passed 必须能追溯到已接受的代码审查工作项,skipped 必须能证明本次没有代码类改动。核对代码审查问题闭环:实现修复任务必须带 review_fix_of:<job_id>#<problem_id>,方案/混合问题必须有用户决策或后续修复证据。核对 RED/GREEN:同一 task_completed.attempt_id 下必须有 RED/characterization 与 GREEN;test-run 证据应包含 test_id、command、cwd、exit_code、semantic_status;审查修复的回归 test-run 可用 covers_task_ids 说明覆盖了哪些已完成任务;缺少 attempt_id 的旧证据只能弱引用。`
660
+ : requiresReviewer(job.role)
661
+ ? `最小格式:{"role":"${job.role}","verdict":"pass|fail","findings":[],"reviewer":{"kind":"codex-subagent","id":"<thread-or-agent-id>"}}`
662
+ : `最小格式:{"role":"${job.role}","verdict":"pass|fail","findings":[]}`),
336
663
  stop_conditions: ["审查完成后提交报告,不要修改文档"],
337
664
  created_from_transition: job.created_from_transition,
338
665
  },
package/dist/review.js CHANGED
@@ -60,19 +60,27 @@ export function reviewBoundFiles(changeRoot) {
60
60
  .map(path => ({ path, sha: sha256File(join(changeRoot, path)) ?? "sha256:missing" }));
61
61
  }
62
62
  function evidenceSortKey(record) {
63
- return [
63
+ const parts = [
64
64
  record.kind,
65
65
  record.task_id ?? "",
66
66
  record.attempt_id ?? "",
67
67
  record.task_structure_digest ?? "",
68
68
  record.test_id ?? "",
69
69
  record.semantic_status ?? "",
70
- record.command ?? "",
71
- record.cwd ?? "",
72
- record.exit_code == null ? "" : String(record.exit_code),
73
- record.target_fingerprint ?? "",
74
- record.event_digest,
75
- ].join("\u0000");
70
+ ];
71
+ if (record.covers_task_ids && record.covers_task_ids.length > 0) {
72
+ parts.push(`covers:${JSON.stringify(record.covers_task_ids)}`);
73
+ }
74
+ parts.push(record.command ?? "", record.cwd ?? "", record.exit_code == null ? "" : String(record.exit_code), record.target_fingerprint ?? "", record.event_digest);
75
+ return parts.join("\u0000");
76
+ }
77
+ function normalizedCoveredTaskIds(value) {
78
+ if (!Array.isArray(value))
79
+ return [];
80
+ return [...new Set(value
81
+ .filter((item) => typeof item === "string")
82
+ .map(item => item.trim())
83
+ .filter(Boolean))].sort();
76
84
  }
77
85
  export function reviewEvidenceDigest(events) {
78
86
  const attemptsById = new Map();
@@ -116,12 +124,14 @@ export function reviewEvidenceDigest(events) {
116
124
  const matchesLegacyDigest = attemptId == null && structureDigest != null && completedStructureDigests.has(structureDigest);
117
125
  if (!matchesCompletedAttempt && !matchesLegacyDigest)
118
126
  continue;
127
+ const coversTaskIds = normalizedCoveredTaskIds(payload.covers_task_ids);
119
128
  records.push({
120
129
  kind: "test_run_recorded",
121
130
  test_id: typeof payload.test_id === "string" ? payload.test_id : null,
122
131
  attempt_id: attemptId,
123
132
  task_structure_digest: structureDigest,
124
133
  semantic_status: typeof payload.semantic_status === "string" ? payload.semantic_status : null,
134
+ ...(coversTaskIds.length > 0 ? { covers_task_ids: coversTaskIds } : {}),
125
135
  command: typeof payload.command === "string" ? payload.command : "",
126
136
  cwd: typeof payload.cwd === "string" ? payload.cwd : "",
127
137
  exit_code: typeof payload.exit_code === "number" ? payload.exit_code : null,
package/dist/store.d.ts CHANGED
@@ -21,6 +21,7 @@ export declare function computeDocumentDigests(changeRoot: string, docPaths: str
21
21
  export declare function digestOf(obj: unknown): string;
22
22
  export declare function appendEvent(projectRoot: string, change: string, event: Event): void;
23
23
  export declare function readEvents(projectRoot: string, change: string): Event[];
24
+ export declare function findLatestEvent(events: Event[], eventType: Event["event_type"], predicate: (event: Event) => boolean): Event | null;
24
25
  export declare function makeEvent(change: string, eventType: Event["event_type"], payload: Record<string, unknown>, opts?: {
25
26
  transitionId?: string | null;
26
27
  idempotencyKey?: string | null;
package/dist/store.js CHANGED
@@ -93,6 +93,16 @@ export function readEvents(projectRoot, change) {
93
93
  }
94
94
  return events;
95
95
  }
96
+ export function findLatestEvent(events, eventType, predicate) {
97
+ for (let i = events.length - 1; i >= 0; i--) {
98
+ const event = events[i];
99
+ if (event.event_type !== eventType)
100
+ continue;
101
+ if (predicate(event))
102
+ return event;
103
+ }
104
+ return null;
105
+ }
96
106
  export function makeEvent(change, eventType, payload, opts = {}) {
97
107
  const base = {
98
108
  change_id: change,