@peterxiaoyang/superspec 0.1.34 → 0.1.35

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/README.md CHANGED
@@ -11,7 +11,7 @@
11
11
  SuperSpec 会把一次需求变更拆成 5 步:
12
12
 
13
13
  ```text
14
- 探索需求 -> 写方案 -> 做实现 -> 做审查 -> 归档收尾
14
+ 探索需求 -> 写方案 -> 做实现 -> 代码审查与最终验证 -> 归档收尾
15
15
  ```
16
16
 
17
17
  这样做的目的很简单:
@@ -112,10 +112,10 @@ superspec.cmd install
112
112
  使用 superspec-apply,按任务实现。
113
113
  ```
114
114
 
115
- 实现完成后,审查:
115
+ 实现完成后,代码审查与最终验证:
116
116
 
117
117
  ```text
118
- 使用 superspec-review,检查实现、测试和风险。
118
+ 使用 superspec-review,完成代码审查、问题处理和最终验证。
119
119
  ```
120
120
 
121
121
  审查通过后,归档:
@@ -131,7 +131,7 @@ superspec.cmd install
131
131
  | `superspec-explore` | 需求刚开始时 | 读代码、查现状、整理范围和风险;这一步不改业务代码 |
132
132
  | `superspec-propose` | 需求已经清楚后 | 写正式方案、规格、设计和任务,并提前规划测试 |
133
133
  | `superspec-apply` | 方案通过后 | 按任务实现代码,记录测试或验证结果 |
134
- | `superspec-review` | 实现完成后 | 做代码审查、架构审查、反方审查和最终验证 |
134
+ | `superspec-review` | 实现完成后 | 检查代码实现是否符合方案,处理审查问题,并完成最终验证 |
135
135
  | `superspec-archive` | 审查通过后 | 用 OpenSpec 完成归档,并检查关键记录是否保留 |
136
136
 
137
137
  你日常主要记住这五个入口就够了。
package/dist/cli.js CHANGED
@@ -501,7 +501,8 @@ async function main(argv) {
501
501
  transition 子命令:
502
502
  init / explore / sync / next / propose-ready / start-apply
503
503
  task-start --task <T> / task-complete --task <T>
504
- reopen --to apply --reason <TEXT>
504
+ reopen --to apply --reason <TEXT> [--review-fix <JOB#FINDING>]
505
+ reopen --to propose --reason <TEXT> --review-finding <JOB#FINDING>
505
506
  review-ready / accept / archive
506
507
 
507
508
  record 子命令:
@@ -693,7 +694,10 @@ jobs 子命令:
693
694
  console.error("reopen 需要 --reason");
694
695
  return 1;
695
696
  }
696
- const result = reopen(projectRoot, change, cr, to, reason);
697
+ const result = reopen(projectRoot, change, cr, to, reason, {
698
+ ...(opts["review-fix"] ? { reviewFix: opts["review-fix"] } : {}),
699
+ ...(opts["review-finding"] ? { reviewFinding: opts["review-finding"] } : {}),
700
+ });
697
701
  console.log(JSON.stringify(result, null, 2));
698
702
  return transitionExitCode(result);
699
703
  }
@@ -0,0 +1,74 @@
1
+ import type { CodeReviewResultKind, Event, Job, Ref } from "./types.ts";
2
+ export declare const CODE_REVIEW_REPAIR_SCOPE_PREFIX = "code_reviewer_report_repair:";
3
+ export declare const CODE_REVIEW_DECISION_SCOPE_PREFIX = "code_review_decision:";
4
+ export type CodeReviewDecisionAnswer = "reopen_propose" | "reopen_apply" | "dismiss";
5
+ export declare const CODE_REVIEW_DECISION_ANSWER_LABELS: Record<CodeReviewDecisionAnswer, string>;
6
+ export interface CodeChangeScan {
7
+ reliable: boolean;
8
+ hasCodeChanges: boolean;
9
+ paths: string[];
10
+ reason: string;
11
+ }
12
+ export interface CodeReviewTerminalResult {
13
+ job: Job;
14
+ event: Event;
15
+ state: "accepted" | "rejected";
16
+ result_kind?: CodeReviewResultKind;
17
+ reason?: string;
18
+ }
19
+ export interface CodeReviewDecision {
20
+ answer: CodeReviewDecisionAnswer;
21
+ reason: string;
22
+ event: Event;
23
+ }
24
+ export interface CodeReviewFindingStatus {
25
+ id: string;
26
+ type: "implementation" | "spec" | "mixed";
27
+ finding: Record<string, unknown>;
28
+ decision: CodeReviewDecision | null;
29
+ }
30
+ export interface CodeReviewFailedStatus {
31
+ terminal: CodeReviewTerminalResult;
32
+ findings: CodeReviewFindingStatus[];
33
+ unresolved: CodeReviewFindingStatus[];
34
+ dismissed: CodeReviewFindingStatus[];
35
+ }
36
+ export interface CodeReviewGateFacts {
37
+ cycleStartIndex: number;
38
+ jobs: Job[];
39
+ openJobs: Job[];
40
+ terminalResults: CodeReviewTerminalResult[];
41
+ latestTerminal: CodeReviewTerminalResult | null;
42
+ latestRejected: CodeReviewTerminalResult | null;
43
+ consecutiveRejected: number;
44
+ }
45
+ export declare function isCodeLikePath(path: string): boolean;
46
+ export declare function scanCodeChanges(projectRoot: string): CodeChangeScan;
47
+ export declare function codeReviewBoundFiles(projectRoot: string, paths: string[]): Ref[];
48
+ export declare function codeReviewJobStaleReason(projectRoot: string, job: Job, currentPaths?: string[]): string | null;
49
+ export declare function codeReviewPacketDigest(input: {
50
+ role: "code-reviewer";
51
+ boundFiles: Ref[];
52
+ checkedDocs: string[];
53
+ created_from_transition: string;
54
+ previous_rejection?: {
55
+ result_kind: CodeReviewResultKind;
56
+ reason: string;
57
+ job_id: string;
58
+ };
59
+ }): string;
60
+ export declare function codeReviewDecisionScope(jobId: string, findingId: string): string;
61
+ export declare function isCodeReviewDecisionAnswer(value: unknown): value is CodeReviewDecisionAnswer;
62
+ export declare function codeReviewDecisionAnswerLabel(answer: CodeReviewDecisionAnswer): string;
63
+ export declare function normalizeCodeReviewDecisionAnswer(value: unknown): CodeReviewDecisionAnswer | null;
64
+ export declare function latestCodeReviewDecision(events: Event[], scope: string): CodeReviewDecision | null;
65
+ export declare function latestCodeReviewFailedStatus(events: Event[]): CodeReviewFailedStatus | null;
66
+ export declare function dismissedCodeReviewSummary(status: CodeReviewFailedStatus): string;
67
+ export declare function currentApplyDoneCycleStart(events: Event[]): number;
68
+ export declare function collectCodeReviewGateFacts(events: Event[]): CodeReviewGateFacts;
69
+ export declare function latestApplyDoneToReviewGate(events: Event[]): {
70
+ decision: "passed" | "skipped";
71
+ job_id?: string;
72
+ reason?: string;
73
+ } | null;
74
+ export declare function requiresFinalVerifierForCurrentReview(events: Event[]): boolean;
@@ -0,0 +1,323 @@
1
+ // SuperSpec code-reviewer gate helpers.
2
+ import { execFileSync } from "node:child_process";
3
+ import { existsSync, readdirSync, statSync } from "node:fs";
4
+ import { join, extname } from "node:path";
5
+ import { findLatestEvent, sha256File, sha256Text } from "./store.js";
6
+ export const CODE_REVIEW_REPAIR_SCOPE_PREFIX = "code_reviewer_report_repair:";
7
+ export const CODE_REVIEW_DECISION_SCOPE_PREFIX = "code_review_decision:";
8
+ export const CODE_REVIEW_DECISION_ANSWER_LABELS = {
9
+ reopen_propose: "回到计划阶段",
10
+ reopen_apply: "回到实现阶段",
11
+ dismiss: "驳回该问题",
12
+ };
13
+ const PROCESS_DOC_RE = /^(?:openspec\/changes\/[^/]+\/)?(?:proposal|design|tasks)\.md$/;
14
+ const PROCESS_ARTIFACT_RE = /^(?:openspec\/changes\/[^/]+\/)?\.superspec\/artifacts\/(?:discovery|business-invariants|test-contract)\.md$/;
15
+ const CODE_EXTENSIONS = new Set([
16
+ ".c", ".cc", ".cpp", ".cs", ".css", ".go", ".h", ".hpp", ".html", ".java", ".js", ".jsx",
17
+ ".json", ".kt", ".mjs", ".mts", ".php", ".py", ".rb", ".rs", ".scss", ".sh", ".sql",
18
+ ".swift", ".toml", ".ts", ".tsx", ".yaml", ".yml",
19
+ ]);
20
+ const CODE_BASENAMES = new Set([
21
+ "Dockerfile", "Makefile", "package.json", "package-lock.json", "pnpm-lock.yaml", "yarn.lock",
22
+ "tsconfig.json", "tsconfig.build.json", "eslint.config.js", "vite.config.ts", "webpack.config.js",
23
+ ]);
24
+ const WALK_SKIP_DIRS = new Set([".git", "node_modules", "dist", "build", ".superspec", ".omx"]);
25
+ function normalizeGitPath(rawPath) {
26
+ const trimmed = rawPath.trim();
27
+ const unquoted = trimmed.startsWith('"') && trimmed.endsWith('"')
28
+ ? trimmed.slice(1, -1).replace(/\\"/g, '"')
29
+ : trimmed;
30
+ const renamed = unquoted.includes(" -> ") ? unquoted.split(" -> ").pop() ?? unquoted : unquoted;
31
+ return renamed.replace(/\\/g, "/");
32
+ }
33
+ function gitChangedPaths(projectRoot) {
34
+ try {
35
+ const output = execFileSync("git", ["-C", projectRoot, "status", "--porcelain", "--untracked-files=all"], {
36
+ encoding: "utf8",
37
+ stdio: ["ignore", "pipe", "ignore"],
38
+ });
39
+ const paths = output
40
+ .split("\n")
41
+ .map(line => line.trimEnd())
42
+ .filter(Boolean)
43
+ .map(line => normalizeGitPath(line.slice(3)))
44
+ .filter(Boolean);
45
+ return { ok: true, paths: [...new Set(paths)].sort() };
46
+ }
47
+ catch (err) {
48
+ return { ok: false, reason: err instanceof Error ? err.message : "git status failed" };
49
+ }
50
+ }
51
+ function isProcessOrOrdinaryDoc(path) {
52
+ if (path.startsWith(".superspec/") || path.startsWith(".omx/"))
53
+ return true;
54
+ if (path.includes("/.superspec/") || path.includes("/.omx/"))
55
+ return true;
56
+ if (PROCESS_DOC_RE.test(path) || PROCESS_ARTIFACT_RE.test(path))
57
+ return true;
58
+ return extname(path).toLowerCase() === ".md";
59
+ }
60
+ export function isCodeLikePath(path) {
61
+ const normalized = path.replace(/\\/g, "/");
62
+ if (!normalized || isProcessOrOrdinaryDoc(normalized))
63
+ return false;
64
+ const base = normalized.split("/").pop() ?? normalized;
65
+ if (CODE_BASENAMES.has(base))
66
+ return true;
67
+ return CODE_EXTENSIONS.has(extname(base).toLowerCase());
68
+ }
69
+ function walkCodeFiles(root, dir = root, out = []) {
70
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
71
+ if (entry.isDirectory()) {
72
+ if (WALK_SKIP_DIRS.has(entry.name))
73
+ continue;
74
+ walkCodeFiles(root, join(dir, entry.name), out);
75
+ continue;
76
+ }
77
+ if (!entry.isFile())
78
+ continue;
79
+ const full = join(dir, entry.name);
80
+ const rel = full.slice(root.length + 1).replace(/\\/g, "/");
81
+ if (isCodeLikePath(rel))
82
+ out.push(rel);
83
+ }
84
+ return out;
85
+ }
86
+ export function scanCodeChanges(projectRoot) {
87
+ const git = gitChangedPaths(projectRoot);
88
+ if (!git.ok) {
89
+ const paths = existsSync(projectRoot) && statSync(projectRoot).isDirectory()
90
+ ? walkCodeFiles(projectRoot).sort()
91
+ : [];
92
+ return {
93
+ reliable: false,
94
+ hasCodeChanges: true,
95
+ paths,
96
+ reason: `无法读取 git 状态,已按当前代码文件范围发起审查:${git.reason}`,
97
+ };
98
+ }
99
+ const paths = git.paths.filter(isCodeLikePath);
100
+ return {
101
+ reliable: true,
102
+ hasCodeChanges: paths.length > 0,
103
+ paths,
104
+ reason: paths.length > 0 ? "检测到代码类改动" : "没有代码类改动",
105
+ };
106
+ }
107
+ export function codeReviewBoundFiles(projectRoot, paths) {
108
+ return paths.map(path => ({ path, sha: sha256File(join(projectRoot, path)) ?? "sha256:missing" }));
109
+ }
110
+ function samePathSet(left, right) {
111
+ const a = [...new Set(left)].sort();
112
+ const b = [...new Set(right)].sort();
113
+ return a.length === b.length && a.every((path, index) => path === b[index]);
114
+ }
115
+ export function codeReviewJobStaleReason(projectRoot, job, currentPaths) {
116
+ if (!isCodeReviewerJob(job))
117
+ return null;
118
+ const scanPaths = currentPaths ?? scanCodeChanges(projectRoot).paths;
119
+ const boundPaths = job.boundFiles.map(file => file.path);
120
+ if (!samePathSet(boundPaths, scanPaths)) {
121
+ return `代码审查范围已变化(原范围:${boundPaths.join(", ") || "<none>"};当前范围:${scanPaths.join(", ") || "<none>"})`;
122
+ }
123
+ for (const bound of job.boundFiles) {
124
+ const currentSha = sha256File(join(projectRoot, bound.path)) ?? "sha256:missing";
125
+ if (currentSha !== bound.sha) {
126
+ return `代码审查范围内的文件 ${bound.path} 已变化(原记录:${bound.sha};当前:${currentSha})`;
127
+ }
128
+ }
129
+ return null;
130
+ }
131
+ export function codeReviewPacketDigest(input) {
132
+ return sha256Text(JSON.stringify(input));
133
+ }
134
+ function isCodeReviewerJob(job) {
135
+ return job.role === "code-reviewer" && job.created_from_transition === "review-ready";
136
+ }
137
+ function codeReviewResultKind(value) {
138
+ return value === "invalid_report" || value === "non_actionable_report" || value === "review_failed"
139
+ ? value
140
+ : undefined;
141
+ }
142
+ export function codeReviewDecisionScope(jobId, findingId) {
143
+ return `${CODE_REVIEW_DECISION_SCOPE_PREFIX}${jobId}#${findingId}`;
144
+ }
145
+ export function isCodeReviewDecisionAnswer(value) {
146
+ return value === "reopen_propose" || value === "reopen_apply" || value === "dismiss";
147
+ }
148
+ export function codeReviewDecisionAnswerLabel(answer) {
149
+ return CODE_REVIEW_DECISION_ANSWER_LABELS[answer];
150
+ }
151
+ export function normalizeCodeReviewDecisionAnswer(value) {
152
+ if (isCodeReviewDecisionAnswer(value))
153
+ return value;
154
+ if (value === CODE_REVIEW_DECISION_ANSWER_LABELS.reopen_propose)
155
+ return "reopen_propose";
156
+ if (value === CODE_REVIEW_DECISION_ANSWER_LABELS.reopen_apply)
157
+ return "reopen_apply";
158
+ if (value === CODE_REVIEW_DECISION_ANSWER_LABELS.dismiss)
159
+ return "dismiss";
160
+ return null;
161
+ }
162
+ export function latestCodeReviewDecision(events, scope) {
163
+ const event = findLatestEvent(events, "user_decision_recorded", ev => {
164
+ const payload = ev.payload;
165
+ if (payload.scope !== scope)
166
+ return false;
167
+ if (payload.accepted === false)
168
+ return false;
169
+ if (!normalizeCodeReviewDecisionAnswer(payload.answer))
170
+ return false;
171
+ if (typeof payload.reason !== "string" || payload.reason.trim() === "")
172
+ return false;
173
+ return true;
174
+ });
175
+ if (!event)
176
+ return null;
177
+ const payload = event.payload;
178
+ const answer = normalizeCodeReviewDecisionAnswer(payload.answer);
179
+ if (!answer)
180
+ return null;
181
+ return {
182
+ answer,
183
+ reason: typeof payload.reason === "string" ? payload.reason.trim() : "",
184
+ event,
185
+ };
186
+ }
187
+ function blockingFindingsFromReviewFailed(event, jobId, events) {
188
+ const payload = event.payload;
189
+ const findings = Array.isArray(payload.findings) ? payload.findings : [];
190
+ const result = [];
191
+ for (const raw of findings) {
192
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
193
+ continue;
194
+ const finding = raw;
195
+ if (finding.blocking !== true)
196
+ continue;
197
+ if (typeof finding.id !== "string" || finding.id.trim() === "")
198
+ continue;
199
+ if (finding.type !== "implementation" && finding.type !== "spec" && finding.type !== "mixed")
200
+ continue;
201
+ const id = finding.id;
202
+ result.push({
203
+ id,
204
+ type: finding.type,
205
+ finding,
206
+ decision: latestCodeReviewDecision(events, codeReviewDecisionScope(jobId, id)),
207
+ });
208
+ }
209
+ return result;
210
+ }
211
+ export function latestCodeReviewFailedStatus(events) {
212
+ const facts = collectCodeReviewGateFacts(events);
213
+ const terminal = facts.latestTerminal;
214
+ if (!terminal || terminal.state !== "rejected" || terminal.result_kind !== "review_failed")
215
+ return null;
216
+ const findings = blockingFindingsFromReviewFailed(terminal.event, terminal.job.job_id, events);
217
+ const dismissed = findings.filter(item => item.decision?.answer === "dismiss");
218
+ const unresolved = findings.filter(item => item.decision?.answer !== "dismiss");
219
+ return { terminal, findings, unresolved, dismissed };
220
+ }
221
+ export function dismissedCodeReviewSummary(status) {
222
+ const details = status.dismissed
223
+ .map(item => `${item.id}:${item.decision?.reason || "主流程已驳回该问题"}`)
224
+ .join(";");
225
+ return details
226
+ ? `上一次代码审查提出的问题已被主流程复核驳回:${details}。没有新的具体证据时,不要重复提出同一问题。`
227
+ : "上一次代码审查提出的问题已被主流程复核驳回。没有新的具体证据时,不要重复提出同一问题。";
228
+ }
229
+ export function currentApplyDoneCycleStart(events) {
230
+ for (let i = events.length - 1; i >= 0; i--) {
231
+ const ev = events[i];
232
+ if (ev.event_type !== "transition_commit")
233
+ continue;
234
+ const payload = ev.payload;
235
+ if (payload.to_state === "apply_done" && payload.from_state !== "apply_done")
236
+ return i;
237
+ }
238
+ return 0;
239
+ }
240
+ export function collectCodeReviewGateFacts(events) {
241
+ const cycleStartIndex = currentApplyDoneCycleStart(events);
242
+ const jobs = [];
243
+ const jobsById = new Map();
244
+ const terminalResults = [];
245
+ const terminalJobIds = new Set();
246
+ for (let i = cycleStartIndex; i < events.length; i++) {
247
+ const ev = events[i];
248
+ if (ev.event_type !== "transition_commit")
249
+ continue;
250
+ const newJobs = ev.payload.new_jobs ?? [];
251
+ for (const job of newJobs) {
252
+ if (!isCodeReviewerJob(job))
253
+ continue;
254
+ jobs.push(job);
255
+ jobsById.set(job.job_id, job);
256
+ }
257
+ }
258
+ for (let i = cycleStartIndex; i < events.length; i++) {
259
+ const ev = events[i];
260
+ if (ev.event_type !== "job_accepted" && ev.event_type !== "job_rejected")
261
+ continue;
262
+ const payload = ev.payload;
263
+ if (typeof payload.job_id !== "string")
264
+ continue;
265
+ const job = jobsById.get(payload.job_id);
266
+ if (!job)
267
+ continue;
268
+ terminalJobIds.add(job.job_id);
269
+ terminalResults.push({
270
+ job,
271
+ event: ev,
272
+ state: ev.event_type === "job_accepted" ? "accepted" : "rejected",
273
+ result_kind: ev.event_type === "job_rejected"
274
+ ? codeReviewResultKind(payload.result_kind) ?? "invalid_report"
275
+ : undefined,
276
+ reason: typeof payload.reason === "string" ? payload.reason : undefined,
277
+ });
278
+ }
279
+ let consecutiveRejected = 0;
280
+ for (let i = terminalResults.length - 1; i >= 0; i--) {
281
+ const result = terminalResults[i];
282
+ if (result.state !== "rejected")
283
+ break;
284
+ if (result.result_kind !== "invalid_report" && result.result_kind !== "non_actionable_report")
285
+ break;
286
+ consecutiveRejected += 1;
287
+ }
288
+ const latestTerminal = terminalResults.at(-1) ?? null;
289
+ const latestRejected = [...terminalResults].reverse().find(result => result.state === "rejected") ?? null;
290
+ return {
291
+ cycleStartIndex,
292
+ jobs,
293
+ openJobs: jobs.filter(job => !terminalJobIds.has(job.job_id)),
294
+ terminalResults,
295
+ latestTerminal,
296
+ latestRejected,
297
+ consecutiveRejected,
298
+ };
299
+ }
300
+ export function latestApplyDoneToReviewGate(events) {
301
+ for (let i = events.length - 1; i >= 0; i--) {
302
+ const ev = events[i];
303
+ if (ev.event_type !== "transition_commit")
304
+ continue;
305
+ const payload = ev.payload;
306
+ if (payload.transition !== "review-ready")
307
+ continue;
308
+ if (payload.from_state !== "apply_done" || payload.to_state !== "review")
309
+ continue;
310
+ const gate = payload.code_review_gate;
311
+ if (!gate || (gate.decision !== "passed" && gate.decision !== "skipped"))
312
+ return null;
313
+ return {
314
+ decision: gate.decision,
315
+ ...(typeof gate.job_id === "string" ? { job_id: gate.job_id } : {}),
316
+ ...(typeof gate.reason === "string" ? { reason: gate.reason } : {}),
317
+ };
318
+ }
319
+ return null;
320
+ }
321
+ export function requiresFinalVerifierForCurrentReview(events) {
322
+ return latestApplyDoneToReviewGate(events) != null;
323
+ }
package/dist/format.js CHANGED
@@ -127,15 +127,25 @@ export function tasksStructureDigest(content, sha256Text) {
127
127
  // "cwd": "/path",
128
128
  // "exit_code": 1,
129
129
  // "semantic_status": "expected_failure",
130
+ // "covers_task_ids": ["TASK-001"],
130
131
  // "target_fingerprint": "sha256:..."
131
132
  // }
132
133
  //
133
- // 引擎校验:test_id + task_structure_digest 必填,其余可选
134
+ // 引擎校验:test_id + task_structure_digest 必填,covers_task_ids 如存在必须是非空字符串数组,其余可选
134
135
  export function validateTestRunInput(tr) {
135
136
  if (!tr.test_id)
136
137
  return { ok: false, message: "缺少 test_id" };
137
138
  if (!tr.task_structure_digest)
138
139
  return { ok: false, message: "缺少 task_structure_digest" };
140
+ if (tr.covers_task_ids !== undefined) {
141
+ if (!Array.isArray(tr.covers_task_ids))
142
+ return { ok: false, message: "covers_task_ids 必须是字符串数组" };
143
+ if (tr.covers_task_ids.length === 0)
144
+ return { ok: false, message: "covers_task_ids 不能是空数组" };
145
+ if (!tr.covers_task_ids.every(item => typeof item === "string" && item.trim().length > 0)) {
146
+ return { ok: false, message: "covers_task_ids 不能包含空字符串或非字符串" };
147
+ }
148
+ }
139
149
  return { ok: true, message: "" };
140
150
  }
141
151
  // ===== user-decision JSON =====
package/dist/next.js CHANGED
@@ -4,6 +4,7 @@ import { readFileSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import { readEvents, sha256Text } from "./store.js";
6
6
  import { isFreshReviewVerifier, isReviewReadyVerifier, readReviewPolicyFromEvents, reviewEvidenceDigest } from "./review.js";
7
+ import { CODE_REVIEW_DECISION_ANSWER_LABELS, CODE_REVIEW_REPAIR_SCOPE_PREFIX, codeReviewDecisionScope, collectCodeReviewGateFacts, latestCodeReviewFailedStatus, requiresFinalVerifierForCurrentReview, } from "./code_review.js";
7
8
  import { requiredJobActions } from "./job_action.js";
8
9
  import { validateDiscovery, countDiscoveryOpenQuestions, collectProposeOpenQuestions, parseTasksMd, pendingTasksInContent } from "./format.js";
9
10
  const ACTIVE_PROPOSAL_REVIEW_ROLES = new Set(["critic", "architect", "test-engineer"]);
@@ -212,6 +213,83 @@ export function next(projectRoot, change, changeRoot, defaultRisk = "strict") {
212
213
  if (snapshot.open_jobs.length > 0) {
213
214
  return requiredJobsOutput("apply_done", change, snapshot.open_jobs, `有 ${snapshot.open_jobs.length} 个待完成工作项`);
214
215
  }
216
+ const events = readEvents(projectRoot, change);
217
+ const facts = collectCodeReviewGateFacts(events);
218
+ const latest = facts.latestTerminal;
219
+ if (latest?.state === "rejected" && latest.result_kind === "review_failed") {
220
+ const status = latestCodeReviewFailedStatus(events);
221
+ const pending = status?.unresolved[0] ?? null;
222
+ if (status && status.findings.length > 0 && !pending) {
223
+ return {
224
+ state: "apply_done",
225
+ path: "next_command",
226
+ next_command: transitionCommand(change, "review-ready", riskFlag(defaultRisk)),
227
+ reason: "代码审查问题已被主流程复核驳回,重新发起代码审查",
228
+ missing_inputs: [],
229
+ };
230
+ }
231
+ const findingId = pending?.id ?? "";
232
+ const type = pending?.type;
233
+ const decision = pending?.decision;
234
+ if (findingId && type === "implementation") {
235
+ return {
236
+ state: "apply_done",
237
+ path: "next_command",
238
+ next_command: transitionCommand(change, "reopen", `--to apply --review-fix ${latest.job.job_id}#${findingId} --reason "修复代码审查问题 ${findingId}"`),
239
+ reason: `代码审查发现纯代码实现问题 ${findingId},回到实现阶段修复`,
240
+ missing_inputs: [],
241
+ };
242
+ }
243
+ if (findingId && (type === "spec" || type === "mixed")) {
244
+ if (decision?.answer === "reopen_propose") {
245
+ return {
246
+ state: "apply_done",
247
+ path: "next_command",
248
+ next_command: transitionCommand(change, "reopen", `--to propose --review-finding ${latest.job.job_id}#${findingId} --reason "根据代码审查问题 ${findingId} 回到计划阶段"`),
249
+ reason: `使用者已确认问题 ${findingId} 需要回到计划阶段`,
250
+ missing_inputs: [],
251
+ };
252
+ }
253
+ if (decision?.answer === "reopen_apply") {
254
+ return {
255
+ state: "apply_done",
256
+ path: "next_command",
257
+ next_command: transitionCommand(change, "reopen", `--to apply --review-fix ${latest.job.job_id}#${findingId} --reason "根据代码审查问题 ${findingId} 回到实现阶段修复"`),
258
+ reason: `使用者已确认问题 ${findingId} 直接回到实现阶段修复`,
259
+ missing_inputs: [],
260
+ };
261
+ }
262
+ const problemKind = type === "spec"
263
+ ? "方案或需求文档可能需要调整"
264
+ : "代码实现和方案文档都可能有关";
265
+ const ask = {
266
+ question: `代码审查发现问题 ${findingId}:${problemKind}。请选择回到计划阶段修改文档、确认现有文档方向不变并回到实现阶段修代码,或驳回该问题;无论选择哪一项都必须写明原因。`,
267
+ allowed_answers: [
268
+ CODE_REVIEW_DECISION_ANSWER_LABELS.reopen_propose,
269
+ CODE_REVIEW_DECISION_ANSWER_LABELS.reopen_apply,
270
+ CODE_REVIEW_DECISION_ANSWER_LABELS.dismiss,
271
+ ],
272
+ scope: codeReviewDecisionScope(latest.job.job_id, findingId),
273
+ };
274
+ return { state: "apply_done", path: "ask_user", ask_user: ask, reason: `代码审查发现需要使用者判断的问题 ${findingId}` };
275
+ }
276
+ const ask = {
277
+ question: "代码审查报告里缺少可用于处理问题的编号或分类。请修正审查报告后重新执行 review-ready。",
278
+ allowed_answers: ["报告已修正"],
279
+ scope: `${CODE_REVIEW_REPAIR_SCOPE_PREFIX}${change}`,
280
+ };
281
+ return { state: "apply_done", path: "ask_user", ask_user: ask, reason: "代码审查报告中的阻塞问题无法处理" };
282
+ }
283
+ if (latest?.state === "rejected" &&
284
+ (latest.result_kind === "invalid_report" || latest.result_kind === "non_actionable_report") &&
285
+ facts.consecutiveRejected >= 2) {
286
+ const ask = {
287
+ question: "代码审查报告连续两次不符合要求,或者没有给出可处理的问题。请先修正报告生成方式、模板或审查口径;修正后仍可显式执行 review-ready。",
288
+ allowed_answers: ["已修正"],
289
+ scope: `${CODE_REVIEW_REPAIR_SCOPE_PREFIX}${change}`,
290
+ };
291
+ return { state: "apply_done", path: "ask_user", ask_user: ask, reason: "代码审查报告连续不符合要求或没有可处理问题" };
292
+ }
215
293
  return {
216
294
  state: "apply_done",
217
295
  path: "next_command",
@@ -246,7 +324,7 @@ export function next(projectRoot, change, changeRoot, defaultRisk = "strict") {
246
324
  missing_inputs: [],
247
325
  };
248
326
  }
249
- if (policy.requires_verifier) {
327
+ if (requiresFinalVerifierForCurrentReview(events) || policy.requires_verifier) {
250
328
  const currentEvidenceDigest = reviewEvidenceDigest(events);
251
329
  const verifierAccepted = snapshot.accepted_jobs.find(job => isFreshReviewVerifier(job, changeRoot, currentEvidenceDigest));
252
330
  if (!verifierAccepted) {
@@ -254,7 +332,7 @@ export function next(projectRoot, change, changeRoot, defaultRisk = "strict") {
254
332
  state: "review",
255
333
  path: "next_command",
256
334
  next_command: transitionCommand(change, "review-ready", riskFlag(defaultRisk)),
257
- reason: "缺少 fresh verifier,先补最终验证",
335
+ reason: "最终验证已经缺失或不再匹配当前证据,先补最终验证",
258
336
  missing_inputs: [],
259
337
  };
260
338
  }