@peterxiaoyang/superspec 0.1.37 → 0.1.38

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/cli.js CHANGED
@@ -66,6 +66,20 @@ function proposeReadyExitCode(result) {
66
66
  return 0;
67
67
  return result.events_written === 0 && result.message.includes("不能") ? 1 : 0;
68
68
  }
69
+ function parseReviewRisk(value) {
70
+ if (value == null)
71
+ return "strict";
72
+ if (value === "minimal" || value === "normal" || value === "strict")
73
+ return value;
74
+ return null;
75
+ }
76
+ function readReviewRisk(opts) {
77
+ const risk = parseReviewRisk(opts.risk);
78
+ if (!risk) {
79
+ console.error("--risk 只能是 minimal、normal 或 strict");
80
+ }
81
+ return risk;
82
+ }
69
83
  function parseVersion(version) {
70
84
  const match = version.trim().match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/);
71
85
  if (!match)
@@ -631,7 +645,9 @@ jobs 子命令:
631
645
  return 0;
632
646
  case "explore":
633
647
  {
634
- const risk = opts.risk ?? "strict";
648
+ const risk = readReviewRisk(opts);
649
+ if (!risk)
650
+ return 1;
635
651
  console.log(JSON.stringify(transitionExplore(projectRoot, change, cr, risk), null, 2));
636
652
  }
637
653
  return 0;
@@ -647,13 +663,17 @@ jobs 子命令:
647
663
  return 0;
648
664
  }
649
665
  case "next": {
650
- const risk = opts.risk ?? "strict";
666
+ const risk = readReviewRisk(opts);
667
+ if (!risk)
668
+ return 1;
651
669
  const result = nextCmd(projectRoot, change, cr, risk);
652
670
  console.log(JSON.stringify(result, null, 2));
653
671
  return 0;
654
672
  }
655
673
  case "propose-ready": {
656
- const risk = opts.risk ?? "strict";
674
+ const risk = readReviewRisk(opts);
675
+ if (!risk)
676
+ return 1;
657
677
  const result = proposeReady(projectRoot, change, cr, risk);
658
678
  console.log(JSON.stringify(result, null, 2));
659
679
  return proposeReadyExitCode(result);
@@ -702,7 +722,9 @@ jobs 子命令:
702
722
  return transitionExitCode(result);
703
723
  }
704
724
  case "review-ready": {
705
- const risk = opts.risk ?? "strict";
725
+ const risk = readReviewRisk(opts);
726
+ if (!risk)
727
+ return 1;
706
728
  const result = reviewReady(projectRoot, change, cr, risk);
707
729
  console.log(JSON.stringify(result, null, 2));
708
730
  return transitionExitCode(result);
@@ -48,6 +48,7 @@ export declare function codeReviewBoundFiles(projectRoot: string, paths: string[
48
48
  export declare function codeReviewJobStaleReason(projectRoot: string, job: Job, currentPaths?: string[]): string | null;
49
49
  export declare function codeReviewPacketDigest(input: {
50
50
  role: "code-reviewer";
51
+ gate_id?: "review.code_review";
51
52
  boundFiles: Ref[];
52
53
  checkedDocs: string[];
53
54
  created_from_transition: string;
@@ -3,6 +3,7 @@ import { execFileSync } from "node:child_process";
3
3
  import { existsSync, readdirSync, statSync } from "node:fs";
4
4
  import { join, extname } from "node:path";
5
5
  import { findLatestEvent, sha256File, sha256Text } from "./store.js";
6
+ import { REVIEW_CODE_REVIEW_GATE } from "./review_job_gates.js";
6
7
  export const CODE_REVIEW_REPAIR_SCOPE_PREFIX = "code_reviewer_report_repair:";
7
8
  export const CODE_REVIEW_DECISION_SCOPE_PREFIX = "code_review_decision:";
8
9
  export const CODE_REVIEW_DECISION_ANSWER_LABELS = {
@@ -132,7 +133,7 @@ export function codeReviewPacketDigest(input) {
132
133
  return sha256Text(JSON.stringify(input));
133
134
  }
134
135
  function isCodeReviewerJob(job) {
135
- return job.role === "code-reviewer" && job.created_from_transition === "review-ready";
136
+ return job.role === "code-reviewer" && REVIEW_CODE_REVIEW_GATE.isJobForGate(job);
136
137
  }
137
138
  function codeReviewResultKind(value) {
138
139
  return value === "invalid_report" || value === "non_actionable_report" || value === "review_failed"
@@ -0,0 +1,15 @@
1
+ import type { Event, Job } from "./types.ts";
2
+ export interface SubmittedReportValidityContext {
3
+ projectRoot: string;
4
+ changeRoot: string;
5
+ events: Event[];
6
+ reportPath?: string | null;
7
+ ignoredCodePaths?: string[];
8
+ }
9
+ export declare function invalidReasonForSnapshot(input: {
10
+ job: Job;
11
+ projectRoot: string;
12
+ changeRoot: string;
13
+ currentReviewEvidenceDigest: string;
14
+ }): string | null;
15
+ export declare function invalidReasonForSubmittedReport(job: Job, context: SubmittedReportValidityContext): string | null;
@@ -0,0 +1,34 @@
1
+ import { reviewEvidenceDigest, reviewVerifierStaleReason, } from "./review.js";
2
+ import { codeReviewJobStaleReason, scanCodeChanges, } from "./code_review.js";
3
+ const MISSING_REVIEW_EVIDENCE_REASON = "最终验证工作项缺少执行证据版本";
4
+ function reviewReadyVerifierWithoutEvidenceReason(job) {
5
+ if (job.role === "verifier" &&
6
+ job.created_from_transition === "review-ready" &&
7
+ !job.gate_id &&
8
+ (typeof job.review_evidence_digest !== "string" || job.review_evidence_digest.length === 0)) {
9
+ return MISSING_REVIEW_EVIDENCE_REASON;
10
+ }
11
+ return null;
12
+ }
13
+ export function invalidReasonForSnapshot(input) {
14
+ if (input.job.role === "code-reviewer") {
15
+ return codeReviewJobStaleReason(input.projectRoot, input.job);
16
+ }
17
+ const invalidReviewReadyVerifier = reviewReadyVerifierWithoutEvidenceReason(input.job);
18
+ if (invalidReviewReadyVerifier)
19
+ return invalidReviewReadyVerifier;
20
+ return reviewVerifierStaleReason(input.job, input.changeRoot, input.currentReviewEvidenceDigest);
21
+ }
22
+ export function invalidReasonForSubmittedReport(job, context) {
23
+ if (job.role === "code-reviewer") {
24
+ const ignored = new Set(context.ignoredCodePaths ?? []);
25
+ if (context.reportPath)
26
+ ignored.add(context.reportPath);
27
+ const currentPaths = scanCodeChanges(context.projectRoot).paths.filter(path => !ignored.has(path));
28
+ return codeReviewJobStaleReason(context.projectRoot, job, currentPaths);
29
+ }
30
+ const invalidReviewReadyVerifier = reviewReadyVerifierWithoutEvidenceReason(job);
31
+ if (invalidReviewReadyVerifier)
32
+ return invalidReviewReadyVerifier;
33
+ return reviewVerifierStaleReason(job, context.changeRoot, reviewEvidenceDigest(context.events));
34
+ }
package/dist/next.js CHANGED
@@ -2,18 +2,13 @@
2
2
  import { rebuildSnapshot } from "./sync.js";
3
3
  import { readFileSync } from "node:fs";
4
4
  import { join } from "node:path";
5
- import { readEvents, sha256Text } from "./store.js";
5
+ import { readEvents } from "./store.js";
6
6
  import { isFreshReviewVerifier, isReviewReadyVerifier, readReviewPolicyFromEvents, reviewEvidenceDigest } from "./review.js";
7
7
  import { CODE_REVIEW_DECISION_ANSWER_LABELS, CODE_REVIEW_REPAIR_SCOPE_PREFIX, codeReviewDecisionScope, collectCodeReviewGateFacts, latestCodeReviewFailedStatus, requiresFinalVerifierForCurrentReview, } from "./code_review.js";
8
8
  import { requiredJobActions } from "./job_action.js";
9
- import { validateDiscovery, countDiscoveryOpenQuestions, collectProposeOpenQuestions, parseTasksMd, pendingTasksInContent } from "./format.js";
10
- const ACTIVE_PROPOSAL_REVIEW_ROLES = new Set(["critic", "architect", "test-engineer"]);
11
- function isActiveProposalReviewJob(job) {
12
- return job.created_from_transition === "propose-ready" && ACTIVE_PROPOSAL_REVIEW_ROLES.has(job.role);
13
- }
14
- function isExploreReviewJob(job) {
15
- return job.created_from_transition === "explore" && job.role === "critic";
16
- }
9
+ import { EXPLORE_DISCOVERY_REVIEW_GATE, PROPOSE_FINAL_REVIEW_GATE } from "./review_job_gates.js";
10
+ import { taskEvidenceReadiness } from "./task_evidence.js";
11
+ import { validateDiscovery, countDiscoveryOpenQuestions, collectProposeOpenQuestions, pendingTasksInContent } from "./format.js";
17
12
  function requiredJobsOutput(state, change, jobs, reason) {
18
13
  return {
19
14
  state,
@@ -35,43 +30,6 @@ function pendingTaskIds(changeRoot) {
35
30
  function reopenCommand(change, pending) {
36
31
  return transitionCommand(change, "reopen", `--to apply --reason "pending tasks: ${pending.join(", ")}"`);
37
32
  }
38
- function taskCompletionReadiness(projectRoot, change, changeRoot, attempt) {
39
- const tasksContent = readFileSync(join(changeRoot, "tasks.md"), "utf8");
40
- const tasks = parseTasksMd(tasksContent);
41
- const taskInfo = tasks.find(task => task.taskId === attempt.task_id);
42
- const missing = [];
43
- if (!taskInfo)
44
- return { ready: false, missing: [`任务 ${attempt.task_id} 不存在`] };
45
- const currentDigest = sha256Text(tasksContent.replace(/- \[[xX]\]/g, "- [ ]"));
46
- // Keep the wording aligned with task-complete; no command should be suggested if it would fail this guard.
47
- if (currentDigest !== attempt.task_structure_digest)
48
- missing.push("任务结构指纹");
49
- if (!taskInfo.tddRequired) {
50
- if (!taskInfo.noTddReason)
51
- missing.push("no_tdd_reason");
52
- return { ready: missing.length === 0, missing };
53
- }
54
- let hasRed = false;
55
- let hasGreen = false;
56
- for (const ev of readEvents(projectRoot, change)) {
57
- if (ev.event_type !== "test_run_recorded")
58
- continue;
59
- const tr = ev.payload;
60
- const matches = tr.attempt_id === attempt.attempt_id ||
61
- (!tr.attempt_id && tr.task_structure_digest === attempt.task_structure_digest);
62
- if (!matches)
63
- continue;
64
- if (tr.semantic_status === "expected_failure" || tr.semantic_status === "characterization_pass")
65
- hasRed = true;
66
- if (tr.semantic_status === "expected_success")
67
- hasGreen = true;
68
- }
69
- if (!hasRed)
70
- missing.push("RED 证据");
71
- if (!hasGreen)
72
- missing.push("GREEN 证据");
73
- return { ready: missing.length === 0, missing };
74
- }
75
33
  /** next 命令:读 snapshot,返回唯一可执行路径 */
76
34
  export function next(projectRoot, change, changeRoot, defaultRisk = "strict") {
77
35
  const snapshot = rebuildSnapshot(projectRoot, change, changeRoot);
@@ -88,7 +46,7 @@ export function next(projectRoot, change, changeRoot, defaultRisk = "strict") {
88
46
  missing_inputs: [],
89
47
  };
90
48
  case "explore": {
91
- const exploreReviewJobs = snapshot.open_jobs.filter(isExploreReviewJob);
49
+ const exploreReviewJobs = EXPLORE_DISCOVERY_REVIEW_GATE.openJobsForGate(snapshot);
92
50
  if (exploreReviewJobs.length > 0) {
93
51
  return requiredJobsOutput("explore", change, exploreReviewJobs, `有 ${exploreReviewJobs.length} 个待完成探索审查工作项`);
94
52
  }
@@ -132,7 +90,7 @@ export function next(projectRoot, change, changeRoot, defaultRisk = "strict") {
132
90
  };
133
91
  return { state: "propose", path: "ask_user", ask_user: ask, reason: `有 ${openQuestions.openCount} 个 propose 未确认问题` };
134
92
  }
135
- const proposalReviewJobs = snapshot.open_jobs.filter(isActiveProposalReviewJob);
93
+ const proposalReviewJobs = PROPOSE_FINAL_REVIEW_GATE.openJobsForGate(snapshot);
136
94
  if (proposalReviewJobs.length > 0) {
137
95
  return requiredJobsOutput("propose", change, proposalReviewJobs, `有 ${proposalReviewJobs.length} 个待完成 proposal 审查工作项`);
138
96
  }
@@ -145,7 +103,7 @@ export function next(projectRoot, change, changeRoot, defaultRisk = "strict") {
145
103
  };
146
104
  }
147
105
  case "propose_ready": {
148
- const proposalReviewJobs = snapshot.open_jobs.filter(isActiveProposalReviewJob);
106
+ const proposalReviewJobs = PROPOSE_FINAL_REVIEW_GATE.openJobsForGate(snapshot);
149
107
  if (proposalReviewJobs.length > 0) {
150
108
  return requiredJobsOutput("propose_ready", change, proposalReviewJobs, `有 ${proposalReviewJobs.length} 个待完成 proposal 审查工作项`);
151
109
  }
@@ -166,7 +124,7 @@ export function next(projectRoot, change, changeRoot, defaultRisk = "strict") {
166
124
  if (pending.length > 0) {
167
125
  const activePending = snapshot.active_task_attempts.find(attempt => attempt.state === "active" && pending.includes(attempt.task_id));
168
126
  if (activePending) {
169
- const readiness = taskCompletionReadiness(projectRoot, change, changeRoot, activePending);
127
+ const readiness = taskEvidenceReadiness(projectRoot, change, changeRoot, activePending);
170
128
  if (readiness.ready) {
171
129
  return {
172
130
  state: "apply",
package/dist/record.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { RecordResult, Job } from "./types.ts";
1
+ import type { RecordResult, Job, JobPacket } from "./types.ts";
2
2
  /** record job-submit:登记工作项结果 */
3
3
  export declare function recordJobSubmit(projectRoot: string, change: string, changeRoot: string, jobId: string, reportFile: string): RecordResult;
4
4
  /** record job-submit:从 JSON 内容登记工作项结果 */
@@ -19,6 +19,6 @@ export declare function jobsList(projectRoot: string, change: string): {
19
19
  /** jobs packet:返回工作项执行说明 */
20
20
  export declare function jobsPacket(projectRoot: string, change: string, jobId: string): {
21
21
  found: boolean;
22
- packet?: Record<string, unknown>;
22
+ packet?: JobPacket;
23
23
  message: string;
24
24
  };
package/dist/record.js CHANGED
@@ -2,8 +2,8 @@
2
2
  import { readFileSync, existsSync } from "node:fs";
3
3
  import { isAbsolute, join, relative, resolve } from "node:path";
4
4
  import { ensureChangeLayout, readEvents, appendEvent, makeEvent, sha256File, sha256Text, withLock, appendRawRecord, } from "./store.js";
5
- import { reviewEvidenceDigest, reviewVerifierStaleReason } from "./review.js";
6
- import { CODE_REVIEW_DECISION_ANSWER_LABELS, CODE_REVIEW_DECISION_SCOPE_PREFIX, codeReviewDecisionAnswerLabel, codeReviewJobStaleReason, normalizeCodeReviewDecisionAnswer, scanCodeChanges, } from "./code_review.js";
5
+ import { CODE_REVIEW_DECISION_ANSWER_LABELS, CODE_REVIEW_DECISION_SCOPE_PREFIX, codeReviewDecisionAnswerLabel, normalizeCodeReviewDecisionAnswer, } from "./code_review.js";
6
+ import { invalidReasonForSubmittedReport } from "./job_validity.js";
7
7
  import { jobSubmitArgv } from "./job_action.js";
8
8
  const REVIEW_REPORT_REQUIRED_FIELDS = ["role", "verdict", "findings"];
9
9
  const REVIEW_REPORT_OPTIONAL_FIELDS = ["summary", "evidence_refs", "risks", "open_questions"];
@@ -220,22 +220,6 @@ function projectRelativePath(projectRoot, path) {
220
220
  return null;
221
221
  return rel;
222
222
  }
223
- function staleBoundFilesChecks(job, projectRoot, changeRoot, ignoredCodePaths = []) {
224
- const checks = [];
225
- if (job.role === "code-reviewer") {
226
- const ignored = new Set(ignoredCodePaths);
227
- const currentPaths = scanCodeChanges(projectRoot).paths.filter(path => !ignored.has(path));
228
- const reason = codeReviewJobStaleReason(projectRoot, job, currentPaths);
229
- return reason ? [reason] : [];
230
- }
231
- for (const bf of job.boundFiles) {
232
- const currentSha = sha256File(join(changeRoot, bf.path)) ?? "sha256:missing";
233
- if (currentSha !== bf.sha) {
234
- checks.push(`绑定文件 ${bf.path} 已变化(${bf.sha} → ${currentSha})`);
235
- }
236
- }
237
- return checks;
238
- }
239
223
  /** 从 events 中查找 job(H4 修复:job 只在 transition_commit 的 new_jobs payload 里) */
240
224
  function findJob(events, jobId) {
241
225
  for (const ev of events) {
@@ -312,12 +296,14 @@ function recordJobSubmitLoaded(projectRoot, change, changeRoot, jobId, job, even
312
296
  checks.push("报告必须是有效 JSON");
313
297
  }
314
298
  const reportPath = reportFile ? projectRelativePath(projectRoot, reportFile) : null;
315
- checks.push(...staleBoundFilesChecks(job, projectRoot, changeRoot, reportPath ? [reportPath] : []));
316
- const reviewStaleReason = job.role === "code-reviewer"
317
- ? null
318
- : reviewVerifierStaleReason(job, changeRoot, reviewEvidenceDigest(events));
319
- if (reviewStaleReason && !checks.includes(reviewStaleReason)) {
320
- checks.push(reviewStaleReason);
299
+ const invalidReason = invalidReasonForSubmittedReport(job, {
300
+ projectRoot,
301
+ changeRoot,
302
+ events,
303
+ reportPath,
304
+ });
305
+ if (invalidReason) {
306
+ checks.push(invalidReason);
321
307
  }
322
308
  if (!reportContent.trim()) {
323
309
  checks.push("报告内容为空");
@@ -633,6 +619,7 @@ export function jobsPacket(projectRoot, change, jobId) {
633
619
  packet: {
634
620
  job_id: job.job_id,
635
621
  role: job.role,
622
+ ...(job.gate_id ? { gate_id: job.gate_id } : {}),
636
623
  recommended_agent: recommendedAgentForRole(job.role),
637
624
  boundFiles: job.boundFiles,
638
625
  ...(job.review_evidence_digest ? { review_evidence_digest: job.review_evidence_digest } : {}),
package/dist/review.js CHANGED
@@ -2,6 +2,7 @@
2
2
  import { existsSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { sha256File, sha256Text } from "./store.js";
5
+ import { REVIEW_FINAL_VERIFIER_GATE } from "./review_job_gates.js";
5
6
  export const REVIEW_DOC_PATHS = [
6
7
  "proposal.md",
7
8
  "tasks.md",
@@ -52,7 +53,7 @@ export function readReviewPolicyFromEvents(events) {
52
53
  return null;
53
54
  }
54
55
  export function isReviewReadyVerifier(job) {
55
- return job.role === "verifier" && job.created_from_transition === "review-ready";
56
+ return job.role === "verifier" && REVIEW_FINAL_VERIFIER_GATE.isJobForGate(job);
56
57
  }
57
58
  export function reviewBoundFiles(changeRoot) {
58
59
  return REVIEW_DOC_PATHS
@@ -0,0 +1,20 @@
1
+ import type { ReviewRisk } from "./review.ts";
2
+ import type { Job, JobRole, ReviewJobGateId, Snapshot } from "./types.ts";
3
+ export interface ReviewGateRule {
4
+ gate_id: ReviewJobGateId;
5
+ created_from_transition: "explore" | "propose-ready" | "review-ready";
6
+ allowedRoles: JobRole[];
7
+ reviewedDocPaths: string[];
8
+ requiredRolesForRisk(risk: ReviewRisk): JobRole[];
9
+ matchesOldJob(job: Job): boolean;
10
+ isJobForGate(job: Job): boolean;
11
+ openJobsForGate(snapshot: Snapshot): Job[];
12
+ }
13
+ export declare const EXPLORE_DISCOVERY_REVIEW_GATE_ID: "explore.discovery_review";
14
+ export declare const PROPOSE_FINAL_REVIEW_GATE_ID: "propose.final_review";
15
+ export declare const REVIEW_CODE_REVIEW_GATE_ID: "review.code_review";
16
+ export declare const REVIEW_FINAL_VERIFIER_GATE_ID: "review.final_verifier";
17
+ export declare const EXPLORE_DISCOVERY_REVIEW_GATE: ReviewGateRule;
18
+ export declare const PROPOSE_FINAL_REVIEW_GATE: ReviewGateRule;
19
+ export declare const REVIEW_CODE_REVIEW_GATE: ReviewGateRule;
20
+ export declare const REVIEW_FINAL_VERIFIER_GATE: ReviewGateRule;
@@ -0,0 +1,84 @@
1
+ import { reviewRolesForGate } from "./workflow_profile.js";
2
+ function makeReviewGateRule(input) {
3
+ const gate = {
4
+ ...input,
5
+ isJobForGate(job) {
6
+ if (!input.allowedRoles.includes(job.role))
7
+ return false;
8
+ if (job.gate_id)
9
+ return job.gate_id === input.gate_id;
10
+ return input.matchesOldJob(job);
11
+ },
12
+ openJobsForGate(snapshot) {
13
+ return snapshot.open_jobs.filter(job => gate.isJobForGate(job));
14
+ },
15
+ };
16
+ return gate;
17
+ }
18
+ export const EXPLORE_DISCOVERY_REVIEW_GATE_ID = "explore.discovery_review";
19
+ export const PROPOSE_FINAL_REVIEW_GATE_ID = "propose.final_review";
20
+ export const REVIEW_CODE_REVIEW_GATE_ID = "review.code_review";
21
+ export const REVIEW_FINAL_VERIFIER_GATE_ID = "review.final_verifier";
22
+ const EXPLORE_DISCOVERY_REVIEW_ROLES = ["critic"];
23
+ const PROPOSAL_REVIEW_ROLES = ["critic", "architect", "test-engineer"];
24
+ const PROPOSAL_REVIEW_ROLE_SET = new Set(PROPOSAL_REVIEW_ROLES);
25
+ const REVIEW_CODE_REVIEW_ROLES = ["code-reviewer"];
26
+ const REVIEW_FINAL_VERIFIER_ROLES = ["verifier"];
27
+ export const EXPLORE_DISCOVERY_REVIEW_GATE = makeReviewGateRule({
28
+ gate_id: EXPLORE_DISCOVERY_REVIEW_GATE_ID,
29
+ created_from_transition: "explore",
30
+ allowedRoles: EXPLORE_DISCOVERY_REVIEW_ROLES,
31
+ reviewedDocPaths: [".superspec/artifacts/discovery.md"],
32
+ requiredRolesForRisk(risk) {
33
+ return reviewRolesForGate(EXPLORE_DISCOVERY_REVIEW_GATE_ID, risk);
34
+ },
35
+ matchesOldJob(job) {
36
+ return job.created_from_transition === "explore" && job.role === "critic";
37
+ },
38
+ });
39
+ export const PROPOSE_FINAL_REVIEW_GATE = makeReviewGateRule({
40
+ gate_id: PROPOSE_FINAL_REVIEW_GATE_ID,
41
+ created_from_transition: "propose-ready",
42
+ allowedRoles: PROPOSAL_REVIEW_ROLES,
43
+ reviewedDocPaths: [
44
+ "proposal.md",
45
+ "tasks.md",
46
+ "design.md",
47
+ ".superspec/artifacts/discovery.md",
48
+ ".superspec/artifacts/business-invariants.md",
49
+ ".superspec/artifacts/test-contract.md",
50
+ ],
51
+ requiredRolesForRisk(risk) {
52
+ return reviewRolesForGate(PROPOSE_FINAL_REVIEW_GATE_ID, risk);
53
+ },
54
+ matchesOldJob(job) {
55
+ return job.created_from_transition === "propose-ready" && PROPOSAL_REVIEW_ROLE_SET.has(job.role);
56
+ },
57
+ });
58
+ export const REVIEW_CODE_REVIEW_GATE = makeReviewGateRule({
59
+ gate_id: REVIEW_CODE_REVIEW_GATE_ID,
60
+ created_from_transition: "review-ready",
61
+ allowedRoles: REVIEW_CODE_REVIEW_ROLES,
62
+ reviewedDocPaths: [],
63
+ requiredRolesForRisk(risk) {
64
+ return reviewRolesForGate(REVIEW_CODE_REVIEW_GATE_ID, risk);
65
+ },
66
+ matchesOldJob(job) {
67
+ return job.created_from_transition === "review-ready" && job.role === "code-reviewer";
68
+ },
69
+ });
70
+ export const REVIEW_FINAL_VERIFIER_GATE = makeReviewGateRule({
71
+ gate_id: REVIEW_FINAL_VERIFIER_GATE_ID,
72
+ created_from_transition: "review-ready",
73
+ allowedRoles: REVIEW_FINAL_VERIFIER_ROLES,
74
+ reviewedDocPaths: [],
75
+ requiredRolesForRisk(risk) {
76
+ return reviewRolesForGate(REVIEW_FINAL_VERIFIER_GATE_ID, risk);
77
+ },
78
+ matchesOldJob(job) {
79
+ return job.created_from_transition === "review-ready" &&
80
+ job.role === "verifier" &&
81
+ typeof job.review_evidence_digest === "string" &&
82
+ job.review_evidence_digest.length > 0;
83
+ },
84
+ });
package/dist/sync.js CHANGED
@@ -2,8 +2,8 @@
2
2
  import { readFileSync, existsSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { readEvents, eventsDigest, computeDocumentDigests, sha256Text, ensureChangeLayout, } from "./store.js";
5
- import { reviewEvidenceDigest, reviewVerifierStaleReason } from "./review.js";
6
- import { codeReviewJobStaleReason } from "./code_review.js";
5
+ import { reviewEvidenceDigest } from "./review.js";
6
+ import { invalidReasonForSnapshot } from "./job_validity.js";
7
7
  const TRACKED_DOCS = [
8
8
  "proposal.md", "design.md", "tasks.md",
9
9
  ".superspec/artifacts/discovery.md",
@@ -84,9 +84,7 @@ function replayEvents(events) {
84
84
  function checkStaleJobs(jobs, projectRoot, changeRoot, currentReviewEvidenceDigest) {
85
85
  const stale = [];
86
86
  for (const job of jobs) {
87
- const reason = job.role === "code-reviewer"
88
- ? codeReviewJobStaleReason(projectRoot, job)
89
- : reviewVerifierStaleReason(job, changeRoot, currentReviewEvidenceDigest);
87
+ const reason = invalidReasonForSnapshot({ job, projectRoot, changeRoot, currentReviewEvidenceDigest });
90
88
  if (reason) {
91
89
  stale.push({ job_id: job.job_id, reason });
92
90
  }
@@ -0,0 +1,10 @@
1
+ import type { TaskAttempt } from "./types.ts";
2
+ export type TaskEvidenceReadiness = {
3
+ ready: true;
4
+ missing: [];
5
+ } | {
6
+ ready: false;
7
+ missing: string[];
8
+ reason: string;
9
+ };
10
+ export declare function taskEvidenceReadiness(projectRoot: string, change: string, changeRoot: string, attempt: TaskAttempt): TaskEvidenceReadiness;
@@ -0,0 +1,46 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { parseTasksMd } from "./format.js";
4
+ import { readEvents, sha256Text } from "./store.js";
5
+ export function taskEvidenceReadiness(projectRoot, change, changeRoot, attempt) {
6
+ const tasksContent = readFileSync(join(changeRoot, "tasks.md"), "utf8");
7
+ const tasks = parseTasksMd(tasksContent);
8
+ const taskInfo = tasks.find(task => task.taskId === attempt.task_id);
9
+ const missing = [];
10
+ if (!taskInfo) {
11
+ missing.push(`任务 ${attempt.task_id} 不存在`);
12
+ return { ready: false, missing, reason: missing.join("、") };
13
+ }
14
+ const currentDigest = sha256Text(tasksContent.replace(/- \[[xX]\]/g, "- [ ]"));
15
+ if (currentDigest !== attempt.task_structure_digest)
16
+ missing.push("任务结构指纹");
17
+ if (!taskInfo.tddRequired) {
18
+ if (!taskInfo.noTddReason)
19
+ missing.push("no_tdd_reason");
20
+ return missing.length === 0
21
+ ? { ready: true, missing: [] }
22
+ : { ready: false, missing, reason: missing.join("、") };
23
+ }
24
+ let hasRed = false;
25
+ let hasGreen = false;
26
+ for (const ev of readEvents(projectRoot, change)) {
27
+ if (ev.event_type !== "test_run_recorded")
28
+ continue;
29
+ const tr = ev.payload;
30
+ const matches = tr.attempt_id === attempt.attempt_id ||
31
+ (!tr.attempt_id && tr.task_structure_digest === attempt.task_structure_digest);
32
+ if (!matches)
33
+ continue;
34
+ if (tr.semantic_status === "expected_failure" || tr.semantic_status === "characterization_pass")
35
+ hasRed = true;
36
+ if (tr.semantic_status === "expected_success")
37
+ hasGreen = true;
38
+ }
39
+ if (!hasRed)
40
+ missing.push("RED 证据");
41
+ if (!hasGreen)
42
+ missing.push("GREEN 证据");
43
+ return missing.length === 0
44
+ ? { ready: true, missing: [] }
45
+ : { ready: false, missing, reason: missing.join("、") };
46
+ }
@@ -5,36 +5,26 @@ 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, readReviewPolicyFromEvents, reviewBoundFiles, reviewEvidenceDigest, reviewPolicyForRisk, REVIEW_DOC_PATHS, } from "./review.js";
8
+ import { EXPLORE_DISCOVERY_REVIEW_GATE, PROPOSE_FINAL_REVIEW_GATE, REVIEW_CODE_REVIEW_GATE_ID, REVIEW_FINAL_VERIFIER_GATE_ID, } from "./review_job_gates.js";
8
9
  import { codeReviewBoundFiles, codeReviewDecisionScope, codeReviewJobStaleReason, codeReviewPacketDigest, collectCodeReviewGateFacts, dismissedCodeReviewSummary, latestCodeReviewDecision, latestCodeReviewFailedStatus, requiresFinalVerifierForCurrentReview, scanCodeChanges, } from "./code_review.js";
9
- import { validateDiscovery, collectProposeOpenQuestions, findTaskInLines, parseTasksMd, pendingTasksInContent, tasksStructureDigest } from "./format.js";
10
+ import { taskEvidenceReadiness } from "./task_evidence.js";
11
+ import { validateDiscovery, collectProposeOpenQuestions, findTaskInLines, pendingTasksInContent } from "./format.js";
10
12
  let transitionSeq = 0;
11
13
  function newTransitionId() { return `T-${Date.now()}-${++transitionSeq}`; }
12
14
  let jobSeq = 0;
13
15
  function newJobId(change, role) { return `JOB-${change.slice(0, 8)}-${role.slice(0, 4)}-${Date.now()}-${++jobSeq}`; }
14
- const TRANSITION_REQUIREMENTS = {
15
- "propose-ready": {
16
- minimal: [],
17
- normal: ["critic"],
18
- strict: ["critic", "architect", "test-engineer"],
19
- },
20
- "explore": {
21
- minimal: [],
22
- normal: [],
23
- strict: ["critic"],
24
- },
25
- };
26
16
  /**
27
17
  * 通用 job 检查/创建逻辑——任何 transition 都能调用。
28
18
  * 检查 requiredRoles 是否有 fresh accepted job;缺则创建新 job。
29
19
  * 返回 null = 全部满足;返回 Decision = 需要创建 job(状态不变)。
30
20
  */
31
- function checkOrCreateReviewJobs(snapshot, requiredRoles, changeRoot, change, transitionName, bindDocPaths) {
21
+ function checkOrCreateReviewJobs(snapshot, gate, requiredRoles, changeRoot, change) {
32
22
  const staleRoles = [];
33
23
  for (const role of requiredRoles) {
34
- const openForRole = snapshot.open_jobs.find(j => j.role === role && j.created_from_transition === transitionName);
24
+ const openForRole = snapshot.open_jobs.find(j => gate.isJobForGate(j) && j.role === role);
35
25
  if (openForRole)
36
26
  return { blocked: true, reason: `状态未推进;已有待完成工作项 ${role}(${openForRole.job_id})`, jobs: [openForRole] };
37
- const fresh = snapshot.accepted_jobs.find(j => j.role === role && j.created_from_transition === transitionName);
27
+ const fresh = snapshot.accepted_jobs.find(j => gate.isJobForGate(j) && j.role === role);
38
28
  if (!fresh) {
39
29
  staleRoles.push({ role, reason: `需求 ${role} 无已接受的工作项` });
40
30
  }
@@ -50,8 +40,22 @@ function checkOrCreateReviewJobs(snapshot, requiredRoles, changeRoot, change, tr
50
40
  }
51
41
  if (staleRoles.length > 0) {
52
42
  const newJobs = staleRoles.map(({ role }) => {
53
- const boundFiles = bindDocPaths.filter(p => existsSync(join(changeRoot, p))).map(p => ({ path: p, sha: sha256File(join(changeRoot, p)) ?? "sha256:missing" }));
54
- return { job_id: newJobId(change, role), role, state: "requested", boundFiles, packet_digest: sha256Text(JSON.stringify({ role, boundFiles })), created_from_transition: transitionName, created_at: new Date().toISOString() };
43
+ const boundFiles = gate.reviewedDocPaths.filter(p => existsSync(join(changeRoot, p))).map(p => ({ path: p, sha: sha256File(join(changeRoot, p)) ?? "sha256:missing" }));
44
+ return {
45
+ job_id: newJobId(change, role),
46
+ role,
47
+ state: "requested",
48
+ gate_id: gate.gate_id,
49
+ boundFiles,
50
+ packet_digest: sha256Text(JSON.stringify({
51
+ role,
52
+ gate_id: gate.gate_id,
53
+ boundFiles,
54
+ created_from_transition: gate.created_from_transition,
55
+ })),
56
+ created_from_transition: gate.created_from_transition,
57
+ created_at: new Date().toISOString(),
58
+ };
55
59
  });
56
60
  return {
57
61
  fromState: snapshot.state, toState: snapshot.state, outcome: "job_created",
@@ -67,7 +71,7 @@ function historicalProposeReadyRoles(projectRoot, change) {
67
71
  continue;
68
72
  const newJobs = ev.payload.new_jobs ?? [];
69
73
  for (const job of newJobs) {
70
- if (job.created_from_transition === "propose-ready" &&
74
+ if (PROPOSE_FINAL_REVIEW_GATE.isJobForGate(job) &&
71
75
  (job.role === "critic" || job.role === "architect" || job.role === "test-engineer"))
72
76
  roles.add(job.role);
73
77
  }
@@ -119,6 +123,7 @@ function createCodeReviewerJob(change, projectRoot, events) {
119
123
  : undefined;
120
124
  const packetInput = {
121
125
  role: "code-reviewer",
126
+ gate_id: REVIEW_CODE_REVIEW_GATE_ID,
122
127
  boundFiles,
123
128
  checkedDocs: REVIEW_DOC_PATHS,
124
129
  created_from_transition: "review-ready",
@@ -130,6 +135,7 @@ function createCodeReviewerJob(change, projectRoot, events) {
130
135
  job_id: newJobId(change, "code-reviewer"),
131
136
  role: "code-reviewer",
132
137
  state: "requested",
138
+ gate_id: REVIEW_CODE_REVIEW_GATE_ID,
133
139
  boundFiles,
134
140
  packet_digest: codeReviewPacketDigest(packetInput),
135
141
  created_from_transition: "review-ready",
@@ -176,6 +182,141 @@ function appendReviewFixTask(changeRoot, ref, finding) {
176
182
  function isFreshOpenCodeReviewerJob(job, projectRoot) {
177
183
  return codeReviewJobStaleReason(projectRoot, job) == null;
178
184
  }
185
+ function evaluateApplyDoneCodeReviewGate(input) {
186
+ const scan = scanCodeChanges(input.projectRoot);
187
+ const facts = collectCodeReviewGateFacts(input.events);
188
+ if (scan.hasCodeChanges) {
189
+ const freshOpenJobs = facts.openJobs.filter(job => isFreshOpenCodeReviewerJob(job, input.projectRoot));
190
+ if (freshOpenJobs.length > 0) {
191
+ return {
192
+ blocked: true,
193
+ reason: `状态未推进;已有待完成代码审查工作项 ${freshOpenJobs[0].job_id}`,
194
+ jobs: [freshOpenJobs[0]],
195
+ };
196
+ }
197
+ const latest = facts.latestTerminal;
198
+ if (latest?.state === "accepted") {
199
+ return {
200
+ fromState: "apply_done",
201
+ toState: "review",
202
+ outcome: "advanced",
203
+ reason: "代码审查已通过,进入最终审查阶段",
204
+ commitPayload: {
205
+ ...input.policyPayload,
206
+ code_review_gate: { decision: "passed", job_id: latest.job.job_id },
207
+ },
208
+ };
209
+ }
210
+ if (latest?.state === "rejected" && latest.result_kind === "review_failed") {
211
+ const reviewFailedStatus = latestCodeReviewFailedStatus(input.events);
212
+ if (reviewFailedStatus && reviewFailedStatus.findings.length > 0 && reviewFailedStatus.unresolved.length === 0) {
213
+ const { job, scanReason } = createCodeReviewerJob(input.change, input.projectRoot, input.events);
214
+ return {
215
+ fromState: "apply_done",
216
+ toState: "apply_done",
217
+ outcome: "job_created",
218
+ newJobs: [job],
219
+ reason: `重新创建代码审查工作项;${scanReason};上一次阻塞问题已被主流程复核驳回`,
220
+ };
221
+ }
222
+ return {
223
+ skip: true,
224
+ message: "代码审查发现需要处理的问题,请先执行 next,根据提示回到实现阶段修复或让使用者决定是否回到计划阶段",
225
+ };
226
+ }
227
+ const { job, scanReason } = createCodeReviewerJob(input.change, input.projectRoot, input.events);
228
+ return {
229
+ fromState: "apply_done",
230
+ toState: "apply_done",
231
+ outcome: "job_created",
232
+ newJobs: [job],
233
+ reason: latest?.state === "rejected"
234
+ ? `重新创建代码审查工作项;上一次报告未被接受,原因:${latest.reason ?? "报告不符合要求"}`
235
+ : `创建代码审查工作项;${scanReason}`,
236
+ };
237
+ }
238
+ return {
239
+ fromState: "apply_done",
240
+ toState: "review",
241
+ outcome: "advanced",
242
+ reason: "没有代码类改动,直接进入最终审查阶段",
243
+ commitPayload: {
244
+ ...input.policyPayload,
245
+ code_review_gate: { decision: "skipped", reason: "no_code_changes" },
246
+ },
247
+ };
248
+ }
249
+ function createFinalVerifierJob(change, changeRoot, currentEvidenceDigest) {
250
+ const boundFiles = reviewBoundFiles(changeRoot);
251
+ return {
252
+ job_id: newJobId(change, "verifier"),
253
+ role: "verifier",
254
+ state: "requested",
255
+ gate_id: REVIEW_FINAL_VERIFIER_GATE_ID,
256
+ boundFiles,
257
+ review_evidence_digest: currentEvidenceDigest,
258
+ packet_digest: sha256Text(JSON.stringify({
259
+ role: "verifier",
260
+ gate_id: REVIEW_FINAL_VERIFIER_GATE_ID,
261
+ boundFiles,
262
+ review_evidence_digest: currentEvidenceDigest,
263
+ created_from_transition: "review-ready",
264
+ })),
265
+ created_from_transition: "review-ready",
266
+ created_at: new Date().toISOString(),
267
+ };
268
+ }
269
+ function evaluateFinalVerifierGate(input) {
270
+ const verifierOpen = input.snapshot.open_jobs.find(isReviewReadyVerifier);
271
+ if (verifierOpen) {
272
+ return {
273
+ blocked: true,
274
+ reason: `状态未推进;已有待完成最终验证工作项 ${verifierOpen.job_id}`,
275
+ jobs: [verifierOpen],
276
+ };
277
+ }
278
+ const verifierAccepted = input.snapshot.accepted_jobs.find(job => isFreshReviewVerifier(job, input.changeRoot, input.currentEvidenceDigest));
279
+ const finalVerifierRequired = requiresFinalVerifierForCurrentReview(input.events) || input.policy.requires_verifier;
280
+ if (!finalVerifierRequired) {
281
+ if (!input.hasStoredPolicy) {
282
+ return {
283
+ fromState: "review",
284
+ toState: "review",
285
+ outcome: "advanced",
286
+ reason: `补写审查策略=${input.policy.review_risk}`,
287
+ commitPayload: input.policyPayload,
288
+ };
289
+ }
290
+ return { skip: true, message: "已在 review 状态,审查策略无需 verifier" };
291
+ }
292
+ if (!verifierAccepted) {
293
+ const previousVerifierRejected = hasRejectedReviewReadyVerifier(input.events);
294
+ const job = createFinalVerifierJob(input.change, input.changeRoot, input.currentEvidenceDigest);
295
+ return {
296
+ fromState: input.snapshot.state,
297
+ toState: input.snapshot.state,
298
+ outcome: "job_created",
299
+ newJobs: [job],
300
+ reason: previousVerifierRejected
301
+ ? "此前最终验证未通过;请先根据验证报告修改任务或文档,确认无需修改时再执行新的最终验证工作项"
302
+ : "创建最终验证工作项",
303
+ commitPayload: input.policyPayload,
304
+ ...(previousVerifierRejected ? {
305
+ details: { advisory: "此前最终验证未通过;请先根据验证报告修改任务或文档,确认无需修改时再执行新的最终验证工作项" },
306
+ } : {}),
307
+ };
308
+ }
309
+ if (!input.hasStoredPolicy) {
310
+ return {
311
+ fromState: "review",
312
+ toState: "review",
313
+ outcome: "advanced",
314
+ reason: `补写审查策略=${input.policy.review_risk}`,
315
+ commitPayload: input.policyPayload,
316
+ };
317
+ }
318
+ return { skip: true, message: "已在 review 状态,最终验证仍然有效" };
319
+ }
179
320
  function documentBaseline(changeRoot) {
180
321
  const docs = ["proposal.md", "design.md", "tasks.md", ".superspec/artifacts/test-contract.md"];
181
322
  const baseline = {};
@@ -313,8 +454,8 @@ export function proposeReady(projectRoot, change, changeRoot, risk = "strict") {
313
454
  }
314
455
  }
315
456
  // 通用 job 检查(用提取的 helper)
316
- const requiredRoles = TRANSITION_REQUIREMENTS["propose-ready"]?.[risk] ?? [];
317
- const reviewResult = checkOrCreateReviewJobs(snapshot, requiredRoles, changeRoot, change, "propose-ready", ["proposal.md", "tasks.md", "design.md", ".superspec/artifacts/discovery.md", ".superspec/artifacts/business-invariants.md", ".superspec/artifacts/test-contract.md"]);
457
+ const requiredRoles = PROPOSE_FINAL_REVIEW_GATE.requiredRolesForRisk(risk);
458
+ const reviewResult = checkOrCreateReviewJobs(snapshot, PROPOSE_FINAL_REVIEW_GATE, requiredRoles, changeRoot, change);
318
459
  if (reviewResult)
319
460
  return reviewResult;
320
461
  return { fromState: "propose", toState: "propose_ready", outcome: "advanced", reason: `risk=${risk},所有需求已满足` };
@@ -349,8 +490,8 @@ export function transitionExplore(projectRoot, change, changeRoot, risk = "stric
349
490
  if (!discoveryCheck.ok)
350
491
  return { skip: true, message: discoveryCheck.message };
351
492
  // 通用 job 审查(和 propose-ready 同一个 helper)
352
- const requiredRoles = TRANSITION_REQUIREMENTS["explore"]?.[risk] ?? [];
353
- const reviewResult = checkOrCreateReviewJobs(snapshot, requiredRoles, changeRoot, change, "explore", [".superspec/artifacts/discovery.md"]);
493
+ const requiredRoles = EXPLORE_DISCOVERY_REVIEW_GATE.requiredRolesForRisk(risk);
494
+ const reviewResult = checkOrCreateReviewJobs(snapshot, EXPLORE_DISCOVERY_REVIEW_GATE, requiredRoles, changeRoot, change);
354
495
  if (reviewResult)
355
496
  return reviewResult;
356
497
  return { fromState: "explore", toState: "propose", outcome: "advanced", reason: "探索完成" };
@@ -373,7 +514,7 @@ export function startApply(projectRoot, change, changeRoot) {
373
514
  }
374
515
  const reviewedRoles = historicalProposeReadyRoles(projectRoot, change);
375
516
  if (reviewedRoles.length > 0) {
376
- const reviewResult = checkOrCreateReviewJobs(snapshot, reviewedRoles, changeRoot, change, "propose-ready", ["proposal.md", "tasks.md", "design.md", ".superspec/artifacts/discovery.md", ".superspec/artifacts/business-invariants.md", ".superspec/artifacts/test-contract.md"]);
517
+ const reviewResult = checkOrCreateReviewJobs(snapshot, PROPOSE_FINAL_REVIEW_GATE, reviewedRoles, changeRoot, change);
377
518
  if (reviewResult) {
378
519
  return {
379
520
  ...reviewResult,
@@ -538,111 +679,24 @@ export function reviewReady(projectRoot, change, changeRoot, risk = "strict") {
538
679
  };
539
680
  }
540
681
  if (snapshot.state === "apply_done") {
541
- const scan = scanCodeChanges(projectRoot);
542
- const facts = collectCodeReviewGateFacts(events);
543
- if (scan.hasCodeChanges) {
544
- const freshOpenJobs = facts.openJobs.filter(job => isFreshOpenCodeReviewerJob(job, projectRoot));
545
- if (freshOpenJobs.length > 0) {
546
- return {
547
- blocked: true,
548
- reason: `状态未推进;已有待完成代码审查工作项 ${freshOpenJobs[0].job_id}`,
549
- jobs: [freshOpenJobs[0]],
550
- };
551
- }
552
- const latest = facts.latestTerminal;
553
- if (latest?.state === "accepted") {
554
- return {
555
- fromState: "apply_done", toState: "review", outcome: "advanced",
556
- reason: "代码审查已通过,进入最终审查阶段",
557
- commitPayload: {
558
- ...policyPayload,
559
- code_review_gate: { decision: "passed", job_id: latest.job.job_id },
560
- },
561
- };
562
- }
563
- if (latest?.state === "rejected" && latest.result_kind === "review_failed") {
564
- const reviewFailedStatus = latestCodeReviewFailedStatus(events);
565
- if (reviewFailedStatus && reviewFailedStatus.findings.length > 0 && reviewFailedStatus.unresolved.length === 0) {
566
- const { job, scanReason } = createCodeReviewerJob(change, projectRoot, events);
567
- return {
568
- fromState: "apply_done", toState: "apply_done", outcome: "job_created",
569
- newJobs: [job],
570
- reason: `重新创建代码审查工作项;${scanReason};上一次阻塞问题已被主流程复核驳回`,
571
- };
572
- }
573
- return {
574
- skip: true,
575
- message: "代码审查发现需要处理的问题,请先执行 next,根据提示回到实现阶段修复或让使用者决定是否回到计划阶段",
576
- };
577
- }
578
- const { job, scanReason } = createCodeReviewerJob(change, projectRoot, events);
579
- return {
580
- fromState: "apply_done", toState: "apply_done", outcome: "job_created",
581
- newJobs: [job],
582
- reason: latest?.state === "rejected"
583
- ? `重新创建代码审查工作项;上一次报告未被接受,原因:${latest.reason ?? "报告不符合要求"}`
584
- : `创建代码审查工作项;${scanReason}`,
585
- };
586
- }
587
- return {
588
- fromState: "apply_done", toState: "review", outcome: "advanced",
589
- reason: "没有代码类改动,直接进入最终审查阶段",
590
- commitPayload: {
591
- ...policyPayload,
592
- code_review_gate: { decision: "skipped", reason: "no_code_changes" },
593
- },
594
- };
682
+ return evaluateApplyDoneCodeReviewGate({
683
+ events,
684
+ projectRoot,
685
+ change,
686
+ policyPayload,
687
+ });
595
688
  }
596
689
  if (snapshot.state === "review") {
597
- const verifierOpen = snapshot.open_jobs.find(isReviewReadyVerifier);
598
- if (verifierOpen)
599
- return { blocked: true, reason: `状态未推进;已有待完成最终验证工作项 ${verifierOpen.job_id}`, jobs: [verifierOpen] };
600
- const verifierAccepted = snapshot.accepted_jobs.find(job => isFreshReviewVerifier(job, changeRoot, currentEvidenceDigest));
601
- const finalVerifierRequired = requiresFinalVerifierForCurrentReview(events) || policy.requires_verifier;
602
- if (!finalVerifierRequired) {
603
- if (!storedPolicy) {
604
- return {
605
- fromState: "review", toState: "review", outcome: "advanced",
606
- reason: `补写审查策略=${policy.review_risk}`,
607
- commitPayload: policyPayload,
608
- };
609
- }
610
- return { skip: true, message: "已在 review 状态,审查策略无需 verifier" };
611
- }
612
- if (!verifierAccepted) {
613
- const boundFiles = reviewBoundFiles(changeRoot);
614
- const previousVerifierRejected = hasRejectedReviewReadyVerifier(events);
615
- const job = {
616
- job_id: newJobId(change, "verifier"), role: "verifier", state: "requested",
617
- boundFiles, review_evidence_digest: currentEvidenceDigest,
618
- packet_digest: sha256Text(JSON.stringify({
619
- role: "verifier",
620
- boundFiles,
621
- review_evidence_digest: currentEvidenceDigest,
622
- created_from_transition: "review-ready",
623
- })),
624
- created_from_transition: "review-ready", created_at: new Date().toISOString(),
625
- };
626
- return {
627
- fromState: snapshot.state, toState: snapshot.state, outcome: "job_created",
628
- newJobs: [job],
629
- reason: previousVerifierRejected
630
- ? "此前最终验证未通过;请先根据验证报告修改任务或文档,确认无需修改时再执行新的最终验证工作项"
631
- : "创建最终验证工作项",
632
- commitPayload: policyPayload,
633
- ...(previousVerifierRejected ? {
634
- details: { advisory: "此前最终验证未通过;请先根据验证报告修改任务或文档,确认无需修改时再执行新的最终验证工作项" },
635
- } : {}),
636
- };
637
- }
638
- if (!storedPolicy) {
639
- return {
640
- fromState: "review", toState: "review", outcome: "advanced",
641
- reason: `补写审查策略=${policy.review_risk}`,
642
- commitPayload: policyPayload,
643
- };
644
- }
645
- return { skip: true, message: "已在 review 状态,最终验证仍然有效" };
690
+ return evaluateFinalVerifierGate({
691
+ snapshot,
692
+ events,
693
+ change,
694
+ changeRoot,
695
+ currentEvidenceDigest,
696
+ policy,
697
+ policyPayload,
698
+ hasStoredPolicy: Boolean(storedPolicy),
699
+ });
646
700
  }
647
701
  return { skip: true, message: `当前状态 ${snapshot.state},review-ready 不适用` };
648
702
  },
@@ -711,42 +765,9 @@ export function taskComplete(projectRoot, change, changeRoot, taskId) {
711
765
  const attempt = snapshot.active_task_attempts?.find(a => a.task_id === taskId && a.state === "active");
712
766
  if (!attempt)
713
767
  return { skip: true, message: `任务 ${taskId} 无活跃执行尝试` };
714
- const tasksContent = readFileSync(join(changeRoot, "tasks.md"), "utf8");
715
- const currentDigest = sha256Text(tasksContent.replace(/- \[[xX]\]/g, "- [ ]"));
716
- if (currentDigest !== attempt.task_structure_digest)
717
- return { skip: true, message: "任务结构指纹不匹配" };
718
- // 解析任务属性(委托给 format.ts 的 parseTasksMd)
719
- const tasks = parseTasksMd(tasksContent);
720
- const taskInfo = tasks.find(t => t.taskId === taskId);
721
- const isTdd = taskInfo?.tddRequired ?? true;
722
- const hasNoTddReason = taskInfo?.noTddReason != null;
723
- // RED/GREEN 检查(HIGH-2 修复:按 attempt_id 匹配,避免多任务 digest 碰撞)
724
- const events = readEvents(projectRoot, change);
725
- let hasRed = false, hasGreen = false;
726
- for (const ev of events) {
727
- if (ev.event_type === "test_run_recorded") {
728
- const tr = ev.payload;
729
- // 匹配当前 attempt(优先按 attempt_id,回退到 digest)
730
- const matches = tr.attempt_id === attempt.attempt_id ||
731
- (!tr.attempt_id && tr.task_structure_digest === attempt.task_structure_digest);
732
- if (matches) {
733
- if (tr.semantic_status === "expected_failure" || tr.semantic_status === "characterization_pass")
734
- hasRed = true;
735
- if (tr.semantic_status === "expected_success")
736
- hasGreen = true;
737
- }
738
- }
739
- }
740
- if (isTdd) {
741
- if (!hasRed)
742
- return { skip: true, message: `TDD 任务 ${taskId} 缺少 RED 证据` };
743
- if (!hasGreen)
744
- return { skip: true, message: `TDD 任务 ${taskId} 缺少 GREEN 证据` };
745
- }
746
- else {
747
- if (!hasNoTddReason)
748
- return { skip: true, message: `非 TDD 任务 ${taskId} 缺少 no_tdd_reason` };
749
- }
768
+ const readiness = taskEvidenceReadiness(projectRoot, change, changeRoot, attempt);
769
+ if (!readiness.ready)
770
+ return { skip: true, message: `任务 ${taskId} 无法完成:${readiness.reason}` };
750
771
  // B1 修复:checkbox 写入移到 postCommit(commit 事件写入后执行)
751
772
  return {
752
773
  fromState: "apply", toState: "apply", outcome: "advanced",
package/dist/types.d.ts CHANGED
@@ -6,6 +6,7 @@ export type Ref = {
6
6
  };
7
7
  export type JobState = "requested" | "accepted" | "rejected";
8
8
  export type JobRole = "critic" | "architect" | "test-engineer" | "executor" | "test-run" | "verifier" | "code-reviewer";
9
+ export type ReviewJobGateId = "explore.discovery_review" | "propose.final_review" | "review.code_review" | "review.final_verifier";
9
10
  export type CodeReviewResultKind = "invalid_report" | "non_actionable_report" | "review_failed";
10
11
  export interface CodeReviewPreviousRejection {
11
12
  result_kind: CodeReviewResultKind;
@@ -16,6 +17,7 @@ export interface Job {
16
17
  job_id: string;
17
18
  role: JobRole;
18
19
  state: JobState;
20
+ gate_id?: ReviewJobGateId;
19
21
  boundFiles: Ref[];
20
22
  review_evidence_digest?: string;
21
23
  packet_digest: string;
@@ -26,9 +28,11 @@ export interface Job {
26
28
  export interface JobPacket {
27
29
  job_id: string;
28
30
  role: JobRole;
31
+ gate_id?: ReviewJobGateId;
29
32
  recommended_agent?: string;
30
33
  boundFiles: Ref[];
31
34
  review_evidence_digest?: string;
35
+ previous_rejection?: CodeReviewPreviousRejection;
32
36
  packet_digest: string;
33
37
  required_output_kind: string;
34
38
  preferred_input_mode?: "stdin" | "file";
@@ -0,0 +1,11 @@
1
+ import type { ReviewRisk } from "./review.ts";
2
+ import type { JobRole, ReviewJobGateId } from "./types.ts";
3
+ export type WorkflowProfile = "light" | "normal" | "strict";
4
+ export interface WorkflowProfileResolution {
5
+ profile: WorkflowProfile;
6
+ risk: ReviewRisk;
7
+ reviewRolesByGate: Partial<Record<ReviewJobGateId, JobRole[]>>;
8
+ }
9
+ export declare function workflowProfileForRisk(risk: ReviewRisk): WorkflowProfile;
10
+ export declare function resolveWorkflowProfile(risk: ReviewRisk): WorkflowProfileResolution;
11
+ export declare function reviewRolesForGate(gateId: ReviewJobGateId, risk: ReviewRisk): JobRole[];
@@ -0,0 +1,39 @@
1
+ const CURRENT_GATE_ROLES_BY_RISK = {
2
+ minimal: {
3
+ "explore.discovery_review": [],
4
+ "propose.final_review": [],
5
+ "review.code_review": ["code-reviewer"],
6
+ "review.final_verifier": ["verifier"],
7
+ },
8
+ normal: {
9
+ "explore.discovery_review": [],
10
+ "propose.final_review": ["critic"],
11
+ "review.code_review": ["code-reviewer"],
12
+ "review.final_verifier": ["verifier"],
13
+ },
14
+ strict: {
15
+ "explore.discovery_review": ["critic"],
16
+ "propose.final_review": ["critic", "architect", "test-engineer"],
17
+ "review.code_review": ["code-reviewer"],
18
+ "review.final_verifier": ["verifier"],
19
+ },
20
+ };
21
+ export function workflowProfileForRisk(risk) {
22
+ if (risk === "minimal")
23
+ return "light";
24
+ if (risk === "normal")
25
+ return "normal";
26
+ return "strict";
27
+ }
28
+ export function resolveWorkflowProfile(risk) {
29
+ const profile = workflowProfileForRisk(risk);
30
+ const rolesByGate = CURRENT_GATE_ROLES_BY_RISK[risk];
31
+ const reviewRolesByGate = {};
32
+ for (const [gateId, roles] of Object.entries(rolesByGate)) {
33
+ reviewRolesByGate[gateId] = [...roles];
34
+ }
35
+ return { profile, risk, reviewRolesByGate };
36
+ }
37
+ export function reviewRolesForGate(gateId, risk) {
38
+ return resolveWorkflowProfile(risk).reviewRolesByGate[gateId] ?? [];
39
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peterxiaoyang/superspec",
3
- "version": "0.1.37",
3
+ "version": "0.1.38",
4
4
  "description": "SuperSpec 流程引擎 — transition engine with lightweight fact-sync",
5
5
  "type": "module",
6
6
  "engines": {