@peterxiaoyang/superspec 0.1.46 → 0.1.47

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.
@@ -1,7 +1,8 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { EXPLORE_DISCOVERY_REVIEW_GATE, PROPOSE_FINAL_REVIEW_GATE } from "./review_job_gates.js";
4
- import { collectProposeOpenQuestions, countDiscoveryOpenQuestions, parseExecutionRequirements, parseTasksMd, pendingTasksInContent, validateDiscovery, validateExecutionRequirements, validateExecutionRequirementDocumentReferences, validateProposalImpact, validateTasksDocument, } from "./format.js";
4
+ import { currentExploreRoundId, exploreAnswerRegistrationPayload, unregisteredClosedExploreQuestions, } from "./explore_round.js";
5
+ import { collectProposeOpenQuestions, discoveryOpenQuestionDisplayText, discoveryOpenQuestionScope, parseExecutionRequirements, parseDiscoveryOpenQuestions, parseTasksMd, pendingTasksInContent, validateDiscovery, validateExecutionRequirements, validateExecutionRequirementDocumentReferences, validateProposalImpact, validateTasksDocument, } from "./format.js";
5
6
  import { currentGitHead } from "./git_state.js";
6
7
  import { validateOpenSpecChange } from "./openspec.js";
7
8
  import { docRef, sha256File } from "./store.js";
@@ -184,6 +185,11 @@ export function discoveryDocsBaseline(changeRoot) {
184
185
  }
185
186
  return baseline;
186
187
  }
188
+ /** 新 Explore 轮次冻结已有已确认事项,避免把历史答复当作本轮遗漏。 */
189
+ export function exploreAnswerRegistrationPayloadForChange(changeRoot) {
190
+ const discoveryPath = join(changeRoot, ".superspec", "artifacts", "discovery.md");
191
+ return exploreAnswerRegistrationPayload(existsSync(discoveryPath) ? readFileSync(discoveryPath, "utf8") : null);
192
+ }
187
193
  function isDigestMap(value) {
188
194
  if (!value || typeof value !== "object" || Array.isArray(value))
189
195
  return false;
@@ -452,10 +458,6 @@ export function planNextStep(context) {
452
458
  reason: "回到 explore 后至少一个 discovery 材料必须变化",
453
459
  };
454
460
  }
455
- const exploreReviewJobs = EXPLORE_DISCOVERY_REVIEW_GATE.openJobsForGate(snapshot);
456
- if (exploreReviewJobs.length > 0) {
457
- return requiredJobs("explore", exploreReviewJobs, `有 ${exploreReviewJobs.length} 个待完成探索审查工作项`);
458
- }
459
461
  const discoveryCheck = validateDiscovery(changeRoot);
460
462
  if (!discoveryCheck.ok) {
461
463
  const ask = {
@@ -466,14 +468,32 @@ export function planNextStep(context) {
466
468
  return { kind: "ask_user", state: "explore", ask, reason: discoveryCheck.message };
467
469
  }
468
470
  const content = readFileSync(join(changeRoot, ".superspec", "artifacts", "discovery.md"), "utf8");
469
- const openQs = countDiscoveryOpenQuestions(content);
470
- if (openQs > 0) {
471
+ const currentQuestion = parseDiscoveryOpenQuestions(content)[0];
472
+ if (currentQuestion) {
473
+ const questionText = discoveryOpenQuestionDisplayText(currentQuestion);
471
474
  const ask = {
472
- question: `discovery.md 有 ${openQs} 个未解决的待确认问题,请逐个确认`,
473
- allowed_answers: ["所有问题已确认"],
474
- scope: "explore_open_questions",
475
+ question: `现在有一件事需要你确认:${questionText}\n\n请只回答这一件事。主流程会先登记答复,再将结论回写 discovery.md;回写完成前会继续询问这一件事。`,
476
+ // 用户可以接受建议、选择其他方向或补充事实;状态机不解释答案语义。
477
+ allowed_answers: [],
478
+ scope: discoveryOpenQuestionScope(currentQuestion, currentExploreRoundId(events)),
475
479
  };
476
- return { kind: "ask_user", state: "explore", ask, reason: `有 ${openQs} 个未确认问题` };
480
+ return { kind: "ask_user", state: "explore", ask, reason: "等待用户确认" };
481
+ }
482
+ const unregisteredClosedQuestions = unregisteredClosedExploreQuestions(events, content);
483
+ if (unregisteredClosedQuestions.length > 0) {
484
+ const ask = {
485
+ question: "发现一项已标记为已确认的事项没有对应的答复登记。请先将该项恢复为待确认,按主流程重新登记用户答复并回写 discovery.md 后继续。",
486
+ allowed_answers: ["已处理"],
487
+ scope: "explore_answer_registration",
488
+ };
489
+ return { kind: "ask_user", state: "explore", ask, reason: "存在未登记答复的已确认事项" };
490
+ }
491
+ // 先让用户澄清当前 Discovery,再审查材料;否则 critic 会审查一份仍有
492
+ // 关键业务未知的文档。正常创建的 job 已绑定 discovery 指纹,材料变化后
493
+ // 会由 snapshot freshness 自动失效。
494
+ const exploreReviewJobs = EXPLORE_DISCOVERY_REVIEW_GATE.openJobsForGate(snapshot);
495
+ if (exploreReviewJobs.length > 0) {
496
+ return requiredJobs("explore", exploreReviewJobs, `有 ${exploreReviewJobs.length} 个待完成探索审查工作项`);
477
497
  }
478
498
  const requiredRoles = EXPLORE_DISCOVERY_REVIEW_GATE.requiredRolesForRisk(mode.risk);
479
499
  if (!reviewGatePlan(snapshot, events, changeRoot, EXPLORE_DISCOVERY_REVIEW_GATE, requiredRoles)) {
@@ -642,6 +662,16 @@ function planApplyDoneNext(context) {
642
662
  }
643
663
  const latest = facts.latestTerminal;
644
664
  if (latest?.state === "rejected" && latest.result_kind === "review_failed") {
665
+ const staleReason = codeReviewJobStaleReason(context.projectRoot, latest.job, currentWorkingPaths);
666
+ if (staleReason) {
667
+ return {
668
+ kind: "run_transition",
669
+ state: "apply_done",
670
+ transition: "review-ready",
671
+ risk: mode.risk,
672
+ reason: `代码审查结论已过期,重新发起审查:${staleReason}`,
673
+ };
674
+ }
645
675
  const status = latestCodeReviewFailedStatus(events);
646
676
  const pendingFinding = status?.unresolved[0] ?? null;
647
677
  if (status && status.findings.length > 0 && !pendingFinding) {
@@ -804,7 +834,13 @@ export function planTransition(name, context) {
804
834
  function planExploreTransition(context) {
805
835
  const { changeRoot, events, mode, projectRoot, snapshot } = context;
806
836
  if (snapshot.state === "init") {
807
- return { kind: "advance", fromState: "init", toState: "explore", reason: "进入探索阶段" };
837
+ return {
838
+ kind: "advance",
839
+ fromState: "init",
840
+ toState: "explore",
841
+ reason: "进入探索阶段",
842
+ payload: exploreAnswerRegistrationPayloadForChange(changeRoot),
843
+ };
808
844
  }
809
845
  if (snapshot.state !== "explore") {
810
846
  return { kind: "skip", message: `当前状态 ${snapshot.state},explore 不适用` };
@@ -816,6 +852,14 @@ function planExploreTransition(context) {
816
852
  const discoveryCheck = validateDiscovery(changeRoot);
817
853
  if (!discoveryCheck.ok)
818
854
  return { kind: "skip", message: discoveryCheck.message };
855
+ const discoveryContent = readFileSync(join(changeRoot, ".superspec", "artifacts", "discovery.md"), "utf8");
856
+ const currentQuestion = parseDiscoveryOpenQuestions(discoveryContent)[0];
857
+ if (currentQuestion) {
858
+ return { kind: "skip", message: "discovery.md 仍有需要确认的事项,请先完成确认并回写 discovery.md" };
859
+ }
860
+ if (unregisteredClosedExploreQuestions(events, discoveryContent).length > 0) {
861
+ return { kind: "skip", message: "discovery.md 有已确认事项缺少对应答复登记,请先恢复为待确认并按主流程登记答复" };
862
+ }
819
863
  const requiredRoles = EXPLORE_DISCOVERY_REVIEW_GATE.requiredRolesForRisk(mode.risk);
820
864
  const gatePlan = reviewGatePlan(snapshot, events, changeRoot, EXPLORE_DISCOVERY_REVIEW_GATE, requiredRoles);
821
865
  if (gatePlan)
package/dist/record.js CHANGED
@@ -1,26 +1,31 @@
1
1
  // SuperSpec 流程引擎 — record:工作项结果登记
2
- import { existsSync } from "node:fs";
2
+ import { existsSync, readFileSync } 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
5
  import { rebuildSnapshot } from "./sync.js";
6
6
  import { changeRoot as openspecChangeRoot } from "./openspec.js";
7
7
  import { isPhaseConfirmationScope, phaseActionForAnswer, phaseConfirmationForCurrentState, workflowRiskForPhaseConfirmation, } from "./phase_confirmation.js";
8
- import { CODE_REVIEW_DECISION_ANSWER_LABELS, CODE_REVIEW_DECISION_SCOPE_PREFIX, codeReviewDecisionAnswerLabel, normalizeCodeReviewDecisionAnswer, } from "./code_review.js";
8
+ import { CODE_REVIEW_DECISION_ANSWER_LABELS, CODE_REVIEW_DECISION_SCOPE_PREFIX, codeReviewJobStaleReason, codeReviewDecisionAnswerLabel, currentCodeReviewWorkingPaths, latestCodeReviewFailedStatus, normalizeCodeReviewDecisionAnswer, parseCodeReviewDecisionScope, } from "./code_review.js";
9
9
  import { invalidReasonForSubmittedReport } from "./job_validity.js";
10
10
  import { jobSubmitArgv } from "./job_action.js";
11
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
+ import { currentExploreRoundId } from "./explore_round.js";
15
+ import { discoveryOpenQuestionDisplayText, discoveryQuestionContextFingerprint, discoveryOpenQuestionScope, EXPLORE_OPEN_QUESTION_SCOPE_PREFIX, parseDiscoveryOpenQuestions, } from "./format.js";
14
16
  const REVIEW_REPORT_REQUIRED_FIELDS = ["role", "verdict", "findings"];
15
17
  const REVIEW_REPORT_OPTIONAL_FIELDS = ["summary", "evidence_refs", "risks", "open_questions"];
16
18
  const REVIEWER_KINDS = new Set(["codex-subagent", "human", "external-agent"]);
17
19
  const CODE_REVIEW_FINDING_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/;
20
+ function isReviewRole(role) {
21
+ return role === "critic" || role === "architect" || role === "test-engineer" || role === "code-reviewer" || role === "verifier";
22
+ }
18
23
  function requiresReviewer(role) {
19
24
  return role === "critic" || role === "architect" || role === "test-engineer" || role === "code-reviewer";
20
25
  }
21
26
  /** All review reports with bound material acknowledge full file coverage. */
22
27
  function requiresReviewScope(job) {
23
- return job.boundFiles.length > 0 && job.role !== "executor" && job.role !== "test-run";
28
+ return job.boundFiles.length > 0 && isReviewRole(job.role);
24
29
  }
25
30
  function projectLocalInvalidReportMustTerminate(job) {
26
31
  return job.role === "code-reviewer" || job.packet_context?.code_state_check !== undefined;
@@ -62,14 +67,13 @@ function previousRejectionInstruction(job) {
62
67
  if (!previous)
63
68
  return "";
64
69
  const reason = `上一次同角色审查没有形成可推进结论,原因:${previous.reason}。`;
65
- if (!previous.findings || previous.findings.length === 0)
66
- return `${reason}本轮是修复复核;完整读取材料只用于核对当前修正和直接一致性,不得借复核重新审计与修正无关的历史设计,`;
67
- return `${reason}本轮是修复复核:逐项判断本工作项附带的上一次同角色 finding 是否仍成立。Finding 中的 recommendation 只是非绑定建议,不是需求或验收标准;先独立核对 underlying problem、直接证据和本次验收,不得因原建议指定了某种架构就要求照做。同一问题仍存在时复用原 finding ID;legacy finding 没有 ID 时沿用其原始语义并补一个稳定 ID;已解决或已由等价证据闭环的问题不要重复报告,不得通过更换 ID、标题或措辞重复同一问题。默认只复核历史 finding;新 blocker 仅允许是本次修正直接引入的回归,并必须说明“修正动作 → 新问题”的因果链,不得展开无关的故障模型、消费者或架构议题。`;
68
- }
69
- function ordinaryReviewerFindingInstruction(job) {
70
- if (!isOrdinaryReviewer(job.role))
71
- return "";
72
- return "问题列表中的每个新 finding 必须分配稳定 ID,后续同一问题沿用该 ID,";
70
+ if (!previous.findings || previous.findings.length === 0) {
71
+ return `${reason}上一轮没有可复核的历史 finding;请按当前 gate 的完整范围独立审查,不要把拒绝原因当作需求或验收标准,`;
72
+ }
73
+ const identityRule = job.role === "code-reviewer"
74
+ ? "同一问题仍存在时复用原 finding ID;legacy finding 没有 ID 时沿用原始语义并补一个稳定 ID;"
75
+ : "已解决或已由等价证据闭环的问题不要重复报告,不得通过更换标题或措辞重复同一问题;";
76
+ return `${reason}本轮是修复复核:逐项判断本工作项附带的上一次同角色 finding 是否仍成立。Finding 中的 recommendation 只是非绑定建议,不是需求或验收标准;先独立核对 underlying problem、直接证据和本次验收,不得因原建议指定了某种架构就要求照做。${identityRule}默认只复核历史 finding;新 blocker 仅允许是本次修正直接引入的回归,并必须说明“修正动作 → 新问题”的因果链,不得展开无关的故障模型、消费者或架构议题。`;
73
77
  }
74
78
  function reviewScopeForJob(job) {
75
79
  if (job.review_targets !== undefined || job.read_only_refs !== undefined) {
@@ -94,10 +98,15 @@ function reviewScopeInstruction(job, reviewTargets, readOnlyRefs) {
94
98
  : "";
95
99
  return targets + refs;
96
100
  }
97
- function reviewCoverageInstruction(job) {
101
+ function genericReviewCoverageInstruction(job) {
98
102
  if (!requiresReviewScope(job))
99
103
  return "";
100
- return `审查顺序固定为:先建立覆盖索引,按文件和标题/行段完整浏览全部 boundFiles 至文件末尾;长文档必须分段读取,不能在发现第一个 blocker 时停止覆盖。read_only_refs 按本次问题需要读取,用于核对目标与上下游一致性,不要求机械全文遍历或写入 checked_paths。完整读取只用于核对本次 change 的目标、直接修改及跨文档一致性,不等于允许重新审计全部历史设计。若 proposal.md 存在“需求变化”,以其中记录的受影响能力、直接修改章节和保持不变范围作为本轮增量审查的权威锚点;本轮新 finding 必须由该需求变化、为接入变化所做的直接修改,或这些修改造成的跨文档矛盾引起,并说明因果链。此前已通过且被明确记录为保持不变的设计不得重新打开为 blocker;不要凭通用风险类别猜测变化范围。注意事项和故障类别是条件式检查项,不是必须穷举的清单。Recommendation 只能描述需要补足的结果、契约或证据,不得把未经 proposal、design 或用户决定选定的新基础设施写成 required fix。报告中的 review_scope.checked_paths 必须列出全部已浏览的 boundFiles;它只是覆盖回执,不能代替语义审查,也不扩大可报告问题的范围。`;
104
+ return "完整审查全部 boundFiles,不因发现第一个 blocker 停止;read_only_refs 只在核对本次问题与上下游一致性时读取。review_scope.checked_paths 必须回执已浏览的全部 boundFiles,但该回执不能代替语义审查,也不扩大可报告问题的范围。";
105
+ }
106
+ function proposalIncrementalReviewInstruction(job) {
107
+ if (!PROPOSE_FINAL_REVIEW_GATE.isJobForGate(job))
108
+ return "";
109
+ return "若 proposal.md 有“需求变化”,以其中记录的受影响能力、直接修改章节和保持不变范围作为增量审查锚点;新 finding 必须由该变化、其直接修改或由此造成的跨文档矛盾引起,并说明因果链。此前已通过且明确保持不变的设计不得重新打开为 blocker;Recommendation 只描述需要补足的结果、契约或证据,不得把未经 proposal、design 或用户决定选定的新基础设施写成 required fix。";
101
110
  }
102
111
  function migrationEvidenceInstruction(job) {
103
112
  const isProposeReview = PROPOSE_FINAL_REVIEW_GATE.isJobForGate(job);
@@ -117,6 +126,66 @@ function asObject(value) {
117
126
  function nonEmptyString(value) {
118
127
  return typeof value === "string" && value.trim() !== "";
119
128
  }
129
+ function currentDiscoveryOpenQuestion(projectRoot, change) {
130
+ const discoveryPath = join(openspecChangeRoot(projectRoot, change), ".superspec", "artifacts", "discovery.md");
131
+ if (!existsSync(discoveryPath))
132
+ return null;
133
+ return parseDiscoveryOpenQuestions(readFileSync(discoveryPath, "utf8"))[0] ?? null;
134
+ }
135
+ function latestAcceptedExploreOpenQuestionDecision(events, scope) {
136
+ return [...events].reverse().find(event => {
137
+ if (event.event_type !== "user_decision_recorded")
138
+ return false;
139
+ const payload = event.payload;
140
+ return payload.accepted !== false && payload.scope === scope;
141
+ }) ?? null;
142
+ }
143
+ function isExploreOpenQuestionScope(scope) {
144
+ return scope.startsWith(EXPLORE_OPEN_QUESTION_SCOPE_PREFIX);
145
+ }
146
+ function isWellFormedExploreOpenQuestionScope(scope) {
147
+ return /^explore_open_question:sha256:[a-f0-9]{64}:(?:Q-[A-Za-z0-9][A-Za-z0-9_-]*|item-[1-9]\d*)$/.test(scope);
148
+ }
149
+ function invalidExploreOpenQuestionResult(projectRoot, change, inputDigest, existing, decision, reason) {
150
+ const existingPayload = existing?.payload;
151
+ if (existingPayload?.accepted === false && existingPayload.reason === reason) {
152
+ return {
153
+ event_type: "user_decision_recorded",
154
+ accepted: false,
155
+ message: "幂等返回:同一无效待确认问题答复已登记",
156
+ };
157
+ }
158
+ appendEvent(projectRoot, change, makeEvent(change, "user_decision_recorded", {
159
+ accepted: false,
160
+ scope: decision.scope,
161
+ answer: decision.answer,
162
+ reason,
163
+ input_digest: inputDigest,
164
+ }));
165
+ return {
166
+ event_type: "user_decision_recorded",
167
+ accepted: false,
168
+ message: reason === "invalid_explore_open_question_scope"
169
+ ? "确认事项的内部标识无效,请重新执行 next 获取当前事项"
170
+ : reason === "explore_open_question_already_recorded"
171
+ ? "这件事已有已登记答复,请先回写 discovery.md 后重新执行 next"
172
+ : "需要确认的事项已变化或已完成,请重新执行 next 获取当前事项",
173
+ };
174
+ }
175
+ function invalidCodeReviewDecisionResult(projectRoot, change, inputDigest, existing, decision, reason, message) {
176
+ const existingPayload = existing?.payload;
177
+ if (existingPayload?.accepted === false && existingPayload.reason === reason) {
178
+ return { event_type: "user_decision_recorded", accepted: false, message: "幂等返回:同一无效代码审查决策已登记" };
179
+ }
180
+ appendEvent(projectRoot, change, makeEvent(change, "user_decision_recorded", {
181
+ accepted: false,
182
+ scope: decision.scope,
183
+ answer: decision.answer,
184
+ reason,
185
+ input_digest: inputDigest,
186
+ }));
187
+ return { event_type: "user_decision_recorded", accepted: false, message };
188
+ }
120
189
  function stringArray(value) {
121
190
  return Array.isArray(value) && value.every(item => typeof item === "string");
122
191
  }
@@ -421,8 +490,8 @@ function recordJobSubmitLoaded(projectRoot, change, changeRoot, jobId, job, even
421
490
  if (!Array.isArray(obj.findings)) {
422
491
  checks.push("报告问题列表 findings 必须是数组");
423
492
  }
424
- else if (isOrdinaryReviewer(job.role) && obj.verdict === "fail" && obj.findings.length === 0) {
425
- checks.push("普通审查报告结论为 fail 时 findings 至少包含一个问题");
493
+ else if ((isOrdinaryReviewer(job.role) || job.role === "verifier") && obj.verdict === "fail" && obj.findings.length === 0) {
494
+ checks.push("审查报告结论为 fail 时 findings 至少包含一个问题");
426
495
  }
427
496
  if (requiresReviewer(job.role)) {
428
497
  validateReviewer(obj, checks);
@@ -633,6 +702,40 @@ function recordUserDecisionLoaded(projectRoot, change, events, content, inputDig
633
702
  }));
634
703
  return { event_type: "user_decision_recorded", accepted: false, message: "决策文件缺少决策范围(scope)或答复内容(answer)" };
635
704
  }
705
+ // Explore 的用户答复只能绑定当前文档顺序中的第一项。这里不判断答案是否
706
+ // “正确”,只机械校验当前项、Discovery 决策上下文和 Explore 轮次仍与 next
707
+ // 返回时一致,防止旧问题在材料改写或重新探索后被错误登记为新问题的决定。
708
+ let exploreOpenQuestion = null;
709
+ let exploreOpenQuestionRoundId = null;
710
+ let exploreOpenQuestionContextFingerprint = null;
711
+ if (isExploreOpenQuestionScope(decision.scope)) {
712
+ if (!isWellFormedExploreOpenQuestionScope(decision.scope)) {
713
+ return invalidExploreOpenQuestionResult(projectRoot, change, inputDigest, existing, { scope: decision.scope, answer: decision.answer }, "invalid_explore_open_question_scope");
714
+ }
715
+ const current = currentDiscoveryOpenQuestion(projectRoot, change);
716
+ const expectedScope = current ? discoveryOpenQuestionScope(current, currentExploreRoundId(events)) : null;
717
+ if (!expectedScope || decision.scope !== expectedScope) {
718
+ return invalidExploreOpenQuestionResult(projectRoot, change, inputDigest, existing, { scope: decision.scope, answer: decision.answer }, "stale_explore_open_question_scope");
719
+ }
720
+ const acceptedForCurrentScope = latestAcceptedExploreOpenQuestionDecision(events, decision.scope);
721
+ if (acceptedForCurrentScope) {
722
+ const previousAnswer = acceptedForCurrentScope.payload.answer;
723
+ if (previousAnswer === decision.answer) {
724
+ return {
725
+ event_type: "user_decision_recorded",
726
+ accepted: true,
727
+ message: "幂等返回:同一用户决策已登记",
728
+ };
729
+ }
730
+ return invalidExploreOpenQuestionResult(projectRoot, change, inputDigest, existing, { scope: decision.scope, answer: decision.answer }, "explore_open_question_already_recorded");
731
+ }
732
+ exploreOpenQuestion = current;
733
+ exploreOpenQuestionRoundId = currentExploreRoundId(events);
734
+ const discoveryPath = join(openspecChangeRoot(projectRoot, change), ".superspec", "artifacts", "discovery.md");
735
+ exploreOpenQuestionContextFingerprint = current
736
+ ? discoveryQuestionContextFingerprint(readFileSync(discoveryPath, "utf8"), current)
737
+ : null;
738
+ }
636
739
  let phaseConfirmation = null;
637
740
  let phaseAction = null;
638
741
  let phaseReviewRisk = null;
@@ -699,7 +802,10 @@ function recordUserDecisionLoaded(projectRoot, change, events, content, inputDig
699
802
  phaseAction = action;
700
803
  phaseReviewRisk = decisionRisk;
701
804
  }
702
- if (existing && !phaseAction) {
805
+ // Explore scope 已在上面按当前问题和同 scope 的已接受答复完成校验。此前同一
806
+ // input 曾因 stale 被拒绝、但材料后来回到完全相同的当前项时,不能让旧拒绝
807
+ // 记录永久吞掉一次现在有效的登记。
808
+ if (existing && !phaseAction && !exploreOpenQuestion) {
703
809
  const accepted = existing.payload.accepted !== false;
704
810
  return {
705
811
  event_type: "user_decision_recorded",
@@ -760,36 +866,45 @@ function recordUserDecisionLoaded(projectRoot, change, events, content, inputDig
760
866
  };
761
867
  reviewRejectionDecisionSource = decision.decision_source;
762
868
  }
869
+ let codeReviewDecisionReference = null;
763
870
  if (decision.scope.startsWith(CODE_REVIEW_DECISION_SCOPE_PREFIX)) {
764
871
  const normalizedAnswer = normalizeCodeReviewDecisionAnswer(decision.answer);
765
872
  if (!normalizedAnswer) {
766
- appendEvent(projectRoot, change, makeEvent(change, "user_decision_recorded", {
767
- accepted: false,
768
- scope: decision.scope,
769
- answer: decision.answer,
770
- reason: "invalid_code_review_decision_answer",
771
- input_digest: inputDigest,
772
- }));
773
- return {
774
- event_type: "user_decision_recorded",
775
- accepted: false,
776
- message: `代码审查决策必须是 ${CODE_REVIEW_DECISION_ANSWER_LABELS.reopen_propose}、${CODE_REVIEW_DECISION_ANSWER_LABELS.reopen_apply} 或 ${CODE_REVIEW_DECISION_ANSWER_LABELS.dismiss}`,
777
- };
873
+ return invalidCodeReviewDecisionResult(projectRoot, change, inputDigest, existing, { scope: decision.scope, answer: decision.answer }, "invalid_code_review_decision_answer", `代码审查决策必须是 ${CODE_REVIEW_DECISION_ANSWER_LABELS.reopen_propose}、${CODE_REVIEW_DECISION_ANSWER_LABELS.reopen_apply} 或 ${CODE_REVIEW_DECISION_ANSWER_LABELS.dismiss}`);
778
874
  }
779
875
  if (!nonEmptyString(decision.reason)) {
780
- appendEvent(projectRoot, change, makeEvent(change, "user_decision_recorded", {
781
- accepted: false,
782
- scope: decision.scope,
783
- answer: decision.answer,
784
- reason: "missing_reason",
785
- input_digest: inputDigest,
786
- }));
787
- return {
788
- event_type: "user_decision_recorded",
789
- accepted: false,
790
- message: "代码审查决策必须写明原因",
791
- };
876
+ return invalidCodeReviewDecisionResult(projectRoot, change, inputDigest, existing, { scope: decision.scope, answer: decision.answer }, "missing_reason", "代码审查决策必须写明原因");
792
877
  }
878
+ const ref = parseCodeReviewDecisionScope(decision.scope);
879
+ const changeRoot = openspecChangeRoot(projectRoot, change);
880
+ const snapshot = rebuildSnapshot(projectRoot, change, changeRoot);
881
+ const status = latestCodeReviewFailedStatus(events);
882
+ const finding = ref && status?.terminal.job.job_id === ref.jobId
883
+ ? status.findings.find(item => item.id === ref.findingId && (item.type === "spec" || item.type === "mixed"))
884
+ : null;
885
+ const staleReason = status && ref && status.terminal.job.job_id === ref.jobId
886
+ ? codeReviewJobStaleReason(projectRoot, status.terminal.job, currentCodeReviewWorkingPaths(projectRoot, events))
887
+ : null;
888
+ if (!ref || snapshot.state !== "apply_done" || !status || !finding || staleReason) {
889
+ const reason = !ref
890
+ ? "invalid_code_review_decision_scope"
891
+ : snapshot.state !== "apply_done"
892
+ ? "code_review_decision_not_current"
893
+ : staleReason
894
+ ? "stale_code_review_decision_target"
895
+ : "code_review_decision_target_not_found";
896
+ const message = staleReason
897
+ ? "代码审查材料已不再匹配当前代码,请先重新执行 review-ready 获取新的审查结论"
898
+ : "代码审查决策只能关联当前代码审查报告中的待决定问题,请先执行 next 获取当前选择";
899
+ return invalidCodeReviewDecisionResult(projectRoot, change, inputDigest, existing, { scope: decision.scope, answer: decision.answer }, reason, message);
900
+ }
901
+ codeReviewDecisionReference = {
902
+ job_id: status.terminal.job.job_id,
903
+ finding_id: finding.id,
904
+ rejection_event_id: status.terminal.event.event_id,
905
+ rejection_event_digest: status.terminal.event.event_digest,
906
+ packet_digest: status.terminal.job.packet_digest,
907
+ };
793
908
  }
794
909
  const normalizedAnswer = decision.scope.startsWith(CODE_REVIEW_DECISION_SCOPE_PREFIX)
795
910
  ? normalizeCodeReviewDecisionAnswer(decision.answer)
@@ -798,13 +913,25 @@ function recordUserDecisionLoaded(projectRoot, change, events, content, inputDig
798
913
  scope: decision.scope,
799
914
  question: phaseConfirmation
800
915
  ? phaseConfirmation.ask.question
801
- : typeof decision.question === "string" ? decision.question : "",
916
+ : exploreOpenQuestion
917
+ ? discoveryOpenQuestionDisplayText(exploreOpenQuestion)
918
+ : typeof decision.question === "string" ? decision.question : "",
802
919
  answer: phaseAction
803
920
  ? phaseAction.label
804
921
  : normalizedAnswer ? codeReviewDecisionAnswerLabel(normalizedAnswer) : decision.answer,
805
922
  ...(typeof decision.reason === "string" ? { reason: decision.reason.trim() } : {}),
806
923
  ...(reviewRejectionDecisionSource ? { decision_source: reviewRejectionDecisionSource } : {}),
807
924
  ...(reviewRejectionOverride ? { review_rejection_override: reviewRejectionOverride } : {}),
925
+ ...(codeReviewDecisionReference ? { code_review_decision: codeReviewDecisionReference } : {}),
926
+ ...(exploreOpenQuestion && exploreOpenQuestionRoundId && exploreOpenQuestionContextFingerprint ? {
927
+ explore_open_question: {
928
+ round_id: exploreOpenQuestionRoundId,
929
+ question_id: exploreOpenQuestion.id,
930
+ question_ordinal: exploreOpenQuestion.ordinal,
931
+ document_fingerprint: exploreOpenQuestion.documentFingerprint,
932
+ context_fingerprint: exploreOpenQuestionContextFingerprint,
933
+ },
934
+ } : {}),
808
935
  ...(phaseAction ? {
809
936
  phase_confirmation: {
810
937
  boundary: phaseAction.boundary,
@@ -824,7 +951,9 @@ function recordUserDecisionLoaded(projectRoot, change, events, content, inputDig
824
951
  return {
825
952
  event_type: "user_decision_recorded",
826
953
  accepted: true,
827
- message: `用户决策已登记:决策范围(scope)=${decision.scope}`,
954
+ message: exploreOpenQuestion
955
+ ? "这件事的答复已登记"
956
+ : `用户决策已登记:决策范围(scope)=${decision.scope}`,
828
957
  };
829
958
  }
830
959
  /** record user-decision:登记用户决策 */
@@ -913,6 +1042,7 @@ function packetFieldDescriptions() {
913
1042
  unattributed_paths: "代码审查范围中暂时无法归属到某个任务(task)的文件。",
914
1043
  unknown_attribution_tasks: "因为缺少边界快照或提交段 diff 失败而无法完整计算改动归属的任务(task)。",
915
1044
  coverage_exemption_refs: "测试覆盖豁免引用:说明某个 TEST 为什么没有绑定到任务(task)。",
1045
+ code_review_gate: "最终验证读取的代码审查门禁事实:passed 指向已接受的代码审查工作项,skipped 表示本轮没有代码类改动。",
916
1046
  code_state_check: "代码状态检查:最终验证时用于判断代码审查后代码是否又发生变化。",
917
1047
  event_id: "事件 ID,用于追溯证据来源。",
918
1048
  event_digest: "事件摘要,用于确认引用的证据事件没有被替换。",
@@ -932,6 +1062,7 @@ export function jobsPacket(projectRoot, change, jobId) {
932
1062
  return { found: false, message: `工作项 ${jobId} 不存在` };
933
1063
  }
934
1064
  const isCodeReviewer = job.role === "code-reviewer";
1065
+ const isReviewer = isReviewRole(job.role);
935
1066
  const hasReviewScope = requiresReviewScope(job);
936
1067
  const packetContext = job.packet_context;
937
1068
  const { reviewTargets, readOnlyRefs } = reviewScopeForJob(job);
@@ -943,12 +1074,13 @@ export function jobsPacket(projectRoot, change, jobId) {
943
1074
  ...(job.gate_id ? { gate_id: job.gate_id } : {}),
944
1075
  recommended_agent: recommendedAgentForRole(job.role),
945
1076
  boundFiles: job.boundFiles,
946
- ...(reviewTargets.length > 0 ? { review_targets: reviewTargets } : {}),
947
- ...(readOnlyRefs.length > 0 ? { read_only_refs: readOnlyRefs } : {}),
1077
+ ...(isReviewer && reviewTargets.length > 0 ? { review_targets: reviewTargets } : {}),
1078
+ ...(isReviewer && readOnlyRefs.length > 0 ? { read_only_refs: readOnlyRefs } : {}),
948
1079
  ...(job.review_evidence_digest ? { review_evidence_digest: job.review_evidence_digest } : {}),
949
1080
  ...(job.previous_rejection ? { previous_rejection: job.previous_rejection } : {}),
950
1081
  ...(packetContext ? { packet_context: packetContext } : {}),
951
1082
  ...(packetContext?.code_review_scope ? { code_review_scope: packetContext.code_review_scope } : {}),
1083
+ ...(packetContext?.code_review_gate ? { code_review_gate: packetContext.code_review_gate } : {}),
952
1084
  ...(packetContext?.coverage_exemption_refs ? { coverage_exemption_refs: packetContext.coverage_exemption_refs } : {}),
953
1085
  ...(packetContext?.task_execution_index ? { task_execution_index: packetContext.task_execution_index } : {}),
954
1086
  ...(packetContext?.unattributed_paths ? { unattributed_paths: packetContext.unattributed_paths } : {}),
@@ -970,29 +1102,31 @@ export function jobsPacket(projectRoot, change, jobId) {
970
1102
  output_contract_optional_fields: [...REVIEW_REPORT_OPTIONAL_FIELDS],
971
1103
  字段说明: packetFieldDescriptions(),
972
1104
  output_instructions: `${roleDescription(job.role)}。` +
973
- reviewScopeInstruction(job, reviewTargets, readOnlyRefs) +
974
- migrationEvidenceInstruction(job) +
1105
+ (isReviewer ? reviewScopeInstruction(job, reviewTargets, readOnlyRefs) : "") +
1106
+ (isReviewer ? migrationEvidenceInstruction(job) : "") +
975
1107
  (job.review_evidence_digest ? `本工作项对应的执行证据版本为 ${job.review_evidence_digest},` : "") +
976
- reviewCoverageInstruction(job) +
977
- previousRejectionInstruction(job) +
1108
+ (isReviewer ? genericReviewCoverageInstruction(job) + proposalIncrementalReviewInstruction(job) + previousRejectionInstruction(job) : "") +
978
1109
  (requiresReviewer(job.role) ? `必须由独立 ${recommendedAgentForRole(job.role)} 审查角色执行,并在审查者来源字段(reviewer.kind/id)中记录来源,` : "") +
979
- ordinaryReviewerFindingInstruction(job) +
980
1110
  `产出 JSON 报告内容并优先通过 --report - 从 stdin 登记;文件路径模式仅作备用。${recordInputInstruction(job)}协议字段含义见 packet 顶层“字段说明”,普通对话不要原样复述 JSON。` +
981
1111
  (isCodeReviewer
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>"}。`
1112
+ ? `最小格式:{"role":"code-reviewer","verdict":"pass","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>"}}。verdict 只能为 pass 或 fail;审查覆盖范围(review_scope)用来说明本次审查覆盖了哪些文件和文档,已检查路径(checked_paths)与未检查项(unchecked)必须合起来覆盖全部绑定文件(boundFiles),unchecked 条目格式为 {"path":"<path>","reason":"<reason>"}。`
983
1113
  + `报告结论为 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 表示需要使用者判断的混合问题。`
984
1114
  + (packetContext?.task_execution_index
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 豁免。`
1115
+ ? `本工作项带任务执行索引(task_execution_index):按 task 对照其执行依据快照(contract)审查——实现路线对照 design 引用原文、累计 diff 对照 guard 边界、测试断言对照 tests 声明的 scenario;每项的 required_evidence 是 task-start 冻结的证据口径,red_required/green_required 分别说明是否需要 RED/GREEN;fix 非空表示状态机创建的实现修复,source、parent_task_id 和 reason 说明其归属,code_review 来源还需核对 review_finding;每项的 scope_note 是执行者登记的范围扩大说明,判断其合理性与验证充分性;changed_paths 是归属线索不是结论(null 表示未知);unattributed_paths 中的无主改动逐个判断合理性;coverage_exemption_refs 解释未绑定 task 的 TEST 豁免。`
986
1116
  : "")
987
1117
  : job.role === "verifier"
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)的旧证据只能弱引用。` +
1118
+ ? `最小格式:{"role":"verifier","verdict":"pass","findings":[]${hasReviewScope ? `,"review_scope":{"checked_paths":${JSON.stringify(job.boundFiles.map(file => file.path))}}` : ""}}。verdict 只能为 pass 或 fail;核对代码审查记录(code_review_gate):passed 必须能追溯到已接受的代码审查工作项,skipped 必须能证明本次没有代码类改动。核对修复闭环:task_execution_index.fix.source=code_review 时必须核对 review_finding 对应问题是否关闭;source=self_test 时必须核对 parent_task_id、记录的自测原因、本次 attempt 验证和最新代码审查是否共同闭环。方案/混合问题必须有用户决策或后续修复证据。按 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)。修复 task 的回归测试运行可用回归覆盖任务列表(covers_task_ids)说明覆盖了哪些已完成任务;缺少任务尝试 ID(attempt_id)的旧证据只能弱引用。` +
989
1119
  (packetContext?.code_state_check
990
- ? `本工作项带代码状态检查(code_state_check):head_matches false changed_paths 非空表示代码审查后代码又发生变化,须在报告中列出差异并交主流程与用户裁决,不自行判定无害,也不据此自动否定已接受的代码审查。`
1120
+ ? `本工作项带代码状态检查(code_state_check),它是创建 packet 时的快照:验证期间若代码状态已变化,不要提交该报告;主流程会通过 next 创建携带最新事实的验证工作项。`
991
1121
  : "")
992
- : requiresReviewer(job.role)
993
- ? `最小格式:{"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>"}}`
994
- : `最小格式:{"role":"${job.role}","verdict":"pass|fail","findings":[]${hasReviewScope ? `,"review_scope":{"checked_paths":${JSON.stringify(job.boundFiles.map(file => file.path))}}` : ""}}`),
995
- stop_conditions: ["审查完成后提交报告,不要修改文档"],
1122
+ : isReviewer
1123
+ ? `最小格式:{"role":"${job.role}","verdict":"pass","findings":[]${hasReviewScope ? `,"review_scope":{"checked_paths":${JSON.stringify(job.boundFiles.map(file => file.path))}}` : ""},"reviewer":{"kind":"codex-subagent","id":"<thread-or-agent-id>"}}。verdict 只能为 pass 或 fail。`
1124
+ : `最小格式:{"role":"${job.role}","verdict":"pass","findings":[]}。verdict 只能为 pass fail。`),
1125
+ stop_conditions: isReviewer
1126
+ ? ["完成审查后提交报告,不要修改文档"]
1127
+ : job.role === "executor"
1128
+ ? ["完成绑定范围内的实现后提交执行报告,不要修改未绑定范围"]
1129
+ : ["完成指定验证后提交测试报告,不要修改项目文档"],
996
1130
  created_from_transition: job.created_from_transition,
997
1131
  },
998
1132
  message: `工作项 ${jobId} 的执行说明`,
package/dist/review.js CHANGED
@@ -90,6 +90,8 @@ function reviewCycleState(gate) {
90
90
  return "explore";
91
91
  if (gate.gate_id === PROPOSE_FINAL_REVIEW_GATE_ID)
92
92
  return "propose";
93
+ if (gate.gate_id === REVIEW_FINAL_VERIFIER_GATE.gate_id)
94
+ return "review";
93
95
  return null;
94
96
  }
95
97
  function currentReviewGateCycleStart(events, gate) {
@@ -4,6 +4,11 @@ import { parseTasksMd } from "./format.js";
4
4
  import { readEvents, sha256Text } from "./store.js";
5
5
  import { GREEN_ONLY_NO_TDD_REASON } from "./types.js";
6
6
  export function taskEvidenceReadiness(projectRoot, change, changeRoot, attempt) {
7
+ // 有效证据计划是 task-start 在当时策略下冻结的事实。状态机新建的 Fix
8
+ // 即使来自历史/v1 Apply,也必须优先按这个快照回放。
9
+ if (attempt.required_evidence) {
10
+ return effectiveContractTaskEvidenceReadiness(projectRoot, change, attempt);
11
+ }
7
12
  if (attempt.contract_mode === true) {
8
13
  return contractTaskEvidenceReadiness(projectRoot, change, attempt);
9
14
  }
@@ -52,9 +57,6 @@ export function taskEvidenceReadiness(projectRoot, change, changeRoot, attempt)
52
57
  : { ready: false, missing, reason: missing.join("、") };
53
58
  }
54
59
  function contractTaskEvidenceReadiness(projectRoot, change, attempt) {
55
- if (attempt.required_evidence) {
56
- return effectiveContractTaskEvidenceReadiness(projectRoot, change, attempt);
57
- }
58
60
  return legacyContractTaskEvidenceReadiness(projectRoot, change, attempt);
59
61
  }
60
62
  /** 新执行依据模式:只消费 task-start 冻结的有效证据计划。 */
@@ -40,6 +40,7 @@ export declare function taskStart(projectRoot: string, change: string, changeRoo
40
40
  export declare function reopen(projectRoot: string, change: string, changeRoot: string, to: State, reason: string, opts?: {
41
41
  reviewFix?: string;
42
42
  reviewFinding?: string;
43
+ selfTestFix?: string;
43
44
  }): TransitionResult;
44
45
  export declare function reviewReady(projectRoot: string, change: string, changeRoot: string, risk?: ReviewRisk): TransitionResult;
45
46
  export declare function accept(projectRoot: string, change: string, changeRoot: string): TransitionResult;