@peterxiaoyang/superspec 0.1.43 → 0.1.45

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 (46) hide show
  1. package/README.md +13 -1
  2. package/dist/cli.js +23 -24
  3. package/dist/code_review.js +7 -2
  4. package/dist/format.d.ts +4 -3
  5. package/dist/format.js +53 -29
  6. package/dist/git_state.d.ts +12 -1
  7. package/dist/git_state.js +46 -1
  8. package/dist/install.d.ts +1 -0
  9. package/dist/install.js +12 -0
  10. package/dist/next.d.ts +1 -1
  11. package/dist/next.js +3 -8
  12. package/dist/phase_confirmation.d.ts +6 -0
  13. package/dist/phase_confirmation.js +51 -7
  14. package/dist/phase_plan.d.ts +9 -2
  15. package/dist/phase_plan.js +178 -45
  16. package/dist/record.d.ts +1 -1
  17. package/dist/record.js +102 -10
  18. package/dist/review.d.ts +48 -1
  19. package/dist/review.js +108 -4
  20. package/dist/review_job_gates.d.ts +5 -0
  21. package/dist/review_job_gates.js +52 -1
  22. package/dist/sync.js +13 -5
  23. package/dist/task.js +15 -2
  24. package/dist/task_evidence.d.ts +1 -1
  25. package/dist/task_evidence.js +85 -10
  26. package/dist/transition.d.ts +4 -3
  27. package/dist/transition.js +172 -38
  28. package/dist/types.d.ts +25 -1
  29. package/dist/types.js +1 -0
  30. package/dist/workflow_config.d.ts +24 -0
  31. package/dist/workflow_config.js +127 -0
  32. package/package.json +1 -1
  33. package/templates/workflow/AGENTS.md +1 -1
  34. package/templates/workflow/agents/executor.toml +1 -1
  35. package/templates/workflow/agents/test-runner.toml +1 -1
  36. package/templates/workflow/prompts/architect.md +1 -1
  37. package/templates/workflow/prompts/code-reviewer.md +2 -2
  38. package/templates/workflow/prompts/critic.md +4 -6
  39. package/templates/workflow/prompts/executor.md +2 -2
  40. package/templates/workflow/prompts/test-engineer.md +5 -6
  41. package/templates/workflow/prompts/test-runner.md +1 -1
  42. package/templates/workflow/prompts/verifier.md +1 -1
  43. package/templates/workflow/skills/superspec-apply/SKILL.md +13 -71
  44. package/templates/workflow/skills/superspec-explore/SKILL.md +7 -9
  45. package/templates/workflow/skills/superspec-propose/SKILL.md +36 -48
  46. package/templates/workflow/skills/superspec-review/SKILL.md +21 -50
package/dist/record.js CHANGED
@@ -4,11 +4,11 @@ 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 { rebuildSnapshot } from "./sync.js";
6
6
  import { changeRoot as openspecChangeRoot } from "./openspec.js";
7
- import { isPhaseConfirmationScope, phaseActionForAnswer, phaseConfirmationForCurrentState, } from "./phase_confirmation.js";
7
+ import { isPhaseConfirmationScope, phaseActionForAnswer, phaseConfirmationForCurrentState, workflowRiskForPhaseConfirmation, } from "./phase_confirmation.js";
8
8
  import { CODE_REVIEW_DECISION_ANSWER_LABELS, CODE_REVIEW_DECISION_SCOPE_PREFIX, codeReviewDecisionAnswerLabel, normalizeCodeReviewDecisionAnswer, } from "./code_review.js";
9
9
  import { invalidReasonForSubmittedReport } from "./job_validity.js";
10
10
  import { jobSubmitArgv } from "./job_action.js";
11
- import { REVIEW_DOC_PATHS } from "./review.js";
11
+ import { REVIEW_DOC_PATHS, REVIEW_REJECTION_OVERRIDE_SCOPE_PREFIX, REVIEW_REJECTION_OVERRIDE_ANSWER, parseReviewRejectionOverrideScope, reviewGateRoleResolution, reviewRejectionOverrideScope, } from "./review.js";
12
12
  import { EXPLORE_DISCOVERY_REVIEW_GATE, PROPOSE_FINAL_REVIEW_GATE } from "./review_job_gates.js";
13
13
  import { RecordInputDecodingError, readRecordInputFile } from "./record_input.js";
14
14
  const REVIEW_REPORT_REQUIRED_FIELDS = ["role", "verdict", "findings"];
@@ -90,14 +90,14 @@ function reviewScopeInstruction(job, reviewTargets, readOnlyRefs) {
90
90
  }
91
91
  const targets = reviewTargets.length > 0 ? `本 gate 可提出修改建议的审查目标为 ${reviewTargets.join(", ")}。` : "";
92
92
  const refs = readOnlyRefs.length > 0
93
- ? `只读上游引用为 ${readOnlyRefs.join(", ")};只允许读取和核对一致性,不得要求在当前阶段修改、追加、删除、重排或格式化这些文件,不得把修改只读引用列为本阶段 required fix。只读引用自身的缺失、错误或矛盾可在 risks open_questions 中上报给主流程,但不得单独作为本 gate fail。只有能定位到审查目标的不一致,才能作为 fail 并指向该审查目标的修复。`
93
+ ? `只读上游引用为 ${readOnlyRefs.join(", ")};只允许读取和核对一致性,不得要求在当前阶段修改、追加、删除、重排或格式化这些文件。只读引用中与本次目标或绑定上游事实无直接因果关系的历史质量问题不得作为本 gate fail。如果本次审查目标或 boundFiles 中绑定的上游事实直接造成变更包跨文档不一致,并且会影响明确验收或实施落地,可以 fail;finding 必须锚定为当前变更包未完成一致性闭环,required outcome 只要求消除矛盾,不得指定必须修改哪份文档或采用哪种技术方案。`
94
94
  : "";
95
95
  return targets + refs;
96
96
  }
97
97
  function reviewCoverageInstruction(job) {
98
98
  if (!requiresReviewScope(job))
99
99
  return "";
100
- return `审查顺序固定为:先建立覆盖索引,按文件和标题/行段完整浏览全部 boundFiles 至文件末尾;长文档必须分段读取,不能在发现第一个 blocker 时停止覆盖。完整读取只用于核对本次 change 的目标、直接修改及跨文档一致性,不等于允许重新审计全部历史设计。若 proposal.md 存在“需求变化”,以其中记录的受影响能力、直接修改章节和保持不变范围作为本轮增量审查的权威锚点;本轮新 finding 必须由该需求变化、为接入变化所做的直接修改,或这些修改造成的跨文档矛盾引起,并说明因果链。此前已通过且被明确记录为保持不变的设计不得重新打开为 blocker;不要凭通用风险类别猜测变化范围。注意事项和故障类别是条件式检查项,不是必须穷举的清单。Recommendation 只能描述需要补足的结果、契约或证据,不得把未经 proposal、design 或用户决定选定的新基础设施写成 required fix。报告中的 review_scope.checked_paths 必须列出全部已浏览的 boundFiles;它只是覆盖回执,不能代替语义审查,也不扩大可报告问题的范围。`;
100
+ return `审查顺序固定为:先建立覆盖索引,按文件和标题/行段完整浏览全部 boundFiles 至文件末尾;长文档必须分段读取,不能在发现第一个 blocker 时停止覆盖。read_only_refs 按本次问题需要读取,用于核对目标与上下游一致性,不要求机械全文遍历或写入 checked_paths。完整读取只用于核对本次 change 的目标、直接修改及跨文档一致性,不等于允许重新审计全部历史设计。若 proposal.md 存在“需求变化”,以其中记录的受影响能力、直接修改章节和保持不变范围作为本轮增量审查的权威锚点;本轮新 finding 必须由该需求变化、为接入变化所做的直接修改,或这些修改造成的跨文档矛盾引起,并说明因果链。此前已通过且被明确记录为保持不变的设计不得重新打开为 blocker;不要凭通用风险类别猜测变化范围。注意事项和故障类别是条件式检查项,不是必须穷举的清单。Recommendation 只能描述需要补足的结果、契约或证据,不得把未经 proposal、design 或用户决定选定的新基础设施写成 required fix。报告中的 review_scope.checked_paths 必须列出全部已浏览的 boundFiles;它只是覆盖回执,不能代替语义审查,也不扩大可报告问题的范围。`;
101
101
  }
102
102
  function migrationEvidenceInstruction(job) {
103
103
  const isProposeReview = PROPOSE_FINAL_REVIEW_GATE.isJobForGate(job);
@@ -321,6 +321,21 @@ function findJob(events, jobId) {
321
321
  }
322
322
  return null;
323
323
  }
324
+ function ordinaryReviewGateForJob(job) {
325
+ return [EXPLORE_DISCOVERY_REVIEW_GATE, PROPOSE_FINAL_REVIEW_GATE]
326
+ .find(gate => gate.isJobForGate(job)) ?? null;
327
+ }
328
+ function invalidReviewRejectionOverrideResult(projectRoot, change, inputDigest, decision, reason, message) {
329
+ appendEvent(projectRoot, change, makeEvent(change, "user_decision_recorded", {
330
+ accepted: false,
331
+ scope: decision.scope,
332
+ answer: decision.answer,
333
+ ...(decision.decision_source !== undefined ? { decision_source: decision.decision_source } : {}),
334
+ reason,
335
+ input_digest: inputDigest,
336
+ }));
337
+ return { event_type: "user_decision_recorded", accepted: false, message };
338
+ }
324
339
  /** 检查 job 是否已终态 */
325
340
  function jobTerminalState(events, jobId) {
326
341
  for (const ev of events) {
@@ -331,6 +346,10 @@ function jobTerminalState(events, jobId) {
331
346
  }
332
347
  return null;
333
348
  }
349
+ function jobInvalidated(events, jobId) {
350
+ return events.some(ev => ev.event_type === "job_invalidated" &&
351
+ ev.payload.job_id === jobId);
352
+ }
334
353
  function terminalJobSubmitResult(events, jobId, terminal, reportDigest) {
335
354
  const existing = events.find(e => (e.event_type === "job_accepted" || e.event_type === "job_rejected")
336
355
  && e.payload.job_id === jobId
@@ -532,6 +551,9 @@ export function recordJobSubmit(projectRoot, change, changeRoot, jobId, reportFi
532
551
  if (!job) {
533
552
  return { event_type: "job_rejected", accepted: false, message: `工作项 ${jobId} 不存在` };
534
553
  }
554
+ if (jobInvalidated(events, jobId)) {
555
+ return { accepted: false, message: `工作项 ${jobId} 已因回退到更早阶段失效,不接受新报告。` };
556
+ }
535
557
  const terminal = jobTerminalState(events, jobId);
536
558
  if (terminal) {
537
559
  const reportDigest = sha256File(reportFile) ?? "sha256:unknown";
@@ -569,6 +591,9 @@ export function recordJobSubmitContent(projectRoot, change, changeRoot, jobId, r
569
591
  if (!job) {
570
592
  return { event_type: "job_rejected", accepted: false, message: `工作项 ${jobId} 不存在` };
571
593
  }
594
+ if (jobInvalidated(events, jobId)) {
595
+ return { accepted: false, message: `工作项 ${jobId} 已因回退到更早阶段失效,不接受新报告。` };
596
+ }
572
597
  const reportDigest = sha256Text(reportContent);
573
598
  const terminal = jobTerminalState(events, jobId);
574
599
  if (terminal) {
@@ -610,6 +635,7 @@ function recordUserDecisionLoaded(projectRoot, change, events, content, inputDig
610
635
  }
611
636
  let phaseConfirmation = null;
612
637
  let phaseAction = null;
638
+ let phaseReviewRisk = null;
613
639
  if (isPhaseConfirmationScope(decision.scope)) {
614
640
  const existingAccepted = existing &&
615
641
  existing.payload.accepted !== false;
@@ -623,7 +649,9 @@ function recordUserDecisionLoaded(projectRoot, change, events, content, inputDig
623
649
  });
624
650
  const changeRoot = openspecChangeRoot(projectRoot, change);
625
651
  const snapshot = rebuildSnapshot(projectRoot, change, changeRoot);
626
- const current = phaseConfirmationForCurrentState(projectRoot, events, snapshot);
652
+ // mode 是状态机从配置/round 快照推导的输入,不接受 user-decision JSON 注入。
653
+ const decisionRisk = workflowRiskForPhaseConfirmation(projectRoot, events, snapshot);
654
+ const current = phaseConfirmationForCurrentState(projectRoot, events, snapshot, decisionRisk);
627
655
  if (existingAccepted &&
628
656
  current?.scope === decision.scope &&
629
657
  latestAcceptedForScope?.event_id === existing.event_id) {
@@ -669,6 +697,7 @@ function recordUserDecisionLoaded(projectRoot, change, events, content, inputDig
669
697
  }
670
698
  phaseConfirmation = current;
671
699
  phaseAction = action;
700
+ phaseReviewRisk = decisionRisk;
672
701
  }
673
702
  if (existing && !phaseAction) {
674
703
  const accepted = existing.payload.accepted !== false;
@@ -678,6 +707,59 @@ function recordUserDecisionLoaded(projectRoot, change, events, content, inputDig
678
707
  message: accepted ? "幂等返回:同一用户决策已登记" : "幂等返回:同一无效用户决策已登记",
679
708
  };
680
709
  }
710
+ let reviewRejectionOverride = null;
711
+ let reviewRejectionDecisionSource = null;
712
+ if (decision.scope.startsWith(REVIEW_REJECTION_OVERRIDE_SCOPE_PREFIX)) {
713
+ const jobId = parseReviewRejectionOverrideScope(decision.scope);
714
+ if (!jobId) {
715
+ return invalidReviewRejectionOverrideResult(projectRoot, change, inputDigest, { scope: decision.scope, answer: decision.answer, decision_source: decision.decision_source }, "invalid_review_rejection_override_scope", "审查拒绝裁决 scope 必须包含有效 job ID");
716
+ }
717
+ if (decision.scope !== reviewRejectionOverrideScope(jobId)) {
718
+ return invalidReviewRejectionOverrideResult(projectRoot, change, inputDigest, { scope: decision.scope, answer: decision.answer, decision_source: decision.decision_source }, "invalid_review_rejection_override_scope", `审查拒绝裁决 scope 必须精确为 ${reviewRejectionOverrideScope(jobId)}`);
719
+ }
720
+ if (decision.answer !== REVIEW_REJECTION_OVERRIDE_ANSWER) {
721
+ return invalidReviewRejectionOverrideResult(projectRoot, change, inputDigest, { scope: decision.scope, answer: decision.answer, decision_source: decision.decision_source }, "invalid_review_rejection_override_answer", `审查拒绝裁决 answer 必须是 ${REVIEW_REJECTION_OVERRIDE_ANSWER}`);
722
+ }
723
+ if (decision.decision_source !== "main_process" && decision.decision_source !== "user") {
724
+ return invalidReviewRejectionOverrideResult(projectRoot, change, inputDigest, { scope: decision.scope, answer: decision.answer, decision_source: decision.decision_source }, "invalid_review_rejection_override_source", "审查拒绝裁决 decision_source 必须是 main_process 或 user");
725
+ }
726
+ if (!nonEmptyString(decision.reason)) {
727
+ return invalidReviewRejectionOverrideResult(projectRoot, change, inputDigest, { scope: decision.scope, answer: decision.answer, decision_source: decision.decision_source }, "missing_review_rejection_override_reason", "审查拒绝裁决必须写明原因");
728
+ }
729
+ const job = findJob(events, jobId);
730
+ const gate = job ? ordinaryReviewGateForJob(job) : null;
731
+ if (!job || !gate || !isOrdinaryReviewer(job.role)) {
732
+ return invalidReviewRejectionOverrideResult(projectRoot, change, inputDigest, { scope: decision.scope, answer: decision.answer, decision_source: decision.decision_source }, "review_rejection_override_job_not_found", "审查拒绝裁决必须指向当前 Explore / Propose gate 的普通 Reviewer job");
733
+ }
734
+ const changeRoot = openspecChangeRoot(projectRoot, change);
735
+ const snapshot = rebuildSnapshot(projectRoot, change, changeRoot);
736
+ const gateIsCurrent = gate.gate_id === EXPLORE_DISCOVERY_REVIEW_GATE.gate_id
737
+ ? snapshot.state === "explore"
738
+ : snapshot.state === "propose" || snapshot.state === "propose_ready";
739
+ if (!gateIsCurrent) {
740
+ return invalidReviewRejectionOverrideResult(projectRoot, change, inputDigest, { scope: decision.scope, answer: decision.answer, decision_source: decision.decision_source }, "review_rejection_override_gate_not_current", "审查拒绝裁决只能在对应 Explore / Propose gate 仍为当前阶段时登记");
741
+ }
742
+ const resolution = reviewGateRoleResolution(events, changeRoot, gate, job.role);
743
+ if (resolution.kind === "none" || resolution.terminal.job.job_id !== job.job_id) {
744
+ return invalidReviewRejectionOverrideResult(projectRoot, change, inputDigest, { scope: decision.scope, answer: decision.answer, decision_source: decision.decision_source }, "review_rejection_override_not_latest_terminal", "审查拒绝裁决只能指向当前 gate cycle 中该角色最新的 terminal job");
745
+ }
746
+ if (resolution.kind === "stale") {
747
+ return invalidReviewRejectionOverrideResult(projectRoot, change, inputDigest, { scope: decision.scope, answer: decision.answer, decision_source: decision.decision_source }, "stale_review_rejection_override_job", `审查拒绝裁决指向的 job 已过期:${resolution.stale_reason}`);
748
+ }
749
+ if (resolution.kind === "accepted") {
750
+ return invalidReviewRejectionOverrideResult(projectRoot, change, inputDigest, { scope: decision.scope, answer: decision.answer, decision_source: decision.decision_source }, "review_rejection_override_job_not_rejected", "审查拒绝裁决只能指向 rejected job");
751
+ }
752
+ if (resolution.kind === "rejected_invalid") {
753
+ return invalidReviewRejectionOverrideResult(projectRoot, change, inputDigest, { scope: decision.scope, answer: decision.answer, decision_source: decision.decision_source }, "review_rejection_override_not_review_failed", "只有 result_kind=review_failed 的拒绝报告可以裁决;无效报告必须修正后重新提交");
754
+ }
755
+ reviewRejectionOverride = {
756
+ job_id: job.job_id,
757
+ role: job.role,
758
+ gate_id: gate.gate_id,
759
+ packet_digest: job.packet_digest,
760
+ };
761
+ reviewRejectionDecisionSource = decision.decision_source;
762
+ }
681
763
  if (decision.scope.startsWith(CODE_REVIEW_DECISION_SCOPE_PREFIX)) {
682
764
  const normalizedAnswer = normalizeCodeReviewDecisionAnswer(decision.answer);
683
765
  if (!normalizedAnswer) {
@@ -721,10 +803,13 @@ function recordUserDecisionLoaded(projectRoot, change, events, content, inputDig
721
803
  ? phaseAction.label
722
804
  : normalizedAnswer ? codeReviewDecisionAnswerLabel(normalizedAnswer) : decision.answer,
723
805
  ...(typeof decision.reason === "string" ? { reason: decision.reason.trim() } : {}),
806
+ ...(reviewRejectionDecisionSource ? { decision_source: reviewRejectionDecisionSource } : {}),
807
+ ...(reviewRejectionOverride ? { review_rejection_override: reviewRejectionOverride } : {}),
724
808
  ...(phaseAction ? {
725
809
  phase_confirmation: {
726
810
  boundary: phaseAction.boundary,
727
811
  decision: phaseAction.decision,
812
+ review_risk: phaseReviewRisk ?? "strict",
728
813
  },
729
814
  } : {}),
730
815
  };
@@ -773,7 +858,7 @@ export function recordUserDecisionContent(projectRoot, change, content) {
773
858
  return recordUserDecisionLoaded(projectRoot, change, events, content, sha256Text(content));
774
859
  });
775
860
  }
776
- /** jobs list(HIGH-1 修复:从 transition_commit.new_jobs 提取,不再依赖已删除的 job_requested 事件) */
861
+ /** jobs list(job transition_commit.new_jobs 提取;reopen 可用 job_invalidated 关闭未完成工作项) */
777
862
  export function jobsList(projectRoot, change) {
778
863
  const events = readEvents(projectRoot, change);
779
864
  const open = [];
@@ -804,6 +889,12 @@ export function jobsList(projectRoot, change) {
804
889
  open.splice(idx, 1)[0];
805
890
  rejected.push({ job_id, role });
806
891
  }
892
+ else if (ev.event_type === "job_invalidated") {
893
+ const { job_id } = ev.payload;
894
+ const idx = open.findIndex(j => j.job_id === job_id);
895
+ if (idx >= 0)
896
+ open.splice(idx, 1);
897
+ }
807
898
  }
808
899
  return { open, accepted, rejected };
809
900
  }
@@ -814,8 +905,9 @@ function packetFieldDescriptions() {
814
905
  boundFiles: "本工作项绑定的文件清单;审查报告必须说明这些文件是否都看过。",
815
906
  review_scope: "报告中的审查覆盖范围;普通 reviewer/verifier 用 checked_paths 回执全部绑定文件,code-reviewer 还需按专用协议说明未检查项。",
816
907
  code_review_scope: "代码审查范围:从已审基点到当前 HEAD 的提交改动、工作区改动和未跟踪代码文件。",
817
- task_execution_index: "按任务汇总的执行证据:每个任务(task)的执行依据、声明测试、测试证据和改动文件。",
818
- contract: "任务启动时的执行依据快照:tests/design/source/reason/guard 分别对应 测试/设计/来源/原因/边界;null 表示历史任务没有执行依据。",
908
+ task_execution_index: "按任务汇总的执行证据:每个任务(task)的执行依据、有效证据要求、声明测试、测试证据和改动文件。",
909
+ contract: "任务启动时的执行依据快照:tests/design/source/acceptance/guard 分别对应 测试/设计/来源/验收/边界;null 表示历史任务没有执行依据。",
910
+ required_evidence: "task-start 结合冻结策略编译并写入 attempt 的有效证据要求:test_ids、red_required、green_required 和允许的 GREEN 语义状态;null 表示历史任务按旧记录回放。",
819
911
  changed_paths: "与某个任务(task)或代码状态检查相关的改动文件。",
820
912
  changed_paths_partial_reason: "该任务(task)的提交段 diff 失败原因;存在时 changed_paths 只包含工作区对比结果,归属可能不完整。",
821
913
  unattributed_paths: "代码审查范围中暂时无法归属到某个任务(task)的文件。",
@@ -890,10 +982,10 @@ export function jobsPacket(projectRoot, change, jobId) {
890
982
  ? `最小格式:{"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>"}。`
891
983
  + `报告结论为 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 表示需要使用者判断的混合问题。`
892
984
  + (packetContext?.task_execution_index
893
- ? `本工作项带任务执行索引(task_execution_index):按 task 对照其执行依据快照(contract)审查——实现路线对照 design 引用原文、累计 diff 对照 guard 边界、测试断言对照 tests 声明的 scenario;每项的 scope_note 是执行者登记的范围扩大说明,判断其合理性与验证充分性;changed_paths 是归属线索不是结论(null 表示未知);unattributed_paths 中的无主改动逐个判断合理性;coverage_exemption_refs 解释未绑定 task 的 TEST 豁免。`
985
+ ? `本工作项带任务执行索引(task_execution_index):按 task 对照其执行依据快照(contract)审查——实现路线对照 design 引用原文、累计 diff 对照 guard 边界、测试断言对照 tests 声明的 scenario;每项的 required_evidence 是 task-start 冻结的证据口径,red_required/green_required 分别说明是否需要 RED/GREEN;每项的 scope_note 是执行者登记的范围扩大说明,判断其合理性与验证充分性;changed_paths 是归属线索不是结论(null 表示未知);unattributed_paths 中的无主改动逐个判断合理性;coverage_exemption_refs 解释未绑定 task 的 TEST 豁免。`
894
986
  : "")
895
987
  : job.role === "verifier"
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)的旧证据只能弱引用。` +
988
+ ? `最小格式:{"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>),方案/混合问题必须有用户决策或后续修复证据。按 task_execution_index required_evidence 核对测试证据:red_required 时需要同一 TEST RED(expected_failure)后 GREEN;green_required 时每个声明 TEST 都需要允许的 GREEN 语义状态;测试运行证据应包含测试 ID(test_id)、命令(command)、工作目录(cwd)、退出码(exit_code)、语义状态(semantic_status)。审查修复的回归测试运行可用回归覆盖任务列表(covers_task_ids)说明覆盖了哪些已完成任务;缺少任务尝试 ID(attempt_id)的旧证据只能弱引用。` +
897
989
  (packetContext?.code_state_check
898
990
  ? `本工作项带代码状态检查(code_state_check):head_matches 为 false 或 changed_paths 非空表示代码审查后代码又发生变化,须在报告中列出差异并交主流程与用户裁决,不自行判定无害,也不据此自动否定已接受的代码审查。`
899
991
  : "")
package/dist/review.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { type ReviewGateRule } from "./review_job_gates.ts";
2
- import type { Event, Job, JobRole, Ref, ReviewPreviousRejection } from "./types.ts";
2
+ import type { CodeReviewResultKind, Event, Job, JobRole, Ref, ReviewPreviousRejection } from "./types.ts";
3
3
  export type ReviewRisk = "minimal" | "normal" | "strict";
4
4
  export interface ReviewPolicy {
5
5
  review_risk: ReviewRisk;
@@ -9,7 +9,54 @@ export declare const REVIEW_DOC_PATHS: string[];
9
9
  export declare function assertCommitPayloadExtension(payload: Record<string, unknown>): void;
10
10
  export declare function reviewPolicyForRisk(risk: ReviewRisk): ReviewPolicy;
11
11
  export declare function readReviewPolicyFromEvents(events: Event[]): ReviewPolicy | null;
12
+ export declare const REVIEW_REJECTION_OVERRIDE_SCOPE_PREFIX = "review_rejection_override:";
13
+ export declare const REVIEW_REJECTION_OVERRIDE_ANSWER = "do_not_block";
14
+ export type ReviewRejectionDecisionSource = "main_process" | "user";
15
+ export interface ReviewRejectionOverrideAudit {
16
+ job_id: string;
17
+ role: JobRole;
18
+ gate_id: ReviewGateRule["gate_id"];
19
+ packet_digest: string;
20
+ decision_source: ReviewRejectionDecisionSource;
21
+ reason: string;
22
+ decision_event_id: string;
23
+ decision_event_digest: string;
24
+ }
25
+ export declare function reviewRejectionOverrideScope(jobId: string): string;
26
+ export declare function parseReviewRejectionOverrideScope(scope: string): string | null;
27
+ export interface ReviewTerminalResult {
28
+ job: Job;
29
+ event: Event;
30
+ state: "accepted" | "rejected";
31
+ result_kind?: CodeReviewResultKind;
32
+ reason?: string;
33
+ findings?: unknown[];
34
+ }
35
+ export declare function latestReviewTerminalForGateRole(events: Event[], gate: ReviewGateRule, role: JobRole): ReviewTerminalResult | null;
36
+ export declare function effectiveReviewRejectionOverride(events: Event[], gate: ReviewGateRule, terminal: ReviewTerminalResult): ReviewRejectionOverrideAudit | null;
37
+ export type ReviewGateRoleResolution = {
38
+ kind: "none";
39
+ } | {
40
+ kind: "stale";
41
+ terminal: ReviewTerminalResult;
42
+ stale_reason: string;
43
+ } | {
44
+ kind: "accepted";
45
+ terminal: ReviewTerminalResult;
46
+ } | {
47
+ kind: "rejected_invalid";
48
+ terminal: ReviewTerminalResult;
49
+ } | {
50
+ kind: "rejected_pending";
51
+ terminal: ReviewTerminalResult;
52
+ } | {
53
+ kind: "overridden";
54
+ terminal: ReviewTerminalResult;
55
+ override: ReviewRejectionOverrideAudit;
56
+ };
57
+ export declare function reviewGateRoleResolution(events: Event[], changeRoot: string, gate: ReviewGateRule, role: JobRole): ReviewGateRoleResolution;
12
58
  export declare function latestReviewHistoryForGateRole(events: Event[], gate: ReviewGateRule, role: JobRole): ReviewPreviousRejection | null;
59
+ export declare function historicalProposeReadyRoles(events: Event[]): JobRole[];
13
60
  export declare function isReviewReadyVerifier(job: Job): boolean;
14
61
  export declare function reviewBoundFiles(changeRoot: string): Ref[];
15
62
  export declare function reviewEvidenceDigest(events: Event[]): string;
package/dist/review.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { existsSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { docRef, sha256File, sha256Text } from "./store.js";
5
- import { EXPLORE_DISCOVERY_REVIEW_GATE_ID, PROPOSE_FINAL_REVIEW_GATE_ID, REVIEW_FINAL_VERIFIER_GATE, } from "./review_job_gates.js";
5
+ import { EXPLORE_DISCOVERY_REVIEW_GATE_ID, PROPOSE_FINAL_REVIEW_GATE, PROPOSE_FINAL_REVIEW_GATE_ID, REVIEW_FINAL_VERIFIER_GATE, } from "./review_job_gates.js";
6
6
  import { computeCodeStateCheck, effectiveCoverageExemptionRefsFromEvents } from "./code_review.js";
7
7
  export const REVIEW_DOC_PATHS = [
8
8
  "proposal.md",
@@ -10,7 +10,6 @@ export const REVIEW_DOC_PATHS = [
10
10
  "design.md",
11
11
  "specs/",
12
12
  ".superspec/artifacts/discovery.md",
13
- ".superspec/artifacts/business-invariants.md",
14
13
  ".superspec/artifacts/test-contract.md",
15
14
  ];
16
15
  const CORE_COMMIT_FIELDS = new Set([
@@ -43,11 +42,27 @@ function isReviewPolicy(value) {
43
42
  typeof obj.requires_verifier === "boolean");
44
43
  }
45
44
  export function readReviewPolicyFromEvents(events) {
46
- for (const ev of events) {
45
+ // 新格式在 start-apply 写入,并且只能读取最新 Apply round 的策略。
46
+ // 回退到该 round 内的 review-ready,是为了回放升级前的历史 event。
47
+ let latestStartApplyIndex = -1;
48
+ for (let i = events.length - 1; i >= 0; i--) {
49
+ const event = events[i];
50
+ if (event.event_type !== "transition_commit")
51
+ continue;
52
+ const payload = event.payload;
53
+ if (payload.transition === "start-apply" && payload.to_state === "apply") {
54
+ latestStartApplyIndex = i;
55
+ break;
56
+ }
57
+ }
58
+ if (latestStartApplyIndex < 0)
59
+ return null;
60
+ for (let i = events.length - 1; i >= latestStartApplyIndex; i--) {
61
+ const ev = events[i];
47
62
  if (ev.event_type !== "transition_commit")
48
63
  continue;
49
64
  const payload = ev.payload;
50
- if (payload.transition !== "review-ready")
65
+ if (payload.transition !== "start-apply" && payload.transition !== "review-ready")
51
66
  continue;
52
67
  if (isReviewPolicy(payload.review_policy))
53
68
  return payload.review_policy;
@@ -59,6 +74,17 @@ function reviewResultKind(value) {
59
74
  ? value
60
75
  : null;
61
76
  }
77
+ export const REVIEW_REJECTION_OVERRIDE_SCOPE_PREFIX = "review_rejection_override:";
78
+ export const REVIEW_REJECTION_OVERRIDE_ANSWER = "do_not_block";
79
+ export function reviewRejectionOverrideScope(jobId) {
80
+ return `${REVIEW_REJECTION_OVERRIDE_SCOPE_PREFIX}${jobId}`;
81
+ }
82
+ export function parseReviewRejectionOverrideScope(scope) {
83
+ if (!scope.startsWith(REVIEW_REJECTION_OVERRIDE_SCOPE_PREFIX))
84
+ return null;
85
+ const jobId = scope.slice(REVIEW_REJECTION_OVERRIDE_SCOPE_PREFIX.length).trim();
86
+ return jobId === "" ? null : jobId;
87
+ }
62
88
  function reviewCycleState(gate) {
63
89
  if (gate.gate_id === EXPLORE_DISCOVERY_REVIEW_GATE_ID)
64
90
  return "explore";
@@ -109,6 +135,7 @@ function reviewTerminalResultsForGateRole(events, gate, role) {
109
135
  const findings = Array.isArray(payload.findings) ? payload.findings : undefined;
110
136
  results.push({
111
137
  job,
138
+ event,
112
139
  state: event.event_type === "job_accepted" ? "accepted" : "rejected",
113
140
  ...(event.event_type === "job_rejected"
114
141
  ? { result_kind: reviewResultKind(payload.result_kind) ?? (findings ? "review_failed" : "invalid_report") }
@@ -119,6 +146,69 @@ function reviewTerminalResultsForGateRole(events, gate, role) {
119
146
  }
120
147
  return results;
121
148
  }
149
+ export function latestReviewTerminalForGateRole(events, gate, role) {
150
+ return reviewTerminalResultsForGateRole(events, gate, role).at(-1) ?? null;
151
+ }
152
+ function validReviewRejectionOverridePayload(event, terminal, gate) {
153
+ if (event.event_type !== "user_decision_recorded")
154
+ return null;
155
+ const payload = event.payload;
156
+ if (payload.accepted !== true ||
157
+ payload.scope !== reviewRejectionOverrideScope(terminal.job.job_id) ||
158
+ payload.answer !== REVIEW_REJECTION_OVERRIDE_ANSWER ||
159
+ (payload.decision_source !== "main_process" && payload.decision_source !== "user") ||
160
+ typeof payload.reason !== "string" || payload.reason.trim() === "" ||
161
+ !payload.review_rejection_override ||
162
+ typeof payload.review_rejection_override !== "object" ||
163
+ Array.isArray(payload.review_rejection_override))
164
+ return null;
165
+ const audit = payload.review_rejection_override;
166
+ if (audit.job_id !== terminal.job.job_id ||
167
+ audit.role !== terminal.job.role ||
168
+ audit.gate_id !== gate.gate_id ||
169
+ audit.packet_digest !== terminal.job.packet_digest)
170
+ return null;
171
+ return {
172
+ job_id: terminal.job.job_id,
173
+ role: terminal.job.role,
174
+ gate_id: gate.gate_id,
175
+ packet_digest: terminal.job.packet_digest,
176
+ decision_source: payload.decision_source,
177
+ reason: payload.reason.trim(),
178
+ decision_event_id: event.event_id,
179
+ decision_event_digest: event.event_digest,
180
+ };
181
+ }
182
+ export function effectiveReviewRejectionOverride(events, gate, terminal) {
183
+ const cycleStartIndex = currentReviewGateCycleStart(events, gate);
184
+ if (cycleStartIndex == null)
185
+ return null;
186
+ const terminalIndex = events.findIndex(event => event.event_id === terminal.event.event_id);
187
+ if (terminalIndex < cycleStartIndex)
188
+ return null;
189
+ for (let i = events.length - 1; i > terminalIndex; i--) {
190
+ const audit = validReviewRejectionOverridePayload(events[i], terminal, gate);
191
+ if (audit)
192
+ return audit;
193
+ }
194
+ return null;
195
+ }
196
+ export function reviewGateRoleResolution(events, changeRoot, gate, role) {
197
+ const terminal = latestReviewTerminalForGateRole(events, gate, role);
198
+ if (!terminal)
199
+ return { kind: "none" };
200
+ const staleReason = boundFilesStaleReason(terminal.job, changeRoot);
201
+ if (staleReason)
202
+ return { kind: "stale", terminal, stale_reason: staleReason };
203
+ if (terminal.state === "accepted")
204
+ return { kind: "accepted", terminal };
205
+ if (terminal.result_kind !== "review_failed")
206
+ return { kind: "rejected_invalid", terminal };
207
+ const override = effectiveReviewRejectionOverride(events, gate, terminal);
208
+ return override
209
+ ? { kind: "overridden", terminal, override }
210
+ : { kind: "rejected_pending", terminal };
211
+ }
122
212
  export function latestReviewHistoryForGateRole(events, gate, role) {
123
213
  const terminalResults = reviewTerminalResultsForGateRole(events, gate, role);
124
214
  const latest = terminalResults.at(-1);
@@ -156,6 +246,20 @@ export function latestReviewHistoryForGateRole(events, gate, role) {
156
246
  job_id: latest.job.job_id,
157
247
  };
158
248
  }
249
+ export function historicalProposeReadyRoles(events) {
250
+ const roles = new Set();
251
+ for (const event of events) {
252
+ if (event.event_type !== "transition_commit")
253
+ continue;
254
+ const jobs = event.payload.new_jobs ?? [];
255
+ for (const job of jobs) {
256
+ if (PROPOSE_FINAL_REVIEW_GATE.isJobForGate(job) &&
257
+ (job.role === "critic" || job.role === "architect" || job.role === "test-engineer"))
258
+ roles.add(job.role);
259
+ }
260
+ }
261
+ return [...roles];
262
+ }
159
263
  export function isReviewReadyVerifier(job) {
160
264
  return job.role === "verifier" && REVIEW_FINAL_VERIFIER_GATE.isJobForGate(job);
161
265
  }
@@ -15,6 +15,11 @@ export declare const EXPLORE_DISCOVERY_REVIEW_GATE_ID: "explore.discovery_review
15
15
  export declare const PROPOSE_FINAL_REVIEW_GATE_ID: "propose.final_review";
16
16
  export declare const REVIEW_CODE_REVIEW_GATE_ID: "review.code_review";
17
17
  export declare const REVIEW_FINAL_VERIFIER_GATE_ID: "review.final_verifier";
18
+ export declare function reviewScopeForGateRole(gate: ReviewGateRule, role: JobRole): {
19
+ reviewTargets: string[];
20
+ readOnlyRefs: string[];
21
+ boundPaths: string[];
22
+ };
18
23
  export declare const EXPLORE_DISCOVERY_REVIEW_GATE: ReviewGateRule;
19
24
  export declare const PROPOSE_FINAL_REVIEW_GATE: ReviewGateRule;
20
25
  export declare const REVIEW_CODE_REVIEW_GATE: ReviewGateRule;
@@ -19,6 +19,58 @@ export const EXPLORE_DISCOVERY_REVIEW_GATE_ID = "explore.discovery_review";
19
19
  export const PROPOSE_FINAL_REVIEW_GATE_ID = "propose.final_review";
20
20
  export const REVIEW_CODE_REVIEW_GATE_ID = "review.code_review";
21
21
  export const REVIEW_FINAL_VERIFIER_GATE_ID = "review.final_verifier";
22
+ function defaultReviewScope(gate) {
23
+ return {
24
+ reviewTargets: [...gate.reviewTargets],
25
+ readOnlyRefs: [...gate.readOnlyRefs],
26
+ boundPaths: [...new Set([...gate.reviewTargets, ...gate.readOnlyRefs])],
27
+ };
28
+ }
29
+ export function reviewScopeForGateRole(gate, role) {
30
+ if (gate.gate_id !== PROPOSE_FINAL_REVIEW_GATE_ID)
31
+ return defaultReviewScope(gate);
32
+ if (role === "critic") {
33
+ const reviewTargets = ["proposal.md", "specs/", "tasks.md"];
34
+ const readOnlyRefs = [
35
+ "design.md",
36
+ ".superspec/artifacts/test-contract.md",
37
+ ".superspec/artifacts/discovery.md",
38
+ ];
39
+ return {
40
+ reviewTargets,
41
+ readOnlyRefs,
42
+ boundPaths: [...reviewTargets, ".superspec/artifacts/discovery.md"],
43
+ };
44
+ }
45
+ if (role === "architect") {
46
+ const reviewTargets = ["design.md"];
47
+ return {
48
+ reviewTargets,
49
+ readOnlyRefs: [
50
+ "proposal.md",
51
+ "specs/",
52
+ "tasks.md",
53
+ ".superspec/artifacts/discovery.md",
54
+ ".superspec/artifacts/test-contract.md",
55
+ ],
56
+ boundPaths: [...reviewTargets],
57
+ };
58
+ }
59
+ if (role === "test-engineer") {
60
+ const reviewTargets = [".superspec/artifacts/test-contract.md", "tasks.md"];
61
+ return {
62
+ reviewTargets,
63
+ readOnlyRefs: [
64
+ "proposal.md",
65
+ "specs/",
66
+ "design.md",
67
+ ".superspec/artifacts/discovery.md",
68
+ ],
69
+ boundPaths: [...reviewTargets],
70
+ };
71
+ }
72
+ return defaultReviewScope(gate);
73
+ }
22
74
  const EXPLORE_DISCOVERY_REVIEW_ROLES = ["critic"];
23
75
  const PROPOSAL_REVIEW_ROLES = ["critic", "architect", "test-engineer"];
24
76
  const PROPOSAL_REVIEW_ROLE_SET = new Set(PROPOSAL_REVIEW_ROLES);
@@ -46,7 +98,6 @@ export const PROPOSE_FINAL_REVIEW_GATE = makeReviewGateRule({
46
98
  "tasks.md",
47
99
  "design.md",
48
100
  "specs/",
49
- ".superspec/artifacts/business-invariants.md",
50
101
  ".superspec/artifacts/test-contract.md",
51
102
  ],
52
103
  readOnlyRefs: [".superspec/artifacts/discovery.md"],
package/dist/sync.js CHANGED
@@ -7,7 +7,6 @@ import { invalidReasonForSnapshot } from "./job_validity.js";
7
7
  const TRACKED_DOCS = [
8
8
  "proposal.md", "design.md", "tasks.md", "specs/",
9
9
  ".superspec/artifacts/discovery.md",
10
- ".superspec/artifacts/business-invariants.md",
11
10
  ".superspec/artifacts/test-contract.md",
12
11
  ];
13
12
  /** 从 events.jsonl 推导:当前状态、jobs、attempts、pending decisions */
@@ -35,7 +34,8 @@ function replayEvents(events) {
35
34
  }
36
35
  break;
37
36
  }
38
- // H4 修复:删除 job_requested / job_invalidated 死分支——job 只通过 transition_commit 的 new_jobs payload 创建,staleness 只在 sync 内存计算(不写事件)
37
+ // job 只通过 transition_commit 的 new_jobs payload 创建。reopen 到更早阶段时,
38
+ // 已绑定旧材料的待完成工作项会被 job_invalidated 关闭,避免阻塞新一轮 gate。
39
39
  case "job_accepted": {
40
40
  const { job_id } = ev.payload;
41
41
  const idx = openJobs.findIndex(j => j.job_id === job_id);
@@ -54,7 +54,13 @@ function replayEvents(events) {
54
54
  }
55
55
  break;
56
56
  }
57
- // H4:job_invalidated 事件从不发射(删除死分支)
57
+ case "job_invalidated": {
58
+ const { job_id } = ev.payload;
59
+ const idx = openJobs.findIndex(j => j.job_id === job_id);
60
+ if (idx >= 0)
61
+ openJobs.splice(idx, 1);
62
+ break;
63
+ }
58
64
  case "task_started": {
59
65
  const attempt = ev.payload;
60
66
  activeAttempts.push(attempt);
@@ -72,8 +78,10 @@ function replayEvents(events) {
72
78
  case "task_abandoned": {
73
79
  const { attempt_id } = ev.payload;
74
80
  const idx = activeAttempts.findIndex(a => a.attempt_id === attempt_id);
75
- if (idx >= 0)
76
- activeAttempts.splice(idx, 1);
81
+ if (idx >= 0) {
82
+ const [attempt] = activeAttempts.splice(idx, 1);
83
+ taskStatuses[attempt.task_id] = "todo";
84
+ }
77
85
  break;
78
86
  }
79
87
  }
package/dist/task.js CHANGED
@@ -4,6 +4,7 @@ import { join } from "node:path";
4
4
  import { sha256Text, ensureChangeLayout, appendEvent, makeEvent, withLock, appendRawRecord, readEvents } from "./store.js";
5
5
  import { tasksStructureDigest as formatDigest } from "./format.js";
6
6
  import { RecordInputDecodingError, readRecordInputFile } from "./record_input.js";
7
+ import { GREEN_ONLY_NO_TDD_REASON } from "./types.js";
7
8
  /** tasks.md 结构指纹(委托给 format.ts 统一实现) */
8
9
  export function tasksStructureDigestOf(changeRoot) {
9
10
  const p = join(changeRoot, "tasks.md");
@@ -124,16 +125,28 @@ function validateContractTestRunInput(tr, attempt) {
124
125
  if (!["expected_failure", "expected_success", "characterization_pass"].includes(tr.semantic_status)) {
125
126
  return { ok: false, message: "语义状态(semantic_status)必须是 expected_failure、expected_success 或 characterization_pass" };
126
127
  }
128
+ const requiredEvidence = attempt.required_evidence;
129
+ if (tr.semantic_status === "expected_failure" && requiredEvidence && !requiredEvidence.red_required) {
130
+ return { ok: false, message: "当前任务执行快照不要求 RED(expected_failure);请登记声明 TEST 的 GREEN" };
131
+ }
132
+ if (tr.semantic_status === "expected_failure" && !requiredEvidence &&
133
+ attempt.execution_policy === "green_only" &&
134
+ attempt.no_tdd_reason === GREEN_ONLY_NO_TDD_REASON) {
135
+ return { ok: false, message: "GREEN-only 任务不登记 RED(expected_failure);请在实现后登记声明 TEST 的 GREEN" };
136
+ }
127
137
  if (tr.semantic_status === "expected_failure" && tr.exit_code === 0) {
128
138
  return { ok: false, message: "RED 预期失败(expected_failure)要求退出码(exit_code)非 0" };
129
139
  }
130
140
  if ((tr.semantic_status === "expected_success" || tr.semantic_status === "characterization_pass") && tr.exit_code !== 0) {
131
141
  return { ok: false, message: `语义状态(semantic_status=${tr.semantic_status})要求退出码(exit_code)为 0` };
132
142
  }
133
- if (tr.semantic_status === "characterization_pass" && !(attempt.tdd_required === false && attempt.no_tdd_reason === "characterization")) {
143
+ if (tr.semantic_status === "characterization_pass" && requiredEvidence && !requiredEvidence.accepted_green_statuses.includes("characterization_pass")) {
144
+ return { ok: false, message: "当前任务执行快照不接受特征化通过(characterization_pass)" };
145
+ }
146
+ if (tr.semantic_status === "characterization_pass" && !requiredEvidence && !(attempt.tdd_required === false && attempt.no_tdd_reason === "characterization")) {
134
147
  return { ok: false, message: "特征化通过(characterization_pass)只适用于无需 TDD 的特征化任务(tdd_required:false,no_tdd_reason:characterization)" };
135
148
  }
136
- const declaredTests = attempt.contract?.tests ?? [];
149
+ const declaredTests = requiredEvidence?.test_ids ?? attempt.contract?.tests ?? [];
137
150
  if (declaredTests.length > 0 && !declaredTests.includes(tr.test_id)) {
138
151
  return { ok: false, message: `测试 ID(test_id=${tr.test_id})不属于当前任务契约声明的测试列表` };
139
152
  }
@@ -1,4 +1,4 @@
1
- import type { TaskAttempt } from "./types.ts";
1
+ import { type TaskAttempt } from "./types.ts";
2
2
  export type TaskEvidenceReadiness = {
3
3
  ready: true;
4
4
  missing: [];