@peterxiaoyang/superspec 0.1.43 → 0.1.44

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/format.d.ts CHANGED
@@ -40,7 +40,6 @@ export interface ParsedExecutionRequirement {
40
40
  export interface TestContractEntry {
41
41
  test_id: string;
42
42
  scenario: string;
43
- invariant: string;
44
43
  }
45
44
  export type TestContractParseResult = {
46
45
  ok: true;
package/dist/format.js CHANGED
@@ -320,7 +320,6 @@ export function parseTestContractEntries(content) {
320
320
  if (!isMarkdownTableSeparator(lines[i + 1]))
321
321
  continue;
322
322
  sawMatchingHeader = true;
323
- const invariantIdx = normalizedHeader.indexOf("invariant");
324
323
  for (let rowIndex = i + 2; rowIndex < lines.length; rowIndex++) {
325
324
  const row = splitMarkdownTableRow(lines[rowIndex]);
326
325
  if (row.length === 0)
@@ -335,7 +334,6 @@ export function parseTestContractEntries(content) {
335
334
  entries.push({
336
335
  test_id: testId,
337
336
  scenario: (row[scenarioIdx] ?? "").trim(),
338
- invariant: invariantIdx >= 0 ? (row[invariantIdx] ?? "").trim() : "",
339
337
  });
340
338
  }
341
339
  }
@@ -450,21 +448,13 @@ export function validateUserDecision(d) {
450
448
  return { ok: false, message: "决策文件缺少答复内容(answer)" };
451
449
  return { ok: true, message: "" };
452
450
  }
453
- // ===== business-invariants.md =====
454
- //
455
- // 格式(propose skill 定义):
456
- // # Business Invariants
457
- // - INV-001 用户密码必须加密存储
458
- //
459
- // 引擎行为:Phase 1-5 只校验文件存在性(轻量)。
460
- // 内部结构(INV-XXX 编号)是 agent 指引,引擎不逐行解析。
461
451
  // ===== test-contract.md =====
462
452
  //
463
453
  // 格式(propose skill 定义):
464
454
  // # Test Contract
465
- // | test_id | invariant | scenario |
466
- // |---|---|---|
467
- // | TEST-001 | INV-001 | 注册时密码被加密 |
455
+ // | test_id | scenario |
456
+ // |---|---|
457
+ // | TEST-001 | 注册时密码被加密 |
468
458
  //
469
459
  // 引擎行为:Phase 1-5 只校验文件存在性(轻量)。
470
460
  // 表格结构是 agent 指引,引擎不逐行解析。
package/dist/git_state.js CHANGED
@@ -3,7 +3,7 @@ import { existsSync, lstatSync, readdirSync, readlinkSync, statSync } from "node
3
3
  import { extname, join } from "node:path";
4
4
  import { sha256File, sha256Text } from "./store.js";
5
5
  const PROCESS_DOC_RE = /^(?:openspec\/changes\/[^/]+\/)?(?:proposal|design|tasks)\.md$/;
6
- const PROCESS_ARTIFACT_RE = /^(?:openspec\/changes\/[^/]+\/)?\.superspec\/artifacts\/(?:discovery|business-invariants|test-contract)\.md$/;
6
+ const PROCESS_ARTIFACT_RE = /^(?:openspec\/changes\/[^/]+\/)?\.superspec\/artifacts\/(?:discovery|test-contract)\.md$/;
7
7
  const CODE_EXTENSIONS = new Set([
8
8
  ".c", ".cc", ".cpp", ".cs", ".css", ".go", ".h", ".hpp", ".html", ".java", ".js", ".jsx",
9
9
  ".json", ".kt", ".mjs", ".mts", ".php", ".py", ".rb", ".rs", ".scss", ".sh", ".sql",
@@ -1,5 +1,7 @@
1
1
  import { currentGitHead, dirtyCodeFiles } from "./git_state.js";
2
- import { reviewEvidenceDigest } from "./review.js";
2
+ import { historicalProposeReadyRoles, reviewEvidenceDigest, reviewGateRoleResolution } from "./review.js";
3
+ import { EXPLORE_DISCOVERY_REVIEW_GATE, PROPOSE_FINAL_REVIEW_GATE } from "./review_job_gates.js";
4
+ import { changeRoot as openspecChangeRoot } from "./openspec.js";
3
5
  import { findLatestEvent, sha256Text } from "./store.js";
4
6
  export const PHASE_CONFIRMATION_SCOPE_PREFIX = "phase_confirmation:";
5
7
  function latestTransition(events, predicate) {
@@ -63,7 +65,12 @@ function boundaryForState(state) {
63
65
  default: return null;
64
66
  }
65
67
  }
66
- function materialDigest(projectRoot, events, snapshot) {
68
+ function ordinaryReviewRolesForBoundary(events, boundary, gate, risk) {
69
+ return boundary === "propose_to_apply"
70
+ ? historicalProposeReadyRoles(events)
71
+ : gate.requiredRolesForRisk(risk);
72
+ }
73
+ function materialDigest(projectRoot, events, snapshot, boundary, risk) {
67
74
  const head = currentGitHead(projectRoot);
68
75
  const dirty = dirtyCodeFiles(projectRoot);
69
76
  const acceptedJobs = snapshot.accepted_jobs
@@ -77,10 +84,31 @@ function materialDigest(projectRoot, events, snapshot) {
77
84
  .sort((a, b) => a.job_id.localeCompare(b.job_id));
78
85
  const documents = Object.entries(snapshot.document_digests)
79
86
  .sort(([left], [right]) => left.localeCompare(right));
87
+ const ordinaryReviewGate = boundary === "explore_to_propose"
88
+ ? EXPLORE_DISCOVERY_REVIEW_GATE
89
+ : boundary === "propose_to_apply"
90
+ ? PROPOSE_FINAL_REVIEW_GATE
91
+ : null;
92
+ const currentChangeRoot = openspecChangeRoot(projectRoot, snapshot.change_id);
93
+ const overriddenReviewJobs = ordinaryReviewGate
94
+ ? ordinaryReviewRolesForBoundary(events, boundary, ordinaryReviewGate, risk)
95
+ .map(role => reviewGateRoleResolution(events, currentChangeRoot, ordinaryReviewGate, role))
96
+ .filter(resolution => resolution.kind === "overridden")
97
+ .map(resolution => ({
98
+ job_id: resolution.override.job_id,
99
+ role: resolution.override.role,
100
+ gate_id: resolution.override.gate_id,
101
+ packet_digest: resolution.override.packet_digest,
102
+ decision_event_id: resolution.override.decision_event_id,
103
+ decision_event_digest: resolution.override.decision_event_digest,
104
+ }))
105
+ .sort((left, right) => `${left.gate_id}\u0000${left.role}\u0000${left.job_id}`.localeCompare(`${right.gate_id}\u0000${right.role}\u0000${right.job_id}`))
106
+ : [];
80
107
  return sha256Text(JSON.stringify({
81
108
  documents,
82
109
  tasks_structure_digest: snapshot.tasks_structure_digest,
83
110
  accepted_jobs: acceptedJobs,
111
+ ...(overriddenReviewJobs.length > 0 ? { overridden_review_jobs: overriddenReviewJobs } : {}),
84
112
  review_evidence_digest: reviewEvidenceDigest(events),
85
113
  code_state: {
86
114
  head: head.head,
@@ -126,7 +154,7 @@ function buildActions(change, boundary, scope, question, specs, risk) {
126
154
  reason: spec.reason,
127
155
  ...(spec.reasonPrompt ? { reason_prompt: spec.reasonPrompt } : {}),
128
156
  record_argv: phaseRecordArgv(change),
129
- record_input: { scope, question, answer: spec.label },
157
+ record_input: { scope, question, answer: spec.label, review_risk: risk },
130
158
  resume: spec.resume.kind === "next"
131
159
  ? { kind: "next", argv: nextArgv(change, risk) }
132
160
  : spec.resume.kind === "continue_current_phase"
@@ -147,7 +175,7 @@ export function phaseConfirmationForBoundary(projectRoot, events, snapshot, boun
147
175
  return null;
148
176
  const epoch = spec.epoch(events);
149
177
  const epochEventId = epoch?.event_id ?? `legacy-${spec.state}`;
150
- const digest = materialDigest(projectRoot, events, snapshot);
178
+ const digest = materialDigest(projectRoot, events, snapshot, boundary, risk);
151
179
  const scope = `${spec.scopePrefix}:${epochEventId}:${digest}`;
152
180
  const actions = buildActions(snapshot.change_id, boundary, scope, spec.question, spec.actions, risk);
153
181
  return {
@@ -64,6 +64,7 @@ export type TransitionDecisionPlan = {
64
64
  kind: "blocked";
65
65
  jobs: Job[];
66
66
  reason: string;
67
+ details?: Record<string, unknown>;
67
68
  } | {
68
69
  kind: "create_gate_jobs";
69
70
  gate: ReviewGateRule;
@@ -86,7 +87,6 @@ export declare function proposalDocsBaseline(changeRoot: string): Record<string,
86
87
  export declare function latestAcceptedProposalBaseline(events: Event[]): Record<string, string> | null;
87
88
  export declare function latestReopenProposeBaseline(events: Event[]): Record<string, string> | null;
88
89
  export declare function proposalDocsChangedSinceBaseline(changeRoot: string, baseline: Record<string, string>): boolean;
89
- export declare function historicalProposeReadyRoles(events: Event[]): JobRole[];
90
90
  export declare function pendingTaskIds(changeRoot: string): string[];
91
91
  export declare function applyRequirementModeForCurrentRound(events: Event[]): boolean;
92
92
  export declare function pendingTaskStatusForApply(changeRoot: string, events: Event[]): ApplyPendingTaskStatus;
@@ -4,7 +4,7 @@ import { EXPLORE_DISCOVERY_REVIEW_GATE, PROPOSE_FINAL_REVIEW_GATE } from "./revi
4
4
  import { collectProposeOpenQuestions, countDiscoveryOpenQuestions, parseTasksMd, pendingTasksInContent, validateDiscovery, validateExecutionRequirements, } from "./format.js";
5
5
  import { currentGitHead } from "./git_state.js";
6
6
  import { docRef, sha256File } from "./store.js";
7
- import { isReviewReadyVerifier, isFreshReviewVerifier, readReviewPolicyFromEvents, reviewEvidenceDigest, } from "./review.js";
7
+ import { isReviewReadyVerifier, isFreshReviewVerifier, historicalProposeReadyRoles, readReviewPolicyFromEvents, reviewGateRoleResolution, reviewRejectionOverrideScope, reviewEvidenceDigest, } from "./review.js";
8
8
  import { CODE_REVIEW_DECISION_ANSWER_LABELS, CODE_REVIEW_REPAIR_SCOPE_PREFIX, codeReviewDecisionScope, codeReviewJobStaleReason, collectCodeReviewGateFacts, currentCodeReviewWorkingPaths, latestCodeReviewFailedStatus, requiresFinalVerifierForCurrentReview, scanCodeChangesForReview, } from "./code_review.js";
9
9
  import { isPhaseAdvanceAuthorized, latestAcceptedPhaseDecision, phaseConfirmationCommitPayload, phaseConfirmationForBoundary, phaseConfirmationMissingMessage, } from "./phase_confirmation.js";
10
10
  import { taskEvidenceReadiness } from "./task_evidence.js";
@@ -22,16 +22,49 @@ function phaseConfirmationStep(context, boundary, reason) {
22
22
  reason,
23
23
  };
24
24
  }
25
- function reviewGatePlan(snapshot, gate, requiredRoles) {
25
+ function reviewGatePlan(snapshot, events, changeRoot, gate, requiredRoles) {
26
26
  const missingRoles = [];
27
27
  for (const role of requiredRoles) {
28
28
  const openForRole = snapshot.open_jobs.find(job => gate.isJobForGate(job) && job.role === role);
29
29
  if (openForRole) {
30
30
  return { kind: "blocked", reason: `状态未推进;已有待完成工作项 ${role}(${openForRole.job_id})`, jobs: [openForRole] };
31
31
  }
32
- const acceptedForRole = snapshot.accepted_jobs.find(job => gate.isJobForGate(job) && job.role === role);
33
- if (!acceptedForRole)
34
- missingRoles.push({ role, reason: `需求 ${role} 无已接受的工作项` });
32
+ const resolution = reviewGateRoleResolution(events, changeRoot, gate, role);
33
+ if (resolution.kind === "accepted" || resolution.kind === "overridden")
34
+ continue;
35
+ if (resolution.kind === "rejected_pending") {
36
+ const terminal = resolution.terminal;
37
+ const overrideScope = reviewRejectionOverrideScope(terminal.job.job_id);
38
+ return {
39
+ kind: "blocked",
40
+ jobs: [],
41
+ reason: `状态未推进;${role} 审查工作项 ${terminal.job.job_id} 已拒绝。请修改绑定材料、在整份报告没有有效 blocker 时登记整体裁决,或在涉及业务决定时询问用户`,
42
+ details: {
43
+ review_rejection: {
44
+ job_id: terminal.job.job_id,
45
+ role,
46
+ gate_id: gate.gate_id,
47
+ packet_digest: terminal.job.packet_digest,
48
+ result_kind: terminal.result_kind,
49
+ reason: terminal.reason ?? "报告结论为 fail,工作项未通过",
50
+ override_scope: overrideScope,
51
+ allowed_actions: ["modify_materials", "record_override", "ask_user"],
52
+ record_input: {
53
+ scope: overrideScope,
54
+ answer: "do_not_block",
55
+ reason: "<说明整份报告为何没有有效 blocker>",
56
+ decision_source: "main_process",
57
+ },
58
+ },
59
+ },
60
+ };
61
+ }
62
+ const reason = resolution.kind === "stale"
63
+ ? `需求 ${role} 的最新审查工作项已过期:${resolution.stale_reason}`
64
+ : resolution.kind === "rejected_invalid"
65
+ ? `需求 ${role} 的最新审查报告无效,不能通过整体裁决绕过`
66
+ : `需求 ${role} 无已接受的工作项`;
67
+ missingRoles.push({ role, reason });
35
68
  }
36
69
  if (missingRoles.length > 0) {
37
70
  return {
@@ -68,7 +101,7 @@ function missingBaseArtifact(changeRoot, risk) {
68
101
  if (risk === "minimal")
69
102
  return null;
70
103
  const artifactsDir = join(changeRoot, ".superspec", "artifacts");
71
- for (const doc of ["discovery.md", "business-invariants.md", "test-contract.md"]) {
104
+ for (const doc of ["discovery.md", "test-contract.md"]) {
72
105
  if (!existsSync(join(artifactsDir, doc)))
73
106
  return `基础职责缺失:${doc} 不存在(risk=${risk} 需要)`;
74
107
  }
@@ -123,20 +156,6 @@ export function proposalDocsChangedSinceBaseline(changeRoot, baseline) {
123
156
  const current = proposalDocsBaseline(changeRoot);
124
157
  return Object.entries(baseline).some(([path, digest]) => current[path] !== digest);
125
158
  }
126
- export function historicalProposeReadyRoles(events) {
127
- const roles = new Set();
128
- for (const ev of events) {
129
- if (ev.event_type !== "transition_commit")
130
- continue;
131
- const newJobs = ev.payload.new_jobs ?? [];
132
- for (const job of newJobs) {
133
- if (PROPOSE_FINAL_REVIEW_GATE.isJobForGate(job) &&
134
- (job.role === "critic" || job.role === "architect" || job.role === "test-engineer"))
135
- roles.add(job.role);
136
- }
137
- }
138
- return [...roles];
139
- }
140
159
  export function pendingTaskIds(changeRoot) {
141
160
  const tasksContent = readFileSync(join(changeRoot, "tasks.md"), "utf8");
142
161
  return pendingTasksInContent(tasksContent).map(task => task.taskId);
@@ -286,7 +305,7 @@ export function planNextStep(context) {
286
305
  return { kind: "ask_user", state: "explore", ask, reason: `有 ${openQs} 个未确认问题` };
287
306
  }
288
307
  const requiredRoles = EXPLORE_DISCOVERY_REVIEW_GATE.requiredRolesForRisk(mode.risk);
289
- if (!reviewGatePlan(snapshot, EXPLORE_DISCOVERY_REVIEW_GATE, requiredRoles)) {
308
+ if (!reviewGatePlan(snapshot, events, changeRoot, EXPLORE_DISCOVERY_REVIEW_GATE, requiredRoles)) {
290
309
  const confirmation = phaseConfirmationStep(context, "explore_to_propose", "探索完成,等待用户确认进入计划阶段");
291
310
  if (confirmation)
292
311
  return confirmation;
@@ -613,10 +632,10 @@ function planExploreTransition(context) {
613
632
  if (!discoveryCheck.ok)
614
633
  return { kind: "skip", message: discoveryCheck.message };
615
634
  const requiredRoles = EXPLORE_DISCOVERY_REVIEW_GATE.requiredRolesForRisk(mode.risk);
616
- const gatePlan = reviewGatePlan(snapshot, EXPLORE_DISCOVERY_REVIEW_GATE, requiredRoles);
635
+ const gatePlan = reviewGatePlan(snapshot, events, changeRoot, EXPLORE_DISCOVERY_REVIEW_GATE, requiredRoles);
617
636
  if (gatePlan)
618
637
  return gatePlan;
619
- const confirmation = phaseConfirmationForBoundary(projectRoot, events, snapshot, "explore_to_propose");
638
+ const confirmation = phaseConfirmationForBoundary(projectRoot, events, snapshot, "explore_to_propose", mode.risk);
620
639
  const decision = confirmation ? latestAcceptedPhaseDecision(events, confirmation) : null;
621
640
  if (!confirmation || decision?.decision !== "advance") {
622
641
  return { kind: "skip", message: confirmation ? phaseConfirmationMissingMessage(confirmation) : "无法建立 Explore 阶段确认范围" };
@@ -649,10 +668,15 @@ function planProposeReadyTransition(context) {
649
668
  if (missingArtifact)
650
669
  return { kind: "skip", message: missingArtifact };
651
670
  const requiredRoles = PROPOSE_FINAL_REVIEW_GATE.requiredRolesForRisk(risk);
652
- const gatePlan = reviewGatePlan(snapshot, PROPOSE_FINAL_REVIEW_GATE, requiredRoles);
671
+ const gatePlan = reviewGatePlan(snapshot, context.events, changeRoot, PROPOSE_FINAL_REVIEW_GATE, requiredRoles);
653
672
  if (gatePlan)
654
673
  return gatePlan;
655
- return { kind: "advance", fromState: "propose", toState: "propose_ready", reason: `risk=${risk},所有需求已满足` };
674
+ return {
675
+ kind: "advance",
676
+ fromState: "propose",
677
+ toState: "propose_ready",
678
+ reason: `risk=${risk},所有需求已满足`,
679
+ };
656
680
  }
657
681
  function planStartApplyTransition(context, enforceConfirmation = true) {
658
682
  const { changeRoot, events, projectRoot, snapshot } = context;
@@ -665,7 +689,7 @@ function planStartApplyTransition(context, enforceConfirmation = true) {
665
689
  }
666
690
  const reviewedRoles = historicalProposeReadyRoles(events);
667
691
  if (reviewedRoles.length > 0) {
668
- const gatePlan = reviewGatePlan(snapshot, PROPOSE_FINAL_REVIEW_GATE, reviewedRoles);
692
+ const gatePlan = reviewGatePlan(snapshot, events, changeRoot, PROPOSE_FINAL_REVIEW_GATE, reviewedRoles);
669
693
  if (gatePlan) {
670
694
  return {
671
695
  ...gatePlan,
package/dist/record.js CHANGED
@@ -8,7 +8,7 @@ import { isPhaseConfirmationScope, phaseActionForAnswer, phaseConfirmationForCur
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) {
@@ -610,6 +625,7 @@ function recordUserDecisionLoaded(projectRoot, change, events, content, inputDig
610
625
  }
611
626
  let phaseConfirmation = null;
612
627
  let phaseAction = null;
628
+ let phaseReviewRisk = null;
613
629
  if (isPhaseConfirmationScope(decision.scope)) {
614
630
  const existingAccepted = existing &&
615
631
  existing.payload.accepted !== false;
@@ -623,7 +639,14 @@ function recordUserDecisionLoaded(projectRoot, change, events, content, inputDig
623
639
  });
624
640
  const changeRoot = openspecChangeRoot(projectRoot, change);
625
641
  const snapshot = rebuildSnapshot(projectRoot, change, changeRoot);
626
- const current = phaseConfirmationForCurrentState(projectRoot, events, snapshot);
642
+ const decisionRisk = decision.review_risk === undefined
643
+ ? "strict"
644
+ : decision.review_risk === "minimal" || decision.review_risk === "normal" || decision.review_risk === "strict"
645
+ ? decision.review_risk
646
+ : null;
647
+ const current = decisionRisk
648
+ ? phaseConfirmationForCurrentState(projectRoot, events, snapshot, decisionRisk)
649
+ : null;
627
650
  if (existingAccepted &&
628
651
  current?.scope === decision.scope &&
629
652
  latestAcceptedForScope?.event_id === existing.event_id) {
@@ -634,15 +657,17 @@ function recordUserDecisionLoaded(projectRoot, change, events, content, inputDig
634
657
  };
635
658
  }
636
659
  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;
660
+ const rejectionReason = !decisionRisk
661
+ ? "invalid_phase_confirmation_risk"
662
+ : !current
663
+ ? "phase_confirmation_not_pending"
664
+ : decision.scope !== current.scope
665
+ ? "stale_phase_confirmation_scope"
666
+ : !action
667
+ ? "invalid_phase_confirmation_answer"
668
+ : action.reason === "required" && !nonEmptyString(decision.reason)
669
+ ? "missing_phase_confirmation_reason"
670
+ : null;
646
671
  if (rejectionReason) {
647
672
  if (existing &&
648
673
  existing.payload.accepted === false &&
@@ -662,13 +687,16 @@ function recordUserDecisionLoaded(projectRoot, change, events, content, inputDig
662
687
  }));
663
688
  const message = rejectionReason === "invalid_phase_confirmation_answer" && current
664
689
  ? `阶段确认答复必须精确为:${current.ask.allowed_answers.join("、")}`
665
- : rejectionReason === "missing_phase_confirmation_reason" && action
666
- ? action.reason_prompt ?? "当前选择必须写明原因"
667
- : "阶段确认已失效或当前没有待确认的阶段边界,请重新执行 next";
690
+ : rejectionReason === "invalid_phase_confirmation_risk"
691
+ ? "阶段确认 review_risk 必须是 minimal、normal 或 strict"
692
+ : rejectionReason === "missing_phase_confirmation_reason" && action
693
+ ? action.reason_prompt ?? "当前选择必须写明原因"
694
+ : "阶段确认已失效或当前没有待确认的阶段边界,请重新执行 next";
668
695
  return { event_type: "user_decision_recorded", accepted: false, message };
669
696
  }
670
697
  phaseConfirmation = current;
671
698
  phaseAction = action;
699
+ phaseReviewRisk = decisionRisk;
672
700
  }
673
701
  if (existing && !phaseAction) {
674
702
  const accepted = existing.payload.accepted !== false;
@@ -678,6 +706,59 @@ function recordUserDecisionLoaded(projectRoot, change, events, content, inputDig
678
706
  message: accepted ? "幂等返回:同一用户决策已登记" : "幂等返回:同一无效用户决策已登记",
679
707
  };
680
708
  }
709
+ let reviewRejectionOverride = null;
710
+ let reviewRejectionDecisionSource = null;
711
+ if (decision.scope.startsWith(REVIEW_REJECTION_OVERRIDE_SCOPE_PREFIX)) {
712
+ const jobId = parseReviewRejectionOverrideScope(decision.scope);
713
+ if (!jobId) {
714
+ return invalidReviewRejectionOverrideResult(projectRoot, change, inputDigest, { scope: decision.scope, answer: decision.answer, decision_source: decision.decision_source }, "invalid_review_rejection_override_scope", "审查拒绝裁决 scope 必须包含有效 job ID");
715
+ }
716
+ if (decision.scope !== reviewRejectionOverrideScope(jobId)) {
717
+ return invalidReviewRejectionOverrideResult(projectRoot, change, inputDigest, { scope: decision.scope, answer: decision.answer, decision_source: decision.decision_source }, "invalid_review_rejection_override_scope", `审查拒绝裁决 scope 必须精确为 ${reviewRejectionOverrideScope(jobId)}`);
718
+ }
719
+ if (decision.answer !== REVIEW_REJECTION_OVERRIDE_ANSWER) {
720
+ 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}`);
721
+ }
722
+ if (decision.decision_source !== "main_process" && decision.decision_source !== "user") {
723
+ 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");
724
+ }
725
+ if (!nonEmptyString(decision.reason)) {
726
+ return invalidReviewRejectionOverrideResult(projectRoot, change, inputDigest, { scope: decision.scope, answer: decision.answer, decision_source: decision.decision_source }, "missing_review_rejection_override_reason", "审查拒绝裁决必须写明原因");
727
+ }
728
+ const job = findJob(events, jobId);
729
+ const gate = job ? ordinaryReviewGateForJob(job) : null;
730
+ if (!job || !gate || !isOrdinaryReviewer(job.role)) {
731
+ 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");
732
+ }
733
+ const changeRoot = openspecChangeRoot(projectRoot, change);
734
+ const snapshot = rebuildSnapshot(projectRoot, change, changeRoot);
735
+ const gateIsCurrent = gate.gate_id === EXPLORE_DISCOVERY_REVIEW_GATE.gate_id
736
+ ? snapshot.state === "explore"
737
+ : snapshot.state === "propose" || snapshot.state === "propose_ready";
738
+ if (!gateIsCurrent) {
739
+ 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 仍为当前阶段时登记");
740
+ }
741
+ const resolution = reviewGateRoleResolution(events, changeRoot, gate, job.role);
742
+ if (resolution.kind === "none" || resolution.terminal.job.job_id !== job.job_id) {
743
+ 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");
744
+ }
745
+ if (resolution.kind === "stale") {
746
+ 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}`);
747
+ }
748
+ if (resolution.kind === "accepted") {
749
+ return invalidReviewRejectionOverrideResult(projectRoot, change, inputDigest, { scope: decision.scope, answer: decision.answer, decision_source: decision.decision_source }, "review_rejection_override_job_not_rejected", "审查拒绝裁决只能指向 rejected job");
750
+ }
751
+ if (resolution.kind === "rejected_invalid") {
752
+ 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 的拒绝报告可以裁决;无效报告必须修正后重新提交");
753
+ }
754
+ reviewRejectionOverride = {
755
+ job_id: job.job_id,
756
+ role: job.role,
757
+ gate_id: gate.gate_id,
758
+ packet_digest: job.packet_digest,
759
+ };
760
+ reviewRejectionDecisionSource = decision.decision_source;
761
+ }
681
762
  if (decision.scope.startsWith(CODE_REVIEW_DECISION_SCOPE_PREFIX)) {
682
763
  const normalizedAnswer = normalizeCodeReviewDecisionAnswer(decision.answer);
683
764
  if (!normalizedAnswer) {
@@ -721,10 +802,13 @@ function recordUserDecisionLoaded(projectRoot, change, events, content, inputDig
721
802
  ? phaseAction.label
722
803
  : normalizedAnswer ? codeReviewDecisionAnswerLabel(normalizedAnswer) : decision.answer,
723
804
  ...(typeof decision.reason === "string" ? { reason: decision.reason.trim() } : {}),
805
+ ...(reviewRejectionDecisionSource ? { decision_source: reviewRejectionDecisionSource } : {}),
806
+ ...(reviewRejectionOverride ? { review_rejection_override: reviewRejectionOverride } : {}),
724
807
  ...(phaseAction ? {
725
808
  phase_confirmation: {
726
809
  boundary: phaseAction.boundary,
727
810
  decision: phaseAction.decision,
811
+ review_risk: phaseReviewRisk ?? "strict",
728
812
  },
729
813
  } : {}),
730
814
  };
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([
@@ -59,6 +58,17 @@ function reviewResultKind(value) {
59
58
  ? value
60
59
  : null;
61
60
  }
61
+ export const REVIEW_REJECTION_OVERRIDE_SCOPE_PREFIX = "review_rejection_override:";
62
+ export const REVIEW_REJECTION_OVERRIDE_ANSWER = "do_not_block";
63
+ export function reviewRejectionOverrideScope(jobId) {
64
+ return `${REVIEW_REJECTION_OVERRIDE_SCOPE_PREFIX}${jobId}`;
65
+ }
66
+ export function parseReviewRejectionOverrideScope(scope) {
67
+ if (!scope.startsWith(REVIEW_REJECTION_OVERRIDE_SCOPE_PREFIX))
68
+ return null;
69
+ const jobId = scope.slice(REVIEW_REJECTION_OVERRIDE_SCOPE_PREFIX.length).trim();
70
+ return jobId === "" ? null : jobId;
71
+ }
62
72
  function reviewCycleState(gate) {
63
73
  if (gate.gate_id === EXPLORE_DISCOVERY_REVIEW_GATE_ID)
64
74
  return "explore";
@@ -109,6 +119,7 @@ function reviewTerminalResultsForGateRole(events, gate, role) {
109
119
  const findings = Array.isArray(payload.findings) ? payload.findings : undefined;
110
120
  results.push({
111
121
  job,
122
+ event,
112
123
  state: event.event_type === "job_accepted" ? "accepted" : "rejected",
113
124
  ...(event.event_type === "job_rejected"
114
125
  ? { result_kind: reviewResultKind(payload.result_kind) ?? (findings ? "review_failed" : "invalid_report") }
@@ -119,6 +130,69 @@ function reviewTerminalResultsForGateRole(events, gate, role) {
119
130
  }
120
131
  return results;
121
132
  }
133
+ export function latestReviewTerminalForGateRole(events, gate, role) {
134
+ return reviewTerminalResultsForGateRole(events, gate, role).at(-1) ?? null;
135
+ }
136
+ function validReviewRejectionOverridePayload(event, terminal, gate) {
137
+ if (event.event_type !== "user_decision_recorded")
138
+ return null;
139
+ const payload = event.payload;
140
+ if (payload.accepted !== true ||
141
+ payload.scope !== reviewRejectionOverrideScope(terminal.job.job_id) ||
142
+ payload.answer !== REVIEW_REJECTION_OVERRIDE_ANSWER ||
143
+ (payload.decision_source !== "main_process" && payload.decision_source !== "user") ||
144
+ typeof payload.reason !== "string" || payload.reason.trim() === "" ||
145
+ !payload.review_rejection_override ||
146
+ typeof payload.review_rejection_override !== "object" ||
147
+ Array.isArray(payload.review_rejection_override))
148
+ return null;
149
+ const audit = payload.review_rejection_override;
150
+ if (audit.job_id !== terminal.job.job_id ||
151
+ audit.role !== terminal.job.role ||
152
+ audit.gate_id !== gate.gate_id ||
153
+ audit.packet_digest !== terminal.job.packet_digest)
154
+ return null;
155
+ return {
156
+ job_id: terminal.job.job_id,
157
+ role: terminal.job.role,
158
+ gate_id: gate.gate_id,
159
+ packet_digest: terminal.job.packet_digest,
160
+ decision_source: payload.decision_source,
161
+ reason: payload.reason.trim(),
162
+ decision_event_id: event.event_id,
163
+ decision_event_digest: event.event_digest,
164
+ };
165
+ }
166
+ export function effectiveReviewRejectionOverride(events, gate, terminal) {
167
+ const cycleStartIndex = currentReviewGateCycleStart(events, gate);
168
+ if (cycleStartIndex == null)
169
+ return null;
170
+ const terminalIndex = events.findIndex(event => event.event_id === terminal.event.event_id);
171
+ if (terminalIndex < cycleStartIndex)
172
+ return null;
173
+ for (let i = events.length - 1; i > terminalIndex; i--) {
174
+ const audit = validReviewRejectionOverridePayload(events[i], terminal, gate);
175
+ if (audit)
176
+ return audit;
177
+ }
178
+ return null;
179
+ }
180
+ export function reviewGateRoleResolution(events, changeRoot, gate, role) {
181
+ const terminal = latestReviewTerminalForGateRole(events, gate, role);
182
+ if (!terminal)
183
+ return { kind: "none" };
184
+ const staleReason = boundFilesStaleReason(terminal.job, changeRoot);
185
+ if (staleReason)
186
+ return { kind: "stale", terminal, stale_reason: staleReason };
187
+ if (terminal.state === "accepted")
188
+ return { kind: "accepted", terminal };
189
+ if (terminal.result_kind !== "review_failed")
190
+ return { kind: "rejected_invalid", terminal };
191
+ const override = effectiveReviewRejectionOverride(events, gate, terminal);
192
+ return override
193
+ ? { kind: "overridden", terminal, override }
194
+ : { kind: "rejected_pending", terminal };
195
+ }
122
196
  export function latestReviewHistoryForGateRole(events, gate, role) {
123
197
  const terminalResults = reviewTerminalResultsForGateRole(events, gate, role);
124
198
  const latest = terminalResults.at(-1);
@@ -156,6 +230,20 @@ export function latestReviewHistoryForGateRole(events, gate, role) {
156
230
  job_id: latest.job.job_id,
157
231
  };
158
232
  }
233
+ export function historicalProposeReadyRoles(events) {
234
+ const roles = new Set();
235
+ for (const event of events) {
236
+ if (event.event_type !== "transition_commit")
237
+ continue;
238
+ const jobs = event.payload.new_jobs ?? [];
239
+ for (const job of jobs) {
240
+ if (PROPOSE_FINAL_REVIEW_GATE.isJobForGate(job) &&
241
+ (job.role === "critic" || job.role === "architect" || job.role === "test-engineer"))
242
+ roles.add(job.role);
243
+ }
244
+ }
245
+ return [...roles];
246
+ }
159
247
  export function isReviewReadyVerifier(job) {
160
248
  return job.role === "verifier" && REVIEW_FINAL_VERIFIER_GATE.isJobForGate(job);
161
249
  }
@@ -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 */
@@ -5,7 +5,7 @@ import { ensureChangeLayout, readEvents, appendEvent, makeEvent, writeSnapshot,
5
5
  import { rebuildSnapshot } from "./sync.js";
6
6
  import { requiredJobActions } from "./job_action.js";
7
7
  import { assertCommitPayloadExtension, isFreshReviewVerifier, isReviewReadyVerifier, latestReviewHistoryForGateRole, readReviewPolicyFromEvents, reviewBoundFiles, reviewEvidenceDigest, reviewPolicyForRisk, REVIEW_DOC_PATHS, } from "./review.js";
8
- import { REVIEW_CODE_REVIEW_GATE_ID, REVIEW_FINAL_VERIFIER_GATE_ID, } from "./review_job_gates.js";
8
+ import { REVIEW_CODE_REVIEW_GATE_ID, REVIEW_FINAL_VERIFIER_GATE_ID, reviewScopeForGateRole, } from "./review_job_gates.js";
9
9
  import { codeReviewBoundFiles, codeReviewDecisionScope, codeReviewJobStaleReason, codeReviewPacketContext, codeReviewPacketDigest, collectCodeReviewGateFacts, computeCodeStateCheck, currentCodeReviewWorkingPaths, dismissedCodeReviewSummary, latestCodeReviewDecision, latestCodeReviewFailedStatus, missingCoverageExemptionTestIds, requiresFinalVerifierForCurrentReview, scanCodeChangesForReview, } from "./code_review.js";
10
10
  import { taskEvidenceReadiness } from "./task_evidence.js";
11
11
  import { adoptedContractForTask, findTaskInLines, isReviewFixTaskId, parseTasksMd, parseTestContractEntries, } from "./format.js";
@@ -18,8 +18,9 @@ let jobSeq = 0;
18
18
  function newJobId(change, role) { return `JOB-${change.slice(0, 8)}-${role.slice(0, 4)}-${Date.now()}-${++jobSeq}`; }
19
19
  function createReviewJobsForGate(state, gate, roles, changeRoot, change, reason, events) {
20
20
  const newJobs = roles.map(role => {
21
- const boundPaths = [...new Set([...gate.reviewTargets, ...gate.readOnlyRefs])];
22
- // 所有审查目标和只读引用都绑定时点指纹:单文件缺失使用 sha256:missing,目录缺失使用稳定空指纹。
21
+ const scope = reviewScopeForGateRole(gate, role);
22
+ // 角色职责目标和显式 freshness 路径绑定时点指纹:单文件缺失使用 sha256:missing,目录缺失使用稳定空指纹。
23
+ const boundPaths = [...new Set(scope.boundPaths)];
23
24
  const boundFiles = boundPaths
24
25
  .map(p => docRef(changeRoot, p));
25
26
  const previousRejection = latestReviewHistoryForGateRole(events, gate, role);
@@ -29,14 +30,14 @@ function createReviewJobsForGate(state, gate, roles, changeRoot, change, reason,
29
30
  state: "requested",
30
31
  gate_id: gate.gate_id,
31
32
  boundFiles,
32
- ...(gate.reviewTargets.length > 0 ? { review_targets: [...gate.reviewTargets] } : {}),
33
- ...(gate.readOnlyRefs.length > 0 ? { read_only_refs: [...gate.readOnlyRefs] } : {}),
33
+ ...(scope.reviewTargets.length > 0 ? { review_targets: [...scope.reviewTargets] } : {}),
34
+ ...(scope.readOnlyRefs.length > 0 ? { read_only_refs: [...scope.readOnlyRefs] } : {}),
34
35
  packet_digest: sha256Text(JSON.stringify({
35
36
  role,
36
37
  gate_id: gate.gate_id,
37
38
  boundFiles,
38
- review_targets: gate.reviewTargets,
39
- read_only_refs: gate.readOnlyRefs,
39
+ review_targets: scope.reviewTargets,
40
+ read_only_refs: scope.readOnlyRefs,
40
41
  created_from_transition: gate.created_from_transition,
41
42
  ...(previousRejection ? { previous_rejection: previousRejection } : {}),
42
43
  })),
@@ -440,7 +441,7 @@ function transitionPlanToDecision(snapshot, changeRoot, change, plan, events) {
440
441
  case "skip":
441
442
  return { skip: true, message: plan.message };
442
443
  case "blocked":
443
- return { blocked: true, reason: plan.reason, jobs: plan.jobs };
444
+ return { blocked: true, reason: plan.reason, jobs: plan.jobs, ...(plan.details ? { details: plan.details } : {}) };
444
445
  case "create_gate_jobs":
445
446
  return createReviewJobsForGate(snapshot.state, plan.gate, plan.roles, changeRoot, change, plan.reason, events);
446
447
  case "advance":
@@ -483,7 +484,7 @@ export function commitTransition(projectRoot, change, changeRoot, opts) {
483
484
  from_state: snapshot.state,
484
485
  to_state: snapshot.state,
485
486
  created_jobs: [],
486
- required_jobs: requiredJobActions(change, decision.jobs),
487
+ ...(decision.jobs.length > 0 ? { required_jobs: requiredJobActions(change, decision.jobs) } : {}),
487
488
  message: decision.reason,
488
489
  events_written: 0,
489
490
  ...(decision.details ? { details: decision.details } : {}),
package/dist/types.d.ts CHANGED
@@ -244,6 +244,7 @@ export interface AskUserAction {
244
244
  question: string;
245
245
  answer: string;
246
246
  reason?: string;
247
+ review_risk?: "minimal" | "normal" | "strict";
247
248
  };
248
249
  resume: AskUserActionResume;
249
250
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peterxiaoyang/superspec",
3
- "version": "0.1.43",
3
+ "version": "0.1.44",
4
4
  "description": "SuperSpec 流程引擎 — transition engine with lightweight fact-sync",
5
5
  "type": "module",
6
6
  "engines": {
@@ -9,5 +9,5 @@ Task binding: load `.codex/prompts/executor.md` first, then read the current tas
9
9
 
10
10
  Boundary: mutating but bounded. Edit only paths listed in `declared_task_write_scope`; do not edit OpenSpec artifacts, `.superspec/**`, task checkboxes, evidence, review reports, or archives. Stop and report blockers when scope or context is insufficient.
11
11
 
12
- Output: concise Simplified Chinese implementation report with changed files, task/test/invariant mapping, suggested GREEN checks, artifact refs, and residual risk.
12
+ Output: concise Simplified Chinese implementation report with changed files, task/test mapping, suggested GREEN checks, artifact refs, and residual risk.
13
13
  """
@@ -9,5 +9,5 @@ Task binding: load `.codex/prompts/test-runner.md` first, then read the current
9
9
 
10
10
  Boundary: read-only by default. Do not edit production code, OpenSpec artifacts, `.superspec/**`, task checkboxes, evidence, review reports, or archives. Run only the allowed command from current task instructions and report blockers for missing command, unsafe side effects, or incomplete raw transcript refs.
11
11
 
12
- Output: concise Simplified Chinese test report with command, cwd, phase, task/test id, exit status, semantic status candidate, result summary, raw transcript ref, repo head, dirty-state summary, invariant refs, guard fingerprint, and unverified items.
12
+ Output: concise Simplified Chinese test report with command, cwd, phase, task/test id, exit status, semantic status candidate, result summary, raw transcript ref, repo head, dirty-state summary, guard fingerprint, and unverified items.
13
13
  """
@@ -68,7 +68,6 @@ argument-hint: "本次反方审查说明"
68
68
  - discovery 含链路五要素时,有证据确认会被本次 change 改变的下游消费者或视图差异未进入 Impact 且无排除理由;或 Impact 引用的 `CHAIN-xxx` 所代表的用户可观察行为没有测试场景映射且无不覆盖理由。仅被检查但行为不变的消费者不进入 Impact 或测试。design 仅引用 CHAIN 解释路线不重复产生测试映射;design 暴露的新消费者、视图差异或可观察行为影响必须先进入 Impact。对账不要求每条 CHAIN 单独进入 Impact;同一链路已由 IDC 覆盖且互相引用时不重复报错。
69
69
  - proposal、design、specs 与已确认的 CHAIN / IDC 结论显式矛盾,且没有声明为待确认或本次有意变更。
70
70
  - `specs/` 增量与 proposal 能力变化不对应:声明的能力缺规范增量、specs 引入未声明能力,或规范正文写成实现路线 / 过程描述。绑定为目录时须逐个打开 Markdown 规范;无法读取时必须失败。
71
- - `business-invariants.md` 条目不可证伪,或本次行为变化触及的核心规则缺少对应不变量。
72
71
  - `tasks.md` 无法定位到 design 的实现方案或边界约束,任务过粗,或多个独立行为混在同一 RED/GREEN 闭环。
73
72
  - task ID 重复 / 不稳定,标题混入 task ID,缩进 checkbox 或普通说明承载实际工作,task 中写入 RED/GREEN 命令、断言或预期输出。
74
73
  - tasks 顺序与依赖矛盾:被依赖 task 出现在依赖它的 task 之后;标题分组和行内依赖说明不改变全文顶格 checkbox 执行顺序。
@@ -19,8 +19,8 @@ argument-hint: "本次测试审查说明"
19
19
 
20
20
  ## 审查边界与停止条件
21
21
 
22
- - 只有测试缺口来自本次 change 的明确验收、specs、业务不变量、已采纳 design 或有直接证据的回归风险,并且会使主要行为无法证明时,才使用 `verdict:"fail"`;不满足时省略或列为非阻塞观察。
23
- - 测试义务只来源于 specs、明确 acceptance、业务不变量、已采纳 design,以及本次修改直接造成且有证据的回归风险。Reviewer recommendation、未采纳架构、长期可能性和通用故障注入场景不能自动成为 TEST 来源。
22
+ - 只有测试缺口来自本次 change 的明确验收、specs、已采纳 design 或有直接证据的回归风险,并且会使主要行为无法证明时,才使用 `verdict:"fail"`;不满足时省略或列为非阻塞观察。
23
+ - 测试义务只来源于 specs、明确 acceptance、已采纳 design,以及本次修改直接造成且有证据的回归风险。Reviewer recommendation、未采纳架构、长期可能性和通用故障注入场景不能自动成为 TEST 来源。
24
24
  - 复用现有基础设施或通用机制时,可以引用既有测试,只补本次接入正确性的最小测试;除非需求明确提升对应质量等级,或直接证据证明本次接入破坏既有契约,不重新验证该机制的一般故障恢复、一致性或可用性能力。
25
25
  - 如果当前接入无法满足用户已确认的强制需求、规格约束或明确验收结果,应报告缺失的可验证行为,不得要求采用任何未被采纳的具体基础设施或架构方案,也不得自行新增或升级强制测试要求。
26
26
  - recommendation 只能描述需要证明的行为、边界或证据,不把测试偏好和新的基础设施方案写成 required fix;推荐方案不是 finding 成立的证据。
@@ -19,7 +19,7 @@ argument-hint: "本次测试说明"
19
19
 
20
20
  ## 本次任务说明
21
21
 
22
- 先读取主流程提供的本次测试说明。以本次任务说明中的 `task_id`、`test_id`、`phase`、`expected_semantic_status`、`allowed_test_command`、`guard_fingerprint`、`required_invariant_refs`、报告策略和停止条件为准。
22
+ 先读取主流程提供的本次测试说明。以本次任务说明中的 `task_id`、`test_id`、`phase`、`expected_semantic_status`、`allowed_test_command`、`guard_fingerprint`、报告策略和停止条件为准。
23
23
 
24
24
  `phase:"green"` 且 `worker_chain_context:"executor_worker"` 时,必须确认本次任务说明已绑定 `apply_worker_chain_id` 和 `task_code_review_report_pinned_refs`。不要把测试报告直接写成正式 evidence。
25
25
 
@@ -30,7 +30,7 @@ metadata:
30
30
  1. **计划核对**:执行 `task-start` 前,确认当前 task 是 `tasks.md` 顶格任务。task 带 `执行依据:` 块时,以它为主要执行上下文;没有执行依据的历史 task 对应 `design.md` 的实现方向和 `proposal.md` 的 `## Impact` 受影响原因。缺少映射、需要新增能力/验收/影响范围时先停止,交回 propose,不写 RED。
31
31
  2. **任务开始**:执行 next 下发的 task-start 命令。
32
32
  3. **读取执行依据快照**:task-start 的返回结果包含本次任务尝试 ID(`attempt_id`,登记测试时要用)和执行依据快照(五字段在启动时刻的定格版本)。返回结果带快照时,实现和验收以它为准;返回结果标明是历史任务(`legacy_contract`)时,即使 `tasks.md` 里有执行依据文本也不采纳为引擎契约,按原有方式回读 `proposal.md`、`design.md` 和 `test-contract.md`,test-run 走历史规则。
33
- 4. **RED**:执行依据声明了测试时,测试必须对应其中的 `TEST-xxx`(登记其他 TEST 会被拒绝);没有执行依据的历史 task 确认测试意图能对应 `test-contract.md` 的 `test_id` 或 `business-invariants.md`,缺少对应关系时先停止,交回 propose。运行后确认失败,并用 `superspec record test-run --change "<change>" --input -` 登记。
33
+ 4. **RED**:执行依据声明了测试时,测试必须对应其中的 `TEST-xxx`(登记其他 TEST 会被拒绝);没有执行依据的历史 task 确认测试意图能对应 `test-contract.md` 的 `test_id`,缺少对应关系时先停止,交回 propose。运行后确认失败,并用 `superspec record test-run --change "<change>" --input -` 登记。
34
34
  5. **实现**:根据任务写代码,保持范围小。`design.md` 不锁死字段名、函数名、SQL 或局部写法。
35
35
  6. **GREEN**:运行测试确认通过,并登记 test-run。执行依据声明多个测试时,每个声明 TEST 都要有 GREEN;普通 `tdd_required:true` task 还要求至少一个 TEST 形成同 TEST 先 RED 后 GREEN,其余可以只有 GREEN 作为回归覆盖。
36
36
  7. **完成 task**:执行 next 下发的 task-complete 命令。实现中发现改动明显超出 `执行依据:` 的 `边界`、`设计` 或 task 描述暗示的影响范围、但仍服务于当前 task 时,在该命令后追加 `--input -` 登记范围扩大说明(见「范围扩大说明」一节);范围扩大改变了用户可见能力、验收标准或规范时,不要用范围扩大说明掩盖,停止实现交回 propose。
@@ -25,6 +25,8 @@ metadata:
25
25
 
26
26
  执行推进类命令前,对照 critic 的 Discovery 审查阻塞条件快速自检(非穷尽):锚点可核验、链路表完整且状态为枚举值、完成判定可判定、未知去向明确、已勾选问题有行内结论。自检不替代审查工作项,只为减少驳回往返。
27
27
 
28
+ Discovery 审查报告是待验证的独立意见,不会自动扩大本次 change。审查未通过时,主流程先根据用户目标、当前 discovery 的直接证据和明确完成判定,独立判断整份报告是否有资格阻塞:只要存在一个属于本次 change、有直接证据且影响范围判断或阶段完成条件的问题,就补充 discovery 并重新审查;不要为了让报告“全部正确”而处理其余越界建议。只有整份报告提出的问题均不具备阻塞条件时,才可将本次审查结论标记为不阻塞,并按工作流提供的方式留痕。该判断只适用于当前材料;discovery 变化后必须重新审查,不做部分问题裁决或永久豁免。问题是否成立取决于业务范围、完成判定或风险接受时,先询问用户;可由现有材料直接判定的越界、无证据或非阻断建议由主流程说明判断理由。
29
+
28
30
  ## 探索分工
29
31
 
30
32
  主会话负责理解需求、提出探查问题、汇总 discovery、判断哪些未知必须问用户;默认必须启动 `explore` subagent 做只读深扫,避免只按用户表述或局部代码自行判断影响范围。仅纯文档、明显 typo、单文件机械小修、明确无代码影响可跳过 subagent,跳过时在 discovery 说明原因。
@@ -8,7 +8,7 @@ metadata:
8
8
 
9
9
  # SuperSpec Propose
10
10
 
11
- 你是计划阶段。职责:把探索结论转化为可执行的计划——写 proposal.md / specs / design.md / tasks.md + business-invariants.md + test-contract.md。
11
+ 你是计划阶段。职责:把探索结论转化为可执行的计划——写 proposal.md / specs / design.md / tasks.md + test-contract.md。
12
12
 
13
13
  ## 驱动方式
14
14
 
@@ -23,7 +23,7 @@ metadata:
23
23
 
24
24
  什么问题需要用户确认,判定标准见「待用户确认」一节;就绪或审查后向用户只概括任务可验证性、关键风险/证据覆盖和下一步。
25
25
 
26
- 执行 propose-ready 前,对照 critic / architect / test-engineer 的阻塞条件快速自检(非穷尽):Impact 与 CHAIN/IDC 对账、specs 增量与 proposal 能力变化互相对应、design 的功能点与实现方案能推出 tasks、DEC 已有行内结论并回写、task 粒度单一行为且顺序可执行、每个普通 TDD task 有完整可定位的 `执行依据:`(声明的 TEST 都存在于 test-contract,`边界`/`原因` 具体到该 task 而非套话)、不变量可证伪且覆盖核心行为变化、test-contract 覆盖 Impact 引用的 CHAIN、test-contract 中未绑定任何 task 的 TEST 有明确取舍(绑定到 task 或留待用户豁免决策)。自检不替代审查工作项,只为减少驳回往返。
26
+ 执行 propose-ready 前,对照 critic / architect / test-engineer 的阻塞条件快速自检(非穷尽):Impact 与 CHAIN/IDC 对账、specs 增量与 proposal 能力变化互相对应、design 的功能点与实现方案能推出 tasks、DEC 已有行内结论并回写、task 粒度单一行为且顺序可执行、每个普通 TDD task 有完整可定位的 `执行依据:`(声明的 TEST 都存在于 test-contract,`边界`/`原因` 具体到该 task 而非套话)、specs 中的核心业务规则可验证、test-contract 覆盖 Impact 引用的 CHAIN、test-contract 中未绑定任何 task 的 TEST 有明确取舍(绑定到 task 或留待用户豁免决策)。自检不替代审查工作项,只为减少驳回往返。
27
27
 
28
28
  人类可读正文默认使用简体中文;OpenSpec 结构标题、规范关键字、命令、路径、JSON 字段、代码标识符保留原文。OpenSpec 生成文档语言不符合预期时,先检查 `openspec/config.yaml` 的官方 `context` 设置;不要在变更文档里添加自定义 `language` 字段。
29
29
 
@@ -178,33 +178,19 @@ metadata:
178
178
  - 多个行为变化、入口或运行时模块不能形成同一个 RED/GREEN 闭环时拆开;需要“顺便”改多个不相邻模块的 task 在 propose 阶段就拆分或补充任务,不留到 apply 阶段扩大范围
179
179
  - 任务按可执行顺序排列:引擎忽略标题、按全文顶格 checkbox 行的先后顺序逐个驱动执行,被依赖的任务必须排在依赖它的任务之前,跨组同样如此(顺序与分组冲突时调整任务归组或拆组);跨组依赖可在任务行内注明依赖的 task id 作为提示,但注明不改变执行顺序
180
180
 
181
- ### business-invariants.md
182
- 格式:
183
-
184
- ```markdown
185
- # Business Invariants
186
-
187
- - INV-001 用户密码必须加密存储
188
- - INV-002 订单金额不能为负数
189
- ```
190
-
191
- 规则:
192
- - 不变量是本次改动必须保持或新确立的业务规则,必须可违反、可验证——存在能让它失败的具体操作和可观察结果;「系统应稳定」「代码应可维护」这类不可证伪的陈述不算
193
- - 覆盖本次行为变化触及的核心规则即可,不堆砌与本次改动无关的通用约束
194
-
195
181
  ### test-contract.md
196
182
  格式:
197
183
 
198
184
  ```markdown
199
185
  # Test Contract
200
186
 
201
- | test_id | invariant | scenario |
202
- |---|---|---|
203
- | TEST-001 | INV-001 | 注册时提交明文密码,落库字段为加密值且不含明文 |
204
- | TEST-002 | INV-002 | 已登录用户提交金额为 -1 的订单,下单被拒绝并返回校验错误 |
187
+ | test_id | scenario |
188
+ |---|---|
189
+ | TEST-001 | 注册时提交明文密码,落库字段为加密值且不含明文 |
190
+ | TEST-002 | 已登录用户提交金额为 -1 的订单,下单被拒绝并返回校验错误 |
205
191
  ```
206
192
 
207
- scenario 写到能推导断言的程度:给定什么条件、发生什么动作、观察到什么结果;不写测试命令和断言代码。「验证功能正常」这类无法推导断言的写法不合格。
193
+ 核心业务规则写入对应 `specs/` requirement 和 scenario。test-contract 的 scenario 写到能推导断言的程度:给定什么条件、发生什么动作、观察到什么结果;不写测试命令和断言代码。「验证功能正常」这类无法推导断言的写法不合格。
208
194
 
209
195
  如果 discovery 含 `## 输入数据来源核查` 的 IDC 项,在测试表后增加 `## 输入数据覆盖验证`:
210
196
 
@@ -231,10 +217,12 @@ discovery 含 `## 链路五要素` 时,`proposal.md` `## Impact` 中引用的
231
217
 
232
218
  答案来自用户时,先登记再勾选;答案来自需求文档、代码证据等外部事实核对时,行内写明证据来源,不伪造用户决策。用户回答含糊、与候选项不匹配或引出新问题时,不视为已确认;复述理解并获得明确答复后再登记。把结论反映到 proposal/design/test-contract 相关内容,勾选行内注明结论要点;确认项作废或重复时改为 `[x]` 并注明理由,不要删除确认项。局部实现细节、命名、普通文件组织和不影响需求/验收/风险的技术微调不要升级为用户确认。
233
219
 
234
- 进入 propose 后出现新的业务规则、产品口径、验收标准、示例规范或需求源更新时,不要静默覆盖原计划;默认先在 `proposal.md` 记录 `## 需求变化`,说明变化来源、变化内容、受影响能力、直接修改的文档章节、确认保持不变的范围和处理方式(更新当前 change / 新建后续 change / 暂不处理)。该段是后续增量审查判断“本轮变化”的权威锚点;不得把未受影响的历史设计重新列为本轮待审范围。只有影响技术路线、测试契约或业务不变量时,才同步更新 `design.md`、`test-contract.md` 或 `business-invariants.md`。
220
+ 进入 propose 后出现新的业务规则、产品口径、验收标准、示例规范或需求源更新时,不要静默覆盖原计划;默认先在 `proposal.md` 记录 `## 需求变化`,说明变化来源、变化内容、受影响能力、直接修改的文档章节、确认保持不变的范围和处理方式(更新当前 change / 新建后续 change / 暂不处理)。该段是后续增量审查判断“本轮变化”的权威锚点;不得把未受影响的历史设计重新列为本轮待审范围。只有影响规格、技术路线或测试契约时,才同步更新 `specs/`、`design.md` 或 `test-contract.md`。
235
221
 
236
222
  审查报告是待验证的独立意见,不会自动创造新需求。主流程处理 finding 时先分离 underlying problem 与 recommendation:根据本次 change 的目标、直接证据和明确验收独立判断问题是否成立;问题成立时选择满足既有需求的最小修复。Recommendation 只是非绑定建议,不是验收标准;与用户决定、已确认复用路线或 `## 非目标` 冲突的具体方案不实施,也不得仅为通过审查增加未经确认的基础设施、兼容、额外任务、故障场景或测试义务。若 reviewer 指出的事实证据证明现有方案无法满足用户已确认的强制需求、规格约束或明确验收结果,补足对应结果、契约或证据,而不是默认采用 reviewer 指定的架构;Reviewer 不得自行新增或升级强制要求。
237
223
 
224
+ 普通计划审查未通过后,主流程拥有整份报告的最终阻塞准入判断权,但不得篡改原报告或把审查意见直接升级为需求。只要报告中存在一个有直接证据、属于本次 change 且影响明确验收或落地的问题,就修改对应材料并重新审查;不要为了让报告“全部正确”而处理其余越界建议。只有整份报告提出的问题均不具备上述阻塞条件时,才可将本次审查结论标记为不阻塞,并按工作流提供的方式留痕。该判断只适用于当前材料;材料变化后必须重新审查,不做部分问题裁决或永久豁免。问题是否成立取决于需求范围、验收口径、风险接受或技术路线时,先询问用户;可由当前材料直接判定的越界、无证据或非阻断建议由主流程说明判断理由。
225
+
238
226
  ## 完成条件
239
227
 
240
228
  tasks.md 作为计划文档就绪(不是复选框全完成)+ 基础职责文档齐全 → next 返回 propose-ready 命令。
@@ -246,4 +234,4 @@ tasks.md 作为计划文档就绪(不是复选框全完成)+ 基础职责文
246
234
  - tdd_required 标注真实
247
235
  - 不跳过 transition
248
236
  - 不跳过完整审查路径下的审核工作项
249
- - 审查通过后、推进前不做非必要的文档编辑;绑定审查的内容(proposal/design/tasks/specs/discovery/business-invariants/test-contract)变更会作废已通过的审查并触发重审。
237
+ - 审查通过后、推进前不做非必要的文档编辑;计划材料变更会按当前审查模式和角色职责触发必要的复审。