@peterxiaoyang/superspec 0.1.40 → 0.1.42

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,11 +1,16 @@
1
1
  // SuperSpec 流程引擎 — record:工作项结果登记
2
- import { readFileSync, existsSync } from "node:fs";
2
+ import { existsSync } from "node:fs";
3
3
  import { isAbsolute, join, relative, resolve } from "node:path";
4
4
  import { ensureChangeLayout, readEvents, appendEvent, makeEvent, sha256File, sha256Text, withLock, appendRawRecord, } from "./store.js";
5
+ import { rebuildSnapshot } from "./sync.js";
6
+ import { changeRoot as openspecChangeRoot } from "./openspec.js";
7
+ import { isPhaseConfirmationScope, phaseActionForAnswer, phaseConfirmationForCurrentState, } from "./phase_confirmation.js";
5
8
  import { CODE_REVIEW_DECISION_ANSWER_LABELS, CODE_REVIEW_DECISION_SCOPE_PREFIX, codeReviewDecisionAnswerLabel, normalizeCodeReviewDecisionAnswer, } from "./code_review.js";
6
9
  import { invalidReasonForSubmittedReport } from "./job_validity.js";
7
10
  import { jobSubmitArgv } from "./job_action.js";
8
11
  import { REVIEW_DOC_PATHS } from "./review.js";
12
+ import { EXPLORE_DISCOVERY_REVIEW_GATE, PROPOSE_FINAL_REVIEW_GATE } from "./review_job_gates.js";
13
+ import { RecordInputDecodingError, readRecordInputFile } from "./record_input.js";
9
14
  const REVIEW_REPORT_REQUIRED_FIELDS = ["role", "verdict", "findings"];
10
15
  const REVIEW_REPORT_OPTIONAL_FIELDS = ["summary", "evidence_refs", "risks", "open_questions"];
11
16
  const REVIEWER_KINDS = new Set(["codex-subagent", "human", "external-agent"]);
@@ -13,6 +18,16 @@ const CODE_REVIEW_FINDING_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/;
13
18
  function requiresReviewer(role) {
14
19
  return role === "critic" || role === "architect" || role === "test-engineer" || role === "code-reviewer";
15
20
  }
21
+ /** All review reports with bound material acknowledge full file coverage. */
22
+ function requiresReviewScope(job) {
23
+ return job.boundFiles.length > 0 && job.role !== "executor" && job.role !== "test-run";
24
+ }
25
+ function projectLocalInvalidReportMustTerminate(job) {
26
+ return job.role === "code-reviewer" || job.packet_context?.code_state_check !== undefined;
27
+ }
28
+ function isOrdinaryReviewer(role) {
29
+ return role === "critic" || role === "architect" || role === "test-engineer";
30
+ }
16
31
  function recommendedAgentForRole(role) {
17
32
  switch (role) {
18
33
  case "critic": return "critic";
@@ -42,6 +57,60 @@ function roleDescription(role) {
42
57
  return "执行受限测试工作项";
43
58
  }
44
59
  }
60
+ function previousRejectionInstruction(job) {
61
+ const previous = job.previous_rejection;
62
+ if (!previous)
63
+ return "";
64
+ const reason = `上一次同角色审查没有形成可推进结论,原因:${previous.reason}。`;
65
+ if (!previous.findings || previous.findings.length === 0)
66
+ return `${reason}完成全部材料覆盖和角色分析后,再针对该原因复核,`;
67
+ return `${reason}完成全部材料覆盖和角色分析后,再逐项复核本工作项附带的上一次同角色审查尚未闭环问题:同一问题仍存在时复用原 finding ID;legacy finding 没有 ID 时沿用其原始语义并补一个稳定 ID;已解决的问题不要重复报告,不得通过更换 ID、标题或措辞重复同一问题;新增问题必须提供与历史问题不同的具体证据。`;
68
+ }
69
+ function ordinaryReviewerFindingInstruction(job) {
70
+ if (!isOrdinaryReviewer(job.role))
71
+ return "";
72
+ return "问题列表中的每个新 finding 必须分配稳定 ID,后续同一问题沿用该 ID,";
73
+ }
74
+ function reviewScopeForJob(job) {
75
+ if (job.review_targets !== undefined || job.read_only_refs !== undefined) {
76
+ return {
77
+ reviewTargets: job.review_targets ?? [],
78
+ readOnlyRefs: job.read_only_refs ?? [],
79
+ };
80
+ }
81
+ const gate = [EXPLORE_DISCOVERY_REVIEW_GATE, PROPOSE_FINAL_REVIEW_GATE]
82
+ .find(candidate => candidate.isJobForGate(job));
83
+ return gate
84
+ ? { reviewTargets: [...gate.reviewTargets], readOnlyRefs: [...gate.readOnlyRefs] }
85
+ : { reviewTargets: [], readOnlyRefs: [] };
86
+ }
87
+ function reviewScopeInstruction(job, reviewTargets, readOnlyRefs) {
88
+ if (reviewTargets.length === 0 && readOnlyRefs.length === 0) {
89
+ return `请审查 ${job.boundFiles.map(file => file.path).join(", ")},`;
90
+ }
91
+ const targets = reviewTargets.length > 0 ? `本 gate 可提出修改建议的审查目标为 ${reviewTargets.join(", ")}。` : "";
92
+ const refs = readOnlyRefs.length > 0
93
+ ? `只读上游引用为 ${readOnlyRefs.join(", ")};只允许读取和核对一致性,不得要求在当前阶段修改、追加、删除、重排或格式化这些文件,不得把修改只读引用列为本阶段 required fix。只读引用自身的缺失、错误或矛盾可在 risks 或 open_questions 中上报给主流程,但不得单独作为本 gate 的 fail。只有能定位到审查目标的不一致,才能作为 fail 并指向该审查目标的修复。`
94
+ : "";
95
+ return targets + refs;
96
+ }
97
+ function reviewCoverageInstruction(job) {
98
+ if (!requiresReviewScope(job))
99
+ return "";
100
+ return `审查顺序固定为:先建立覆盖索引,按文件和标题/行段完整浏览全部 boundFiles 至文件末尾;长文档必须分段读取,每段只保留短锚点和候选风险,不能在发现第一个 blocker 时提交。随后按本角色职责进行跨文件分析;最后才复核历史 findings 并去重,一次性提交当前快照下发现的全部 blocker。报告中的 review_scope.checked_paths 必须列出全部已浏览的 boundFiles;它只是覆盖回执,不能代替语义审查。`;
101
+ }
102
+ function migrationEvidenceInstruction(job) {
103
+ const isProposeReview = PROPOSE_FINAL_REVIEW_GATE.isJobForGate(job);
104
+ if (!isProposeReview)
105
+ return "";
106
+ return "迁移与环境证据按阶段分层:Explore 记录迁移风险、兼容约束和外部未知;Propose 只需审查迁移策略、回滚/兼容设计、任务与可执行测试计划;Apply/Review 才记录实际 datasource、制品 SHA、Flyway history、审计日志和运行证据。可因策略、计划或文档矛盾而 fail;不得只因实际环境证据尚未产生而 fail。";
107
+ }
108
+ function recordInputInstruction(job) {
109
+ const encoding = "stdin 请传 UTF-8 JSON;Windows PowerShell 请设置 $OutputEncoding 和 [Console]::OutputEncoding 为 UTF-8,文件备用方式请使用 Set-Content -Encoding utf8。为兼容旧版 PowerShell,带 BOM 的 UTF-16LE 文件也可接受。";
110
+ if (!projectLocalInvalidReportMustTerminate(job))
111
+ return encoding;
112
+ return `${encoding}项目内 fallback 报告若存在解码或协议错误会终结当前工作项,以免被后续代码状态检查当作改动;如需保留同一工作项重交,请使用 stdin 或工作区外临时文件。`;
113
+ }
45
114
  function asObject(value) {
46
115
  return value && typeof value === "object" && !Array.isArray(value) ? value : null;
47
116
  }
@@ -125,6 +194,23 @@ function validateCodeReviewScope(obj, job, checks) {
125
194
  }
126
195
  }
127
196
  }
197
+ function validateReviewScope(obj, job, checks) {
198
+ const scope = asObject(obj.review_scope);
199
+ if (!scope) {
200
+ checks.push("审查报告缺少覆盖范围字段 review_scope");
201
+ return;
202
+ }
203
+ if (!stringArray(scope.checked_paths)) {
204
+ checks.push("审查报告覆盖范围里的 checked_paths 必须是字符串数组");
205
+ return;
206
+ }
207
+ const checkedPaths = new Set(scope.checked_paths);
208
+ for (const bound of job.boundFiles) {
209
+ if (!checkedPaths.has(bound.path)) {
210
+ checks.push(`审查报告未说明已检查 ${bound.path}`);
211
+ }
212
+ }
213
+ }
128
214
  function actionableCodeReviewFindings(findings) {
129
215
  if (!Array.isArray(findings))
130
216
  return { actionable: [], reasons: ["报告字段 findings 必须是数组"] };
@@ -205,6 +291,7 @@ function failedReviewReportRejectEventPayload(input) {
205
291
  job_id: input.jobId,
206
292
  role: input.job.role,
207
293
  report_digest: input.reportDigest,
294
+ result_kind: "review_failed",
208
295
  reason: "报告结论为 fail,工作项未通过",
209
296
  findings: Array.isArray(input.parsedReport.findings) ? input.parsedReport.findings : [],
210
297
  ...input.rawRef,
@@ -262,6 +349,35 @@ function terminalJobSubmitResult(events, jobId, terminal, reportDigest) {
262
349
  message: `工作项 ${jobId} 已结束(${terminal}),不接受新报告。需要新工作项请重新执行对应的 transition。`,
263
350
  };
264
351
  }
352
+ function retryableJobSubmitResult(jobId, checks) {
353
+ return {
354
+ accepted: false,
355
+ message: `工作项 ${jobId} 的报告未接受,可修正后以同一工作项重新提交:${checks.join("; ")}`,
356
+ job_state: "requested",
357
+ events_written: 0,
358
+ };
359
+ }
360
+ function terminalInvalidReportResult(input) {
361
+ const rawRef = input.job.role === "code-reviewer" && input.parsedReport
362
+ ? appendRawRecord(input.projectRoot, input.change, "review-reports", input.parsedReport)
363
+ : null;
364
+ appendEvent(input.projectRoot, input.change, makeEvent(input.change, "job_rejected", codeReviewRejectEventPayload({
365
+ jobId: input.jobId,
366
+ job: input.job,
367
+ reportDigest: input.reportDigest,
368
+ resultKind: "invalid_report",
369
+ reason: input.reason,
370
+ parsedReport: input.parsedReport,
371
+ rawRef,
372
+ reportPath: input.reportPath,
373
+ })));
374
+ return {
375
+ event_type: "job_rejected",
376
+ accepted: false,
377
+ message: `工作项 ${input.jobId} 被拒绝:${input.reason}`,
378
+ job_state: "rejected",
379
+ };
380
+ }
265
381
  function recordJobSubmitLoaded(projectRoot, change, changeRoot, jobId, job, events, reportContent, reportDigest, reportFile) {
266
382
  const checks = [];
267
383
  let parsedReport = null;
@@ -286,12 +402,18 @@ function recordJobSubmitLoaded(projectRoot, change, changeRoot, jobId, job, even
286
402
  if (!Array.isArray(obj.findings)) {
287
403
  checks.push("报告问题列表 findings 必须是数组");
288
404
  }
405
+ else if (isOrdinaryReviewer(job.role) && obj.verdict === "fail" && obj.findings.length === 0) {
406
+ checks.push("普通审查报告结论为 fail 时 findings 至少包含一个问题");
407
+ }
289
408
  if (requiresReviewer(job.role)) {
290
409
  validateReviewer(obj, checks);
291
410
  }
292
411
  if (job.role === "code-reviewer") {
293
412
  validateCodeReviewScope(parsedReport, job, checks);
294
413
  }
414
+ else if (requiresReviewScope(job)) {
415
+ validateReviewScope(parsedReport, job, checks);
416
+ }
295
417
  }
296
418
  }
297
419
  catch {
@@ -304,45 +426,22 @@ function recordJobSubmitLoaded(projectRoot, change, changeRoot, jobId, job, even
304
426
  events,
305
427
  reportPath,
306
428
  });
307
- if (invalidReason) {
308
- checks.push(invalidReason);
309
- }
310
- if (!reportContent.trim()) {
429
+ if (!reportContent.trim() && !checks.includes("报告内容为空")) {
311
430
  checks.push("报告内容为空");
312
431
  }
313
- if (checks.length > 0) {
314
- const resultKind = job.role === "code-reviewer" ? "invalid_report" : undefined;
315
- const rawRef = resultKind && parsedReport
316
- ? appendRawRecord(projectRoot, change, "review-reports", parsedReport)
317
- : null;
318
- const rejectEvent = makeEvent(change, "job_rejected", {
319
- ...(resultKind
320
- ? codeReviewRejectEventPayload({
321
- jobId,
322
- job,
323
- reportDigest,
324
- resultKind,
325
- reason: checks.join("; "),
326
- parsedReport,
327
- rawRef,
328
- reportPath,
329
- })
330
- : {
331
- job_id: jobId,
332
- role: job.role,
333
- report_digest: reportDigest,
334
- ...(reportPath ? { report_path: reportPath } : {}),
335
- reason: checks.join("; "),
336
- }),
432
+ if (invalidReason) {
433
+ return terminalInvalidReportResult({
434
+ projectRoot, change, jobId, job, reportDigest, reason: invalidReason, parsedReport, reportPath,
435
+ });
436
+ }
437
+ // Code-state-sensitive review jobs must record project-local fallback paths for later scope scans.
438
+ if (checks.length > 0 && projectLocalInvalidReportMustTerminate(job) && reportPath) {
439
+ return terminalInvalidReportResult({
440
+ projectRoot, change, jobId, job, reportDigest, reason: checks.join("; "), parsedReport, reportPath,
337
441
  });
338
- appendEvent(projectRoot, change, rejectEvent);
339
- return {
340
- event_type: "job_rejected",
341
- accepted: false,
342
- message: `工作项 ${jobId} 被拒绝:${checks.join("; ")}`,
343
- job_state: "rejected",
344
- };
345
442
  }
443
+ if (checks.length > 0)
444
+ return retryableJobSubmitResult(jobId, checks);
346
445
  if (job.role !== "code-reviewer" && parsedReport?.verdict === "fail") {
347
446
  const rawRef = appendRawRecord(projectRoot, change, "review-reports", parsedReport);
348
447
  const rejectEvent = makeEvent(change, "job_rejected", failedReviewReportRejectEventPayload({
@@ -372,23 +471,12 @@ function recordJobSubmitLoaded(projectRoot, change, changeRoot, jobId, job, even
372
471
  if (verdict === "pass") {
373
472
  if (blockingCount > 0) {
374
473
  const reason = "报告结论为 pass 时不能包含 blocking:true 的阻塞问题";
375
- const rawRef = appendRawRecord(projectRoot, change, "review-reports", parsedReport);
376
- appendEvent(projectRoot, change, makeEvent(change, "job_rejected", codeReviewRejectEventPayload({
377
- jobId,
378
- job,
379
- reportDigest,
380
- resultKind: "invalid_report",
381
- reason,
382
- parsedReport,
383
- rawRef,
384
- reportPath,
385
- })));
386
- return {
387
- event_type: "job_rejected",
388
- accepted: false,
389
- message: `工作项 ${jobId} 被拒绝:${reason}`,
390
- job_state: "rejected",
391
- };
474
+ if (projectLocalInvalidReportMustTerminate(job) && reportPath) {
475
+ return terminalInvalidReportResult({
476
+ projectRoot, change, jobId, job, reportDigest, reason, parsedReport, reportPath,
477
+ });
478
+ }
479
+ return retryableJobSubmitResult(jobId, [reason]);
392
480
  }
393
481
  }
394
482
  else if (verdict === "fail") {
@@ -450,10 +538,25 @@ export function recordJobSubmit(projectRoot, change, changeRoot, jobId, reportFi
450
538
  return terminalJobSubmitResult(events, jobId, terminal, reportDigest);
451
539
  }
452
540
  if (!existsSync(reportFile)) {
453
- return { event_type: "job_rejected", accepted: false, message: `报告文件不存在:${reportFile}` };
541
+ return retryableJobSubmitResult(jobId, [`报告文件不存在:${reportFile}`]);
454
542
  }
455
- const reportContent = readFileSync(reportFile, "utf8");
456
543
  const reportDigest = sha256File(reportFile) ?? "sha256:unknown";
544
+ let reportContent;
545
+ try {
546
+ reportContent = readRecordInputFile(reportFile);
547
+ }
548
+ catch (err) {
549
+ if (err instanceof RecordInputDecodingError) {
550
+ const reportPath = projectRelativePath(projectRoot, reportFile);
551
+ if (projectLocalInvalidReportMustTerminate(job) && reportPath) {
552
+ return terminalInvalidReportResult({
553
+ projectRoot, change, jobId, job, reportDigest, reason: err.message, parsedReport: null, reportPath,
554
+ });
555
+ }
556
+ return retryableJobSubmitResult(jobId, [err.message]);
557
+ }
558
+ throw err;
559
+ }
457
560
  return recordJobSubmitLoaded(projectRoot, change, changeRoot, jobId, job, events, reportContent, reportDigest, reportFile);
458
561
  });
459
562
  }
@@ -475,21 +578,21 @@ export function recordJobSubmitContent(projectRoot, change, changeRoot, jobId, r
475
578
  });
476
579
  }
477
580
  function recordUserDecisionLoaded(projectRoot, change, events, content, inputDigest) {
478
- const existing = events.find(e => e.event_type === "user_decision_recorded"
581
+ const existing = [...events].reverse().find(e => e.event_type === "user_decision_recorded"
479
582
  && e.payload.input_digest === inputDigest);
480
- if (existing) {
481
- const accepted = existing.payload.accepted !== false;
482
- return {
483
- event_type: "user_decision_recorded",
484
- accepted,
485
- message: accepted ? "幂等返回:同一用户决策已登记" : "幂等返回:同一无效用户决策已登记",
486
- };
487
- }
488
583
  let decision;
489
584
  try {
490
585
  decision = JSON.parse(content);
491
586
  }
492
587
  catch {
588
+ if (existing) {
589
+ const accepted = existing.payload.accepted !== false;
590
+ return {
591
+ event_type: "user_decision_recorded",
592
+ accepted,
593
+ message: accepted ? "幂等返回:同一用户决策已登记" : "幂等返回:同一无效用户决策已登记",
594
+ };
595
+ }
493
596
  appendEvent(projectRoot, change, makeEvent(change, "user_decision_recorded", {
494
597
  accepted: false,
495
598
  reason: "invalid_json",
@@ -505,6 +608,76 @@ function recordUserDecisionLoaded(projectRoot, change, events, content, inputDig
505
608
  }));
506
609
  return { event_type: "user_decision_recorded", accepted: false, message: "决策文件缺少决策范围(scope)或答复内容(answer)" };
507
610
  }
611
+ let phaseConfirmation = null;
612
+ let phaseAction = null;
613
+ if (isPhaseConfirmationScope(decision.scope)) {
614
+ const existingAccepted = existing &&
615
+ existing.payload.accepted !== false;
616
+ const latestAcceptedForScope = [...events].reverse().find(event => {
617
+ if (event.event_type !== "user_decision_recorded")
618
+ return false;
619
+ const payload = event.payload;
620
+ return payload.accepted !== false &&
621
+ payload.scope === decision.scope &&
622
+ payload.phase_confirmation != null;
623
+ });
624
+ const changeRoot = openspecChangeRoot(projectRoot, change);
625
+ const snapshot = rebuildSnapshot(projectRoot, change, changeRoot);
626
+ const current = phaseConfirmationForCurrentState(projectRoot, events, snapshot);
627
+ if (existingAccepted &&
628
+ current?.scope === decision.scope &&
629
+ latestAcceptedForScope?.event_id === existing.event_id) {
630
+ return {
631
+ event_type: "user_decision_recorded",
632
+ accepted: true,
633
+ message: "幂等返回:同一用户决策已登记",
634
+ };
635
+ }
636
+ const action = current ? phaseActionForAnswer(current, decision.answer) : null;
637
+ const rejectionReason = !current
638
+ ? "phase_confirmation_not_pending"
639
+ : decision.scope !== current.scope
640
+ ? "stale_phase_confirmation_scope"
641
+ : !action
642
+ ? "invalid_phase_confirmation_answer"
643
+ : action.reason === "required" && !nonEmptyString(decision.reason)
644
+ ? "missing_phase_confirmation_reason"
645
+ : null;
646
+ if (rejectionReason) {
647
+ if (existing &&
648
+ existing.payload.accepted === false &&
649
+ existing.payload.reason === rejectionReason) {
650
+ return {
651
+ event_type: "user_decision_recorded",
652
+ accepted: false,
653
+ message: "幂等返回:同一无效阶段确认已登记",
654
+ };
655
+ }
656
+ appendEvent(projectRoot, change, makeEvent(change, "user_decision_recorded", {
657
+ accepted: false,
658
+ scope: decision.scope,
659
+ answer: decision.answer,
660
+ reason: rejectionReason,
661
+ input_digest: inputDigest,
662
+ }));
663
+ const message = rejectionReason === "invalid_phase_confirmation_answer" && current
664
+ ? `阶段确认答复必须精确为:${current.ask.allowed_answers.join("、")}`
665
+ : rejectionReason === "missing_phase_confirmation_reason" && action
666
+ ? action.reason_prompt ?? "当前选择必须写明原因"
667
+ : "阶段确认已失效或当前没有待确认的阶段边界,请重新执行 next";
668
+ return { event_type: "user_decision_recorded", accepted: false, message };
669
+ }
670
+ phaseConfirmation = current;
671
+ phaseAction = action;
672
+ }
673
+ if (existing && !phaseAction) {
674
+ const accepted = existing.payload.accepted !== false;
675
+ return {
676
+ event_type: "user_decision_recorded",
677
+ accepted,
678
+ message: accepted ? "幂等返回:同一用户决策已登记" : "幂等返回:同一无效用户决策已登记",
679
+ };
680
+ }
508
681
  if (decision.scope.startsWith(CODE_REVIEW_DECISION_SCOPE_PREFIX)) {
509
682
  const normalizedAnswer = normalizeCodeReviewDecisionAnswer(decision.answer);
510
683
  if (!normalizedAnswer) {
@@ -541,9 +714,19 @@ function recordUserDecisionLoaded(projectRoot, change, events, content, inputDig
541
714
  : null;
542
715
  const normalizedDecision = {
543
716
  scope: decision.scope,
544
- question: typeof decision.question === "string" ? decision.question : "",
545
- answer: normalizedAnswer ? codeReviewDecisionAnswerLabel(normalizedAnswer) : decision.answer,
717
+ question: phaseConfirmation
718
+ ? phaseConfirmation.ask.question
719
+ : typeof decision.question === "string" ? decision.question : "",
720
+ answer: phaseAction
721
+ ? phaseAction.label
722
+ : normalizedAnswer ? codeReviewDecisionAnswerLabel(normalizedAnswer) : decision.answer,
546
723
  ...(typeof decision.reason === "string" ? { reason: decision.reason.trim() } : {}),
724
+ ...(phaseAction ? {
725
+ phase_confirmation: {
726
+ boundary: phaseAction.boundary,
727
+ decision: phaseAction.decision,
728
+ },
729
+ } : {}),
547
730
  };
548
731
  const rawRef = appendRawRecord(projectRoot, change, "user-decisions", normalizedDecision);
549
732
  const event = makeEvent(change, "user_decision_recorded", {
@@ -568,7 +751,16 @@ export function recordUserDecision(projectRoot, change, inputFile) {
568
751
  appendEvent(projectRoot, change, makeEvent(change, "user_decision_recorded", { accepted: false, reason: "file_not_found", path: inputFile }));
569
752
  return { event_type: "user_decision_recorded", accepted: false, message: `决策文件不存在:${inputFile}` };
570
753
  }
571
- const content = readFileSync(inputFile, "utf8");
754
+ let content;
755
+ try {
756
+ content = readRecordInputFile(inputFile);
757
+ }
758
+ catch (err) {
759
+ if (err instanceof RecordInputDecodingError) {
760
+ return { accepted: false, message: err.message, events_written: 0 };
761
+ }
762
+ throw err;
763
+ }
572
764
  const inputDigest = sha256File(inputFile) ?? "sha256:unknown";
573
765
  return recordUserDecisionLoaded(projectRoot, change, events, content, inputDigest);
574
766
  });
@@ -619,8 +811,8 @@ function packetFieldDescriptions() {
619
811
  return {
620
812
  job_id: "工作项 ID,用于提交本次审查或验证报告。",
621
813
  packet_digest: "工作项说明摘要,用于证明报告对应的是当前这份工作项说明。",
622
- boundFiles: "本工作项绑定的文件清单;代码审查报告必须说明这些文件是否都看过。",
623
- review_scope: "报告中的审查覆盖范围,说明看了哪些文件、哪些没看及原因。",
814
+ boundFiles: "本工作项绑定的文件清单;审查报告必须说明这些文件是否都看过。",
815
+ review_scope: "报告中的审查覆盖范围;普通 reviewer/verifier 用 checked_paths 回执全部绑定文件,code-reviewer 还需按专用协议说明未检查项。",
624
816
  code_review_scope: "代码审查范围:从已审基点到当前 HEAD 的提交改动、工作区改动和未跟踪代码文件。",
625
817
  task_execution_index: "按任务汇总的执行证据:每个任务(task)的执行依据、声明测试、测试证据和改动文件。",
626
818
  contract: "任务启动时的执行依据快照:tests/design/source/reason/guard 分别对应 测试/设计/来源/原因/边界;null 表示历史任务没有执行依据。",
@@ -648,7 +840,9 @@ export function jobsPacket(projectRoot, change, jobId) {
648
840
  return { found: false, message: `工作项 ${jobId} 不存在` };
649
841
  }
650
842
  const isCodeReviewer = job.role === "code-reviewer";
843
+ const hasReviewScope = requiresReviewScope(job);
651
844
  const packetContext = job.packet_context;
845
+ const { reviewTargets, readOnlyRefs } = reviewScopeForJob(job);
652
846
  return {
653
847
  found: true,
654
848
  packet: {
@@ -657,6 +851,8 @@ export function jobsPacket(projectRoot, change, jobId) {
657
851
  ...(job.gate_id ? { gate_id: job.gate_id } : {}),
658
852
  recommended_agent: recommendedAgentForRole(job.role),
659
853
  boundFiles: job.boundFiles,
854
+ ...(reviewTargets.length > 0 ? { review_targets: reviewTargets } : {}),
855
+ ...(readOnlyRefs.length > 0 ? { read_only_refs: readOnlyRefs } : {}),
660
856
  ...(job.review_evidence_digest ? { review_evidence_digest: job.review_evidence_digest } : {}),
661
857
  ...(job.previous_rejection ? { previous_rejection: job.previous_rejection } : {}),
662
858
  ...(packetContext ? { packet_context: packetContext } : {}),
@@ -674,14 +870,22 @@ export function jobsPacket(projectRoot, change, jobId) {
674
870
  file_fallback: true,
675
871
  output_contract_fields: isCodeReviewer
676
872
  ? [...REVIEW_REPORT_REQUIRED_FIELDS, "reviewer", "review_scope"]
677
- : requiresReviewer(job.role) ? [...REVIEW_REPORT_REQUIRED_FIELDS, "reviewer"] : [...REVIEW_REPORT_REQUIRED_FIELDS],
873
+ : [
874
+ ...REVIEW_REPORT_REQUIRED_FIELDS,
875
+ ...(requiresReviewer(job.role) ? ["reviewer"] : []),
876
+ ...(hasReviewScope ? ["review_scope"] : []),
877
+ ],
678
878
  output_contract_optional_fields: [...REVIEW_REPORT_OPTIONAL_FIELDS],
679
879
  字段说明: packetFieldDescriptions(),
680
- output_instructions: `${roleDescription(job.role)}。请审查 ${job.boundFiles.map(f => f.path).join(", ")},` +
880
+ output_instructions: `${roleDescription(job.role)}。` +
881
+ reviewScopeInstruction(job, reviewTargets, readOnlyRefs) +
882
+ migrationEvidenceInstruction(job) +
681
883
  (job.review_evidence_digest ? `本工作项对应的执行证据版本为 ${job.review_evidence_digest},` : "") +
682
- (job.previous_rejection ? `上一次代码审查没有形成可推进结论,原因:${job.previous_rejection.reason}。本次请根据该原因重新审查,` : "") +
884
+ reviewCoverageInstruction(job) +
885
+ previousRejectionInstruction(job) +
683
886
  (requiresReviewer(job.role) ? `必须由独立 ${recommendedAgentForRole(job.role)} 审查角色执行,并在审查者来源字段(reviewer.kind/id)中记录来源,` : "") +
684
- `产出 JSON 报告内容并优先通过 --report - 从 stdin 登记;文件路径模式仅作备用。协议字段含义见 packet 顶层“字段说明”,普通对话不要原样复述 JSON。` +
887
+ ordinaryReviewerFindingInstruction(job) +
888
+ `产出 JSON 报告内容并优先通过 --report - 从 stdin 登记;文件路径模式仅作备用。${recordInputInstruction(job)}协议字段含义见 packet 顶层“字段说明”,普通对话不要原样复述 JSON。` +
685
889
  (isCodeReviewer
686
890
  ? `最小格式:{"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":${JSON.stringify(REVIEW_DOC_PATHS)},"unchecked":[]},"findings":[],"reviewer":{"kind":"codex-subagent","id":"<thread-or-agent-id>"}};审查覆盖范围(review_scope)用来说明本次审查覆盖了哪些文件和文档,已检查路径(checked_paths)与未检查项(unchecked)必须合起来覆盖全部绑定文件(boundFiles),unchecked 条目格式为 {"path":"<path>","reason":"<reason>"}。`
687
891
  + `报告结论为 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 表示需要使用者判断的混合问题。`
@@ -689,13 +893,13 @@ export function jobsPacket(projectRoot, change, jobId) {
689
893
  ? `本工作项带任务执行索引(task_execution_index):按 task 对照其执行依据快照(contract)审查——实现路线对照 design 引用原文、累计 diff 对照 guard 边界、测试断言对照 tests 声明的 scenario;每项的 scope_note 是执行者登记的范围扩大说明,判断其合理性与验证充分性;changed_paths 是归属线索不是结论(null 表示未知);unattributed_paths 中的无主改动逐个判断合理性;coverage_exemption_refs 解释未绑定 task 的 TEST 豁免。`
690
894
  : "")
691
895
  : job.role === "verifier"
692
- ? `最小格式:{"role":"verifier","verdict":"pass|fail","findings":[]}。核对代码审查记录(code_review_gate):passed 必须能追溯到已接受的代码审查工作项,skipped 必须能证明本次没有代码类改动。核对代码审查问题闭环:实现修复任务必须带审查修复引用(review_fix_of:<job_id>#<problem_id>),方案/混合问题必须有用户决策或后续修复证据。核对 RED/GREEN:证据须在同一已完成任务的任务尝试 ID(task_completed.attempt_id)下闭环——普通 TDD 任务至少一个同 TEST 先 RED(expected_failure)后 GREEN(expected_success)配对且每个声明 TEST 都有 GREEN;特征化任务(no_tdd_reason:characterization)可用 characterization_pass 作为通过证据,不要求 RED;测试运行证据应包含测试 ID(test_id)、命令(command)、工作目录(cwd)、退出码(exit_code)、语义状态(semantic_status);审查修复的回归测试运行可用回归覆盖任务列表(covers_task_ids)说明覆盖了哪些已完成任务;缺少任务尝试 ID(attempt_id)的旧证据只能弱引用。` +
896
+ ? `最小格式:{"role":"verifier","verdict":"pass|fail","findings":[]${hasReviewScope ? `,"review_scope":{"checked_paths":${JSON.stringify(job.boundFiles.map(file => file.path))}}` : ""}}。核对代码审查记录(code_review_gate):passed 必须能追溯到已接受的代码审查工作项,skipped 必须能证明本次没有代码类改动。核对代码审查问题闭环:实现修复任务必须带审查修复引用(review_fix_of:<job_id>#<problem_id>),方案/混合问题必须有用户决策或后续修复证据。核对 RED/GREEN:证据须在同一已完成任务的任务尝试 ID(task_completed.attempt_id)下闭环——普通 TDD 任务至少一个同 TEST 先 RED(expected_failure)后 GREEN(expected_success)配对且每个声明 TEST 都有 GREEN;特征化任务(no_tdd_reason:characterization)可用 characterization_pass 作为通过证据,不要求 RED;测试运行证据应包含测试 ID(test_id)、命令(command)、工作目录(cwd)、退出码(exit_code)、语义状态(semantic_status);审查修复的回归测试运行可用回归覆盖任务列表(covers_task_ids)说明覆盖了哪些已完成任务;缺少任务尝试 ID(attempt_id)的旧证据只能弱引用。` +
693
897
  (packetContext?.code_state_check
694
898
  ? `本工作项带代码状态检查(code_state_check):head_matches 为 false 或 changed_paths 非空表示代码审查后代码又发生变化,须在报告中列出差异并交主流程与用户裁决,不自行判定无害,也不据此自动否定已接受的代码审查。`
695
899
  : "")
696
900
  : requiresReviewer(job.role)
697
- ? `最小格式:{"role":"${job.role}","verdict":"pass|fail","findings":[],"reviewer":{"kind":"codex-subagent","id":"<thread-or-agent-id>"}}`
698
- : `最小格式:{"role":"${job.role}","verdict":"pass|fail","findings":[]}`),
901
+ ? `最小格式:{"role":"${job.role}","verdict":"pass|fail","findings":[]${hasReviewScope ? `,"review_scope":{"checked_paths":${JSON.stringify(job.boundFiles.map(file => file.path))}}` : ""},"reviewer":{"kind":"codex-subagent","id":"<thread-or-agent-id>"}}`
902
+ : `最小格式:{"role":"${job.role}","verdict":"pass|fail","findings":[]${hasReviewScope ? `,"review_scope":{"checked_paths":${JSON.stringify(job.boundFiles.map(file => file.path))}}` : ""}}`),
699
903
  stop_conditions: ["审查完成后提交报告,不要修改文档"],
700
904
  created_from_transition: job.created_from_transition,
701
905
  },
@@ -0,0 +1,9 @@
1
+ /**
2
+ * JSON record 输入只接受 UTF-8(可带 BOM)或带 BOM 的 UTF-16LE。
3
+ * 不猜测系统 ANSI/GBK 等编码,避免把损坏文本静默归档为 UTF-8 JSONL。
4
+ */
5
+ export declare class RecordInputDecodingError extends Error {
6
+ constructor(message?: string);
7
+ }
8
+ export declare function decodeRecordInput(bytes: Buffer): string;
9
+ export declare function readRecordInputFile(path: string): string;
@@ -0,0 +1,34 @@
1
+ // SuperSpec 流程引擎 — record JSON 输入的无损解码
2
+ import { readFileSync } from "node:fs";
3
+ /**
4
+ * JSON record 输入只接受 UTF-8(可带 BOM)或带 BOM 的 UTF-16LE。
5
+ * 不猜测系统 ANSI/GBK 等编码,避免把损坏文本静默归档为 UTF-8 JSONL。
6
+ */
7
+ export class RecordInputDecodingError extends Error {
8
+ constructor(message = "JSON 输入编码无效:仅支持 UTF-8(可带 BOM)或带 BOM 的 UTF-16LE") {
9
+ super(message);
10
+ this.name = "RecordInputDecodingError";
11
+ }
12
+ }
13
+ function decode(bytes, encoding) {
14
+ try {
15
+ return new TextDecoder(encoding, { fatal: true }).decode(bytes);
16
+ }
17
+ catch {
18
+ throw new RecordInputDecodingError();
19
+ }
20
+ }
21
+ export function decodeRecordInput(bytes) {
22
+ // UTF-8 BOM.
23
+ if (bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) {
24
+ return decode(bytes.subarray(3), "utf-8");
25
+ }
26
+ // Windows PowerShell 5.1 files and redirected output commonly use this form.
27
+ if (bytes[0] === 0xff && bytes[1] === 0xfe) {
28
+ return decode(bytes.subarray(2), "utf-16le");
29
+ }
30
+ return decode(bytes, "utf-8");
31
+ }
32
+ export function readRecordInputFile(path) {
33
+ return decodeRecordInput(readFileSync(path));
34
+ }
package/dist/review.d.ts CHANGED
@@ -1,4 +1,5 @@
1
- import type { Event, Job, Ref } from "./types.ts";
1
+ import { type ReviewGateRule } from "./review_job_gates.ts";
2
+ import type { Event, Job, JobRole, Ref, ReviewPreviousRejection } from "./types.ts";
2
3
  export type ReviewRisk = "minimal" | "normal" | "strict";
3
4
  export interface ReviewPolicy {
4
5
  review_risk: ReviewRisk;
@@ -8,6 +9,7 @@ export declare const REVIEW_DOC_PATHS: string[];
8
9
  export declare function assertCommitPayloadExtension(payload: Record<string, unknown>): void;
9
10
  export declare function reviewPolicyForRisk(risk: ReviewRisk): ReviewPolicy;
10
11
  export declare function readReviewPolicyFromEvents(events: Event[]): ReviewPolicy | null;
12
+ export declare function latestReviewHistoryForGateRole(events: Event[], gate: ReviewGateRule, role: JobRole): ReviewPreviousRejection | null;
11
13
  export declare function isReviewReadyVerifier(job: Job): boolean;
12
14
  export declare function reviewBoundFiles(changeRoot: string): Ref[];
13
15
  export declare function reviewEvidenceDigest(events: Event[]): string;