@peterxiaoyang/superspec 0.1.37 → 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 (41) hide show
  1. package/dist/cli.js +45 -6
  2. package/dist/code_review.d.ts +18 -2
  3. package/dist/code_review.js +473 -81
  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 +16 -0
  9. package/dist/job_validity.js +37 -0
  10. package/dist/next.js +45 -355
  11. package/dist/phase_plan.d.ts +97 -0
  12. package/dist/phase_plan.js +582 -0
  13. package/dist/record.d.ts +2 -2
  14. package/dist/record.js +67 -31
  15. package/dist/review.d.ts +3 -2
  16. package/dist/review.js +66 -33
  17. package/dist/review_job_gates.d.ts +20 -0
  18. package/dist/review_job_gates.js +85 -0
  19. package/dist/store.d.ts +10 -0
  20. package/dist/store.js +53 -3
  21. package/dist/sync.js +7 -9
  22. package/dist/task.js +87 -9
  23. package/dist/task_evidence.d.ts +10 -0
  24. package/dist/task_evidence.js +126 -0
  25. package/dist/transition.d.ts +1 -1
  26. package/dist/transition.js +449 -337
  27. package/dist/types.d.ts +81 -1
  28. package/dist/workflow_profile.d.ts +11 -0
  29. package/dist/workflow_profile.js +39 -0
  30. package/package.json +1 -1
  31. package/templates/workflow/prompts/architect.md +17 -27
  32. package/templates/workflow/prompts/code-reviewer.md +13 -2
  33. package/templates/workflow/prompts/critic.md +62 -61
  34. package/templates/workflow/prompts/executor.md +1 -1
  35. package/templates/workflow/prompts/explore.md +38 -26
  36. package/templates/workflow/prompts/test-engineer.md +18 -32
  37. package/templates/workflow/prompts/verifier.md +5 -3
  38. package/templates/workflow/skills/superspec-apply/SKILL.md +34 -11
  39. package/templates/workflow/skills/superspec-explore/SKILL.md +65 -64
  40. package/templates/workflow/skills/superspec-propose/SKILL.md +64 -43
  41. package/templates/workflow/skills/superspec-review/SKILL.md +1 -1
@@ -1,78 +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 { 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";
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
+ import { taskEvidenceReadiness } from "./task_evidence.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";
10
14
  let transitionSeq = 0;
11
15
  function newTransitionId() { return `T-${Date.now()}-${++transitionSeq}`; }
12
16
  let jobSeq = 0;
13
17
  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
- /**
27
- * 通用 job 检查/创建逻辑——任何 transition 都能调用。
28
- * 检查 requiredRoles 是否有 fresh accepted job;缺则创建新 job。
29
- * 返回 null = 全部满足;返回 Decision = 需要创建 job(状态不变)。
30
- */
31
- function checkOrCreateReviewJobs(snapshot, requiredRoles, changeRoot, change, transitionName, bindDocPaths) {
32
- const staleRoles = [];
33
- for (const role of requiredRoles) {
34
- const openForRole = snapshot.open_jobs.find(j => j.role === role && j.created_from_transition === transitionName);
35
- if (openForRole)
36
- 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);
38
- if (!fresh) {
39
- staleRoles.push({ role, reason: `需求 ${role} 无已接受的工作项` });
40
- }
41
- else {
42
- for (const bf of fresh.boundFiles) {
43
- const currentSha = sha256File(join(changeRoot, bf.path)) ?? "sha256:missing";
44
- if (currentSha !== bf.sha) {
45
- staleRoles.push({ role, reason: `${role} 绑定文件 ${bf.path} 已变化` });
46
- break;
47
- }
48
- }
49
- }
50
- }
51
- if (staleRoles.length > 0) {
52
- 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() };
55
- });
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));
56
24
  return {
57
- fromState: snapshot.state, toState: snapshot.state, outcome: "job_created",
58
- newJobs, reason: staleRoles.map(s => s.reason).join("; "),
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({
31
+ role,
32
+ gate_id: gate.gate_id,
33
+ boundFiles,
34
+ created_from_transition: gate.created_from_transition,
35
+ })),
36
+ created_from_transition: gate.created_from_transition,
37
+ created_at: new Date().toISOString(),
59
38
  };
60
- }
61
- return null; // 全部满足
62
- }
63
- function historicalProposeReadyRoles(projectRoot, change) {
64
- const roles = new Set();
65
- for (const ev of readEvents(projectRoot, change)) {
66
- if (ev.event_type !== "transition_commit")
67
- continue;
68
- const newJobs = ev.payload.new_jobs ?? [];
69
- for (const job of newJobs) {
70
- if (job.created_from_transition === "propose-ready" &&
71
- (job.role === "critic" || job.role === "architect" || job.role === "test-engineer"))
72
- roles.add(job.role);
73
- }
74
- }
75
- return [...roles];
39
+ });
40
+ return {
41
+ fromState: state,
42
+ toState: state,
43
+ outcome: "job_created",
44
+ newJobs,
45
+ reason,
46
+ };
76
47
  }
77
48
  /**
78
49
  * 已迁移到 format.ts:findTaskInLines / parseTasksMd / tasksStructureDigest
@@ -81,12 +52,94 @@ function historicalProposeReadyRoles(projectRoot, change) {
81
52
  function findTaskLine(lines, taskId) {
82
53
  return findTaskInLines(lines, taskId);
83
54
  }
84
- function pendingTaskIds(changeRoot) {
85
- const tasksContent = readFileSync(join(changeRoot, "tasks.md"), "utf8");
86
- 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
+ };
67
+ }
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
+ };
87
74
  }
88
- function formatPendingTaskMessage(ids, action) {
89
- return `尚有未完成任务:${ids.join(", ")};${action}`;
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;
90
143
  }
91
144
  function hasRejectedReviewReadyVerifier(events) {
92
145
  const reviewReadyVerifierIds = new Set();
@@ -102,9 +155,10 @@ function hasRejectedReviewReadyVerifier(events) {
102
155
  return events.some(ev => ev.event_type === "job_rejected" &&
103
156
  reviewReadyVerifierIds.has(ev.payload.job_id ?? ""));
104
157
  }
105
- function createCodeReviewerJob(change, projectRoot, events) {
106
- const scan = scanCodeChanges(projectRoot);
158
+ function createCodeReviewerJob(change, projectRoot, changeRoot, events) {
159
+ const scan = scanCodeChangesForReview(projectRoot, events);
107
160
  const boundFiles = codeReviewBoundFiles(projectRoot, scan.paths);
161
+ const packetContext = codeReviewPacketContext(changeRoot, projectRoot, scan.scope, events);
108
162
  const facts = collectCodeReviewGateFacts(events);
109
163
  const latestRejected = facts.latestRejected;
110
164
  const reviewFailedStatus = latestCodeReviewFailedStatus(events);
@@ -119,8 +173,10 @@ function createCodeReviewerJob(change, projectRoot, events) {
119
173
  : undefined;
120
174
  const packetInput = {
121
175
  role: "code-reviewer",
176
+ gate_id: REVIEW_CODE_REVIEW_GATE_ID,
122
177
  boundFiles,
123
178
  checkedDocs: REVIEW_DOC_PATHS,
179
+ packet_context: packetContext,
124
180
  created_from_transition: "review-ready",
125
181
  ...(previousRejection ? { previous_rejection: previousRejection } : {}),
126
182
  };
@@ -130,7 +186,9 @@ function createCodeReviewerJob(change, projectRoot, events) {
130
186
  job_id: newJobId(change, "code-reviewer"),
131
187
  role: "code-reviewer",
132
188
  state: "requested",
189
+ gate_id: REVIEW_CODE_REVIEW_GATE_ID,
133
190
  boundFiles,
191
+ packet_context: packetContext,
134
192
  packet_digest: codeReviewPacketDigest(packetInput),
135
193
  created_from_transition: "review-ready",
136
194
  created_at: new Date().toISOString(),
@@ -173,34 +231,195 @@ function appendReviewFixTask(changeRoot, ref, finding) {
173
231
  writeFileSync(tasksPath, `${content}${suffix}${line}\n`);
174
232
  return "created";
175
233
  }
176
- function isFreshOpenCodeReviewerJob(job, projectRoot) {
177
- 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() !== "");
178
244
  }
179
- function documentBaseline(changeRoot) {
180
- const docs = ["proposal.md", "design.md", "tasks.md", ".superspec/artifacts/test-contract.md"];
181
- const baseline = {};
182
- for (const doc of docs) {
183
- baseline[doc] = sha256File(join(changeRoot, doc)) ?? "sha256:missing";
245
+ function evaluateApplyDoneCodeReviewGate(input) {
246
+ const scan = scanCodeChangesForReview(input.projectRoot, input.events);
247
+ const facts = collectCodeReviewGateFacts(input.events);
248
+ if (scan.hasCodeChanges) {
249
+ const currentWorkingPaths = currentCodeReviewWorkingPaths(input.projectRoot, input.events);
250
+ const freshOpenJobs = facts.openJobs.filter(job => isFreshOpenCodeReviewerJob(job, input.projectRoot, currentWorkingPaths));
251
+ if (freshOpenJobs.length > 0) {
252
+ return {
253
+ blocked: true,
254
+ reason: `状态未推进;已有待完成代码审查工作项 ${freshOpenJobs[0].job_id}`,
255
+ jobs: [freshOpenJobs[0]],
256
+ };
257
+ }
258
+ const latest = facts.latestTerminal;
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
+ }
271
+ return {
272
+ fromState: "apply_done",
273
+ toState: "review",
274
+ outcome: "advanced",
275
+ reason: "代码审查已通过,进入最终审查阶段",
276
+ commitPayload: {
277
+ ...input.policyPayload,
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
+ },
284
+ },
285
+ };
286
+ }
287
+ if (latest?.state === "rejected" && latest.result_kind === "review_failed") {
288
+ const reviewFailedStatus = latestCodeReviewFailedStatus(input.events);
289
+ if (reviewFailedStatus && reviewFailedStatus.findings.length > 0 && reviewFailedStatus.unresolved.length === 0) {
290
+ const { job, scanReason } = createCodeReviewerJob(input.change, input.projectRoot, input.changeRoot, input.events);
291
+ return {
292
+ fromState: "apply_done",
293
+ toState: "apply_done",
294
+ outcome: "job_created",
295
+ newJobs: [job],
296
+ reason: `重新创建代码审查工作项;${scanReason};上一次阻塞问题已被主流程复核驳回`,
297
+ };
298
+ }
299
+ return {
300
+ skip: true,
301
+ message: "代码审查发现需要处理的问题,请先执行 next,根据提示回到实现阶段修复或让使用者决定是否回到计划阶段",
302
+ };
303
+ }
304
+ const { job, scanReason } = createCodeReviewerJob(input.change, input.projectRoot, input.changeRoot, input.events);
305
+ return {
306
+ fromState: "apply_done",
307
+ toState: "apply_done",
308
+ outcome: "job_created",
309
+ newJobs: [job],
310
+ reason: latest?.state === "rejected"
311
+ ? `重新创建代码审查工作项;上一次报告未被接受,原因:${latest.reason ?? "报告不符合要求"}`
312
+ : `创建代码审查工作项;${scanReason}`,
313
+ };
184
314
  }
185
- return baseline;
315
+ return {
316
+ fromState: "apply_done",
317
+ toState: "review",
318
+ outcome: "advanced",
319
+ reason: "没有代码类改动,直接进入最终审查阶段",
320
+ commitPayload: {
321
+ ...input.policyPayload,
322
+ code_review_gate: {
323
+ decision: "skipped",
324
+ reason: "no_code_changes",
325
+ head: scan.scope?.current_head ?? null,
326
+ },
327
+ },
328
+ };
186
329
  }
187
- function latestReopenProposeBaseline(events) {
188
- for (let i = events.length - 1; i >= 0; i--) {
189
- const ev = events[i];
190
- if (ev.event_type !== "transition_commit")
191
- continue;
192
- const payload = ev.payload;
193
- if (payload.transition !== "reopen" || payload.reopen_target !== "propose")
194
- continue;
195
- if (!payload.baseline_docs || typeof payload.baseline_docs !== "object" || Array.isArray(payload.baseline_docs))
196
- return null;
197
- return payload.baseline_docs;
330
+ function createFinalVerifierJob(change, projectRoot, changeRoot, events, currentEvidenceDigest) {
331
+ const boundFiles = reviewBoundFiles(changeRoot);
332
+ const packetContext = {
333
+ code_state_check: computeCodeStateCheck(projectRoot, events),
334
+ };
335
+ return {
336
+ job_id: newJobId(change, "verifier"),
337
+ role: "verifier",
338
+ state: "requested",
339
+ gate_id: REVIEW_FINAL_VERIFIER_GATE_ID,
340
+ boundFiles,
341
+ review_evidence_digest: currentEvidenceDigest,
342
+ packet_context: packetContext,
343
+ packet_digest: sha256Text(JSON.stringify({
344
+ role: "verifier",
345
+ gate_id: REVIEW_FINAL_VERIFIER_GATE_ID,
346
+ boundFiles,
347
+ review_evidence_digest: currentEvidenceDigest,
348
+ packet_context: packetContext,
349
+ created_from_transition: "review-ready",
350
+ })),
351
+ created_from_transition: "review-ready",
352
+ created_at: new Date().toISOString(),
353
+ };
354
+ }
355
+ function evaluateFinalVerifierGate(input) {
356
+ const verifierOpen = input.snapshot.open_jobs.find(isReviewReadyVerifier);
357
+ if (verifierOpen) {
358
+ return {
359
+ blocked: true,
360
+ reason: `状态未推进;已有待完成最终验证工作项 ${verifierOpen.job_id}`,
361
+ jobs: [verifierOpen],
362
+ };
363
+ }
364
+ const verifierAccepted = input.snapshot.accepted_jobs.find(job => isFreshReviewVerifier(job, input.changeRoot, input.currentEvidenceDigest, input.projectRoot, input.events));
365
+ const finalVerifierRequired = requiresFinalVerifierForCurrentReview(input.events) || input.policy.requires_verifier;
366
+ if (!finalVerifierRequired) {
367
+ if (!input.hasStoredPolicy) {
368
+ return {
369
+ fromState: "review",
370
+ toState: "review",
371
+ outcome: "advanced",
372
+ reason: `补写审查策略=${input.policy.review_risk}`,
373
+ commitPayload: input.policyPayload,
374
+ };
375
+ }
376
+ return { skip: true, message: "已在 review 状态,审查策略无需 verifier" };
198
377
  }
199
- return null;
378
+ if (!verifierAccepted) {
379
+ const previousVerifierRejected = hasRejectedReviewReadyVerifier(input.events);
380
+ const job = createFinalVerifierJob(input.change, input.projectRoot, input.changeRoot, input.events, input.currentEvidenceDigest);
381
+ return {
382
+ fromState: input.snapshot.state,
383
+ toState: input.snapshot.state,
384
+ outcome: "job_created",
385
+ newJobs: [job],
386
+ reason: previousVerifierRejected
387
+ ? "此前最终验证未通过;请先根据验证报告修改任务或文档,确认无需修改时再执行新的最终验证工作项"
388
+ : "创建最终验证工作项",
389
+ commitPayload: input.policyPayload,
390
+ ...(previousVerifierRejected ? {
391
+ details: { advisory: "此前最终验证未通过;请先根据验证报告修改任务或文档,确认无需修改时再执行新的最终验证工作项" },
392
+ } : {}),
393
+ };
394
+ }
395
+ if (!input.hasStoredPolicy) {
396
+ return {
397
+ fromState: "review",
398
+ toState: "review",
399
+ outcome: "advanced",
400
+ reason: `补写审查策略=${input.policy.review_risk}`,
401
+ commitPayload: input.policyPayload,
402
+ };
403
+ }
404
+ return { skip: true, message: "已在 review 状态,最终验证仍然有效" };
200
405
  }
201
- function proposalDocsChangedSinceBaseline(changeRoot, baseline) {
202
- const current = documentBaseline(changeRoot);
203
- return Object.entries(baseline).some(([path, digest]) => current[path] !== digest);
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
+ };
422
+ }
204
423
  }
205
424
  /**
206
425
  * 统一 transition 提交协议——所有校验在锁内。
@@ -292,32 +511,16 @@ export function proposeReady(projectRoot, change, changeRoot, risk = "strict") {
292
511
  return commitTransition(projectRoot, change, changeRoot, {
293
512
  name: "propose-ready", idempotencyInputs: { risk },
294
513
  decide: (snapshot) => {
295
- if (snapshot.state !== "propose")
296
- return { skip: true, message: `当前状态 ${snapshot.state},不能 propose-ready` };
297
- const tasksPath = join(changeRoot, "tasks.md");
298
- if (!existsSync(tasksPath))
299
- return { skip: true, message: "tasks.md 不存在" };
300
- const tasksContent = readFileSync(tasksPath, "utf8");
301
- if (!tasksContent.includes("# Tasks") && !tasksContent.includes("- [ ]"))
302
- return { skip: true, message: "tasks.md 内容不像任务计划文档" };
303
- const openQuestions = collectProposeOpenQuestions(changeRoot);
304
- if (openQuestions.openCount > 0) {
305
- const files = openQuestions.files.map(f => `${f.path}(${f.openCount})`).join(", ");
306
- return { skip: true, message: `计划文档有 ${openQuestions.openCount} 个待用户确认问题:${files}` };
307
- }
308
- if (risk !== "minimal") {
309
- const artifactsDir = join(changeRoot, ".superspec", "artifacts");
310
- for (const doc of ["discovery.md", "business-invariants.md", "test-contract.md"]) {
311
- if (!existsSync(join(artifactsDir, doc)))
312
- return { skip: true, message: `基础职责缺失:${doc} 不存在(risk=${risk} 需要)` };
313
- }
314
- }
315
- // 通用 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"]);
318
- if (reviewResult)
319
- return reviewResult;
320
- 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);
321
524
  },
322
525
  });
323
526
  }
@@ -338,24 +541,16 @@ export function transitionExplore(projectRoot, change, changeRoot, risk = "stric
338
541
  return commitTransition(projectRoot, change, changeRoot, {
339
542
  name: "explore", idempotencyInputs: { phase: "explore", risk },
340
543
  decide: (snapshot) => {
341
- if (snapshot.state === "init")
342
- return { fromState: "init", toState: "explore", outcome: "advanced", reason: "进入探索阶段" };
343
- if (snapshot.state === "explore") {
344
- const discoveryPath = join(changeRoot, ".superspec", "artifacts", "discovery.md");
345
- if (!existsSync(discoveryPath))
346
- return { skip: true, message: "discovery.md 不存在" };
347
- // explore→propose:校验 discovery + strict 模式下的 critic 审查
348
- const discoveryCheck = validateDiscovery(changeRoot);
349
- if (!discoveryCheck.ok)
350
- return { skip: true, message: discoveryCheck.message };
351
- // 通用 job 审查(和 propose-ready 同一个 helper)
352
- const requiredRoles = TRANSITION_REQUIREMENTS["explore"]?.[risk] ?? [];
353
- const reviewResult = checkOrCreateReviewJobs(snapshot, requiredRoles, changeRoot, change, "explore", [".superspec/artifacts/discovery.md"]);
354
- if (reviewResult)
355
- return reviewResult;
356
- return { fromState: "explore", toState: "propose", outcome: "advanced", reason: "探索完成" };
357
- }
358
- 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);
359
554
  },
360
555
  });
361
556
  }
@@ -364,24 +559,16 @@ export function startApply(projectRoot, change, changeRoot) {
364
559
  return commitTransition(projectRoot, change, changeRoot, {
365
560
  name: "start-apply", idempotencyInputs: { phase: "start-apply" },
366
561
  decide: (snapshot) => {
367
- if (snapshot.state !== "propose_ready")
368
- return { skip: true, message: `当前状态 ${snapshot.state},需要 propose_ready` };
369
562
  const events = readEvents(projectRoot, change);
370
- const reopenBaseline = latestReopenProposeBaseline(events);
371
- if (reopenBaseline && !proposalDocsChangedSinceBaseline(changeRoot, reopenBaseline)) {
372
- return { skip: true, message: "回到 propose 后 proposal/design/tasks/test-contract 至少一个文档必须变化" };
373
- }
374
- const reviewedRoles = historicalProposeReadyRoles(projectRoot, change);
375
- 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"]);
377
- if (reviewResult) {
378
- return {
379
- ...reviewResult,
380
- reason: `进入执行阶段前需要重新完成计划文档审查:${reviewResult.reason}`,
381
- };
382
- }
383
- }
384
- 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);
385
572
  },
386
573
  });
387
574
  }
@@ -393,31 +580,64 @@ export function taskStart(projectRoot, change, changeRoot, taskId) {
393
580
  decide: (snapshot) => {
394
581
  if (snapshot.state !== "apply")
395
582
  return { skip: true, message: `当前状态 ${snapshot.state},需要 apply` };
583
+ const events = readEvents(projectRoot, change);
396
584
  const tasksContent = readFileSync(join(changeRoot, "tasks.md"), "utf8");
397
585
  const lines = tasksContent.split("\n");
398
586
  const taskLineIdx = findTaskLine(lines, taskId);
399
587
  if (taskLineIdx < 0)
400
588
  return { skip: true, message: `任务 ${taskId} 不存在` };
401
- 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]\]/))
402
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} 不存在` };
403
598
  const existing = snapshot.active_task_attempts?.find(a => a.task_id === taskId && a.state === "active");
404
599
  if (existing)
405
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
+ }
406
612
  const structureDigest = sha256Text(tasksContent.replace(/- \[[xX]\]/g, "- [ ]"));
407
613
  const attempt = {
408
614
  attempt_id: `ATT-${taskId}-${Date.now()}-${++attemptSeq}`,
409
615
  task_id: taskId, state: "active",
410
616
  task_structure_digest: structureDigest,
617
+ contract_mode: contractMode,
618
+ contract: adopted.contract,
619
+ tdd_required: taskInfo.tddRequired,
620
+ no_tdd_reason: taskInfo.noTddReason,
411
621
  declared_write_scope: [], pre_edit_source_fingerprint: null,
412
622
  pre_edit_red_ref: null, executor_packet_digest: null,
413
623
  executor_result_ref: null, post_edit_green_ref: null,
414
624
  created_at: new Date().toISOString(),
415
625
  };
626
+ const eventPayload = {
627
+ ...attempt,
628
+ ...boundarySnapshotPayload(projectRoot),
629
+ };
416
630
  return {
417
631
  fromState: "apply", toState: "apply", outcome: "advanced",
418
632
  reason: `创建任务 ${taskId} 执行尝试`,
419
- extraEvents: [{ type: "task_started", payload: attempt }],
420
- 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
+ },
421
641
  };
422
642
  },
423
643
  });
@@ -458,7 +678,7 @@ export function reopen(projectRoot, change, changeRoot, to, reason, opts = {}) {
458
678
  source_job_id: ref.jobId,
459
679
  finding_id: ref.findingId,
460
680
  decision_scope: scope,
461
- baseline_docs: documentBaseline(changeRoot),
681
+ baseline_docs: proposalDocsBaseline(changeRoot),
462
682
  },
463
683
  };
464
684
  }
@@ -503,7 +723,7 @@ export function reopen(projectRoot, change, changeRoot, to, reason, opts = {}) {
503
723
  if (snapshot.state !== "apply_done" && snapshot.state !== "review") {
504
724
  return { skip: true, message: `当前状态 ${snapshot.state},不能 reopen 到 apply` };
505
725
  }
506
- const pending = pendingTaskIds(changeRoot);
726
+ const pending = pendingTaskStatusForApply(changeRoot, events).pending;
507
727
  if (pending.length === 0)
508
728
  return { skip: true, message: "没有未完成任务,不能 reopen 到 apply" };
509
729
  return {
@@ -526,9 +746,18 @@ export function reviewReady(projectRoot, change, changeRoot, risk = "strict") {
526
746
  const policyPayload = storedPolicy ? {} : { review_policy: policy };
527
747
  const currentEvidenceDigest = reviewEvidenceDigest(events);
528
748
  // 检查是否所有任务已完成
529
- const pending = pendingTaskIds(changeRoot);
749
+ const pending = pendingTaskStatusForApply(changeRoot, events).pending;
530
750
  if (pending.length > 0)
531
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
+ }
532
761
  // 如果当前是 apply,先推进到 apply_done
533
762
  if (snapshot.state === "apply") {
534
763
  return {
@@ -538,111 +767,26 @@ export function reviewReady(projectRoot, change, changeRoot, risk = "strict") {
538
767
  };
539
768
  }
540
769
  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
- };
770
+ return evaluateApplyDoneCodeReviewGate({
771
+ events,
772
+ projectRoot,
773
+ changeRoot,
774
+ change,
775
+ policyPayload,
776
+ });
595
777
  }
596
778
  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 状态,最终验证仍然有效" };
779
+ return evaluateFinalVerifierGate({
780
+ snapshot,
781
+ events,
782
+ change,
783
+ projectRoot,
784
+ changeRoot,
785
+ currentEvidenceDigest,
786
+ policy,
787
+ policyPayload,
788
+ hasStoredPolicy: Boolean(storedPolicy),
789
+ });
646
790
  }
647
791
  return { skip: true, message: `当前状态 ${snapshot.state},review-ready 不适用` };
648
792
  },
@@ -653,22 +797,16 @@ export function accept(projectRoot, change, changeRoot) {
653
797
  return commitTransition(projectRoot, change, changeRoot, {
654
798
  name: "accept", idempotencyInputs: { phase: "accept" },
655
799
  decide: (snapshot) => {
656
- if (snapshot.state !== "review")
657
- return { skip: true, message: `当前状态 ${snapshot.state},需要 review` };
658
- const pending = pendingTaskIds(changeRoot);
659
- if (pending.length > 0)
660
- return { skip: true, message: formatPendingTaskMessage(pending, "请先 reopen --to apply 继续执行") };
661
800
  const events = readEvents(projectRoot, change);
662
- const policy = readReviewPolicyFromEvents(events);
663
- if (!policy)
664
- return { skip: true, message: "缺少审查策略,请先运行 review-ready" };
665
- if (requiresFinalVerifierForCurrentReview(events) || policy.requires_verifier) {
666
- const currentEvidenceDigest = reviewEvidenceDigest(events);
667
- const verifierAccepted = snapshot.accepted_jobs.find(job => isFreshReviewVerifier(job, changeRoot, currentEvidenceDigest));
668
- if (!verifierAccepted)
669
- return { skip: true, message: "缺少仍然匹配当前证据的最终验证,请先运行 review-ready" };
670
- }
671
- 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);
672
810
  },
673
811
  });
674
812
  }
@@ -685,13 +823,10 @@ export function archive(projectRoot, change, changeRoot) {
685
823
  for (const p of docPaths) {
686
824
  manifest[p] = sha256File(join(changeRoot, p)) ?? "sha256:missing";
687
825
  }
688
- // specs/ 目录
826
+ // specs/ 目录:递归收录 .md(覆盖 specs/<capability>/spec.md 布局)
689
827
  const specsDir = join(changeRoot, "specs");
690
- if (existsSync(specsDir)) {
691
- for (const f of readdirSync(specsDir)) {
692
- if (f.endsWith(".md"))
693
- manifest[`specs/${f}`] = sha256File(join(specsDir, f)) ?? "sha256:missing";
694
- }
828
+ for (const rel of listMarkdownFiles(specsDir)) {
829
+ manifest[`specs/${rel}`] = sha256File(join(specsDir, rel)) ?? "sha256:missing";
695
830
  }
696
831
  return {
697
832
  fromState: "accepted", toState: "archive", outcome: "advanced",
@@ -702,73 +837,50 @@ export function archive(projectRoot, change, changeRoot) {
702
837
  });
703
838
  }
704
839
  // ===== task-complete =====
705
- export function taskComplete(projectRoot, change, changeRoot, taskId) {
840
+ export function taskComplete(projectRoot, change, changeRoot, taskId, inputContent = null) {
841
+ const scopeInput = parseScopeNoteInput(inputContent);
706
842
  return commitTransition(projectRoot, change, changeRoot, {
707
- name: "task-complete", idempotencyInputs: { task: taskId, phase: "complete" },
843
+ name: "task-complete", idempotencyInputs: { task: taskId, phase: "complete", scope_note_digest: scopeInput.digest ?? "" },
708
844
  decide: (snapshot) => {
845
+ if (!scopeInput.ok)
846
+ return { skip: true, message: scopeInput.message };
709
847
  if (snapshot.state !== "apply")
710
848
  return { skip: true, message: `当前状态 ${snapshot.state},需要 apply` };
711
849
  const attempt = snapshot.active_task_attempts?.find(a => a.task_id === taskId && a.state === "active");
712
850
  if (!attempt)
713
851
  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
- }
750
- // B1 修复:checkbox 写入移到 postCommit(commit 事件写入后执行)
852
+ const readiness = taskEvidenceReadiness(projectRoot, change, changeRoot, attempt);
853
+ if (!readiness.ready)
854
+ return { skip: true, message: `任务 ${taskId} 无法完成:${readiness.reason}` };
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
+ };
751
862
  return {
752
863
  fromState: "apply", toState: "apply", outcome: "advanced",
753
864
  reason: `任务 ${taskId} 完成`,
754
- extraEvents: [{ type: "task_completed", payload: { task_id: taskId, attempt_id: attempt.attempt_id } }],
865
+ extraEvents: [{ type: "task_completed", payload: completedPayload }],
755
866
  postCommit: (_pr, _ch, cr) => {
756
867
  const lines = readFileSync(join(cr, "tasks.md"), "utf8").split("\n");
757
868
  const idx = findTaskLine(lines, taskId);
758
- if (idx < 0 || !lines[idx].match(/- \[ \]/))
759
- throw new Error(`找不到 ${taskId} 的未完成复选框`);
760
- // H2 修复:应用前验证结构指纹
761
- 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
+ }
762
881
  lines[idx] = lines[idx].replace(/- \[ \]/, "- [x]");
763
882
  writeFileSync(join(cr, "tasks.md"), lines.join("\n"));
764
- // H2 修复:应用后验证只有目标变了
765
- const afterLines = readFileSync(join(cr, "tasks.md"), "utf8").split("\n");
766
- const afterDigest = sha256Text(afterLines.join("\n").replace(/- \[[xX]\]/g, "- [ ]"));
767
- if (afterDigest !== beforeDigest) {
768
- afterLines[idx] = afterLines[idx].replace(/- \[x\]/, "- [ ]");
769
- writeFileSync(join(cr, "tasks.md"), afterLines.join("\n"));
770
- throw new Error(`复选框补丁导致结构变化:${taskId}`);
771
- }
883
+ completedPayload.checkbox_update = { status: "applied" };
772
884
  },
773
885
  };
774
886
  },