@peterxiaoyang/superspec 0.1.22 → 0.1.23

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/next.js CHANGED
@@ -3,6 +3,7 @@ import { rebuildSnapshot } from "./sync.js";
3
3
  import { readFileSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import { readEvents, sha256Text } from "./store.js";
6
+ import { isFreshReviewVerifier, isReviewReadyVerifier, readReviewPolicyFromEvents, reviewEvidenceDigest } from "./review.js";
6
7
  import { validateDiscovery, countDiscoveryOpenQuestions, collectProposeOpenQuestions, parseTasksMd, pendingTasksInContent } from "./format.js";
7
8
  const ACTIVE_PROPOSAL_REVIEW_ROLES = new Set(["critic", "architect", "test-engineer"]);
8
9
  function isActiveProposalReviewJob(job) {
@@ -244,6 +245,43 @@ export function next(projectRoot, change, changeRoot, defaultRisk = "strict") {
244
245
  missing_inputs: [],
245
246
  };
246
247
  }
248
+ const reviewVerifierJobs = snapshot.open_jobs.filter(isReviewReadyVerifier);
249
+ if (reviewVerifierJobs.length > 0) {
250
+ return {
251
+ state: "review",
252
+ path: "required_job",
253
+ required_jobs: reviewVerifierJobs.map(j => ({
254
+ job_id: j.job_id,
255
+ role: j.role,
256
+ packet_command: packetCommand(change, j.job_id),
257
+ })),
258
+ reason: `有 ${reviewVerifierJobs.length} 个待完成最终验证工作项`,
259
+ };
260
+ }
261
+ const events = readEvents(projectRoot, change);
262
+ const policy = readReviewPolicyFromEvents(events);
263
+ if (!policy) {
264
+ return {
265
+ state: "review",
266
+ path: "next_command",
267
+ next_command: transitionCommand(change, "review-ready", riskFlag(defaultRisk)),
268
+ reason: "缺少审查策略,先补 review-ready",
269
+ missing_inputs: [],
270
+ };
271
+ }
272
+ if (policy.requires_verifier) {
273
+ const currentEvidenceDigest = reviewEvidenceDigest(events);
274
+ const verifierAccepted = snapshot.accepted_jobs.find(job => isFreshReviewVerifier(job, changeRoot, currentEvidenceDigest));
275
+ if (!verifierAccepted) {
276
+ return {
277
+ state: "review",
278
+ path: "next_command",
279
+ next_command: transitionCommand(change, "review-ready", riskFlag(defaultRisk)),
280
+ reason: "缺少 fresh verifier,先补最终验证",
281
+ missing_inputs: [],
282
+ };
283
+ }
284
+ }
247
285
  return {
248
286
  state: "review",
249
287
  path: "next_command",
package/dist/record.js CHANGED
@@ -1,7 +1,8 @@
1
1
  // SuperSpec 流程引擎 — record:工作项结果登记
2
2
  import { readFileSync, existsSync } from "node:fs";
3
3
  import { join } from "node:path";
4
- import { ensureChangeLayout, readEvents, appendEvent, makeEvent, sha256File, withLock, } from "./store.js";
4
+ import { ensureChangeLayout, readEvents, appendEvent, makeEvent, sha256File, withLock, appendRawRecord, } from "./store.js";
5
+ import { reviewEvidenceDigest, reviewVerifierStaleReason } from "./review.js";
5
6
  const REVIEW_REPORT_REQUIRED_FIELDS = ["role", "verdict", "findings"];
6
7
  const REVIEW_REPORT_OPTIONAL_FIELDS = ["summary", "evidence_refs", "risks", "open_questions"];
7
8
  const REVIEWER_KINDS = new Set(["codex-subagent", "human", "external-agent"]);
@@ -97,6 +98,7 @@ export function recordJobSubmit(projectRoot, change, changeRoot, jobId, reportFi
97
98
  const reportDigest = sha256File(reportFile) ?? "sha256:unknown";
98
99
  // acceptance checks
99
100
  const checks = [];
101
+ let parsedReport = null;
100
102
  // 0. 报告格式和角色匹配(最小 JSON contract)
101
103
  try {
102
104
  const report = JSON.parse(reportContent);
@@ -104,7 +106,8 @@ export function recordJobSubmit(projectRoot, change, changeRoot, jobId, reportFi
104
106
  checks.push("报告必须是 JSON object");
105
107
  }
106
108
  else {
107
- const obj = report;
109
+ parsedReport = report;
110
+ const obj = parsedReport;
108
111
  for (const field of REVIEW_REPORT_REQUIRED_FIELDS) {
109
112
  if (!(field in obj))
110
113
  checks.push(`报告缺少必填字段 ${field}`);
@@ -149,6 +152,10 @@ export function recordJobSubmit(projectRoot, change, changeRoot, jobId, reportFi
149
152
  checks.push(`绑定文件 ${bf.path} 已变化(${bf.sha} → ${currentSha})`);
150
153
  }
151
154
  }
155
+ const reviewStaleReason = reviewVerifierStaleReason(job, changeRoot, reviewEvidenceDigest(events));
156
+ if (reviewStaleReason && !checks.includes(reviewStaleReason)) {
157
+ checks.push(reviewStaleReason);
158
+ }
152
159
  // 2. 报告格式基本校验(非空 JSON 或文本)
153
160
  if (!reportContent.trim()) {
154
161
  checks.push("报告内容为空");
@@ -170,11 +177,13 @@ export function recordJobSubmit(projectRoot, change, changeRoot, jobId, reportFi
170
177
  };
171
178
  }
172
179
  // 接受
180
+ const rawRef = appendRawRecord(projectRoot, change, "review-reports", parsedReport);
173
181
  const acceptEvent = makeEvent(change, "job_accepted", {
174
182
  job_id: jobId,
175
183
  role: job.role,
176
184
  report_digest: reportDigest,
177
185
  accepted_at: new Date().toISOString(),
186
+ ...rawRef,
178
187
  });
179
188
  appendEvent(projectRoot, change, acceptEvent);
180
189
  return {
@@ -189,6 +198,7 @@ export function recordJobSubmit(projectRoot, change, changeRoot, jobId, reportFi
189
198
  export function recordUserDecision(projectRoot, change, inputFile) {
190
199
  return withLock(projectRoot, change, () => {
191
200
  ensureChangeLayout(projectRoot, change);
201
+ const events = readEvents(projectRoot, change);
192
202
  if (!existsSync(inputFile)) {
193
203
  appendEvent(projectRoot, change, makeEvent(change, "user_decision_recorded", { accepted: false, reason: "file_not_found", path: inputFile }));
194
204
  return { event_type: "user_decision_recorded", accepted: false, message: `决策文件不存在:${inputFile}` };
@@ -206,11 +216,26 @@ export function recordUserDecision(projectRoot, change, inputFile) {
206
216
  appendEvent(projectRoot, change, makeEvent(change, "user_decision_recorded", { accepted: false, reason: "missing_scope_or_answer" }));
207
217
  return { event_type: "user_decision_recorded", accepted: false, message: "决策文件缺少 scope 或 answer" };
208
218
  }
209
- const event = makeEvent(change, "user_decision_recorded", {
219
+ const inputDigest = sha256File(inputFile) ?? "sha256:unknown";
220
+ const existing = events.find(e => e.event_type === "user_decision_recorded"
221
+ && e.payload.input_digest === inputDigest);
222
+ if (existing) {
223
+ return {
224
+ event_type: "user_decision_recorded",
225
+ accepted: true,
226
+ message: "幂等返回:同 user decision 已登记",
227
+ };
228
+ }
229
+ const normalizedDecision = {
210
230
  scope: decision.scope,
211
231
  question: decision.question ?? "",
212
232
  answer: decision.answer,
213
- input_digest: sha256File(inputFile) ?? "sha256:unknown",
233
+ };
234
+ const rawRef = appendRawRecord(projectRoot, change, "user-decisions", normalizedDecision);
235
+ const event = makeEvent(change, "user_decision_recorded", {
236
+ ...normalizedDecision,
237
+ input_digest: inputDigest,
238
+ ...rawRef,
214
239
  });
215
240
  appendEvent(projectRoot, change, event);
216
241
  return {
@@ -268,11 +293,13 @@ export function jobsPacket(projectRoot, change, jobId) {
268
293
  role: job.role,
269
294
  recommended_agent: recommendedAgentForRole(job.role),
270
295
  boundFiles: job.boundFiles,
296
+ ...(job.review_evidence_digest ? { review_evidence_digest: job.review_evidence_digest } : {}),
271
297
  packet_digest: job.packet_digest,
272
298
  required_output_kind: "job_report_json",
273
299
  output_contract_fields: requiresReviewer(job.role) ? [...REVIEW_REPORT_REQUIRED_FIELDS, "reviewer"] : [...REVIEW_REPORT_REQUIRED_FIELDS],
274
300
  output_contract_optional_fields: [...REVIEW_REPORT_OPTIONAL_FIELDS],
275
301
  output_instructions: `${roleDescription(job.role)}。请审查 ${job.boundFiles.map(f => f.path).join(", ")},` +
302
+ (job.review_evidence_digest ? `本工作项绑定的执行证据版本为 ${job.review_evidence_digest},` : "") +
276
303
  (requiresReviewer(job.role) ? `必须由独立 ${recommendedAgentForRole(job.role)} reviewer 执行并在 reviewer.kind/id 中记录来源,` : "") +
277
304
  `产出 JSON 报告文件并通过 superspec record job-submit 登记。` +
278
305
  (requiresReviewer(job.role)
@@ -0,0 +1,17 @@
1
+ import type { Event, Job, Ref } from "./types.ts";
2
+ export type ReviewRisk = "minimal" | "normal" | "strict";
3
+ export interface ReviewPolicy {
4
+ review_risk: ReviewRisk;
5
+ requires_verifier: boolean;
6
+ }
7
+ export declare const REVIEW_DOC_PATHS: string[];
8
+ export declare function assertCommitPayloadExtension(payload: Record<string, unknown>): void;
9
+ export declare function reviewPolicyForRisk(risk: ReviewRisk): ReviewPolicy;
10
+ export declare function readReviewPolicyFromEvents(events: Event[]): ReviewPolicy | null;
11
+ export declare function isReviewReadyVerifier(job: Job): boolean;
12
+ export declare function reviewBoundFiles(changeRoot: string): Ref[];
13
+ export declare function reviewEvidenceDigest(events: Event[]): string;
14
+ export declare function boundFilesStaleReason(job: Job, changeRoot: string): string | null;
15
+ export declare function reviewEvidenceStaleReason(job: Job, currentDigest: string): string | null;
16
+ export declare function reviewVerifierStaleReason(job: Job, changeRoot: string, currentEvidenceDigest: string): string | null;
17
+ export declare function isFreshReviewVerifier(job: Job, changeRoot: string, currentEvidenceDigest: string): boolean;
package/dist/review.js ADDED
@@ -0,0 +1,159 @@
1
+ // SuperSpec review helpers: policy, verifier freshness, evidence digest
2
+ import { existsSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { sha256File, sha256Text } from "./store.js";
5
+ export const REVIEW_DOC_PATHS = [
6
+ "proposal.md",
7
+ "tasks.md",
8
+ "design.md",
9
+ ".superspec/artifacts/discovery.md",
10
+ ".superspec/artifacts/business-invariants.md",
11
+ ".superspec/artifacts/test-contract.md",
12
+ ];
13
+ const CORE_COMMIT_FIELDS = new Set([
14
+ "transition",
15
+ "from_state",
16
+ "to_state",
17
+ "outcome",
18
+ "created_job_ids",
19
+ "new_jobs",
20
+ "reason",
21
+ ]);
22
+ export function assertCommitPayloadExtension(payload) {
23
+ for (const key of Object.keys(payload)) {
24
+ if (CORE_COMMIT_FIELDS.has(key)) {
25
+ throw new Error(`commitPayload 不能覆盖核心字段:${key}`);
26
+ }
27
+ }
28
+ }
29
+ export function reviewPolicyForRisk(risk) {
30
+ return {
31
+ review_risk: risk,
32
+ requires_verifier: risk !== "minimal",
33
+ };
34
+ }
35
+ function isReviewPolicy(value) {
36
+ if (!value || typeof value !== "object" || Array.isArray(value))
37
+ return false;
38
+ const obj = value;
39
+ return ((obj.review_risk === "minimal" || obj.review_risk === "normal" || obj.review_risk === "strict") &&
40
+ typeof obj.requires_verifier === "boolean");
41
+ }
42
+ export function readReviewPolicyFromEvents(events) {
43
+ for (const ev of events) {
44
+ if (ev.event_type !== "transition_commit")
45
+ continue;
46
+ const payload = ev.payload;
47
+ if (payload.transition !== "review-ready")
48
+ continue;
49
+ if (isReviewPolicy(payload.review_policy))
50
+ return payload.review_policy;
51
+ }
52
+ return null;
53
+ }
54
+ export function isReviewReadyVerifier(job) {
55
+ return job.role === "verifier" && job.created_from_transition === "review-ready";
56
+ }
57
+ export function reviewBoundFiles(changeRoot) {
58
+ return REVIEW_DOC_PATHS
59
+ .filter(path => existsSync(join(changeRoot, path)))
60
+ .map(path => ({ path, sha: sha256File(join(changeRoot, path)) ?? "sha256:missing" }));
61
+ }
62
+ function evidenceSortKey(record) {
63
+ return [
64
+ record.kind,
65
+ record.task_id ?? "",
66
+ record.attempt_id ?? "",
67
+ record.task_structure_digest ?? "",
68
+ record.test_id ?? "",
69
+ record.semantic_status ?? "",
70
+ record.command ?? "",
71
+ record.cwd ?? "",
72
+ record.exit_code == null ? "" : String(record.exit_code),
73
+ record.target_fingerprint ?? "",
74
+ record.event_digest,
75
+ ].join("\u0000");
76
+ }
77
+ export function reviewEvidenceDigest(events) {
78
+ const attemptsById = new Map();
79
+ const completed = [];
80
+ for (const ev of events) {
81
+ if (ev.event_type === "task_started") {
82
+ const attempt = ev.payload;
83
+ attemptsById.set(attempt.attempt_id, attempt);
84
+ }
85
+ else if (ev.event_type === "task_completed") {
86
+ const payload = ev.payload;
87
+ if (typeof payload.task_id !== "string" || typeof payload.attempt_id !== "string")
88
+ continue;
89
+ const attempt = attemptsById.get(payload.attempt_id);
90
+ completed.push({
91
+ task_id: payload.task_id,
92
+ attempt_id: payload.attempt_id,
93
+ task_structure_digest: attempt?.task_structure_digest ?? null,
94
+ event_digest: ev.event_digest,
95
+ });
96
+ }
97
+ }
98
+ const completedAttemptIds = new Set(completed.map(item => item.attempt_id));
99
+ const completedStructureDigests = new Set(completed
100
+ .map(item => item.task_structure_digest)
101
+ .filter((digest) => typeof digest === "string" && digest.length > 0));
102
+ const records = completed.map(item => ({
103
+ kind: "task_completed",
104
+ task_id: item.task_id,
105
+ attempt_id: item.attempt_id,
106
+ task_structure_digest: item.task_structure_digest,
107
+ event_digest: item.event_digest,
108
+ }));
109
+ for (const ev of events) {
110
+ if (ev.event_type !== "test_run_recorded")
111
+ continue;
112
+ const payload = ev.payload;
113
+ const attemptId = typeof payload.attempt_id === "string" ? payload.attempt_id : null;
114
+ const structureDigest = typeof payload.task_structure_digest === "string" ? payload.task_structure_digest : null;
115
+ const matchesCompletedAttempt = attemptId != null && completedAttemptIds.has(attemptId);
116
+ const matchesLegacyDigest = attemptId == null && structureDigest != null && completedStructureDigests.has(structureDigest);
117
+ if (!matchesCompletedAttempt && !matchesLegacyDigest)
118
+ continue;
119
+ records.push({
120
+ kind: "test_run_recorded",
121
+ test_id: typeof payload.test_id === "string" ? payload.test_id : null,
122
+ attempt_id: attemptId,
123
+ task_structure_digest: structureDigest,
124
+ semantic_status: typeof payload.semantic_status === "string" ? payload.semantic_status : null,
125
+ command: typeof payload.command === "string" ? payload.command : "",
126
+ cwd: typeof payload.cwd === "string" ? payload.cwd : "",
127
+ exit_code: typeof payload.exit_code === "number" ? payload.exit_code : null,
128
+ target_fingerprint: typeof payload.target_fingerprint === "string" ? payload.target_fingerprint : null,
129
+ event_digest: ev.event_digest,
130
+ });
131
+ }
132
+ records.sort((a, b) => evidenceSortKey(a).localeCompare(evidenceSortKey(b)));
133
+ return sha256Text(JSON.stringify(records));
134
+ }
135
+ export function boundFilesStaleReason(job, changeRoot) {
136
+ for (const bf of job.boundFiles) {
137
+ const current = sha256File(join(changeRoot, bf.path)) ?? "sha256:missing";
138
+ if (current !== bf.sha) {
139
+ return `绑定文件 ${bf.path} 已变化(${bf.sha} → ${current})`;
140
+ }
141
+ }
142
+ return null;
143
+ }
144
+ export function reviewEvidenceStaleReason(job, currentDigest) {
145
+ if (!isReviewReadyVerifier(job))
146
+ return null;
147
+ if (!job.review_evidence_digest)
148
+ return "最终验证工作项缺少执行证据版本";
149
+ if (job.review_evidence_digest !== currentDigest) {
150
+ return `最终验证执行证据版本已变化(${job.review_evidence_digest} → ${currentDigest})`;
151
+ }
152
+ return null;
153
+ }
154
+ export function reviewVerifierStaleReason(job, changeRoot, currentEvidenceDigest) {
155
+ return boundFilesStaleReason(job, changeRoot) ?? reviewEvidenceStaleReason(job, currentEvidenceDigest);
156
+ }
157
+ export function isFreshReviewVerifier(job, changeRoot, currentEvidenceDigest) {
158
+ return isReviewReadyVerifier(job) && reviewVerifierStaleReason(job, changeRoot, currentEvidenceDigest) == null;
159
+ }
package/dist/store.d.ts CHANGED
@@ -1,7 +1,15 @@
1
1
  import type { Event, Snapshot, Ref } from "./types.ts";
2
+ export declare const RAW_RECORD_KINDS: readonly ["test-runs", "review-reports", "user-decisions"];
3
+ export type RawRecordKind = typeof RAW_RECORD_KINDS[number];
4
+ export interface RawRecordRef {
5
+ raw_kind: RawRecordKind;
6
+ raw_index: number;
7
+ raw_digest: string;
8
+ }
2
9
  export declare function engineRoot(projectRoot: string): string;
3
10
  export declare function changeDir(projectRoot: string, change: string): string;
4
11
  export declare function eventsFile(projectRoot: string, change: string): string;
12
+ export declare function rawFile(projectRoot: string, change: string, kind: RawRecordKind): string;
5
13
  export declare function snapshotFile(projectRoot: string, change: string): string;
6
14
  export declare function lockFile(projectRoot: string, change: string): string;
7
15
  export declare function stagingDir(projectRoot: string, change: string, transitionId: string): string;
@@ -21,6 +29,13 @@ export declare function makeEvent(change: string, eventType: Event["event_type"]
21
29
  outputRefs?: Ref[];
22
30
  }): Event;
23
31
  export declare function eventsDigest(events: Event[]): string;
32
+ /**
33
+ * Append an accepted record input to the raw archive.
34
+ *
35
+ * Contract: call only while holding the per-change lock. The returned raw_index
36
+ * is the zero-based valid-record index, not necessarily the physical line.
37
+ */
38
+ export declare function appendRawRecord(projectRoot: string, change: string, kind: RawRecordKind, record: unknown): RawRecordRef;
24
39
  export declare function writeSnapshot(projectRoot: string, change: string, snapshot: Snapshot): void;
25
40
  export declare function readSnapshot(projectRoot: string, change: string): Snapshot | null;
26
41
  export declare function snapshotDigest(snapshot: Snapshot): string;
package/dist/store.js CHANGED
@@ -4,6 +4,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, openSync, closeSync
4
4
  import { join, dirname } from "node:path";
5
5
  import { hostname } from "node:os";
6
6
  // ===== 路径 =====
7
+ export const RAW_RECORD_KINDS = ["test-runs", "review-reports", "user-decisions"];
7
8
  export function engineRoot(projectRoot) {
8
9
  return join(projectRoot, ".superspec");
9
10
  }
@@ -13,6 +14,9 @@ export function changeDir(projectRoot, change) {
13
14
  export function eventsFile(projectRoot, change) {
14
15
  return join(changeDir(projectRoot, change), "events.jsonl");
15
16
  }
17
+ export function rawFile(projectRoot, change, kind) {
18
+ return join(changeDir(projectRoot, change), "raw", `${kind}.jsonl`);
19
+ }
16
20
  export function snapshotFile(projectRoot, change) {
17
21
  return join(changeDir(projectRoot, change), "snapshot.json");
18
22
  }
@@ -109,6 +113,52 @@ export function makeEvent(change, eventType, payload, opts = {}) {
109
113
  export function eventsDigest(events) {
110
114
  return sha256Text(events.map(e => e.event_digest).join("\n"));
111
115
  }
116
+ // ===== Raw 归档 =====
117
+ function countValidJsonlRecords(filePath) {
118
+ if (!existsSync(filePath))
119
+ return 0;
120
+ let count = 0;
121
+ for (const line of readFileSync(filePath, "utf8").split("\n")) {
122
+ if (!line.trim())
123
+ continue;
124
+ try {
125
+ JSON.parse(line);
126
+ count++;
127
+ }
128
+ catch {
129
+ // raw JSONL 与 events.jsonl 一样容忍截断/损坏行;raw_index 按有效记录计数。
130
+ }
131
+ }
132
+ return count;
133
+ }
134
+ /**
135
+ * Append an accepted record input to the raw archive.
136
+ *
137
+ * Contract: call only while holding the per-change lock. The returned raw_index
138
+ * is the zero-based valid-record index, not necessarily the physical line.
139
+ */
140
+ export function appendRawRecord(projectRoot, change, kind, record) {
141
+ if (!RAW_RECORD_KINDS.includes(kind)) {
142
+ throw new Error(`Unsupported raw record kind: ${kind}`);
143
+ }
144
+ const rf = rawFile(projectRoot, change, kind);
145
+ mkdirSync(dirname(rf), { recursive: true });
146
+ const rawIndex = countValidJsonlRecords(rf);
147
+ const line = JSON.stringify(record);
148
+ const rawDigest = sha256Text(line);
149
+ const fd = openSync(rf, "a");
150
+ try {
151
+ writeFileSync(fd, line + "\n", "utf8");
152
+ }
153
+ finally {
154
+ closeSync(fd);
155
+ }
156
+ return {
157
+ raw_kind: kind,
158
+ raw_index: rawIndex,
159
+ raw_digest: rawDigest,
160
+ };
161
+ }
112
162
  // ===== 快照 =====
113
163
  export function writeSnapshot(projectRoot, change, snapshot) {
114
164
  writeFileSync(snapshotFile(projectRoot, change), JSON.stringify(snapshot, null, 2) + "\n", "utf8");
package/dist/sync.js CHANGED
@@ -1,7 +1,8 @@
1
1
  // SuperSpec 流程引擎 — sync:从当前文档 + events.jsonl 重建 snapshot
2
2
  import { readFileSync, existsSync } from "node:fs";
3
3
  import { join } from "node:path";
4
- import { readEvents, eventsDigest, computeDocumentDigests, sha256File, sha256Text, ensureChangeLayout, } from "./store.js";
4
+ import { readEvents, eventsDigest, computeDocumentDigests, sha256Text, ensureChangeLayout, } from "./store.js";
5
+ import { reviewEvidenceDigest, reviewVerifierStaleReason } from "./review.js";
5
6
  const TRACKED_DOCS = [
6
7
  "proposal.md", "design.md", "tasks.md",
7
8
  ".superspec/artifacts/discovery.md",
@@ -79,18 +80,12 @@ function replayEvents(events) {
79
80
  return { state, openJobs, acceptedJobs, activeAttempts, taskStatuses, lastTransition };
80
81
  }
81
82
  /** 粗粒度失效:检查 job 的 boundFiles 是否仍匹配当前文档 */
82
- function checkStaleJobs(jobs, changeRoot) {
83
+ function checkStaleJobs(jobs, changeRoot, currentReviewEvidenceDigest) {
83
84
  const stale = [];
84
85
  for (const job of jobs) {
85
- for (const bf of job.boundFiles) {
86
- const current = sha256File(join(changeRoot, bf.path)) ?? "sha256:missing";
87
- if (current !== bf.sha) {
88
- stale.push({
89
- job_id: job.job_id,
90
- reason: `绑定文件 ${bf.path} 已变化(${bf.sha} → ${current})`,
91
- });
92
- break; // 一个文件变就够了
93
- }
86
+ const reason = reviewVerifierStaleReason(job, changeRoot, currentReviewEvidenceDigest);
87
+ if (reason) {
88
+ stale.push({ job_id: job.job_id, reason });
94
89
  }
95
90
  }
96
91
  return stale;
@@ -113,9 +108,10 @@ export function rebuildSnapshot(projectRoot, change, changeRoot, openspecStatusD
113
108
  const documentDigests = computeDocumentDigests(changeRoot, TRACKED_DOCS);
114
109
  const tsDigest = tasksStructureDigest(changeRoot);
115
110
  const { state, openJobs, acceptedJobs, activeAttempts, taskStatuses, lastTransition } = replayEvents(events);
111
+ const currentReviewEvidenceDigest = reviewEvidenceDigest(events);
116
112
  // 粗粒度失效检查(只读,不写事件):snapshot 只暴露当前可执行/可复用 job。
117
- const staleOpenInfo = checkStaleJobs(openJobs, changeRoot);
118
- const staleAcceptedInfo = checkStaleJobs(acceptedJobs, changeRoot);
113
+ const staleOpenInfo = checkStaleJobs(openJobs, changeRoot, currentReviewEvidenceDigest);
114
+ const staleAcceptedInfo = checkStaleJobs(acceptedJobs, changeRoot, currentReviewEvidenceDigest);
119
115
  const freshOpen = openJobs.filter(j => !staleOpenInfo.some(s => s.job_id === j.job_id));
120
116
  const freshAccepted = acceptedJobs.filter(j => !staleAcceptedInfo.some(s => s.job_id === j.job_id));
121
117
  return {
package/dist/task.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // SuperSpec 流程引擎 — task:测试运行记录 + 任务结构指纹工具
2
2
  import { existsSync, readFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
- import { sha256Text, ensureChangeLayout, appendEvent, makeEvent, withLock } from "./store.js";
4
+ import { sha256Text, ensureChangeLayout, appendEvent, makeEvent, withLock, appendRawRecord } from "./store.js";
5
5
  import { tasksStructureDigest as formatDigest } from "./format.js";
6
6
  /** tasks.md 结构指纹(委托给 format.ts 统一实现) */
7
7
  export function tasksStructureDigestOf(changeRoot) {
@@ -27,7 +27,7 @@ export function recordTestRun(projectRoot, change, inputFile) {
27
27
  if (!tr.test_id || !tr.task_structure_digest) {
28
28
  return { accepted: false, message: "缺少 test_id 或 task_structure_digest" };
29
29
  }
30
- const event = makeEvent(change, "test_run_recorded", {
30
+ const normalizedTestRun = {
31
31
  test_id: tr.test_id,
32
32
  task_structure_digest: tr.task_structure_digest,
33
33
  attempt_id: tr.attempt_id ?? null,
@@ -37,6 +37,11 @@ export function recordTestRun(projectRoot, change, inputFile) {
37
37
  semantic_status: tr.semantic_status ?? "unknown",
38
38
  target_fingerprint: tr.target_fingerprint ?? null,
39
39
  raw_log_ref: tr.raw_log_ref ?? null,
40
+ };
41
+ const rawRef = appendRawRecord(projectRoot, change, "test-runs", normalizedTestRun);
42
+ const event = makeEvent(change, "test_run_recorded", {
43
+ ...normalizedTestRun,
44
+ ...rawRef,
40
45
  });
41
46
  appendEvent(projectRoot, change, event);
42
47
  return { accepted: true, message: `测试运行已登记:test_id=${tr.test_id}` };
@@ -5,6 +5,7 @@ interface Decision {
5
5
  outcome: "advanced" | "job_created";
6
6
  newJobs?: Job[];
7
7
  reason: string;
8
+ commitPayload?: Record<string, unknown>;
8
9
  extraEvents?: {
9
10
  type: string;
10
11
  payload: Record<string, unknown>;
@@ -1,8 +1,9 @@
1
1
  // SuperSpec 流程引擎 — transition:提交协议 + 所有 transition 处理器
2
2
  import { join } from "node:path";
3
- import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from "node:fs";
4
- import { ensureChangeLayout, readEvents, appendEvent, makeEvent, writeSnapshot, snapshotDigest, withLock, idempotencyKey, stagingDir, sha256File, sha256Text, } from "./store.js";
3
+ import { existsSync, readFileSync, writeFileSync, readdirSync } from "node:fs";
4
+ import { ensureChangeLayout, readEvents, appendEvent, makeEvent, writeSnapshot, snapshotDigest, withLock, idempotencyKey, sha256File, sha256Text, } from "./store.js";
5
5
  import { rebuildSnapshot } from "./sync.js";
6
+ import { assertCommitPayloadExtension, isFreshReviewVerifier, isReviewReadyVerifier, readReviewPolicyFromEvents, reviewBoundFiles, reviewEvidenceDigest, reviewPolicyForRisk, } from "./review.js";
6
7
  import { validateDiscovery, collectProposeOpenQuestions, findTaskInLines, parseTasksMd, pendingTasksInContent, tasksStructureDigest } from "./format.js";
7
8
  let transitionSeq = 0;
8
9
  function newTransitionId() { return `T-${Date.now()}-${++transitionSeq}`; }
@@ -85,6 +86,20 @@ function pendingTaskIds(changeRoot) {
85
86
  function formatPendingTaskMessage(ids, action) {
86
87
  return `尚有未完成任务:${ids.join(", ")};${action}`;
87
88
  }
89
+ function hasRejectedReviewReadyVerifier(events) {
90
+ const reviewReadyVerifierIds = new Set();
91
+ for (const ev of events) {
92
+ if (ev.event_type !== "transition_commit")
93
+ continue;
94
+ const newJobs = ev.payload.new_jobs ?? [];
95
+ for (const job of newJobs) {
96
+ if (isReviewReadyVerifier(job))
97
+ reviewReadyVerifierIds.add(job.job_id);
98
+ }
99
+ }
100
+ return events.some(ev => ev.event_type === "job_rejected" &&
101
+ reviewReadyVerifierIds.has(ev.payload.job_id ?? ""));
102
+ }
88
103
  /**
89
104
  * 统一 transition 提交协议——所有校验在锁内。
90
105
  */
@@ -113,7 +128,8 @@ export function commitTransition(projectRoot, change, changeRoot, opts) {
113
128
  created_jobs: [], message: decision.message, events_written: 0,
114
129
  };
115
130
  }
116
- const { fromState, toState, outcome, newJobs = [], reason, extraEvents = [], details } = decision;
131
+ const { fromState, toState, outcome, newJobs = [], reason, commitPayload = {}, extraEvents = [], details } = decision;
132
+ assertCommitPayloadExtension(commitPayload);
117
133
  if (fromState !== snapshot.state) {
118
134
  return {
119
135
  transition: name, outcome: "advanced",
@@ -137,6 +153,7 @@ export function commitTransition(projectRoot, change, changeRoot, opts) {
137
153
  appendEvent(projectRoot, change, makeEvent(change, "transition_commit", {
138
154
  transition: name, from_state: fromState, to_state: toState,
139
155
  outcome, created_job_ids: newJobs.map(j => j.job_id), new_jobs: newJobs, reason,
156
+ ...commitPayload,
140
157
  }, { transitionId, idempotencyKey: idemKey, prevSnapshotDigest: worldDigest }));
141
158
  // extra events (task_started, task_completed, etc.)
142
159
  for (const ex of extraEvents) {
@@ -311,35 +328,86 @@ export function reviewReady(projectRoot, change, changeRoot, risk = "strict") {
311
328
  return commitTransition(projectRoot, change, changeRoot, {
312
329
  name: "review-ready", idempotencyInputs: { phase: "review-ready", risk },
313
330
  decide: (snapshot) => {
331
+ const events = readEvents(projectRoot, change);
332
+ const storedPolicy = readReviewPolicyFromEvents(events);
333
+ const policy = storedPolicy ?? reviewPolicyForRisk(risk);
334
+ const policyPayload = storedPolicy ? {} : { review_policy: policy };
335
+ const currentEvidenceDigest = reviewEvidenceDigest(events);
314
336
  // 检查是否所有任务已完成
315
337
  const pending = pendingTaskIds(changeRoot);
316
338
  if (pending.length > 0)
317
339
  return { skip: true, message: formatPendingTaskMessage(pending, "请先通过 next/reopen 继续执行") };
318
340
  // 如果当前是 apply,先推进到 apply_done
319
341
  if (snapshot.state === "apply") {
320
- return { fromState: "apply", toState: "apply_done", outcome: "advanced", reason: "所有任务完成" };
342
+ return {
343
+ fromState: "apply", toState: "apply_done", outcome: "advanced",
344
+ reason: `所有任务完成;审查策略=${policy.review_risk}`,
345
+ commitPayload: policyPayload,
346
+ };
321
347
  }
322
- if (snapshot.state === "apply_done") {
323
- const isReviewReadyVerifier = (job) => job.role === "verifier" && job.created_from_transition === "review-ready";
348
+ if (snapshot.state === "apply_done" || snapshot.state === "review") {
324
349
  const verifierOpen = snapshot.open_jobs.find(isReviewReadyVerifier);
325
350
  if (verifierOpen)
326
351
  return { skip: true, message: `有待完成的最终验证工作项 ${verifierOpen.job_id}` };
327
- const verifierAccepted = snapshot.accepted_jobs.find(isReviewReadyVerifier);
328
- // minimal 直接推进;normal/strict 需要 review-ready verifier
329
- if (risk !== "minimal" && !verifierAccepted) {
330
- const docPaths = ["proposal.md", "tasks.md", "design.md", ".superspec/artifacts/discovery.md", ".superspec/artifacts/business-invariants.md", ".superspec/artifacts/test-contract.md"];
331
- const boundFiles = docPaths.filter(p => existsSync(join(changeRoot, p))).map(p => ({ path: p, sha: sha256File(join(changeRoot, p)) ?? "sha256:missing" }));
352
+ const verifierAccepted = snapshot.accepted_jobs.find(job => isFreshReviewVerifier(job, changeRoot, currentEvidenceDigest));
353
+ if (!policy.requires_verifier) {
354
+ if (snapshot.state === "apply_done") {
355
+ return {
356
+ fromState: "apply_done", toState: "review", outcome: "advanced",
357
+ reason: `审查策略=${policy.review_risk},无需最终验证,进入审查阶段`,
358
+ commitPayload: policyPayload,
359
+ };
360
+ }
361
+ if (!storedPolicy) {
362
+ return {
363
+ fromState: "review", toState: "review", outcome: "advanced",
364
+ reason: `补写审查策略=${policy.review_risk}`,
365
+ commitPayload: policyPayload,
366
+ };
367
+ }
368
+ return { skip: true, message: "已在 review 状态,审查策略无需 verifier" };
369
+ }
370
+ if (!verifierAccepted) {
371
+ const boundFiles = reviewBoundFiles(changeRoot);
372
+ const previousVerifierRejected = hasRejectedReviewReadyVerifier(events);
332
373
  const job = {
333
374
  job_id: newJobId(change, "verifier"), role: "verifier", state: "requested",
334
- boundFiles, packet_digest: sha256Text(JSON.stringify({ role: "verifier", boundFiles, created_from_transition: "review-ready" })),
375
+ boundFiles, review_evidence_digest: currentEvidenceDigest,
376
+ packet_digest: sha256Text(JSON.stringify({
377
+ role: "verifier",
378
+ boundFiles,
379
+ review_evidence_digest: currentEvidenceDigest,
380
+ created_from_transition: "review-ready",
381
+ })),
335
382
  created_from_transition: "review-ready", created_at: new Date().toISOString(),
336
383
  };
337
384
  return {
338
- fromState: "apply_done", toState: "apply_done", outcome: "job_created",
339
- newJobs: [job], reason: "创建最终验证工作项",
385
+ fromState: snapshot.state, toState: snapshot.state, outcome: "job_created",
386
+ newJobs: [job],
387
+ reason: previousVerifierRejected
388
+ ? "此前 verifier 未通过;请先根据 findings 修改任务或文档,确认无需修改时再执行新的最终验证工作项"
389
+ : "创建最终验证工作项",
390
+ commitPayload: policyPayload,
391
+ ...(previousVerifierRejected ? {
392
+ details: { advisory: "此前 verifier 未通过;请先根据 findings 修改任务或文档,确认无需修改时再执行新的最终验证工作项" },
393
+ } : {}),
340
394
  };
341
395
  }
342
- return { fromState: "apply_done", toState: "review", outcome: "advanced", reason: "最终验证已接受,进入审查阶段" };
396
+ if (snapshot.state === "apply_done") {
397
+ return {
398
+ fromState: "apply_done", toState: "review", outcome: "advanced",
399
+ reason: "最终验证已接受,进入审查阶段",
400
+ commitPayload: policyPayload,
401
+ };
402
+ }
403
+ if (!storedPolicy) {
404
+ return {
405
+ fromState: "review", toState: "review", outcome: "advanced",
406
+ reason: `补写审查策略=${policy.review_risk}`,
407
+ commitPayload: policyPayload,
408
+ };
409
+ }
410
+ return { skip: true, message: "已在 review 状态,最终验证仍然有效" };
343
411
  }
344
412
  return { skip: true, message: `当前状态 ${snapshot.state},review-ready 不适用` };
345
413
  },
@@ -355,6 +423,16 @@ export function accept(projectRoot, change, changeRoot) {
355
423
  const pending = pendingTaskIds(changeRoot);
356
424
  if (pending.length > 0)
357
425
  return { skip: true, message: formatPendingTaskMessage(pending, "请先 reopen --to apply 继续执行") };
426
+ const events = readEvents(projectRoot, change);
427
+ const policy = readReviewPolicyFromEvents(events);
428
+ if (!policy)
429
+ return { skip: true, message: "缺少审查策略,请先运行 review-ready" };
430
+ if (policy.requires_verifier) {
431
+ const currentEvidenceDigest = reviewEvidenceDigest(events);
432
+ const verifierAccepted = snapshot.accepted_jobs.find(job => isFreshReviewVerifier(job, changeRoot, currentEvidenceDigest));
433
+ if (!verifierAccepted)
434
+ return { skip: true, message: "缺少 fresh verifier,请先运行 review-ready" };
435
+ }
358
436
  return { fromState: "review", toState: "accepted", outcome: "advanced", reason: "审查通过" };
359
437
  },
360
438
  });
package/dist/types.d.ts CHANGED
@@ -11,6 +11,7 @@ export interface Job {
11
11
  role: JobRole;
12
12
  state: JobState;
13
13
  boundFiles: Ref[];
14
+ review_evidence_digest?: string;
14
15
  packet_digest: string;
15
16
  created_from_transition: string;
16
17
  created_at: string;
@@ -20,6 +21,7 @@ export interface JobPacket {
20
21
  role: JobRole;
21
22
  recommended_agent?: string;
22
23
  boundFiles: Ref[];
24
+ review_evidence_digest?: string;
23
25
  packet_digest: string;
24
26
  required_output_kind: string;
25
27
  output_contract_fields?: string[];
@@ -48,7 +50,12 @@ export interface TransitionCommitPayload {
48
50
  to_state: State;
49
51
  outcome: "advanced" | "job_created";
50
52
  created_job_ids: string[];
53
+ new_jobs?: Job[];
51
54
  reason: string;
55
+ review_policy?: {
56
+ review_risk: "minimal" | "normal" | "strict";
57
+ requires_verifier: boolean;
58
+ };
52
59
  }
53
60
  export interface Snapshot {
54
61
  change_id: string;
package/package.json CHANGED
@@ -1,14 +1,25 @@
1
1
  {
2
2
  "name": "@peterxiaoyang/superspec",
3
- "version": "0.1.22",
3
+ "version": "0.1.23",
4
4
  "description": "SuperSpec 流程引擎 — transition engine with lightweight fact-sync",
5
5
  "type": "module",
6
- "engines": { "node": ">=20.19.0" },
7
- "bin": { "superspec": "dist/cli.js" },
6
+ "engines": {
7
+ "node": ">=20.19.0"
8
+ },
9
+ "bin": {
10
+ "superspec": "dist/cli.js"
11
+ },
8
12
  "exports": {
9
- ".": { "types": "./dist/types.d.ts", "default": "./dist/cli.js" }
13
+ ".": {
14
+ "types": "./dist/types.d.ts",
15
+ "default": "./dist/cli.js"
16
+ }
10
17
  },
11
- "files": ["README.md", "dist", "templates"],
18
+ "files": [
19
+ "README.md",
20
+ "dist",
21
+ "templates"
22
+ ],
12
23
  "scripts": {
13
24
  "build": "node build.js",
14
25
  "typecheck": "tsc --noEmit",
@@ -21,12 +21,23 @@ metadata:
21
21
 
22
22
  next 返回需要 verifier 工作项时,先按返回的验证说明执行核对,再用 `superspec record job-submit --change "<change>" --job <JOB> --report <FILE>` 提交验证报告。
23
23
 
24
+ `record job-submit` 沿用现有 raw 归档:报告追加到 `raw/review-reports.jsonl`,不会为 review gate 新增 raw 文件类型。
25
+
24
26
  ## 本阶段做什么
25
27
 
26
28
  1. **确认所有任务完成**:review-ready 会检查 tasks.md 无未完成项
27
29
  2. **处理 verifier**:核对 proposal + 实现 + 测试契约一致性
28
30
  3. **accept**:`superspec transition accept --change "<change>"`
29
31
 
32
+ ## verifier gate 规则
33
+
34
+ - `review-ready` 首次运行会持久化审查策略;后续 risk 参数不会覆盖首次策略。
35
+ - `minimal` 不要求 verifier;`normal` 和 `strict` 要求 verifier。
36
+ - 当前 MVP 中 `strict` 等同 `normal`,不启用额外检查。
37
+ - 如果 review 状态缺少审查策略,先运行 `review-ready` 补策略,不直接 accept。
38
+ - verifier 工作项 packet 会包含绑定文档和 `review_evidence_digest`,用于确认审查对应的任务完成与 RED/GREEN 证据版本。
39
+ - verifier 通过后,如果绑定文档或已登记执行证据版本变化,需要重新运行 `review-ready` 创建新的 verifier。
40
+
30
41
  ## Guardrails
31
42
 
32
43
  - 不改业务代码(审查阶段只读)