@peterxiaoyang/superspec 0.1.38 → 0.1.39

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.
Files changed (36) hide show
  1. package/dist/cli.js +19 -2
  2. package/dist/code_review.d.ts +17 -2
  3. package/dist/code_review.js +471 -80
  4. package/dist/format.d.ts +46 -4
  5. package/dist/format.js +311 -26
  6. package/dist/git_state.d.ts +36 -0
  7. package/dist/git_state.js +174 -0
  8. package/dist/job_validity.d.ts +1 -0
  9. package/dist/job_validity.js +11 -8
  10. package/dist/next.js +46 -314
  11. package/dist/phase_plan.d.ts +97 -0
  12. package/dist/phase_plan.js +582 -0
  13. package/dist/record.js +56 -7
  14. package/dist/review.d.ts +3 -2
  15. package/dist/review.js +64 -32
  16. package/dist/review_job_gates.js +1 -0
  17. package/dist/store.d.ts +10 -0
  18. package/dist/store.js +53 -3
  19. package/dist/sync.js +5 -5
  20. package/dist/task.js +87 -9
  21. package/dist/task_evidence.js +80 -0
  22. package/dist/transition.d.ts +1 -1
  23. package/dist/transition.js +301 -210
  24. package/dist/types.d.ts +77 -1
  25. package/package.json +1 -1
  26. package/templates/workflow/prompts/architect.md +17 -27
  27. package/templates/workflow/prompts/code-reviewer.md +13 -2
  28. package/templates/workflow/prompts/critic.md +62 -61
  29. package/templates/workflow/prompts/executor.md +1 -1
  30. package/templates/workflow/prompts/explore.md +38 -26
  31. package/templates/workflow/prompts/test-engineer.md +18 -32
  32. package/templates/workflow/prompts/verifier.md +5 -3
  33. package/templates/workflow/skills/superspec-apply/SKILL.md +34 -11
  34. package/templates/workflow/skills/superspec-explore/SKILL.md +65 -64
  35. package/templates/workflow/skills/superspec-propose/SKILL.md +64 -43
  36. package/templates/workflow/skills/superspec-review/SKILL.md +1 -1
@@ -1,82 +1,49 @@
1
1
  // SuperSpec 流程引擎 — transition:提交协议 + 所有 transition 处理器
2
2
  import { join } from "node:path";
3
- import { existsSync, readFileSync, writeFileSync, readdirSync } from "node:fs";
4
- import { ensureChangeLayout, readEvents, appendEvent, makeEvent, writeSnapshot, snapshotDigest, withLock, idempotencyKey, sha256File, sha256Text, } from "./store.js";
3
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { ensureChangeLayout, readEvents, appendEvent, makeEvent, writeSnapshot, snapshotDigest, withLock, idempotencyKey, docRef, listMarkdownFiles, sha256File, sha256Text, } from "./store.js";
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";
9
- import { codeReviewBoundFiles, codeReviewDecisionScope, codeReviewJobStaleReason, codeReviewPacketDigest, collectCodeReviewGateFacts, dismissedCodeReviewSummary, latestCodeReviewDecision, latestCodeReviewFailedStatus, requiresFinalVerifierForCurrentReview, scanCodeChanges, } from "./code_review.js";
8
+ import { REVIEW_CODE_REVIEW_GATE_ID, REVIEW_FINAL_VERIFIER_GATE_ID, } from "./review_job_gates.js";
9
+ import { codeReviewBoundFiles, codeReviewDecisionScope, codeReviewJobStaleReason, codeReviewPacketContext, codeReviewPacketDigest, collectCodeReviewGateFacts, computeCodeStateCheck, currentCodeReviewWorkingPaths, dismissedCodeReviewSummary, latestCodeReviewDecision, latestCodeReviewFailedStatus, missingCoverageExemptionTestIds, requiresFinalVerifierForCurrentReview, scanCodeChangesForReview, } from "./code_review.js";
10
10
  import { taskEvidenceReadiness } from "./task_evidence.js";
11
- import { validateDiscovery, collectProposeOpenQuestions, findTaskInLines, pendingTasksInContent } from "./format.js";
11
+ import { adoptedContractForTask, findTaskInLines, isReviewFixTaskId, parseTasksMd, parseTestContractEntries, } from "./format.js";
12
+ import { applyRequirementModeForCurrentRound, formatPendingTaskMessage, pendingTaskStatusForApply, planTransition, proposalDocsBaseline, } from "./phase_plan.js";
13
+ import { currentGitHead, dirtyCodeFiles } from "./git_state.js";
12
14
  let transitionSeq = 0;
13
15
  function newTransitionId() { return `T-${Date.now()}-${++transitionSeq}`; }
14
16
  let jobSeq = 0;
15
17
  function newJobId(change, role) { return `JOB-${change.slice(0, 8)}-${role.slice(0, 4)}-${Date.now()}-${++jobSeq}`; }
16
- /**
17
- * 通用 job 检查/创建逻辑——任何 transition 都能调用。
18
- * 检查 requiredRoles 是否有 fresh accepted job;缺则创建新 job。
19
- * 返回 null = 全部满足;返回 Decision = 需要创建 job(状态不变)。
20
- */
21
- function checkOrCreateReviewJobs(snapshot, gate, requiredRoles, changeRoot, change) {
22
- const staleRoles = [];
23
- for (const role of requiredRoles) {
24
- const openForRole = snapshot.open_jobs.find(j => gate.isJobForGate(j) && j.role === role);
25
- if (openForRole)
26
- return { blocked: true, reason: `状态未推进;已有待完成工作项 ${role}(${openForRole.job_id})`, jobs: [openForRole] };
27
- const fresh = snapshot.accepted_jobs.find(j => gate.isJobForGate(j) && j.role === role);
28
- if (!fresh) {
29
- staleRoles.push({ role, reason: `需求 ${role} 无已接受的工作项` });
30
- }
31
- else {
32
- for (const bf of fresh.boundFiles) {
33
- const currentSha = sha256File(join(changeRoot, bf.path)) ?? "sha256:missing";
34
- if (currentSha !== bf.sha) {
35
- staleRoles.push({ role, reason: `${role} 绑定文件 ${bf.path} 已变化` });
36
- break;
37
- }
38
- }
39
- }
40
- }
41
- if (staleRoles.length > 0) {
42
- const newJobs = staleRoles.map(({ role }) => {
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),
18
+ function createReviewJobsForGate(state, gate, roles, changeRoot, change, reason) {
19
+ const newJobs = roles.map(role => {
20
+ // 目录路径(以 / 结尾)始终绑定聚合指纹:审查时不存在、审查后新建同样视为变化
21
+ const boundFiles = gate.reviewedDocPaths
22
+ .filter(p => p.endsWith("/") || existsSync(join(changeRoot, p)))
23
+ .map(p => docRef(changeRoot, p));
24
+ return {
25
+ job_id: newJobId(change, role),
26
+ role,
27
+ state: "requested",
28
+ gate_id: gate.gate_id,
29
+ boundFiles,
30
+ packet_digest: sha256Text(JSON.stringify({
46
31
  role,
47
- state: "requested",
48
32
  gate_id: gate.gate_id,
49
33
  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
34
  created_from_transition: gate.created_from_transition,
57
- created_at: new Date().toISOString(),
58
- };
59
- });
60
- return {
61
- fromState: snapshot.state, toState: snapshot.state, outcome: "job_created",
62
- newJobs, reason: staleRoles.map(s => s.reason).join("; "),
35
+ })),
36
+ created_from_transition: gate.created_from_transition,
37
+ created_at: new Date().toISOString(),
63
38
  };
64
- }
65
- return null; // 全部满足
66
- }
67
- function historicalProposeReadyRoles(projectRoot, change) {
68
- const roles = new Set();
69
- for (const ev of readEvents(projectRoot, change)) {
70
- if (ev.event_type !== "transition_commit")
71
- continue;
72
- const newJobs = ev.payload.new_jobs ?? [];
73
- for (const job of newJobs) {
74
- if (PROPOSE_FINAL_REVIEW_GATE.isJobForGate(job) &&
75
- (job.role === "critic" || job.role === "architect" || job.role === "test-engineer"))
76
- roles.add(job.role);
77
- }
78
- }
79
- return [...roles];
39
+ });
40
+ return {
41
+ fromState: state,
42
+ toState: state,
43
+ outcome: "job_created",
44
+ newJobs,
45
+ reason,
46
+ };
80
47
  }
81
48
  /**
82
49
  * 已迁移到 format.ts:findTaskInLines / parseTasksMd / tasksStructureDigest
@@ -85,12 +52,94 @@ function historicalProposeReadyRoles(projectRoot, change) {
85
52
  function findTaskLine(lines, taskId) {
86
53
  return findTaskInLines(lines, taskId);
87
54
  }
88
- function pendingTaskIds(changeRoot) {
89
- const tasksContent = readFileSync(join(changeRoot, "tasks.md"), "utf8");
90
- return pendingTasksInContent(tasksContent).map(task => task.taskId);
55
+ function boundarySnapshotResult(projectRoot) {
56
+ const head = currentGitHead(projectRoot);
57
+ const dirty = dirtyCodeFiles(projectRoot);
58
+ if (!dirty.ok)
59
+ return { snapshot: null, reason: dirty.reason };
60
+ return {
61
+ snapshot: {
62
+ head: head.head,
63
+ ...(head.reason !== "ok" ? { head_reason: head.reason } : {}),
64
+ dirty_files: dirty.files,
65
+ },
66
+ };
91
67
  }
92
- function formatPendingTaskMessage(ids, action) {
93
- return `尚有未完成任务:${ids.join(", ")};${action}`;
68
+ function boundarySnapshotPayload(projectRoot) {
69
+ const result = boundarySnapshotResult(projectRoot);
70
+ return {
71
+ boundary_snapshot: result.snapshot,
72
+ ...(result.reason ? { boundary_snapshot_reason: result.reason } : {}),
73
+ };
74
+ }
75
+ function parseScopeNoteInput(inputContent) {
76
+ if (inputContent == null)
77
+ return { ok: true, value: null, digest: null };
78
+ const digest = sha256Text(inputContent);
79
+ if (inputContent.trim() === "")
80
+ return { ok: false, message: "范围扩大说明(scope_note)输入不能为空", digest };
81
+ let parsed;
82
+ try {
83
+ parsed = JSON.parse(inputContent);
84
+ }
85
+ catch {
86
+ return { ok: false, message: "范围扩大说明(scope_note)输入必须是有效 JSON", digest };
87
+ }
88
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
89
+ return { ok: false, message: "范围扩大说明(scope_note)输入顶层必须是 JSON object", digest };
90
+ }
91
+ const obj = parsed;
92
+ const keys = Object.keys(obj);
93
+ if (keys.some(key => key !== "scope_note")) {
94
+ return { ok: false, message: "范围扩大说明(scope_note)输入包含未知顶层字段", digest };
95
+ }
96
+ const note = obj.scope_note;
97
+ if (!note || typeof note !== "object" || Array.isArray(note)) {
98
+ return { ok: false, message: "范围扩大说明(scope_note)必须是对象", digest };
99
+ }
100
+ const noteObj = note;
101
+ const allowed = new Set(["reason", "changed_area", "plan_alignment", "verification"]);
102
+ if (Object.keys(noteObj).some(key => !allowed.has(key))) {
103
+ return { ok: false, message: "范围扩大说明(scope_note)包含未知字段", digest };
104
+ }
105
+ for (const field of ["reason", "changed_area", "plan_alignment"]) {
106
+ if (typeof noteObj[field] !== "string" || noteObj[field].trim() === "") {
107
+ return { ok: false, message: `范围扩大说明里的 ${field} 字段(scope_note.${field})必须是非空字符串`, digest };
108
+ }
109
+ }
110
+ if (!Array.isArray(noteObj.verification) || noteObj.verification.length === 0 || !noteObj.verification.every(item => typeof item === "string" && item.trim() !== "")) {
111
+ return { ok: false, message: "范围扩大说明里的验证字段(scope_note.verification)必须是非空字符串数组", digest };
112
+ }
113
+ return {
114
+ ok: true,
115
+ digest,
116
+ value: {
117
+ reason: noteObj.reason,
118
+ changed_area: noteObj.changed_area,
119
+ plan_alignment: noteObj.plan_alignment,
120
+ verification: noteObj.verification,
121
+ },
122
+ };
123
+ }
124
+ function validateTaskStartContract(changeRoot, taskId, tddRequired, parsedContract) {
125
+ if (!parsedContract)
126
+ return null;
127
+ if (parsedContract.errors.length > 0)
128
+ return parsedContract.errors.join(";");
129
+ if (tddRequired && !isReviewFixTaskId(taskId) && parsedContract.contract.tests.length === 0) {
130
+ return `${taskId} 是普通 TDD 任务,执行依据缺少测试`;
131
+ }
132
+ if (parsedContract.contract.tests.length === 0)
133
+ return null;
134
+ const testContractPath = join(changeRoot, ".superspec", "artifacts", "test-contract.md");
135
+ if (!existsSync(testContractPath))
136
+ return "test-contract.md 不存在,无法校验执行依据测试引用";
137
+ const parsed = parseTestContractEntries(readFileSync(testContractPath, "utf8"));
138
+ if (!parsed.ok)
139
+ return parsed.message;
140
+ const known = new Set(parsed.entries.map(entry => entry.test_id));
141
+ const missing = parsedContract.contract.tests.filter(testId => !known.has(testId));
142
+ return missing.length > 0 ? `执行依据引用了不存在的 TEST ID:${missing.join(", ")}` : null;
94
143
  }
95
144
  function hasRejectedReviewReadyVerifier(events) {
96
145
  const reviewReadyVerifierIds = new Set();
@@ -106,9 +155,10 @@ function hasRejectedReviewReadyVerifier(events) {
106
155
  return events.some(ev => ev.event_type === "job_rejected" &&
107
156
  reviewReadyVerifierIds.has(ev.payload.job_id ?? ""));
108
157
  }
109
- function createCodeReviewerJob(change, projectRoot, events) {
110
- const scan = scanCodeChanges(projectRoot);
158
+ function createCodeReviewerJob(change, projectRoot, changeRoot, events) {
159
+ const scan = scanCodeChangesForReview(projectRoot, events);
111
160
  const boundFiles = codeReviewBoundFiles(projectRoot, scan.paths);
161
+ const packetContext = codeReviewPacketContext(changeRoot, projectRoot, scan.scope, events);
112
162
  const facts = collectCodeReviewGateFacts(events);
113
163
  const latestRejected = facts.latestRejected;
114
164
  const reviewFailedStatus = latestCodeReviewFailedStatus(events);
@@ -126,6 +176,7 @@ function createCodeReviewerJob(change, projectRoot, events) {
126
176
  gate_id: REVIEW_CODE_REVIEW_GATE_ID,
127
177
  boundFiles,
128
178
  checkedDocs: REVIEW_DOC_PATHS,
179
+ packet_context: packetContext,
129
180
  created_from_transition: "review-ready",
130
181
  ...(previousRejection ? { previous_rejection: previousRejection } : {}),
131
182
  };
@@ -137,6 +188,7 @@ function createCodeReviewerJob(change, projectRoot, events) {
137
188
  state: "requested",
138
189
  gate_id: REVIEW_CODE_REVIEW_GATE_ID,
139
190
  boundFiles,
191
+ packet_context: packetContext,
140
192
  packet_digest: codeReviewPacketDigest(packetInput),
141
193
  created_from_transition: "review-ready",
142
194
  created_at: new Date().toISOString(),
@@ -179,14 +231,23 @@ function appendReviewFixTask(changeRoot, ref, finding) {
179
231
  writeFileSync(tasksPath, `${content}${suffix}${line}\n`);
180
232
  return "created";
181
233
  }
182
- function isFreshOpenCodeReviewerJob(job, projectRoot) {
183
- return codeReviewJobStaleReason(projectRoot, job) == null;
234
+ function isFreshOpenCodeReviewerJob(job, projectRoot, currentWorkingPaths) {
235
+ return codeReviewJobStaleReason(projectRoot, job, currentWorkingPaths) == null;
236
+ }
237
+ function hasFrozenCodeReviewCurrentHead(scope) {
238
+ if (!scope || typeof scope !== "object")
239
+ return false;
240
+ if (!Object.prototype.hasOwnProperty.call(scope, "current_head"))
241
+ return false;
242
+ const currentHead = scope.current_head;
243
+ return currentHead === null || (typeof currentHead === "string" && currentHead.trim() !== "");
184
244
  }
185
245
  function evaluateApplyDoneCodeReviewGate(input) {
186
- const scan = scanCodeChanges(input.projectRoot);
246
+ const scan = scanCodeChangesForReview(input.projectRoot, input.events);
187
247
  const facts = collectCodeReviewGateFacts(input.events);
188
248
  if (scan.hasCodeChanges) {
189
- const freshOpenJobs = facts.openJobs.filter(job => isFreshOpenCodeReviewerJob(job, input.projectRoot));
249
+ const currentWorkingPaths = currentCodeReviewWorkingPaths(input.projectRoot, input.events);
250
+ const freshOpenJobs = facts.openJobs.filter(job => isFreshOpenCodeReviewerJob(job, input.projectRoot, currentWorkingPaths));
190
251
  if (freshOpenJobs.length > 0) {
191
252
  return {
192
253
  blocked: true,
@@ -196,6 +257,17 @@ function evaluateApplyDoneCodeReviewGate(input) {
196
257
  }
197
258
  const latest = facts.latestTerminal;
198
259
  if (latest?.state === "accepted") {
260
+ const acceptedScope = latest.job.packet_context?.code_review_scope;
261
+ if (!hasFrozenCodeReviewCurrentHead(acceptedScope)) {
262
+ const { job, scanReason } = createCodeReviewerJob(input.change, input.projectRoot, input.changeRoot, input.events);
263
+ return {
264
+ fromState: "apply_done",
265
+ toState: "apply_done",
266
+ outcome: "job_created",
267
+ newJobs: [job],
268
+ reason: `已接受代码审查工作项缺少冻结 current_head,重新创建代码审查工作项;${scanReason}`,
269
+ };
270
+ }
199
271
  return {
200
272
  fromState: "apply_done",
201
273
  toState: "review",
@@ -203,14 +275,19 @@ function evaluateApplyDoneCodeReviewGate(input) {
203
275
  reason: "代码审查已通过,进入最终审查阶段",
204
276
  commitPayload: {
205
277
  ...input.policyPayload,
206
- code_review_gate: { decision: "passed", job_id: latest.job.job_id },
278
+ code_review_gate: {
279
+ decision: "passed",
280
+ job_id: latest.job.job_id,
281
+ packet_digest: latest.job.packet_digest,
282
+ current_head: acceptedScope.current_head,
283
+ },
207
284
  },
208
285
  };
209
286
  }
210
287
  if (latest?.state === "rejected" && latest.result_kind === "review_failed") {
211
288
  const reviewFailedStatus = latestCodeReviewFailedStatus(input.events);
212
289
  if (reviewFailedStatus && reviewFailedStatus.findings.length > 0 && reviewFailedStatus.unresolved.length === 0) {
213
- const { job, scanReason } = createCodeReviewerJob(input.change, input.projectRoot, input.events);
290
+ const { job, scanReason } = createCodeReviewerJob(input.change, input.projectRoot, input.changeRoot, input.events);
214
291
  return {
215
292
  fromState: "apply_done",
216
293
  toState: "apply_done",
@@ -224,7 +301,7 @@ function evaluateApplyDoneCodeReviewGate(input) {
224
301
  message: "代码审查发现需要处理的问题,请先执行 next,根据提示回到实现阶段修复或让使用者决定是否回到计划阶段",
225
302
  };
226
303
  }
227
- const { job, scanReason } = createCodeReviewerJob(input.change, input.projectRoot, input.events);
304
+ const { job, scanReason } = createCodeReviewerJob(input.change, input.projectRoot, input.changeRoot, input.events);
228
305
  return {
229
306
  fromState: "apply_done",
230
307
  toState: "apply_done",
@@ -242,12 +319,19 @@ function evaluateApplyDoneCodeReviewGate(input) {
242
319
  reason: "没有代码类改动,直接进入最终审查阶段",
243
320
  commitPayload: {
244
321
  ...input.policyPayload,
245
- code_review_gate: { decision: "skipped", reason: "no_code_changes" },
322
+ code_review_gate: {
323
+ decision: "skipped",
324
+ reason: "no_code_changes",
325
+ head: scan.scope?.current_head ?? null,
326
+ },
246
327
  },
247
328
  };
248
329
  }
249
- function createFinalVerifierJob(change, changeRoot, currentEvidenceDigest) {
330
+ function createFinalVerifierJob(change, projectRoot, changeRoot, events, currentEvidenceDigest) {
250
331
  const boundFiles = reviewBoundFiles(changeRoot);
332
+ const packetContext = {
333
+ code_state_check: computeCodeStateCheck(projectRoot, events),
334
+ };
251
335
  return {
252
336
  job_id: newJobId(change, "verifier"),
253
337
  role: "verifier",
@@ -255,11 +339,13 @@ function createFinalVerifierJob(change, changeRoot, currentEvidenceDigest) {
255
339
  gate_id: REVIEW_FINAL_VERIFIER_GATE_ID,
256
340
  boundFiles,
257
341
  review_evidence_digest: currentEvidenceDigest,
342
+ packet_context: packetContext,
258
343
  packet_digest: sha256Text(JSON.stringify({
259
344
  role: "verifier",
260
345
  gate_id: REVIEW_FINAL_VERIFIER_GATE_ID,
261
346
  boundFiles,
262
347
  review_evidence_digest: currentEvidenceDigest,
348
+ packet_context: packetContext,
263
349
  created_from_transition: "review-ready",
264
350
  })),
265
351
  created_from_transition: "review-ready",
@@ -275,7 +361,7 @@ function evaluateFinalVerifierGate(input) {
275
361
  jobs: [verifierOpen],
276
362
  };
277
363
  }
278
- const verifierAccepted = input.snapshot.accepted_jobs.find(job => isFreshReviewVerifier(job, input.changeRoot, input.currentEvidenceDigest));
364
+ const verifierAccepted = input.snapshot.accepted_jobs.find(job => isFreshReviewVerifier(job, input.changeRoot, input.currentEvidenceDigest, input.projectRoot, input.events));
279
365
  const finalVerifierRequired = requiresFinalVerifierForCurrentReview(input.events) || input.policy.requires_verifier;
280
366
  if (!finalVerifierRequired) {
281
367
  if (!input.hasStoredPolicy) {
@@ -291,7 +377,7 @@ function evaluateFinalVerifierGate(input) {
291
377
  }
292
378
  if (!verifierAccepted) {
293
379
  const previousVerifierRejected = hasRejectedReviewReadyVerifier(input.events);
294
- const job = createFinalVerifierJob(input.change, input.changeRoot, input.currentEvidenceDigest);
380
+ const job = createFinalVerifierJob(input.change, input.projectRoot, input.changeRoot, input.events, input.currentEvidenceDigest);
295
381
  return {
296
382
  fromState: input.snapshot.state,
297
383
  toState: input.snapshot.state,
@@ -317,31 +403,23 @@ function evaluateFinalVerifierGate(input) {
317
403
  }
318
404
  return { skip: true, message: "已在 review 状态,最终验证仍然有效" };
319
405
  }
320
- function documentBaseline(changeRoot) {
321
- const docs = ["proposal.md", "design.md", "tasks.md", ".superspec/artifacts/test-contract.md"];
322
- const baseline = {};
323
- for (const doc of docs) {
324
- baseline[doc] = sha256File(join(changeRoot, doc)) ?? "sha256:missing";
325
- }
326
- return baseline;
327
- }
328
- function latestReopenProposeBaseline(events) {
329
- for (let i = events.length - 1; i >= 0; i--) {
330
- const ev = events[i];
331
- if (ev.event_type !== "transition_commit")
332
- continue;
333
- const payload = ev.payload;
334
- if (payload.transition !== "reopen" || payload.reopen_target !== "propose")
335
- continue;
336
- if (!payload.baseline_docs || typeof payload.baseline_docs !== "object" || Array.isArray(payload.baseline_docs))
337
- return null;
338
- return payload.baseline_docs;
406
+ function transitionPlanToDecision(snapshot, changeRoot, change, plan) {
407
+ switch (plan.kind) {
408
+ case "skip":
409
+ return { skip: true, message: plan.message };
410
+ case "blocked":
411
+ return { blocked: true, reason: plan.reason, jobs: plan.jobs };
412
+ case "create_gate_jobs":
413
+ return createReviewJobsForGate(snapshot.state, plan.gate, plan.roles, changeRoot, change, plan.reason);
414
+ case "advance":
415
+ return {
416
+ fromState: plan.fromState,
417
+ toState: plan.toState,
418
+ outcome: "advanced",
419
+ reason: plan.reason,
420
+ commitPayload: plan.payload,
421
+ };
339
422
  }
340
- return null;
341
- }
342
- function proposalDocsChangedSinceBaseline(changeRoot, baseline) {
343
- const current = documentBaseline(changeRoot);
344
- return Object.entries(baseline).some(([path, digest]) => current[path] !== digest);
345
423
  }
346
424
  /**
347
425
  * 统一 transition 提交协议——所有校验在锁内。
@@ -433,32 +511,16 @@ export function proposeReady(projectRoot, change, changeRoot, risk = "strict") {
433
511
  return commitTransition(projectRoot, change, changeRoot, {
434
512
  name: "propose-ready", idempotencyInputs: { risk },
435
513
  decide: (snapshot) => {
436
- if (snapshot.state !== "propose")
437
- return { skip: true, message: `当前状态 ${snapshot.state},不能 propose-ready` };
438
- const tasksPath = join(changeRoot, "tasks.md");
439
- if (!existsSync(tasksPath))
440
- return { skip: true, message: "tasks.md 不存在" };
441
- const tasksContent = readFileSync(tasksPath, "utf8");
442
- if (!tasksContent.includes("# Tasks") && !tasksContent.includes("- [ ]"))
443
- return { skip: true, message: "tasks.md 内容不像任务计划文档" };
444
- const openQuestions = collectProposeOpenQuestions(changeRoot);
445
- if (openQuestions.openCount > 0) {
446
- const files = openQuestions.files.map(f => `${f.path}(${f.openCount})`).join(", ");
447
- return { skip: true, message: `计划文档有 ${openQuestions.openCount} 个待用户确认问题:${files}` };
448
- }
449
- if (risk !== "minimal") {
450
- const artifactsDir = join(changeRoot, ".superspec", "artifacts");
451
- for (const doc of ["discovery.md", "business-invariants.md", "test-contract.md"]) {
452
- if (!existsSync(join(artifactsDir, doc)))
453
- return { skip: true, message: `基础职责缺失:${doc} 不存在(risk=${risk} 需要)` };
454
- }
455
- }
456
- // 通用 job 检查(用提取的 helper)
457
- const requiredRoles = PROPOSE_FINAL_REVIEW_GATE.requiredRolesForRisk(risk);
458
- const reviewResult = checkOrCreateReviewJobs(snapshot, PROPOSE_FINAL_REVIEW_GATE, requiredRoles, changeRoot, change);
459
- if (reviewResult)
460
- return reviewResult;
461
- return { fromState: "propose", toState: "propose_ready", outcome: "advanced", reason: `risk=${risk},所有需求已满足` };
514
+ const events = readEvents(projectRoot, change);
515
+ const plan = planTransition("propose-ready", {
516
+ projectRoot,
517
+ change,
518
+ changeRoot,
519
+ events,
520
+ snapshot,
521
+ mode: { kind: "risk", risk },
522
+ });
523
+ return transitionPlanToDecision(snapshot, changeRoot, change, plan);
462
524
  },
463
525
  });
464
526
  }
@@ -479,24 +541,16 @@ export function transitionExplore(projectRoot, change, changeRoot, risk = "stric
479
541
  return commitTransition(projectRoot, change, changeRoot, {
480
542
  name: "explore", idempotencyInputs: { phase: "explore", risk },
481
543
  decide: (snapshot) => {
482
- if (snapshot.state === "init")
483
- return { fromState: "init", toState: "explore", outcome: "advanced", reason: "进入探索阶段" };
484
- if (snapshot.state === "explore") {
485
- const discoveryPath = join(changeRoot, ".superspec", "artifacts", "discovery.md");
486
- if (!existsSync(discoveryPath))
487
- return { skip: true, message: "discovery.md 不存在" };
488
- // explore→propose:校验 discovery + strict 模式下的 critic 审查
489
- const discoveryCheck = validateDiscovery(changeRoot);
490
- if (!discoveryCheck.ok)
491
- return { skip: true, message: discoveryCheck.message };
492
- // 通用 job 审查(和 propose-ready 同一个 helper)
493
- const requiredRoles = EXPLORE_DISCOVERY_REVIEW_GATE.requiredRolesForRisk(risk);
494
- const reviewResult = checkOrCreateReviewJobs(snapshot, EXPLORE_DISCOVERY_REVIEW_GATE, requiredRoles, changeRoot, change);
495
- if (reviewResult)
496
- return reviewResult;
497
- return { fromState: "explore", toState: "propose", outcome: "advanced", reason: "探索完成" };
498
- }
499
- return { skip: true, message: `当前状态 ${snapshot.state},explore 不适用` };
544
+ const events = readEvents(projectRoot, change);
545
+ const plan = planTransition("explore", {
546
+ projectRoot,
547
+ change,
548
+ changeRoot,
549
+ events,
550
+ snapshot,
551
+ mode: { kind: "risk", risk },
552
+ });
553
+ return transitionPlanToDecision(snapshot, changeRoot, change, plan);
500
554
  },
501
555
  });
502
556
  }
@@ -505,24 +559,16 @@ export function startApply(projectRoot, change, changeRoot) {
505
559
  return commitTransition(projectRoot, change, changeRoot, {
506
560
  name: "start-apply", idempotencyInputs: { phase: "start-apply" },
507
561
  decide: (snapshot) => {
508
- if (snapshot.state !== "propose_ready")
509
- return { skip: true, message: `当前状态 ${snapshot.state},需要 propose_ready` };
510
562
  const events = readEvents(projectRoot, change);
511
- const reopenBaseline = latestReopenProposeBaseline(events);
512
- if (reopenBaseline && !proposalDocsChangedSinceBaseline(changeRoot, reopenBaseline)) {
513
- return { skip: true, message: "回到 propose 后 proposal/design/tasks/test-contract 至少一个文档必须变化" };
514
- }
515
- const reviewedRoles = historicalProposeReadyRoles(projectRoot, change);
516
- if (reviewedRoles.length > 0) {
517
- const reviewResult = checkOrCreateReviewJobs(snapshot, PROPOSE_FINAL_REVIEW_GATE, reviewedRoles, changeRoot, change);
518
- if (reviewResult) {
519
- return {
520
- ...reviewResult,
521
- reason: `进入执行阶段前需要重新完成计划文档审查:${reviewResult.reason}`,
522
- };
523
- }
524
- }
525
- return { fromState: "propose_ready", toState: "apply", outcome: "advanced", reason: "进入执行阶段" };
563
+ const plan = planTransition("start-apply", {
564
+ projectRoot,
565
+ change,
566
+ changeRoot,
567
+ events,
568
+ snapshot,
569
+ mode: { kind: "risk", risk: "strict" },
570
+ });
571
+ return transitionPlanToDecision(snapshot, changeRoot, change, plan);
526
572
  },
527
573
  });
528
574
  }
@@ -534,31 +580,64 @@ export function taskStart(projectRoot, change, changeRoot, taskId) {
534
580
  decide: (snapshot) => {
535
581
  if (snapshot.state !== "apply")
536
582
  return { skip: true, message: `当前状态 ${snapshot.state},需要 apply` };
583
+ const events = readEvents(projectRoot, change);
537
584
  const tasksContent = readFileSync(join(changeRoot, "tasks.md"), "utf8");
538
585
  const lines = tasksContent.split("\n");
539
586
  const taskLineIdx = findTaskLine(lines, taskId);
540
587
  if (taskLineIdx < 0)
541
588
  return { skip: true, message: `任务 ${taskId} 不存在` };
542
- if (lines[taskLineIdx].match(/- \[x\]/))
589
+ const contractMode = applyRequirementModeForCurrentRound(events);
590
+ if (contractMode && pendingTaskStatusForApply(changeRoot, events).completedByEvent.includes(taskId)) {
591
+ return { skip: true, message: `任务 ${taskId} 已通过完成事件完成` };
592
+ }
593
+ if (lines[taskLineIdx].match(/- \[[xX]\]/))
543
594
  return { skip: true, message: `任务 ${taskId} 已完成` };
595
+ const taskInfo = parseTasksMd(tasksContent).find(task => task.taskId === taskId);
596
+ if (!taskInfo)
597
+ return { skip: true, message: `任务 ${taskId} 不存在` };
544
598
  const existing = snapshot.active_task_attempts?.find(a => a.task_id === taskId && a.state === "active");
545
599
  if (existing)
546
600
  return { skip: true, message: `任务 ${taskId} 已有活跃尝试` };
601
+ // 统一出口:adopted.contract 非 null 当且仅当契约模式下有绑定块;
602
+ // legacy 轮即使 task 带执行依据文本也输出 null,避免"看似契约、实按 legacy 校验"的误导态
603
+ const adopted = adoptedContractForTask(tasksContent, taskId, contractMode);
604
+ if (contractMode && taskInfo.tddRequired && !isReviewFixTaskId(taskId) && !adopted.parsed) {
605
+ return { skip: true, message: `执行依据模式下,普通 TDD 任务 ${taskId} 缺少执行依据` };
606
+ }
607
+ if (contractMode) {
608
+ const contractError = validateTaskStartContract(changeRoot, taskId, taskInfo.tddRequired, adopted.parsed);
609
+ if (contractError)
610
+ return { skip: true, message: contractError };
611
+ }
547
612
  const structureDigest = sha256Text(tasksContent.replace(/- \[[xX]\]/g, "- [ ]"));
548
613
  const attempt = {
549
614
  attempt_id: `ATT-${taskId}-${Date.now()}-${++attemptSeq}`,
550
615
  task_id: taskId, state: "active",
551
616
  task_structure_digest: structureDigest,
617
+ contract_mode: contractMode,
618
+ contract: adopted.contract,
619
+ tdd_required: taskInfo.tddRequired,
620
+ no_tdd_reason: taskInfo.noTddReason,
552
621
  declared_write_scope: [], pre_edit_source_fingerprint: null,
553
622
  pre_edit_red_ref: null, executor_packet_digest: null,
554
623
  executor_result_ref: null, post_edit_green_ref: null,
555
624
  created_at: new Date().toISOString(),
556
625
  };
626
+ const eventPayload = {
627
+ ...attempt,
628
+ ...boundarySnapshotPayload(projectRoot),
629
+ };
557
630
  return {
558
631
  fromState: "apply", toState: "apply", outcome: "advanced",
559
632
  reason: `创建任务 ${taskId} 执行尝试`,
560
- extraEvents: [{ type: "task_started", payload: attempt }],
561
- details: { attempt_id: attempt.attempt_id },
633
+ extraEvents: [{ type: "task_started", payload: eventPayload }],
634
+ details: {
635
+ attempt_id: attempt.attempt_id,
636
+ task_id: taskId,
637
+ contract: adopted.contract,
638
+ // legacy 轮统一标注历史模式(无论有无执行依据文本);契约轮无块时标 false(如 REVIEW-FIX)
639
+ ...(adopted.contract ? {} : { legacy_contract: !contractMode }),
640
+ },
562
641
  };
563
642
  },
564
643
  });
@@ -599,7 +678,7 @@ export function reopen(projectRoot, change, changeRoot, to, reason, opts = {}) {
599
678
  source_job_id: ref.jobId,
600
679
  finding_id: ref.findingId,
601
680
  decision_scope: scope,
602
- baseline_docs: documentBaseline(changeRoot),
681
+ baseline_docs: proposalDocsBaseline(changeRoot),
603
682
  },
604
683
  };
605
684
  }
@@ -644,7 +723,7 @@ export function reopen(projectRoot, change, changeRoot, to, reason, opts = {}) {
644
723
  if (snapshot.state !== "apply_done" && snapshot.state !== "review") {
645
724
  return { skip: true, message: `当前状态 ${snapshot.state},不能 reopen 到 apply` };
646
725
  }
647
- const pending = pendingTaskIds(changeRoot);
726
+ const pending = pendingTaskStatusForApply(changeRoot, events).pending;
648
727
  if (pending.length === 0)
649
728
  return { skip: true, message: "没有未完成任务,不能 reopen 到 apply" };
650
729
  return {
@@ -667,9 +746,18 @@ export function reviewReady(projectRoot, change, changeRoot, risk = "strict") {
667
746
  const policyPayload = storedPolicy ? {} : { review_policy: policy };
668
747
  const currentEvidenceDigest = reviewEvidenceDigest(events);
669
748
  // 检查是否所有任务已完成
670
- const pending = pendingTaskIds(changeRoot);
749
+ const pending = pendingTaskStatusForApply(changeRoot, events).pending;
671
750
  if (pending.length > 0)
672
751
  return { skip: true, message: formatPendingTaskMessage(pending, "请先通过 next/reopen 继续执行") };
752
+ if (applyRequirementModeForCurrentRound(events)) {
753
+ const missingExemptions = missingCoverageExemptionTestIds(changeRoot, events);
754
+ if (missingExemptions.length > 0) {
755
+ return {
756
+ skip: true,
757
+ message: `test-contract 中存在未绑定任务且缺少覆盖豁免的 TEST:${missingExemptions.join(", ")};请先向用户确认原因(不要代替用户决策),再执行 superspec record user-decision --change "${change}" --input - 登记,stdin 传入 JSON:{"scope":"test_coverage_exemption:${missingExemptions[0]}","question":"<向用户提出的问题>","answer":"<用户给出的豁免原因>"}`,
758
+ };
759
+ }
760
+ }
673
761
  // 如果当前是 apply,先推进到 apply_done
674
762
  if (snapshot.state === "apply") {
675
763
  return {
@@ -682,6 +770,7 @@ export function reviewReady(projectRoot, change, changeRoot, risk = "strict") {
682
770
  return evaluateApplyDoneCodeReviewGate({
683
771
  events,
684
772
  projectRoot,
773
+ changeRoot,
685
774
  change,
686
775
  policyPayload,
687
776
  });
@@ -691,6 +780,7 @@ export function reviewReady(projectRoot, change, changeRoot, risk = "strict") {
691
780
  snapshot,
692
781
  events,
693
782
  change,
783
+ projectRoot,
694
784
  changeRoot,
695
785
  currentEvidenceDigest,
696
786
  policy,
@@ -707,22 +797,16 @@ export function accept(projectRoot, change, changeRoot) {
707
797
  return commitTransition(projectRoot, change, changeRoot, {
708
798
  name: "accept", idempotencyInputs: { phase: "accept" },
709
799
  decide: (snapshot) => {
710
- if (snapshot.state !== "review")
711
- return { skip: true, message: `当前状态 ${snapshot.state},需要 review` };
712
- const pending = pendingTaskIds(changeRoot);
713
- if (pending.length > 0)
714
- return { skip: true, message: formatPendingTaskMessage(pending, "请先 reopen --to apply 继续执行") };
715
800
  const events = readEvents(projectRoot, change);
716
- const policy = readReviewPolicyFromEvents(events);
717
- if (!policy)
718
- return { skip: true, message: "缺少审查策略,请先运行 review-ready" };
719
- if (requiresFinalVerifierForCurrentReview(events) || policy.requires_verifier) {
720
- const currentEvidenceDigest = reviewEvidenceDigest(events);
721
- const verifierAccepted = snapshot.accepted_jobs.find(job => isFreshReviewVerifier(job, changeRoot, currentEvidenceDigest));
722
- if (!verifierAccepted)
723
- return { skip: true, message: "缺少仍然匹配当前证据的最终验证,请先运行 review-ready" };
724
- }
725
- return { fromState: "review", toState: "accepted", outcome: "advanced", reason: "审查通过" };
801
+ const plan = planTransition("accept", {
802
+ projectRoot,
803
+ change,
804
+ changeRoot,
805
+ events,
806
+ snapshot,
807
+ mode: { kind: "risk", risk: "strict" },
808
+ });
809
+ return transitionPlanToDecision(snapshot, changeRoot, change, plan);
726
810
  },
727
811
  });
728
812
  }
@@ -739,13 +823,10 @@ export function archive(projectRoot, change, changeRoot) {
739
823
  for (const p of docPaths) {
740
824
  manifest[p] = sha256File(join(changeRoot, p)) ?? "sha256:missing";
741
825
  }
742
- // specs/ 目录
826
+ // specs/ 目录:递归收录 .md(覆盖 specs/<capability>/spec.md 布局)
743
827
  const specsDir = join(changeRoot, "specs");
744
- if (existsSync(specsDir)) {
745
- for (const f of readdirSync(specsDir)) {
746
- if (f.endsWith(".md"))
747
- manifest[`specs/${f}`] = sha256File(join(specsDir, f)) ?? "sha256:missing";
748
- }
828
+ for (const rel of listMarkdownFiles(specsDir)) {
829
+ manifest[`specs/${rel}`] = sha256File(join(specsDir, rel)) ?? "sha256:missing";
749
830
  }
750
831
  return {
751
832
  fromState: "accepted", toState: "archive", outcome: "advanced",
@@ -756,10 +837,13 @@ export function archive(projectRoot, change, changeRoot) {
756
837
  });
757
838
  }
758
839
  // ===== task-complete =====
759
- export function taskComplete(projectRoot, change, changeRoot, taskId) {
840
+ export function taskComplete(projectRoot, change, changeRoot, taskId, inputContent = null) {
841
+ const scopeInput = parseScopeNoteInput(inputContent);
760
842
  return commitTransition(projectRoot, change, changeRoot, {
761
- name: "task-complete", idempotencyInputs: { task: taskId, phase: "complete" },
843
+ name: "task-complete", idempotencyInputs: { task: taskId, phase: "complete", scope_note_digest: scopeInput.digest ?? "" },
762
844
  decide: (snapshot) => {
845
+ if (!scopeInput.ok)
846
+ return { skip: true, message: scopeInput.message };
763
847
  if (snapshot.state !== "apply")
764
848
  return { skip: true, message: `当前状态 ${snapshot.state},需要 apply` };
765
849
  const attempt = snapshot.active_task_attempts?.find(a => a.task_id === taskId && a.state === "active");
@@ -768,28 +852,35 @@ export function taskComplete(projectRoot, change, changeRoot, taskId) {
768
852
  const readiness = taskEvidenceReadiness(projectRoot, change, changeRoot, attempt);
769
853
  if (!readiness.ready)
770
854
  return { skip: true, message: `任务 ${taskId} 无法完成:${readiness.reason}` };
771
- // B1 修复:checkbox 写入移到 postCommit(commit 事件写入后执行)
855
+ const completedPayload = {
856
+ task_id: taskId,
857
+ attempt_id: attempt.attempt_id,
858
+ ...boundarySnapshotPayload(projectRoot),
859
+ checkbox_update: { status: "pending" },
860
+ ...(scopeInput.value ? { scope_note: scopeInput.value } : {}),
861
+ };
772
862
  return {
773
863
  fromState: "apply", toState: "apply", outcome: "advanced",
774
864
  reason: `任务 ${taskId} 完成`,
775
- extraEvents: [{ type: "task_completed", payload: { task_id: taskId, attempt_id: attempt.attempt_id } }],
865
+ extraEvents: [{ type: "task_completed", payload: completedPayload }],
776
866
  postCommit: (_pr, _ch, cr) => {
777
867
  const lines = readFileSync(join(cr, "tasks.md"), "utf8").split("\n");
778
868
  const idx = findTaskLine(lines, taskId);
779
- if (idx < 0 || !lines[idx].match(/- \[ \]/))
780
- throw new Error(`找不到 ${taskId} 的未完成复选框`);
781
- // H2 修复:应用前验证结构指纹
782
- const beforeDigest = sha256Text(lines.join("\n").replace(/- \[[xX]\]/g, "- [ ]"));
869
+ if (idx < 0) {
870
+ completedPayload.checkbox_update = { status: "failed", reason: `找不到 ${taskId} 的任务行` };
871
+ return;
872
+ }
873
+ if (lines[idx].match(/- \[[xX]\]/)) {
874
+ completedPayload.checkbox_update = { status: "applied" };
875
+ return;
876
+ }
877
+ if (!lines[idx].match(/- \[ \]/)) {
878
+ completedPayload.checkbox_update = { status: "failed", reason: `找不到 ${taskId} 的未完成复选框` };
879
+ return;
880
+ }
783
881
  lines[idx] = lines[idx].replace(/- \[ \]/, "- [x]");
784
882
  writeFileSync(join(cr, "tasks.md"), lines.join("\n"));
785
- // H2 修复:应用后验证只有目标变了
786
- const afterLines = readFileSync(join(cr, "tasks.md"), "utf8").split("\n");
787
- const afterDigest = sha256Text(afterLines.join("\n").replace(/- \[[xX]\]/g, "- [ ]"));
788
- if (afterDigest !== beforeDigest) {
789
- afterLines[idx] = afterLines[idx].replace(/- \[x\]/, "- [ ]");
790
- writeFileSync(join(cr, "tasks.md"), afterLines.join("\n"));
791
- throw new Error(`复选框补丁导致结构变化:${taskId}`);
792
- }
883
+ completedPayload.checkbox_update = { status: "applied" };
793
884
  },
794
885
  };
795
886
  },