@peterxiaoyang/superspec 0.1.44 → 0.1.46

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 (52) hide show
  1. package/README.md +13 -1
  2. package/dist/cli.js +23 -24
  3. package/dist/code_review.js +7 -2
  4. package/dist/format.d.ts +20 -2
  5. package/dist/format.js +217 -26
  6. package/dist/git_state.d.ts +12 -1
  7. package/dist/git_state.js +45 -0
  8. package/dist/install.d.ts +1 -0
  9. package/dist/install.js +12 -0
  10. package/dist/next.d.ts +1 -1
  11. package/dist/next.js +3 -8
  12. package/dist/openspec.d.ts +13 -0
  13. package/dist/openspec.js +28 -0
  14. package/dist/phase_confirmation.d.ts +6 -0
  15. package/dist/phase_confirmation.js +22 -6
  16. package/dist/phase_plan.d.ts +10 -1
  17. package/dist/phase_plan.js +250 -37
  18. package/dist/record.d.ts +1 -1
  19. package/dist/record.js +38 -30
  20. package/dist/review.js +18 -2
  21. package/dist/sync.js +13 -4
  22. package/dist/task.js +15 -2
  23. package/dist/task_evidence.d.ts +1 -1
  24. package/dist/task_evidence.js +85 -10
  25. package/dist/transition.d.ts +4 -3
  26. package/dist/transition.js +166 -29
  27. package/dist/types.d.ts +38 -1
  28. package/dist/types.js +1 -0
  29. package/dist/workflow_config.d.ts +24 -0
  30. package/dist/workflow_config.js +127 -0
  31. package/package.json +1 -1
  32. package/templates/workflow/AGENTS.md +1 -1
  33. package/templates/workflow/agents/architect.toml +1 -1
  34. package/templates/workflow/agents/code-reviewer.toml +1 -1
  35. package/templates/workflow/agents/critic.toml +1 -1
  36. package/templates/workflow/agents/executor.toml +1 -1
  37. package/templates/workflow/agents/explore.toml +1 -1
  38. package/templates/workflow/agents/test-engineer.toml +1 -1
  39. package/templates/workflow/agents/test-runner.toml +1 -1
  40. package/templates/workflow/agents/verifier.toml +1 -1
  41. package/templates/workflow/prompts/architect.md +25 -33
  42. package/templates/workflow/prompts/code-reviewer.md +19 -67
  43. package/templates/workflow/prompts/critic.md +36 -87
  44. package/templates/workflow/prompts/executor.md +17 -19
  45. package/templates/workflow/prompts/explore.md +12 -46
  46. package/templates/workflow/prompts/test-engineer.md +22 -35
  47. package/templates/workflow/prompts/test-runner.md +11 -21
  48. package/templates/workflow/prompts/verifier.md +13 -37
  49. package/templates/workflow/skills/superspec-apply/SKILL.md +17 -85
  50. package/templates/workflow/skills/superspec-explore/SKILL.md +57 -66
  51. package/templates/workflow/skills/superspec-propose/SKILL.md +76 -129
  52. package/templates/workflow/skills/superspec-review/SKILL.md +14 -73
@@ -1,18 +1,22 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { EXPLORE_DISCOVERY_REVIEW_GATE, PROPOSE_FINAL_REVIEW_GATE } from "./review_job_gates.js";
4
- import { collectProposeOpenQuestions, countDiscoveryOpenQuestions, parseTasksMd, pendingTasksInContent, validateDiscovery, validateExecutionRequirements, } from "./format.js";
4
+ import { collectProposeOpenQuestions, countDiscoveryOpenQuestions, parseExecutionRequirements, parseTasksMd, pendingTasksInContent, validateDiscovery, validateExecutionRequirements, validateExecutionRequirementDocumentReferences, validateProposalImpact, validateTasksDocument, } from "./format.js";
5
5
  import { currentGitHead } from "./git_state.js";
6
+ import { validateOpenSpecChange } from "./openspec.js";
6
7
  import { docRef, sha256File } from "./store.js";
7
8
  import { isReviewReadyVerifier, isFreshReviewVerifier, historicalProposeReadyRoles, readReviewPolicyFromEvents, reviewGateRoleResolution, reviewRejectionOverrideScope, reviewEvidenceDigest, } from "./review.js";
8
9
  import { CODE_REVIEW_DECISION_ANSWER_LABELS, CODE_REVIEW_REPAIR_SCOPE_PREFIX, codeReviewDecisionScope, codeReviewJobStaleReason, collectCodeReviewGateFacts, currentCodeReviewWorkingPaths, latestCodeReviewFailedStatus, requiresFinalVerifierForCurrentReview, scanCodeChangesForReview, } from "./code_review.js";
9
10
  import { isPhaseAdvanceAuthorized, latestAcceptedPhaseDecision, phaseConfirmationCommitPayload, phaseConfirmationForBoundary, phaseConfirmationMissingMessage, } from "./phase_confirmation.js";
10
11
  import { taskEvidenceReadiness } from "./task_evidence.js";
12
+ import { workflowRiskForProposeRound, workflowRiskForState } from "./workflow_config.js";
11
13
  function requiredJobs(state, jobs, reason) {
12
14
  return { kind: "required_jobs", state, jobs, reason };
13
15
  }
14
16
  function phaseConfirmationStep(context, boundary, reason) {
15
- const confirmation = phaseConfirmationForBoundary(context.projectRoot, context.events, context.snapshot, boundary, context.mode.risk);
17
+ // 进入 propose_ready / apply 后,mode 来自本轮快照而非当前配置。
18
+ const risk = workflowRiskForState(context.events, context.snapshot.state, context.mode.risk);
19
+ const confirmation = phaseConfirmationForBoundary(context.projectRoot, context.events, context.snapshot, boundary, risk);
16
20
  if (!confirmation || isPhaseAdvanceAuthorized(context.events, confirmation))
17
21
  return null;
18
22
  return {
@@ -76,23 +80,41 @@ function reviewGatePlan(snapshot, events, changeRoot, gate, requiredRoles) {
76
80
  }
77
81
  return null;
78
82
  }
79
- function validateTasksPlan(changeRoot) {
83
+ function validateTasksPlan(changeRoot, executionRequirementVersion) {
80
84
  const tasksPath = join(changeRoot, "tasks.md");
81
85
  if (!existsSync(tasksPath))
82
86
  return "tasks.md 不存在";
83
87
  const tasksContent = readFileSync(tasksPath, "utf8");
84
- if (!tasksContent.includes("# Tasks") && !tasksContent.includes("- [ ]"))
85
- return "tasks.md 内容不像任务计划文档";
88
+ if (executionRequirementVersion === 1) {
89
+ return tasksContent.includes("# Tasks") || tasksContent.includes("- [ ]")
90
+ ? null
91
+ : "tasks.md 内容不像任务计划文档";
92
+ }
93
+ const errors = validateTasksDocument(tasksContent);
94
+ if (errors.length > 0)
95
+ return errors.join(";");
86
96
  return null;
87
97
  }
88
- function validateExecutionRequirementPlan(changeRoot) {
98
+ export function executionPolicyForRisk(risk) {
99
+ return risk === "strict" ? "tdd" : "green_only";
100
+ }
101
+ export function executionPolicyForCurrentRound(events) {
102
+ const index = latestStartApplyIndex(events);
103
+ if (index < 0)
104
+ return "tdd";
105
+ const payload = events[index].payload;
106
+ return payload.execution_policy === "green_only" || payload.execution_policy === "tdd"
107
+ ? payload.execution_policy
108
+ : "tdd";
109
+ }
110
+ function validateExecutionRequirementPlan(changeRoot, executionPolicy, executionRequirementVersion = 2) {
89
111
  const tasksPath = join(changeRoot, "tasks.md");
90
112
  if (!existsSync(tasksPath))
91
113
  return { ok: true, mode: false };
92
114
  const tasksContent = readFileSync(tasksPath, "utf8");
93
115
  const testContractPath = join(changeRoot, ".superspec", "artifacts", "test-contract.md");
94
116
  const testContractContent = existsSync(testContractPath) ? readFileSync(testContractPath, "utf8") : null;
95
- const validation = validateExecutionRequirements(tasksContent, testContractContent);
117
+ const validation = validateExecutionRequirements(tasksContent, testContractContent, executionPolicy, executionRequirementVersion);
96
118
  return validation.ok
97
119
  ? { ok: true, mode: validation.mode }
98
120
  : { ok: false, mode: validation.mode, message: validation.errors.join(";") };
@@ -107,6 +129,43 @@ function missingBaseArtifact(changeRoot, risk) {
107
129
  }
108
130
  return null;
109
131
  }
132
+ /** OpenSpec strict gate 只在当前 planning round 冻结为 strict 时执行。 */
133
+ function validateOpenSpecPlanningDocuments(projectRoot, change, changeRoot, profile) {
134
+ if (profile == null || profile.openspec.mode !== "strict")
135
+ return null;
136
+ const currentConfigDigest = sha256File(join(projectRoot, "openspec", "config.yaml"));
137
+ if (currentConfigDigest !== profile.openspec.config_digest) {
138
+ return "OpenSpec 配置自本 planning round 起已变化;请恢复原配置或 reopen --to propose 创建新的计划轮";
139
+ }
140
+ const tasksPath = join(changeRoot, "tasks.md");
141
+ const referenceErrors = validateExecutionRequirementDocumentReferences(changeRoot, parseExecutionRequirements(readFileSync(tasksPath, "utf8")));
142
+ if (referenceErrors.length > 0)
143
+ return referenceErrors.join(";");
144
+ const proposalPath = join(changeRoot, "proposal.md");
145
+ if (!existsSync(proposalPath))
146
+ return "proposal.md 不存在";
147
+ const impact = validateProposalImpact(readFileSync(proposalPath, "utf8"));
148
+ if (!impact.ok)
149
+ return impact.message;
150
+ const native = validateOpenSpecChange(projectRoot, change);
151
+ return native.ok ? null : native.message;
152
+ }
153
+ function validatePlanningPreflight(projectRoot, change, changeRoot, risk, executionPolicy, profile) {
154
+ const executionRequirementVersion = profile?.version ?? 1;
155
+ const tasksPlanError = validateTasksPlan(changeRoot, executionRequirementVersion);
156
+ if (tasksPlanError)
157
+ return { error: tasksPlanError, contractMode: false };
158
+ const executionRequirementPlan = validateExecutionRequirementPlan(changeRoot, executionPolicy, executionRequirementVersion);
159
+ if (!executionRequirementPlan.ok)
160
+ return { error: executionRequirementPlan.message, contractMode: false };
161
+ const missingArtifact = missingBaseArtifact(changeRoot, risk);
162
+ if (missingArtifact)
163
+ return { error: missingArtifact, contractMode: false };
164
+ return {
165
+ error: validateOpenSpecPlanningDocuments(projectRoot, change, changeRoot, profile),
166
+ contractMode: executionRequirementPlan.mode,
167
+ };
168
+ }
110
169
  export function proposalDocsBaseline(changeRoot) {
111
170
  // 与 Propose gate 的可修改审查目标保持同一来源:specs/ 用目录聚合指纹,避免 reopen 基线漏掉任一个可修改的计划材料。
112
171
  const docs = PROPOSE_FINAL_REVIEW_GATE.reviewTargets;
@@ -116,6 +175,15 @@ export function proposalDocsBaseline(changeRoot) {
116
175
  }
117
176
  return baseline;
118
177
  }
178
+ export function discoveryDocsBaseline(changeRoot) {
179
+ // 与 Explore gate 使用同一组审查目标。回退到 explore 后,至少要更新一项
180
+ // discovery 材料,才允许重新进入 propose,避免把一次纯状态回退误当作新探索轮次。
181
+ const baseline = {};
182
+ for (const doc of EXPLORE_DISCOVERY_REVIEW_GATE.reviewTargets) {
183
+ baseline[doc] = docRef(changeRoot, doc).sha;
184
+ }
185
+ return baseline;
186
+ }
119
187
  function isDigestMap(value) {
120
188
  if (!value || typeof value !== "object" || Array.isArray(value))
121
189
  return false;
@@ -152,10 +220,28 @@ export function latestReopenProposeBaseline(events) {
152
220
  }
153
221
  return null;
154
222
  }
223
+ export function latestReopenExploreBaseline(events) {
224
+ for (let i = events.length - 1; i >= 0; i--) {
225
+ const ev = events[i];
226
+ if (ev.event_type !== "transition_commit")
227
+ continue;
228
+ const payload = ev.payload;
229
+ if (payload.transition !== "reopen" || payload.reopen_target !== "explore")
230
+ continue;
231
+ if (!payload.baseline_docs || typeof payload.baseline_docs !== "object" || Array.isArray(payload.baseline_docs))
232
+ return null;
233
+ return payload.baseline_docs;
234
+ }
235
+ return null;
236
+ }
155
237
  export function proposalDocsChangedSinceBaseline(changeRoot, baseline) {
156
238
  const current = proposalDocsBaseline(changeRoot);
157
239
  return Object.entries(baseline).some(([path, digest]) => current[path] !== digest);
158
240
  }
241
+ export function discoveryDocsChangedSinceBaseline(changeRoot, baseline) {
242
+ const current = discoveryDocsBaseline(changeRoot);
243
+ return Object.entries(baseline).some(([path, digest]) => current[path] !== digest);
244
+ }
159
245
  export function pendingTaskIds(changeRoot) {
160
246
  const tasksContent = readFileSync(join(changeRoot, "tasks.md"), "utf8");
161
247
  return pendingTasksInContent(tasksContent).map(task => task.taskId);
@@ -171,6 +257,83 @@ function latestStartApplyIndex(events) {
171
257
  }
172
258
  return -1;
173
259
  }
260
+ function executionRequirementVersionFromPayload(payload) {
261
+ return payload.execution_requirement_version === 2 ? 2 : 1;
262
+ }
263
+ /** 当前 Apply round 的规则版本;缺失版本的历史 event 保持 v1 回放。 */
264
+ export function executionRequirementVersionForCurrentRound(events) {
265
+ const index = latestStartApplyIndex(events);
266
+ return index < 0 ? 1 : executionRequirementVersionFromPayload(events[index].payload);
267
+ }
268
+ /** Propose-ready 的规则版本决定随后 start-apply 是否采用新的全任务声明要求。 */
269
+ function executionRequirementVersionForProposeRound(events) {
270
+ for (let i = events.length - 1; i >= 0; i--) {
271
+ const event = events[i];
272
+ if (event.event_type !== "transition_commit")
273
+ continue;
274
+ const payload = event.payload;
275
+ if (payload.transition === "propose-ready" && payload.to_state === "propose_ready") {
276
+ return executionRequirementVersionFromPayload(event.payload);
277
+ }
278
+ }
279
+ return 1;
280
+ }
281
+ function isPlanningValidationProfile(value) {
282
+ if (!value || typeof value !== "object" || Array.isArray(value))
283
+ return false;
284
+ const profile = value;
285
+ if (profile.version !== 2 || !profile.openspec || typeof profile.openspec !== "object")
286
+ return false;
287
+ return profile.openspec.mode === "disabled" ||
288
+ profile.openspec.mode === "strict" && typeof profile.openspec.config_digest === "string";
289
+ }
290
+ /** 新 planning round 在进入 propose 时冻结当前 OpenSpec 校验契约。 */
291
+ export function planningValidationProfileForNewRound(projectRoot) {
292
+ const configDigest = sha256File(join(projectRoot, "openspec", "config.yaml"));
293
+ return configDigest == null
294
+ ? { version: 2, openspec: { mode: "disabled" } }
295
+ : { version: 2, openspec: { mode: "strict", config_digest: configDigest } };
296
+ }
297
+ /** propose 状态尚未 ready 时,从进入本 planning round 的事件读取冻结 profile。 */
298
+ function planningValidationProfileForPendingProposeRound(events) {
299
+ for (let i = events.length - 1; i >= 0; i--) {
300
+ const event = events[i];
301
+ if (event.event_type !== "transition_commit")
302
+ continue;
303
+ const payload = event.payload;
304
+ // 只读取“进入 propose”的边界事件。propose-ready 创建审查 job 时也会
305
+ // 保持在 propose;若把它误当作新的 planning round,便会覆盖此前冻结的
306
+ // profile 并把当前轮错误降级为 v1。
307
+ const entersPropose = payload.to_state === "propose" && (payload.transition === "explore" ||
308
+ payload.transition === "propose" ||
309
+ payload.transition === "reopen" && payload.reopen_target === "propose");
310
+ if (!entersPropose)
311
+ continue;
312
+ return isPlanningValidationProfile(payload.planning_validation_profile)
313
+ ? payload.planning_validation_profile
314
+ : null;
315
+ }
316
+ return null;
317
+ }
318
+ /** 已完成 propose-ready 的 round 只回放当时冻结的 profile。 */
319
+ function planningValidationProfileForReadyProposeRound(events) {
320
+ for (let i = events.length - 1; i >= 0; i--) {
321
+ const event = events[i];
322
+ if (event.event_type !== "transition_commit")
323
+ continue;
324
+ const payload = event.payload;
325
+ if (payload.transition !== "propose-ready" || payload.to_state !== "propose_ready")
326
+ continue;
327
+ if (isPlanningValidationProfile(payload.planning_validation_profile))
328
+ return payload.planning_validation_profile;
329
+ // 过渡期已写 v2 执行依据、但尚未带 profile 的事件保持 v2 tasks 契约,
330
+ // 但不在 start-apply 追溯新增 strict gate。
331
+ return executionRequirementVersionFromPayload(event.payload) === 2
332
+ ? { version: 2, openspec: { mode: "disabled" } }
333
+ : null;
334
+ }
335
+ return null;
336
+ }
174
337
  export function applyRequirementModeForCurrentRound(events) {
175
338
  const index = latestStartApplyIndex(events);
176
339
  if (index < 0)
@@ -238,14 +401,13 @@ export function pendingTaskStatusForApply(changeRoot, events) {
238
401
  export function formatPendingTaskMessage(ids, action) {
239
402
  return `尚有未完成任务:${ids.join(", ")};${action}`;
240
403
  }
241
- function nextArgv(change, risk) {
404
+ function nextArgv(change, _risk) {
242
405
  return [
243
406
  "superspec",
244
407
  "transition",
245
408
  "next",
246
409
  "--change",
247
410
  change,
248
- ...(risk === "strict" ? [] : ["--risk", risk]),
249
411
  ];
250
412
  }
251
413
  function acceptedMaterialFollowup(change, risk, planDocsChangedSinceAccept) {
@@ -281,6 +443,15 @@ export function planNextStep(context) {
281
443
  }
282
444
  return { kind: "run_transition", state: "init", transition: "explore", reason: "初始化完成,开始探索" };
283
445
  case "explore": {
446
+ const reopenBaseline = latestReopenExploreBaseline(events);
447
+ if (reopenBaseline && !discoveryDocsChangedSinceBaseline(changeRoot, reopenBaseline)) {
448
+ return {
449
+ kind: "run_transition",
450
+ state: "explore",
451
+ transition: "explore",
452
+ reason: "回到 explore 后至少一个 discovery 材料必须变化",
453
+ };
454
+ }
284
455
  const exploreReviewJobs = EXPLORE_DISCOVERY_REVIEW_GATE.openJobsForGate(snapshot);
285
456
  if (exploreReviewJobs.length > 0) {
286
457
  return requiredJobs("explore", exploreReviewJobs, `有 ${exploreReviewJobs.length} 个待完成探索审查工作项`);
@@ -352,6 +523,16 @@ export function planNextStep(context) {
352
523
  if (confirmation)
353
524
  return confirmation;
354
525
  }
526
+ // 尚未获得用户确认时,按本次 next 的候选风险预检失败不能退回默认 strict
527
+ // 的 start-apply 命令;否则 normal/minimal 的策略不匹配会被静默改写为 strict。
528
+ if (startApplyPlan.kind === "skip") {
529
+ const ask = {
530
+ question: `${startApplyPlan.message}。请更新计划材料后重新执行 next。`,
531
+ allowed_answers: ["计划已更新"],
532
+ scope: "propose_apply_preflight",
533
+ };
534
+ return { kind: "ask_user", state: "propose_ready", ask, reason: startApplyPlan.message };
535
+ }
355
536
  return { kind: "run_transition", state: "propose_ready", transition: "start-apply", reason: "计划就绪,开始执行" };
356
537
  }
357
538
  case "apply":
@@ -628,6 +809,10 @@ function planExploreTransition(context) {
628
809
  if (snapshot.state !== "explore") {
629
810
  return { kind: "skip", message: `当前状态 ${snapshot.state},explore 不适用` };
630
811
  }
812
+ const reopenBaseline = latestReopenExploreBaseline(events);
813
+ if (reopenBaseline && !discoveryDocsChangedSinceBaseline(changeRoot, reopenBaseline)) {
814
+ return { kind: "skip", message: "回到 explore 后至少一个 discovery 材料必须变化" };
815
+ }
631
816
  const discoveryCheck = validateDiscovery(changeRoot);
632
817
  if (!discoveryCheck.ok)
633
818
  return { kind: "skip", message: discoveryCheck.message };
@@ -645,7 +830,11 @@ function planExploreTransition(context) {
645
830
  fromState: "explore",
646
831
  toState: "propose",
647
832
  reason: "探索完成",
648
- payload: phaseConfirmationCommitPayload(confirmation, decision),
833
+ payload: {
834
+ ...phaseConfirmationCommitPayload(confirmation, decision),
835
+ planning_validation_version: 2,
836
+ planning_validation_profile: planningValidationProfileForNewRound(projectRoot),
837
+ },
649
838
  };
650
839
  }
651
840
  function planProposeReadyTransition(context) {
@@ -653,20 +842,15 @@ function planProposeReadyTransition(context) {
653
842
  const risk = mode.risk;
654
843
  if (snapshot.state !== "propose")
655
844
  return { kind: "skip", message: `当前状态 ${snapshot.state},不能 propose-ready` };
656
- const tasksPlanError = validateTasksPlan(changeRoot);
657
- if (tasksPlanError)
658
- return { kind: "skip", message: tasksPlanError };
659
- const executionRequirementPlan = validateExecutionRequirementPlan(changeRoot);
660
- if (!executionRequirementPlan.ok)
661
- return { kind: "skip", message: executionRequirementPlan.message };
845
+ const planningProfile = planningValidationProfileForPendingProposeRound(context.events);
846
+ const preflight = validatePlanningPreflight(context.projectRoot, context.change, changeRoot, risk, executionPolicyForRisk(risk), planningProfile);
847
+ if (preflight.error)
848
+ return { kind: "skip", message: preflight.error };
662
849
  const openQuestions = collectProposeOpenQuestions(changeRoot);
663
850
  if (openQuestions.openCount > 0) {
664
851
  const files = openQuestions.files.map(f => `${f.path}(${f.openCount})`).join(", ");
665
852
  return { kind: "skip", message: `计划文档有 ${openQuestions.openCount} 个待用户确认问题:${files}` };
666
853
  }
667
- const missingArtifact = missingBaseArtifact(changeRoot, risk);
668
- if (missingArtifact)
669
- return { kind: "skip", message: missingArtifact };
670
854
  const requiredRoles = PROPOSE_FINAL_REVIEW_GATE.requiredRolesForRisk(risk);
671
855
  const gatePlan = reviewGatePlan(snapshot, context.events, changeRoot, PROPOSE_FINAL_REVIEW_GATE, requiredRoles);
672
856
  if (gatePlan)
@@ -676,8 +860,21 @@ function planProposeReadyTransition(context) {
676
860
  fromState: "propose",
677
861
  toState: "propose_ready",
678
862
  reason: `risk=${risk},所有需求已满足`,
863
+ payload: {
864
+ workflow_mode: risk,
865
+ ...(planningProfile ? {
866
+ execution_requirement_version: 2,
867
+ planning_validation_version: 2,
868
+ planning_validation_profile: planningProfile,
869
+ } : {}),
870
+ },
679
871
  };
680
872
  }
873
+ function acceptedProposeToApplyConfirmation(context, risk) {
874
+ const confirmation = phaseConfirmationForBoundary(context.projectRoot, context.events, context.snapshot, "propose_to_apply", risk);
875
+ const decision = confirmation ? latestAcceptedPhaseDecision(context.events, confirmation) : null;
876
+ return confirmation && decision?.decision === "advance" ? { confirmation, decision } : null;
877
+ }
681
878
  function planStartApplyTransition(context, enforceConfirmation = true) {
682
879
  const { changeRoot, events, projectRoot, snapshot } = context;
683
880
  if (snapshot.state !== "propose_ready")
@@ -687,23 +884,32 @@ function planStartApplyTransition(context, enforceConfirmation = true) {
687
884
  // 按基线实际键名提示:升级前留下的旧基线可能不含 specs/,静态清单会误导
688
885
  return { kind: "skip", message: `回到 propose 后至少一个计划文档必须变化(基线绑定:${Object.keys(reopenBaseline).join("、")})` };
689
886
  }
690
- const reviewedRoles = historicalProposeReadyRoles(events);
691
- if (reviewedRoles.length > 0) {
692
- const gatePlan = reviewGatePlan(snapshot, events, changeRoot, PROPOSE_FINAL_REVIEW_GATE, reviewedRoles);
693
- if (gatePlan) {
694
- return {
695
- ...gatePlan,
696
- reason: `进入执行阶段前需要重新完成计划文档审查:${gatePlan.reason}`,
697
- };
698
- }
887
+ const risk = workflowRiskForProposeRound(events, context.mode.risk);
888
+ const planningProfile = planningValidationProfileForReadyProposeRound(events);
889
+ const executionRequirementVersion = planningProfile?.version ?? executionRequirementVersionForProposeRound(events);
890
+ const executionPolicy = executionPolicyForRisk(risk);
891
+ const preflight = validatePlanningPreflight(projectRoot, context.change, changeRoot, risk, executionPolicy, planningProfile);
892
+ if (preflight.error)
893
+ return { kind: "skip", message: preflight.error };
894
+ const requiredRoles = executionRequirementVersion === 2
895
+ ? PROPOSE_FINAL_REVIEW_GATE.requiredRolesForRisk(risk)
896
+ : historicalProposeReadyRoles(events);
897
+ const gatePlan = reviewGatePlan(snapshot, events, changeRoot, PROPOSE_FINAL_REVIEW_GATE, requiredRoles);
898
+ if (gatePlan) {
899
+ return {
900
+ ...gatePlan,
901
+ reason: `进入执行阶段前需要重新完成计划文档审查:${gatePlan.reason}`,
902
+ };
699
903
  }
700
- const executionRequirementPlan = validateExecutionRequirementPlan(changeRoot);
701
- if (!executionRequirementPlan.ok)
702
- return { kind: "skip", message: executionRequirementPlan.message };
703
- const confirmation = phaseConfirmationForBoundary(projectRoot, events, snapshot, "propose_to_apply");
704
- const decision = confirmation ? latestAcceptedPhaseDecision(events, confirmation) : null;
705
- if (enforceConfirmation && (!confirmation || decision?.decision !== "advance")) {
706
- return { kind: "skip", message: confirmation ? phaseConfirmationMissingMessage(confirmation) : "无法建立 Propose 阶段确认范围" };
904
+ const acceptedConfirmation = enforceConfirmation
905
+ ? acceptedProposeToApplyConfirmation(context, risk)
906
+ : null;
907
+ if (enforceConfirmation && !acceptedConfirmation) {
908
+ const confirmation = phaseConfirmationForBoundary(projectRoot, events, snapshot, "propose_to_apply", risk);
909
+ return {
910
+ kind: "skip",
911
+ message: confirmation ? phaseConfirmationMissingMessage(confirmation) : "无法建立 Propose 阶段确认范围",
912
+ };
707
913
  }
708
914
  const gitHead = currentGitHead(projectRoot);
709
915
  return {
@@ -714,8 +920,15 @@ function planStartApplyTransition(context, enforceConfirmation = true) {
714
920
  payload: {
715
921
  apply_start_head: gitHead.head,
716
922
  apply_start_head_reason: gitHead.reason,
717
- apply_contract_mode: executionRequirementPlan.mode,
718
- ...(confirmation && decision?.decision === "advance" ? phaseConfirmationCommitPayload(confirmation, decision) : {}),
923
+ apply_contract_mode: preflight.contractMode,
924
+ ...(executionRequirementVersion === 2 ? { execution_requirement_version: 2 } : {}),
925
+ execution_policy: executionPolicy,
926
+ workflow_mode: risk,
927
+ review_policy: {
928
+ review_risk: risk,
929
+ requires_verifier: risk !== "minimal",
930
+ },
931
+ ...(acceptedConfirmation ? phaseConfirmationCommitPayload(acceptedConfirmation.confirmation, acceptedConfirmation.decision) : {}),
719
932
  },
720
933
  };
721
934
  }
package/dist/record.d.ts CHANGED
@@ -7,7 +7,7 @@ export declare function recordJobSubmitContent(projectRoot: string, change: stri
7
7
  export declare function recordUserDecision(projectRoot: string, change: string, inputFile: string): RecordResult;
8
8
  /** record user-decision:从 JSON 内容登记用户决策 */
9
9
  export declare function recordUserDecisionContent(projectRoot: string, change: string, content: string): RecordResult;
10
- /** jobs list(HIGH-1 修复:从 transition_commit.new_jobs 提取,不再依赖已删除的 job_requested 事件) */
10
+ /** jobs list(job transition_commit.new_jobs 提取;reopen 可用 job_invalidated 关闭未完成工作项) */
11
11
  export declare function jobsList(projectRoot: string, change: string): {
12
12
  open: Job[];
13
13
  accepted: Job[];
package/dist/record.js CHANGED
@@ -4,7 +4,7 @@ import { isAbsolute, join, relative, resolve } from "node:path";
4
4
  import { ensureChangeLayout, readEvents, appendEvent, makeEvent, sha256File, sha256Text, withLock, appendRawRecord, } from "./store.js";
5
5
  import { rebuildSnapshot } from "./sync.js";
6
6
  import { changeRoot as openspecChangeRoot } from "./openspec.js";
7
- import { isPhaseConfirmationScope, phaseActionForAnswer, phaseConfirmationForCurrentState, } from "./phase_confirmation.js";
7
+ import { isPhaseConfirmationScope, phaseActionForAnswer, phaseConfirmationForCurrentState, workflowRiskForPhaseConfirmation, } from "./phase_confirmation.js";
8
8
  import { CODE_REVIEW_DECISION_ANSWER_LABELS, CODE_REVIEW_DECISION_SCOPE_PREFIX, codeReviewDecisionAnswerLabel, normalizeCodeReviewDecisionAnswer, } from "./code_review.js";
9
9
  import { invalidReasonForSubmittedReport } from "./job_validity.js";
10
10
  import { jobSubmitArgv } from "./job_action.js";
@@ -346,6 +346,10 @@ function jobTerminalState(events, jobId) {
346
346
  }
347
347
  return null;
348
348
  }
349
+ function jobInvalidated(events, jobId) {
350
+ return events.some(ev => ev.event_type === "job_invalidated" &&
351
+ ev.payload.job_id === jobId);
352
+ }
349
353
  function terminalJobSubmitResult(events, jobId, terminal, reportDigest) {
350
354
  const existing = events.find(e => (e.event_type === "job_accepted" || e.event_type === "job_rejected")
351
355
  && e.payload.job_id === jobId
@@ -547,6 +551,9 @@ export function recordJobSubmit(projectRoot, change, changeRoot, jobId, reportFi
547
551
  if (!job) {
548
552
  return { event_type: "job_rejected", accepted: false, message: `工作项 ${jobId} 不存在` };
549
553
  }
554
+ if (jobInvalidated(events, jobId)) {
555
+ return { accepted: false, message: `工作项 ${jobId} 已因回退到更早阶段失效,不接受新报告。` };
556
+ }
550
557
  const terminal = jobTerminalState(events, jobId);
551
558
  if (terminal) {
552
559
  const reportDigest = sha256File(reportFile) ?? "sha256:unknown";
@@ -584,6 +591,9 @@ export function recordJobSubmitContent(projectRoot, change, changeRoot, jobId, r
584
591
  if (!job) {
585
592
  return { event_type: "job_rejected", accepted: false, message: `工作项 ${jobId} 不存在` };
586
593
  }
594
+ if (jobInvalidated(events, jobId)) {
595
+ return { accepted: false, message: `工作项 ${jobId} 已因回退到更早阶段失效,不接受新报告。` };
596
+ }
587
597
  const reportDigest = sha256Text(reportContent);
588
598
  const terminal = jobTerminalState(events, jobId);
589
599
  if (terminal) {
@@ -639,14 +649,9 @@ function recordUserDecisionLoaded(projectRoot, change, events, content, inputDig
639
649
  });
640
650
  const changeRoot = openspecChangeRoot(projectRoot, change);
641
651
  const snapshot = rebuildSnapshot(projectRoot, change, changeRoot);
642
- const decisionRisk = decision.review_risk === undefined
643
- ? "strict"
644
- : decision.review_risk === "minimal" || decision.review_risk === "normal" || decision.review_risk === "strict"
645
- ? decision.review_risk
646
- : null;
647
- const current = decisionRisk
648
- ? phaseConfirmationForCurrentState(projectRoot, events, snapshot, decisionRisk)
649
- : null;
652
+ // mode 是状态机从配置/round 快照推导的输入,不接受 user-decision JSON 注入。
653
+ const decisionRisk = workflowRiskForPhaseConfirmation(projectRoot, events, snapshot);
654
+ const current = phaseConfirmationForCurrentState(projectRoot, events, snapshot, decisionRisk);
650
655
  if (existingAccepted &&
651
656
  current?.scope === decision.scope &&
652
657
  latestAcceptedForScope?.event_id === existing.event_id) {
@@ -657,17 +662,15 @@ function recordUserDecisionLoaded(projectRoot, change, events, content, inputDig
657
662
  };
658
663
  }
659
664
  const action = current ? phaseActionForAnswer(current, decision.answer) : null;
660
- const rejectionReason = !decisionRisk
661
- ? "invalid_phase_confirmation_risk"
662
- : !current
663
- ? "phase_confirmation_not_pending"
664
- : decision.scope !== current.scope
665
- ? "stale_phase_confirmation_scope"
666
- : !action
667
- ? "invalid_phase_confirmation_answer"
668
- : action.reason === "required" && !nonEmptyString(decision.reason)
669
- ? "missing_phase_confirmation_reason"
670
- : null;
665
+ const rejectionReason = !current
666
+ ? "phase_confirmation_not_pending"
667
+ : decision.scope !== current.scope
668
+ ? "stale_phase_confirmation_scope"
669
+ : !action
670
+ ? "invalid_phase_confirmation_answer"
671
+ : action.reason === "required" && !nonEmptyString(decision.reason)
672
+ ? "missing_phase_confirmation_reason"
673
+ : null;
671
674
  if (rejectionReason) {
672
675
  if (existing &&
673
676
  existing.payload.accepted === false &&
@@ -687,11 +690,9 @@ function recordUserDecisionLoaded(projectRoot, change, events, content, inputDig
687
690
  }));
688
691
  const message = rejectionReason === "invalid_phase_confirmation_answer" && current
689
692
  ? `阶段确认答复必须精确为:${current.ask.allowed_answers.join("、")}`
690
- : rejectionReason === "invalid_phase_confirmation_risk"
691
- ? "阶段确认 review_risk 必须是 minimal、normal 或 strict"
692
- : rejectionReason === "missing_phase_confirmation_reason" && action
693
- ? action.reason_prompt ?? "当前选择必须写明原因"
694
- : "阶段确认已失效或当前没有待确认的阶段边界,请重新执行 next";
693
+ : rejectionReason === "missing_phase_confirmation_reason" && action
694
+ ? action.reason_prompt ?? "当前选择必须写明原因"
695
+ : "阶段确认已失效或当前没有待确认的阶段边界,请重新执行 next";
695
696
  return { event_type: "user_decision_recorded", accepted: false, message };
696
697
  }
697
698
  phaseConfirmation = current;
@@ -857,7 +858,7 @@ export function recordUserDecisionContent(projectRoot, change, content) {
857
858
  return recordUserDecisionLoaded(projectRoot, change, events, content, sha256Text(content));
858
859
  });
859
860
  }
860
- /** jobs list(HIGH-1 修复:从 transition_commit.new_jobs 提取,不再依赖已删除的 job_requested 事件) */
861
+ /** jobs list(job transition_commit.new_jobs 提取;reopen 可用 job_invalidated 关闭未完成工作项) */
861
862
  export function jobsList(projectRoot, change) {
862
863
  const events = readEvents(projectRoot, change);
863
864
  const open = [];
@@ -888,6 +889,12 @@ export function jobsList(projectRoot, change) {
888
889
  open.splice(idx, 1)[0];
889
890
  rejected.push({ job_id, role });
890
891
  }
892
+ else if (ev.event_type === "job_invalidated") {
893
+ const { job_id } = ev.payload;
894
+ const idx = open.findIndex(j => j.job_id === job_id);
895
+ if (idx >= 0)
896
+ open.splice(idx, 1);
897
+ }
891
898
  }
892
899
  return { open, accepted, rejected };
893
900
  }
@@ -898,8 +905,9 @@ function packetFieldDescriptions() {
898
905
  boundFiles: "本工作项绑定的文件清单;审查报告必须说明这些文件是否都看过。",
899
906
  review_scope: "报告中的审查覆盖范围;普通 reviewer/verifier 用 checked_paths 回执全部绑定文件,code-reviewer 还需按专用协议说明未检查项。",
900
907
  code_review_scope: "代码审查范围:从已审基点到当前 HEAD 的提交改动、工作区改动和未跟踪代码文件。",
901
- task_execution_index: "按任务汇总的执行证据:每个任务(task)的执行依据、声明测试、测试证据和改动文件。",
902
- contract: "任务启动时的执行依据快照:tests/design/source/reason/guard 分别对应 测试/设计/来源/原因/边界;null 表示历史任务没有执行依据。",
908
+ task_execution_index: "按任务汇总的执行证据:每个任务(task)的执行依据、有效证据要求、声明测试、测试证据和改动文件。",
909
+ contract: "任务启动时的执行依据快照:tests/design/source/acceptance/guard 分别对应 测试/设计/来源/验收/边界;null 表示历史任务没有执行依据。",
910
+ required_evidence: "task-start 结合冻结策略编译并写入 attempt 的有效证据要求:test_ids、red_required、green_required 和允许的 GREEN 语义状态;null 表示历史任务按旧记录回放。",
903
911
  changed_paths: "与某个任务(task)或代码状态检查相关的改动文件。",
904
912
  changed_paths_partial_reason: "该任务(task)的提交段 diff 失败原因;存在时 changed_paths 只包含工作区对比结果,归属可能不完整。",
905
913
  unattributed_paths: "代码审查范围中暂时无法归属到某个任务(task)的文件。",
@@ -974,10 +982,10 @@ export function jobsPacket(projectRoot, change, jobId) {
974
982
  ? `最小格式:{"role":"code-reviewer","verdict":"pass|fail","review_scope":{"job_id":"${job.job_id}","packet_digest":"${job.packet_digest}","checked_paths":${JSON.stringify(job.boundFiles.map(f => f.path))},"checked_docs":${JSON.stringify(REVIEW_DOC_PATHS)},"unchecked":[]},"findings":[],"reviewer":{"kind":"codex-subagent","id":"<thread-or-agent-id>"}};审查覆盖范围(review_scope)用来说明本次审查覆盖了哪些文件和文档,已检查路径(checked_paths)与未检查项(unchecked)必须合起来覆盖全部绑定文件(boundFiles),unchecked 条目格式为 {"path":"<path>","reason":"<reason>"}。`
975
983
  + `报告结论为 fail 时,问题列表(findings)至少包含一个可处理、可追溯的阻塞问题,字段为 {"id":"<stable-id>","blocking":true,"type":"implementation|spec|mixed","description":"<what>","evidence":"<why>","source_refs":["<path:line>"],"impact":"<impact>","suggested_action":"apply|propose"}。问题类型(type)中 implementation 表示纯代码实现问题,spec 表示方案/需求文档问题,mixed 表示需要使用者判断的混合问题。`
976
984
  + (packetContext?.task_execution_index
977
- ? `本工作项带任务执行索引(task_execution_index):按 task 对照其执行依据快照(contract)审查——实现路线对照 design 引用原文、累计 diff 对照 guard 边界、测试断言对照 tests 声明的 scenario;每项的 scope_note 是执行者登记的范围扩大说明,判断其合理性与验证充分性;changed_paths 是归属线索不是结论(null 表示未知);unattributed_paths 中的无主改动逐个判断合理性;coverage_exemption_refs 解释未绑定 task 的 TEST 豁免。`
985
+ ? `本工作项带任务执行索引(task_execution_index):按 task 对照其执行依据快照(contract)审查——实现路线对照 design 引用原文、累计 diff 对照 guard 边界、测试断言对照 tests 声明的 scenario;每项的 required_evidence 是 task-start 冻结的证据口径,red_required/green_required 分别说明是否需要 RED/GREEN;每项的 scope_note 是执行者登记的范围扩大说明,判断其合理性与验证充分性;changed_paths 是归属线索不是结论(null 表示未知);unattributed_paths 中的无主改动逐个判断合理性;coverage_exemption_refs 解释未绑定 task 的 TEST 豁免。`
978
986
  : "")
979
987
  : job.role === "verifier"
980
- ? `最小格式:{"role":"verifier","verdict":"pass|fail","findings":[]${hasReviewScope ? `,"review_scope":{"checked_paths":${JSON.stringify(job.boundFiles.map(file => file.path))}}` : ""}}。核对代码审查记录(code_review_gate):passed 必须能追溯到已接受的代码审查工作项,skipped 必须能证明本次没有代码类改动。核对代码审查问题闭环:实现修复任务必须带审查修复引用(review_fix_of:<job_id>#<problem_id>),方案/混合问题必须有用户决策或后续修复证据。核对 RED/GREEN:证据须在同一已完成任务的任务尝试 ID(task_completed.attempt_id)下闭环——普通 TDD 任务至少一个同 TEST RED(expected_failure)后 GREEN(expected_success)配对且每个声明 TEST 都有 GREEN;特征化任务(no_tdd_reason:characterization)可用 characterization_pass 作为通过证据,不要求 RED;测试运行证据应包含测试 ID(test_id)、命令(command)、工作目录(cwd)、退出码(exit_code)、语义状态(semantic_status);审查修复的回归测试运行可用回归覆盖任务列表(covers_task_ids)说明覆盖了哪些已完成任务;缺少任务尝试 ID(attempt_id)的旧证据只能弱引用。` +
988
+ ? `最小格式:{"role":"verifier","verdict":"pass|fail","findings":[]${hasReviewScope ? `,"review_scope":{"checked_paths":${JSON.stringify(job.boundFiles.map(file => file.path))}}` : ""}}。核对代码审查记录(code_review_gate):passed 必须能追溯到已接受的代码审查工作项,skipped 必须能证明本次没有代码类改动。核对代码审查问题闭环:实现修复任务必须带审查修复引用(review_fix_of:<job_id>#<problem_id>),方案/混合问题必须有用户决策或后续修复证据。按 task_execution_index required_evidence 核对测试证据:red_required 时需要同一 TEST RED(expected_failure)后 GREEN;green_required 时每个声明 TEST 都需要允许的 GREEN 语义状态;测试运行证据应包含测试 ID(test_id)、命令(command)、工作目录(cwd)、退出码(exit_code)、语义状态(semantic_status)。审查修复的回归测试运行可用回归覆盖任务列表(covers_task_ids)说明覆盖了哪些已完成任务;缺少任务尝试 ID(attempt_id)的旧证据只能弱引用。` +
981
989
  (packetContext?.code_state_check
982
990
  ? `本工作项带代码状态检查(code_state_check):head_matches 为 false 或 changed_paths 非空表示代码审查后代码又发生变化,须在报告中列出差异并交主流程与用户裁决,不自行判定无害,也不据此自动否定已接受的代码审查。`
983
991
  : "")
package/dist/review.js CHANGED
@@ -42,11 +42,27 @@ function isReviewPolicy(value) {
42
42
  typeof obj.requires_verifier === "boolean");
43
43
  }
44
44
  export function readReviewPolicyFromEvents(events) {
45
- for (const ev of events) {
45
+ // 新格式在 start-apply 写入,并且只能读取最新 Apply round 的策略。
46
+ // 回退到该 round 内的 review-ready,是为了回放升级前的历史 event。
47
+ let latestStartApplyIndex = -1;
48
+ for (let i = events.length - 1; i >= 0; i--) {
49
+ const event = events[i];
50
+ if (event.event_type !== "transition_commit")
51
+ continue;
52
+ const payload = event.payload;
53
+ if (payload.transition === "start-apply" && payload.to_state === "apply") {
54
+ latestStartApplyIndex = i;
55
+ break;
56
+ }
57
+ }
58
+ if (latestStartApplyIndex < 0)
59
+ return null;
60
+ for (let i = events.length - 1; i >= latestStartApplyIndex; i--) {
61
+ const ev = events[i];
46
62
  if (ev.event_type !== "transition_commit")
47
63
  continue;
48
64
  const payload = ev.payload;
49
- if (payload.transition !== "review-ready")
65
+ if (payload.transition !== "start-apply" && payload.transition !== "review-ready")
50
66
  continue;
51
67
  if (isReviewPolicy(payload.review_policy))
52
68
  return payload.review_policy;