@peterxiaoyang/superspec 0.1.42 → 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"];
@@ -63,8 +63,8 @@ function previousRejectionInstruction(job) {
63
63
  return "";
64
64
  const reason = `上一次同角色审查没有形成可推进结论,原因:${previous.reason}。`;
65
65
  if (!previous.findings || previous.findings.length === 0)
66
- return `${reason}完成全部材料覆盖和角色分析后,再针对该原因复核,`;
67
- return `${reason}完成全部材料覆盖和角色分析后,再逐项复核本工作项附带的上一次同角色审查尚未闭环问题:同一问题仍存在时复用原 finding ID;legacy finding 没有 ID 时沿用其原始语义并补一个稳定 ID;已解决的问题不要重复报告,不得通过更换 ID、标题或措辞重复同一问题;新增问题必须提供与历史问题不同的具体证据。`;
66
+ return `${reason}本轮是修复复核;完整读取材料只用于核对当前修正和直接一致性,不得借复核重新审计与修正无关的历史设计,`;
67
+ return `${reason}本轮是修复复核:逐项判断本工作项附带的上一次同角色 finding 是否仍成立。Finding 中的 recommendation 只是非绑定建议,不是需求或验收标准;先独立核对 underlying problem、直接证据和本次验收,不得因原建议指定了某种架构就要求照做。同一问题仍存在时复用原 finding ID;legacy finding 没有 ID 时沿用其原始语义并补一个稳定 ID;已解决或已由等价证据闭环的问题不要重复报告,不得通过更换 ID、标题或措辞重复同一问题。默认只复核历史 finding;新 blocker 仅允许是本次修正直接引入的回归,并必须说明“修正动作 → 新问题”的因果链,不得展开无关的故障模型、消费者或架构议题。`;
68
68
  }
69
69
  function ordinaryReviewerFindingInstruction(job) {
70
70
  if (!isOrdinaryReviewer(job.role))
@@ -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 时提交。随后按本角色职责进行跨文件分析;最后才复核历史 findings 并去重,一次性提交当前快照下发现的全部 blocker。报告中的 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;