@peterxiaoyang/superspec 0.1.47 → 0.1.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/record.js CHANGED
@@ -12,7 +12,8 @@ import { REVIEW_DOC_PATHS, REVIEW_REJECTION_OVERRIDE_SCOPE_PREFIX, REVIEW_REJECT
12
12
  import { EXPLORE_DISCOVERY_REVIEW_GATE, PROPOSE_FINAL_REVIEW_GATE } from "./review_job_gates.js";
13
13
  import { RecordInputDecodingError, readRecordInputFile } from "./record_input.js";
14
14
  import { currentExploreRoundId } from "./explore_round.js";
15
- import { discoveryOpenQuestionDisplayText, discoveryQuestionContextFingerprint, discoveryOpenQuestionScope, EXPLORE_OPEN_QUESTION_SCOPE_PREFIX, parseDiscoveryOpenQuestions, } from "./format.js";
15
+ import { currentProposeOpenQuestion, currentProposeQuestionContent, currentProposeRoundId, } from "./propose_round.js";
16
+ import { discoveryOpenQuestionDisplayText, discoveryQuestionContextFingerprint, discoveryQuestionDecisionBasisDigest, discoveryOpenQuestionScope, legacyDiscoveryOpenQuestionScope, EXPLORE_OPEN_QUESTION_SCOPE_PREFIX, parseDiscoveryOpenQuestions, proposeOpenQuestionDisplayText, proposeOpenQuestionScope, proposeQuestionContextFingerprint, proposeQuestionDecisionBasisDigest, legacyProposeOpenQuestionScope, PROPOSE_OPEN_QUESTION_SCOPE_PREFIX, } from "./format.js";
16
17
  const REVIEW_REPORT_REQUIRED_FIELDS = ["role", "verdict", "findings"];
17
18
  const REVIEW_REPORT_OPTIONAL_FIELDS = ["summary", "evidence_refs", "risks", "open_questions"];
18
19
  const REVIEWER_KINDS = new Set(["codex-subagent", "human", "external-agent"]);
@@ -73,7 +74,7 @@ function previousRejectionInstruction(job) {
73
74
  const identityRule = job.role === "code-reviewer"
74
75
  ? "同一问题仍存在时复用原 finding ID;legacy finding 没有 ID 时沿用原始语义并补一个稳定 ID;"
75
76
  : "已解决或已由等价证据闭环的问题不要重复报告,不得通过更换标题或措辞重复同一问题;";
76
- return `${reason}本轮是修复复核:逐项判断本工作项附带的上一次同角色 finding 是否仍成立。Finding 中的 recommendation 只是非绑定建议,不是需求或验收标准;先独立核对 underlying problem、直接证据和本次验收,不得因原建议指定了某种架构就要求照做。${identityRule}默认只复核历史 finding;新 blocker 仅允许是本次修正直接引入的回归,并必须说明“修正动作 → 新问题”的因果链,不得展开无关的故障模型、消费者或架构议题。`;
77
+ return `${reason}本轮是修复复核:逐项判断本工作项附带的上一次同角色 finding 是否仍成立。Finding 中的 recommendation 只是非绑定建议,不是需求或验收标准;先独立核对 underlying problem、直接证据和本次验收,不得因原建议指定了某种架构就要求照做。修正不得通过缩小已确认范围、改写用户决定或删除验收来让 finding 字面消失;这类偏离属于本次修正直接引入的回归。${identityRule}默认只复核历史 finding;新 blocker 仅允许是本次修正直接引入的回归,并必须说明“修正动作 → 新问题”的因果链,不得展开无关的故障模型、消费者或架构议题。`;
77
78
  }
78
79
  function reviewScopeForJob(job) {
79
80
  if (job.review_targets !== undefined || job.read_only_refs !== undefined) {
@@ -132,12 +133,21 @@ function currentDiscoveryOpenQuestion(projectRoot, change) {
132
133
  return null;
133
134
  return parseDiscoveryOpenQuestions(readFileSync(discoveryPath, "utf8"))[0] ?? null;
134
135
  }
135
- function latestAcceptedExploreOpenQuestionDecision(events, scope) {
136
+ function latestAcceptedExploreOpenQuestionDecision(events, scopes, identity) {
136
137
  return [...events].reverse().find(event => {
137
138
  if (event.event_type !== "user_decision_recorded")
138
139
  return false;
139
140
  const payload = event.payload;
140
- return payload.accepted !== false && payload.scope === scope;
141
+ if (payload.accepted === false)
142
+ return false;
143
+ const recorded = payload.explore_open_question;
144
+ if (recorded && typeof recorded.decision_basis_digest === "string") {
145
+ return recorded.round_id === identity.roundId &&
146
+ recorded.question_id === identity.questionId &&
147
+ (!identity.questionId.startsWith("item-") || recorded.question_ordinal === identity.questionOrdinal) &&
148
+ recorded.decision_basis_digest === identity.decisionBasisDigest;
149
+ }
150
+ return typeof payload.scope === "string" && scopes.includes(payload.scope);
141
151
  }) ?? null;
142
152
  }
143
153
  function isExploreOpenQuestionScope(scope) {
@@ -172,6 +182,52 @@ function invalidExploreOpenQuestionResult(projectRoot, change, inputDigest, exis
172
182
  : "需要确认的事项已变化或已完成,请重新执行 next 获取当前事项",
173
183
  };
174
184
  }
185
+ function latestAcceptedProposeOpenQuestionDecision(events, scopes, identity) {
186
+ return [...events].reverse().find(event => {
187
+ if (event.event_type !== "user_decision_recorded")
188
+ return false;
189
+ const payload = event.payload;
190
+ if (payload.accepted === false)
191
+ return false;
192
+ const recorded = payload.propose_open_question;
193
+ if (recorded && typeof recorded.decision_basis_digest === "string") {
194
+ return recorded.round_id === identity.roundId &&
195
+ recorded.path === identity.path &&
196
+ recorded.question_id === identity.questionId &&
197
+ (!identity.questionId.startsWith("item-") || recorded.question_ordinal === identity.questionOrdinal) &&
198
+ recorded.decision_basis_digest === identity.decisionBasisDigest;
199
+ }
200
+ return typeof payload.scope === "string" && scopes.includes(payload.scope);
201
+ }) ?? null;
202
+ }
203
+ function isProposeOpenQuestionScope(scope) {
204
+ return scope.startsWith(PROPOSE_OPEN_QUESTION_SCOPE_PREFIX);
205
+ }
206
+ function isWellFormedProposeOpenQuestionScope(scope) {
207
+ return /^propose_open_question:sha256:[a-f0-9]{64}:(?:DEC-[A-Za-z0-9][A-Za-z0-9_-]*|item-[1-9]\d*)$/.test(scope);
208
+ }
209
+ function invalidProposeOpenQuestionResult(projectRoot, change, inputDigest, existing, decision, reason) {
210
+ const existingPayload = existing?.payload;
211
+ if (existingPayload?.accepted === false && existingPayload.reason === reason) {
212
+ return { event_type: "user_decision_recorded", accepted: false, message: "幂等返回:同一无效设计决定答复已登记" };
213
+ }
214
+ appendEvent(projectRoot, change, makeEvent(change, "user_decision_recorded", {
215
+ accepted: false,
216
+ scope: decision.scope,
217
+ answer: decision.answer,
218
+ reason,
219
+ input_digest: inputDigest,
220
+ }));
221
+ return {
222
+ event_type: "user_decision_recorded",
223
+ accepted: false,
224
+ message: reason === "invalid_propose_open_question_scope"
225
+ ? "设计决定的内部标识无效,请重新执行 next 获取当前事项"
226
+ : reason === "propose_open_question_already_recorded"
227
+ ? "这项设计决定已有答复,请先回写计划材料后重新执行 next"
228
+ : "需要确认的设计决定已变化或已完成,请重新执行 next 获取当前事项",
229
+ };
230
+ }
175
231
  function invalidCodeReviewDecisionResult(projectRoot, change, inputDigest, existing, decision, reason, message) {
176
232
  const existingPayload = existing?.payload;
177
233
  if (existingPayload?.accepted === false && existingPayload.reason === reason) {
@@ -704,20 +760,31 @@ function recordUserDecisionLoaded(projectRoot, change, events, content, inputDig
704
760
  }
705
761
  // Explore 的用户答复只能绑定当前文档顺序中的第一项。这里不判断答案是否
706
762
  // “正确”,只机械校验当前项、Discovery 决策上下文和 Explore 轮次仍与 next
707
- // 返回时一致,防止旧问题在材料改写或重新探索后被错误登记为新问题的决定。
763
+ // 返回时一致。决定身份绑定问题项中明确写出的 basis;其它文档内容不参与当前
764
+ // scope,避免无关材料编辑触发重复提问。
708
765
  let exploreOpenQuestion = null;
709
766
  let exploreOpenQuestionRoundId = null;
710
767
  let exploreOpenQuestionContextFingerprint = null;
768
+ let exploreOpenQuestionBasisDigest = null;
711
769
  if (isExploreOpenQuestionScope(decision.scope)) {
712
770
  if (!isWellFormedExploreOpenQuestionScope(decision.scope)) {
713
771
  return invalidExploreOpenQuestionResult(projectRoot, change, inputDigest, existing, { scope: decision.scope, answer: decision.answer }, "invalid_explore_open_question_scope");
714
772
  }
715
773
  const current = currentDiscoveryOpenQuestion(projectRoot, change);
716
- const expectedScope = current ? discoveryOpenQuestionScope(current, currentExploreRoundId(events)) : null;
717
- if (!expectedScope || decision.scope !== expectedScope) {
774
+ const exploreRoundId = currentExploreRoundId(events);
775
+ const expectedScopes = current
776
+ ? [discoveryOpenQuestionScope(current, exploreRoundId), legacyDiscoveryOpenQuestionScope(current, exploreRoundId)]
777
+ : [];
778
+ if (!expectedScopes.includes(decision.scope)) {
718
779
  return invalidExploreOpenQuestionResult(projectRoot, change, inputDigest, existing, { scope: decision.scope, answer: decision.answer }, "stale_explore_open_question_scope");
719
780
  }
720
- const acceptedForCurrentScope = latestAcceptedExploreOpenQuestionDecision(events, decision.scope);
781
+ const currentBasisDigest = current ? discoveryQuestionDecisionBasisDigest(current) : "";
782
+ const acceptedForCurrentScope = latestAcceptedExploreOpenQuestionDecision(events, expectedScopes, {
783
+ roundId: exploreRoundId,
784
+ questionId: current.id,
785
+ questionOrdinal: current.ordinal,
786
+ decisionBasisDigest: currentBasisDigest,
787
+ });
721
788
  if (acceptedForCurrentScope) {
722
789
  const previousAnswer = acceptedForCurrentScope.payload.answer;
723
790
  if (previousAnswer === decision.answer) {
@@ -730,11 +797,52 @@ function recordUserDecisionLoaded(projectRoot, change, events, content, inputDig
730
797
  return invalidExploreOpenQuestionResult(projectRoot, change, inputDigest, existing, { scope: decision.scope, answer: decision.answer }, "explore_open_question_already_recorded");
731
798
  }
732
799
  exploreOpenQuestion = current;
733
- exploreOpenQuestionRoundId = currentExploreRoundId(events);
800
+ exploreOpenQuestionRoundId = exploreRoundId;
734
801
  const discoveryPath = join(openspecChangeRoot(projectRoot, change), ".superspec", "artifacts", "discovery.md");
735
802
  exploreOpenQuestionContextFingerprint = current
736
803
  ? discoveryQuestionContextFingerprint(readFileSync(discoveryPath, "utf8"), current)
737
804
  : null;
805
+ exploreOpenQuestionBasisDigest = currentBasisDigest;
806
+ }
807
+ let proposeOpenQuestion = null;
808
+ let proposeOpenQuestionRoundId = null;
809
+ let proposeOpenQuestionContextFingerprint = null;
810
+ let proposeOpenQuestionBasisDigest = null;
811
+ if (isProposeOpenQuestionScope(decision.scope)) {
812
+ if (!isWellFormedProposeOpenQuestionScope(decision.scope)) {
813
+ return invalidProposeOpenQuestionResult(projectRoot, change, inputDigest, existing, { scope: decision.scope, answer: decision.answer }, "invalid_propose_open_question_scope");
814
+ }
815
+ const changeRoot = openspecChangeRoot(projectRoot, change);
816
+ const current = currentProposeOpenQuestion(changeRoot);
817
+ const proposeRoundId = currentProposeRoundId(events);
818
+ const expectedScopes = current
819
+ ? [proposeOpenQuestionScope(current, proposeRoundId), legacyProposeOpenQuestionScope(current, proposeRoundId)]
820
+ : [];
821
+ if (!expectedScopes.includes(decision.scope)) {
822
+ return invalidProposeOpenQuestionResult(projectRoot, change, inputDigest, existing, { scope: decision.scope, answer: decision.answer }, "stale_propose_open_question_scope");
823
+ }
824
+ const currentBasisDigest = current ? proposeQuestionDecisionBasisDigest(current) : "";
825
+ const acceptedForCurrentScope = latestAcceptedProposeOpenQuestionDecision(events, expectedScopes, {
826
+ roundId: proposeRoundId,
827
+ path: current.path,
828
+ questionId: current.id,
829
+ questionOrdinal: current.ordinal,
830
+ decisionBasisDigest: currentBasisDigest,
831
+ });
832
+ if (acceptedForCurrentScope) {
833
+ const previousAnswer = acceptedForCurrentScope.payload.answer;
834
+ if (previousAnswer === decision.answer) {
835
+ return { event_type: "user_decision_recorded", accepted: true, message: "幂等返回:同一用户决策已登记" };
836
+ }
837
+ return invalidProposeOpenQuestionResult(projectRoot, change, inputDigest, existing, { scope: decision.scope, answer: decision.answer }, "propose_open_question_already_recorded");
838
+ }
839
+ const content = current ? currentProposeQuestionContent(changeRoot, current) : null;
840
+ proposeOpenQuestion = current;
841
+ proposeOpenQuestionRoundId = proposeRoundId;
842
+ proposeOpenQuestionContextFingerprint = current && content
843
+ ? proposeQuestionContextFingerprint(content, current)
844
+ : null;
845
+ proposeOpenQuestionBasisDigest = currentBasisDigest;
738
846
  }
739
847
  let phaseConfirmation = null;
740
848
  let phaseAction = null;
@@ -915,7 +1023,9 @@ function recordUserDecisionLoaded(projectRoot, change, events, content, inputDig
915
1023
  ? phaseConfirmation.ask.question
916
1024
  : exploreOpenQuestion
917
1025
  ? discoveryOpenQuestionDisplayText(exploreOpenQuestion)
918
- : typeof decision.question === "string" ? decision.question : "",
1026
+ : proposeOpenQuestion
1027
+ ? proposeOpenQuestionDisplayText(proposeOpenQuestion)
1028
+ : typeof decision.question === "string" ? decision.question : "",
919
1029
  answer: phaseAction
920
1030
  ? phaseAction.label
921
1031
  : normalizedAnswer ? codeReviewDecisionAnswerLabel(normalizedAnswer) : decision.answer,
@@ -923,13 +1033,25 @@ function recordUserDecisionLoaded(projectRoot, change, events, content, inputDig
923
1033
  ...(reviewRejectionDecisionSource ? { decision_source: reviewRejectionDecisionSource } : {}),
924
1034
  ...(reviewRejectionOverride ? { review_rejection_override: reviewRejectionOverride } : {}),
925
1035
  ...(codeReviewDecisionReference ? { code_review_decision: codeReviewDecisionReference } : {}),
926
- ...(exploreOpenQuestion && exploreOpenQuestionRoundId && exploreOpenQuestionContextFingerprint ? {
1036
+ ...(exploreOpenQuestion && exploreOpenQuestionRoundId && exploreOpenQuestionContextFingerprint && exploreOpenQuestionBasisDigest ? {
927
1037
  explore_open_question: {
928
1038
  round_id: exploreOpenQuestionRoundId,
929
1039
  question_id: exploreOpenQuestion.id,
930
1040
  question_ordinal: exploreOpenQuestion.ordinal,
931
1041
  document_fingerprint: exploreOpenQuestion.documentFingerprint,
932
1042
  context_fingerprint: exploreOpenQuestionContextFingerprint,
1043
+ decision_basis_digest: exploreOpenQuestionBasisDigest,
1044
+ },
1045
+ } : {}),
1046
+ ...(proposeOpenQuestion && proposeOpenQuestionRoundId && proposeOpenQuestionContextFingerprint && proposeOpenQuestionBasisDigest ? {
1047
+ propose_open_question: {
1048
+ round_id: proposeOpenQuestionRoundId,
1049
+ path: proposeOpenQuestion.path,
1050
+ question_id: proposeOpenQuestion.id,
1051
+ question_ordinal: proposeOpenQuestion.ordinal,
1052
+ document_fingerprint: proposeOpenQuestion.documentFingerprint,
1053
+ context_fingerprint: proposeOpenQuestionContextFingerprint,
1054
+ decision_basis_digest: proposeOpenQuestionBasisDigest,
933
1055
  },
934
1056
  } : {}),
935
1057
  ...(phaseAction ? {
@@ -15,7 +15,7 @@ export declare const EXPLORE_DISCOVERY_REVIEW_GATE_ID: "explore.discovery_review
15
15
  export declare const PROPOSE_FINAL_REVIEW_GATE_ID: "propose.final_review";
16
16
  export declare const REVIEW_CODE_REVIEW_GATE_ID: "review.code_review";
17
17
  export declare const REVIEW_FINAL_VERIFIER_GATE_ID: "review.final_verifier";
18
- export declare function reviewScopeForGateRole(gate: ReviewGateRule, role: JobRole): {
18
+ export declare function reviewScopeForGateRole(gate: ReviewGateRule, role: JobRole, requiredRoles?: readonly JobRole[]): {
19
19
  reviewTargets: string[];
20
20
  readOnlyRefs: string[];
21
21
  boundPaths: string[];
@@ -26,14 +26,22 @@ function defaultReviewScope(gate) {
26
26
  boundPaths: [...new Set([...gate.reviewTargets, ...gate.readOnlyRefs])],
27
27
  };
28
28
  }
29
- export function reviewScopeForGateRole(gate, role) {
29
+ export function reviewScopeForGateRole(gate, role, requiredRoles = gate.allowedRoles) {
30
30
  if (gate.gate_id !== PROPOSE_FINAL_REVIEW_GATE_ID)
31
31
  return defaultReviewScope(gate);
32
32
  if (role === "critic") {
33
- const reviewTargets = ["proposal.md", "specs/", "tasks.md"];
33
+ const ownsDesign = !requiredRoles.includes("architect");
34
+ const ownsTestContract = !requiredRoles.includes("test-engineer");
35
+ const reviewTargets = [
36
+ "proposal.md",
37
+ "specs/",
38
+ ...(ownsDesign ? ["design.md"] : []),
39
+ "tasks.md",
40
+ ...(ownsTestContract ? [".superspec/artifacts/test-contract.md"] : []),
41
+ ];
34
42
  const readOnlyRefs = [
35
- "design.md",
36
- ".superspec/artifacts/test-contract.md",
43
+ ...(!ownsDesign ? ["design.md"] : []),
44
+ ...(!ownsTestContract ? [".superspec/artifacts/test-contract.md"] : []),
37
45
  ".superspec/artifacts/discovery.md",
38
46
  ];
39
47
  return {
@@ -47,6 +47,26 @@ export function simulateLoop(nextFn, executeFn, maxSteps = 20) {
47
47
  return { completed: false, steps, finalState: output.state, message: `工作项执行失败` };
48
48
  }
49
49
  break;
50
+ case "artifact_required":
51
+ step.action = "artifact";
52
+ step.detail = output.artifact.path;
53
+ steps.push(step);
54
+ return {
55
+ completed: false,
56
+ steps,
57
+ finalState: output.state,
58
+ message: `需要创建或更新工作流产物:${output.artifact.path}`,
59
+ };
60
+ case "material_update_required":
61
+ step.action = "material_update";
62
+ step.detail = output.errors.join(";");
63
+ steps.push(step);
64
+ return {
65
+ completed: false,
66
+ steps,
67
+ finalState: output.state,
68
+ message: `需要修正工作流材料:${output.errors.join(";")}`,
69
+ };
50
70
  case "ask_user":
51
71
  step.action = "ask";
52
72
  step.detail = output.ask_user.question;
@@ -9,7 +9,7 @@ import { REVIEW_CODE_REVIEW_GATE_ID, REVIEW_FINAL_VERIFIER_GATE, REVIEW_FINAL_VE
9
9
  import { codeReviewBoundFiles, codeReviewDecisionScope, codeReviewJobStaleReason, codeReviewPacketContext, codeReviewPacketDigest, collectCodeReviewGateFacts, computeCodeStateCheck, currentCodeReviewWorkingPaths, dismissedCodeReviewSummary, effectiveCoverageExemptionRefsFromEvents, latestCodeReviewGateEvidence, latestCodeReviewDecision, latestCodeReviewFailedStatus, missingCoverageExemptionTestIds, requiresFinalVerifierForCurrentReview, scanCodeChangesForReview, taskExecutionIndexForReview, } from "./code_review.js";
10
10
  import { taskEvidenceReadiness } from "./task_evidence.js";
11
11
  import { adoptedContractForTask, findTaskInLines, isFixTaskId, parseTasksMd, parseTestContractEntries, } from "./format.js";
12
- import { applyRequirementModeForCurrentRound, executionRequirementVersionForCurrentRound, blockingJobsForApplyDone, executionPolicyForCurrentRound, formatPendingTaskMessage, latestAcceptedProposalBaseline, pendingTaskStatusForApply, planningValidationProfileForNewRound, planTransition, discoveryDocsBaseline, exploreAnswerRegistrationPayloadForChange, proposalDocsBaseline, } from "./phase_plan.js";
12
+ import { applyRequirementModeForCurrentRound, applyPlanningDocsChangedSinceBaseline, executionRequirementVersionForCurrentRound, blockingJobsForApplyDone, executionPolicyForCurrentRound, formatPendingTaskMessage, latestAcceptedProposalBaseline, pendingTaskStatusForApply, planningValidationProfileForNewRound, planTransition, discoveryDocsBaseline, exploreAnswerRegistrationPayloadForChange, proposeAnswerRegistrationPayloadForChange, proposalDocsBaseline, } from "./phase_plan.js";
13
13
  import { latestAcceptedPhaseDecision, phaseConfirmationCommitPayload, phaseConfirmationForBoundary, phaseConfirmationMissingMessage, } from "./phase_confirmation.js";
14
14
  import { currentGitHead, dirtyCodeFiles, stageProductionJavaFilesSince } from "./git_state.js";
15
15
  import { workflowRiskForProject } from "./workflow_config.js";
@@ -17,9 +17,9 @@ let transitionSeq = 0;
17
17
  function newTransitionId() { return `T-${Date.now()}-${++transitionSeq}`; }
18
18
  let jobSeq = 0;
19
19
  function newJobId(change, role) { return `JOB-${change.slice(0, 8)}-${role.slice(0, 4)}-${Date.now()}-${++jobSeq}`; }
20
- function createReviewJobsForGate(state, gate, roles, changeRoot, change, reason, events) {
20
+ function createReviewJobsForGate(state, gate, roles, requiredRoles, changeRoot, change, reason, events) {
21
21
  const newJobs = roles.map(role => {
22
- const scope = reviewScopeForGateRole(gate, role);
22
+ const scope = reviewScopeForGateRole(gate, role, requiredRoles);
23
23
  // 角色职责目标和显式 freshness 路径绑定时点指纹:单文件缺失使用 sha256:missing,目录缺失使用稳定空指纹。
24
24
  const boundPaths = [...new Set(scope.boundPaths)];
25
25
  const boundFiles = boundPaths
@@ -209,6 +209,28 @@ function compileRequiredEvidence(executionPolicy, testIds, requiresVerificationW
209
209
  accepted_green_statuses: ["expected_success"],
210
210
  };
211
211
  }
212
+ function evidenceActionsForAttempt(change, attemptId, fallbackTestId, required) {
213
+ const testIds = required.test_ids.length > 0 ? required.test_ids : [fallbackTestId];
214
+ const statuses = [];
215
+ if (required.red_required)
216
+ statuses.push("expected_failure");
217
+ if (required.green_required)
218
+ statuses.push(required.accepted_green_statuses[0] ?? "expected_success");
219
+ return testIds.flatMap(testId => statuses.map(semanticStatus => ({
220
+ kind: "test_run",
221
+ test_id: testId,
222
+ record_argv: ["superspec", "record", "test-run", "--change", change, "--input", "-"],
223
+ record_input: {
224
+ test_id: testId,
225
+ attempt_id: attemptId,
226
+ command: null,
227
+ cwd: null,
228
+ exit_code: null,
229
+ semantic_status: semanticStatus,
230
+ },
231
+ required_fields: ["command", "cwd", "exit_code"],
232
+ })));
233
+ }
212
234
  function hasRejectedReviewReadyVerifier(events) {
213
235
  const reviewReadyVerifierIds = new Set();
214
236
  for (const ev of events) {
@@ -627,7 +649,7 @@ function transitionPlanToDecision(snapshot, changeRoot, change, plan, events) {
627
649
  case "blocked":
628
650
  return { blocked: true, reason: plan.reason, jobs: plan.jobs, ...(plan.details ? { details: plan.details } : {}) };
629
651
  case "create_gate_jobs":
630
- return createReviewJobsForGate(snapshot.state, plan.gate, plan.roles, changeRoot, change, plan.reason, events);
652
+ return createReviewJobsForGate(snapshot.state, plan.gate, plan.roles, plan.requiredRoles, changeRoot, change, plan.reason, events);
631
653
  case "advance":
632
654
  return {
633
655
  fromState: plan.fromState,
@@ -798,6 +820,9 @@ export function taskStart(projectRoot, change, changeRoot, taskId) {
798
820
  if (snapshot.state !== "apply")
799
821
  return { skip: true, message: `当前状态 ${snapshot.state},需要 apply` };
800
822
  const events = readEvents(projectRoot, change);
823
+ if (applyPlanningMaterialsChanged(changeRoot, events)) {
824
+ return { skip: true, message: "Apply 期间计划材料已变化;请回到 Propose 核对并重新批准计划后再继续任务" };
825
+ }
801
826
  const tasksContent = readFileSync(join(changeRoot, "tasks.md"), "utf8");
802
827
  const lines = tasksContent.split("\n");
803
828
  const taskLineIdx = findTaskLine(lines, taskId);
@@ -867,6 +892,9 @@ export function taskStart(projectRoot, change, changeRoot, taskId) {
867
892
  ...attempt,
868
893
  ...boundarySnapshotPayload(projectRoot),
869
894
  };
895
+ const evidenceActions = requiredEvidence
896
+ ? evidenceActionsForAttempt(change, attempt.attempt_id, taskId, requiredEvidence)
897
+ : null;
870
898
  return {
871
899
  fromState: "apply", toState: "apply", outcome: "advanced",
872
900
  reason: `创建任务 ${taskId} 执行尝试`,
@@ -878,6 +906,7 @@ export function taskStart(projectRoot, change, changeRoot, taskId) {
878
906
  contract: adopted.contract,
879
907
  ...(fix ? { fix } : {}),
880
908
  ...(requiredEvidence ? { required_evidence: requiredEvidence } : {}),
909
+ ...(evidenceActions ? { evidence_actions: evidenceActions } : {}),
881
910
  // legacy 轮统一标注历史模式(无论有无执行依据文本);契约轮无块时标 false(如 Fix task)
882
911
  ...(adopted.contract ? {} : { legacy_contract: !contractMode }),
883
912
  },
@@ -908,6 +937,36 @@ function invalidateOpenJobs(snapshot, to, reason) {
908
937
  },
909
938
  }));
910
939
  }
940
+ function latestApplyPlanningBaseline(events) {
941
+ for (let index = events.length - 1; index >= 0; index--) {
942
+ const event = events[index];
943
+ if (event.event_type !== "transition_commit")
944
+ continue;
945
+ const payload = event.payload;
946
+ if (payload.transition !== "start-apply")
947
+ continue;
948
+ const baseline = payload.apply_planning_baseline;
949
+ if (!baseline || typeof baseline !== "object" || Array.isArray(baseline))
950
+ return null;
951
+ const entries = Object.entries(baseline);
952
+ return entries.every(([, digest]) => typeof digest === "string")
953
+ ? Object.fromEntries(entries)
954
+ : null;
955
+ }
956
+ return null;
957
+ }
958
+ function applyPlanningMaterialsChanged(changeRoot, events) {
959
+ const baseline = latestApplyPlanningBaseline(events);
960
+ return baseline != null && applyPlanningDocsChangedSinceBaseline(changeRoot, baseline);
961
+ }
962
+ function proposalReopenBaseline(changeRoot, events, source) {
963
+ const applyBaseline = ["apply", "apply_done", "review"].includes(source)
964
+ ? latestApplyPlanningBaseline(events)
965
+ : null;
966
+ return applyBaseline
967
+ ? { baseline: { ...proposalDocsBaseline(changeRoot), ...applyBaseline }, source: "apply" }
968
+ : { baseline: proposalDocsBaseline(changeRoot), source: "reopen_fallback" };
969
+ }
911
970
  function planningReopenExtraEvents(snapshot, to, reason) {
912
971
  return [
913
972
  ...invalidateOpenJobs(snapshot, to, reason),
@@ -969,6 +1028,7 @@ export function reopen(projectRoot, change, changeRoot, to, reason, opts = {}) {
969
1028
  baseline_docs: proposalDocsBaseline(changeRoot),
970
1029
  planning_validation_version: 2,
971
1030
  planning_validation_profile: planningValidationProfileForNewRound(projectRoot),
1031
+ ...proposeAnswerRegistrationPayloadForChange(changeRoot),
972
1032
  },
973
1033
  };
974
1034
  }
@@ -977,6 +1037,9 @@ export function reopen(projectRoot, change, changeRoot, to, reason, opts = {}) {
977
1037
  return { skip: true, message: "--review-fix 只能用于回到实现阶段(reopen --to apply)" };
978
1038
  if (snapshot.state !== "apply_done")
979
1039
  return { skip: true, message: `当前状态 ${snapshot.state},不能通过代码审查修复回到实现阶段` };
1040
+ if (applyPlanningMaterialsChanged(changeRoot, events)) {
1041
+ return { skip: true, message: "计划材料已变化,不能作为纯实现问题回到 Apply;请 reopen --to propose" };
1042
+ }
980
1043
  const ref = parseCodeReviewFindingRef(opts.reviewFix);
981
1044
  if (!ref)
982
1045
  return { skip: true, message: "--review-fix 必须是 <job_id>#<finding_id>" };
@@ -1018,6 +1081,9 @@ export function reopen(projectRoot, change, changeRoot, to, reason, opts = {}) {
1018
1081
  return { skip: true, message: `当前状态 ${snapshot.state},不能通过自测问题回到实现阶段` };
1019
1082
  }
1020
1083
  const parentTaskId = opts.selfTestFix.trim();
1084
+ if (applyPlanningMaterialsChanged(changeRoot, events)) {
1085
+ return { skip: true, message: "计划材料已变化,不能作为纯实现问题创建 self-test 修复;请 reopen --to propose" };
1086
+ }
1021
1087
  const parentTask = parseTasksMd(readFileSync(join(changeRoot, "tasks.md"), "utf8"))
1022
1088
  .find(task => task.taskId === parentTaskId);
1023
1089
  if (!parentTask)
@@ -1091,12 +1157,13 @@ export function reopen(projectRoot, change, changeRoot, to, reason, opts = {}) {
1091
1157
  // 的摘要,并用 reopen 当刻的摘要补齐缺项,确保本轮之后对任一审查目标的修改都能被检测。
1092
1158
  const baselineNeedsBackfill = acceptedBaseline !== null && Object.keys(currentBaseline)
1093
1159
  .some(path => !Object.prototype.hasOwnProperty.call(acceptedBaseline, path));
1160
+ const applyReopenBaseline = acceptedBaseline ? null : proposalReopenBaseline(changeRoot, events, snapshot.state);
1094
1161
  const baselineDocs = acceptedBaseline
1095
1162
  ? Object.fromEntries(Object.entries(currentBaseline).map(([path, digest]) => [
1096
1163
  path,
1097
1164
  Object.prototype.hasOwnProperty.call(acceptedBaseline, path) ? acceptedBaseline[path] : digest,
1098
1165
  ]))
1099
- : currentBaseline;
1166
+ : applyReopenBaseline.baseline;
1100
1167
  return {
1101
1168
  fromState: snapshot.state,
1102
1169
  toState: "propose",
@@ -1105,7 +1172,9 @@ export function reopen(projectRoot, change, changeRoot, to, reason, opts = {}) {
1105
1172
  commitPayload: {
1106
1173
  reopen_target: "propose",
1107
1174
  reopen_source: snapshot.state,
1108
- baseline_source: acceptedBaseline ? (baselineNeedsBackfill ? "accepted_backfill" : "accepted") : "reopen_fallback",
1175
+ baseline_source: acceptedBaseline
1176
+ ? (baselineNeedsBackfill ? "accepted_backfill" : "accepted")
1177
+ : applyReopenBaseline.source,
1109
1178
  baseline_docs: baselineDocs,
1110
1179
  planning_validation_version: 2,
1111
1180
  planning_validation_profile: planningValidationProfileForNewRound(projectRoot),
@@ -1118,6 +1187,9 @@ export function reopen(projectRoot, change, changeRoot, to, reason, opts = {}) {
1118
1187
  if (snapshot.state !== "apply_done" && snapshot.state !== "review") {
1119
1188
  return { skip: true, message: `当前状态 ${snapshot.state},不能 reopen 到 apply` };
1120
1189
  }
1190
+ if (applyPlanningMaterialsChanged(changeRoot, events)) {
1191
+ return { skip: true, message: "计划材料已变化,不能直接回到 Apply;请 reopen --to propose" };
1192
+ }
1121
1193
  const pending = pendingTaskStatusForApply(changeRoot, events).pending;
1122
1194
  if (pending.length === 0)
1123
1195
  return { skip: true, message: "没有未完成任务,不能 reopen 到 apply" };
@@ -1140,6 +1212,9 @@ export function reviewReady(projectRoot, change, changeRoot, risk = workflowRisk
1140
1212
  const policy = storedPolicy ?? reviewPolicyForRisk(risk);
1141
1213
  const policyPayload = storedPolicy ? {} : { review_policy: policy };
1142
1214
  const currentEvidenceDigest = reviewEvidenceDigest(events);
1215
+ if (snapshot.state === "apply" && applyPlanningMaterialsChanged(changeRoot, events)) {
1216
+ return { skip: true, message: "Apply 期间计划材料已变化,不能进入 Review;请回到 Propose 核对并重新批准计划" };
1217
+ }
1143
1218
  // 检查是否所有任务已完成
1144
1219
  const pending = pendingTaskStatusForApply(changeRoot, events).pending;
1145
1220
  if (pending.length > 0)
@@ -1240,10 +1315,14 @@ export function taskComplete(projectRoot, change, changeRoot, taskId, inputConte
1240
1315
  const attempt = snapshot.active_task_attempts?.find(a => a.task_id === taskId && a.state === "active");
1241
1316
  if (!attempt)
1242
1317
  return { skip: true, message: `任务 ${taskId} 无活跃执行尝试` };
1318
+ const events = readEvents(projectRoot, change);
1319
+ if (applyPlanningMaterialsChanged(changeRoot, events)) {
1320
+ return { skip: true, message: "Apply 期间计划材料已变化,不能完成当前任务;请回到 Propose 核对并重新批准计划" };
1321
+ }
1243
1322
  const readiness = taskEvidenceReadiness(projectRoot, change, changeRoot, attempt);
1244
1323
  if (!readiness.ready)
1245
1324
  return { skip: true, message: `任务 ${taskId} 无法完成:${readiness.reason}` };
1246
- const taskStartBoundary = boundarySnapshotForTaskAttempt(readEvents(projectRoot, change), attempt.attempt_id);
1325
+ const taskStartBoundary = boundarySnapshotForTaskAttempt(events, attempt.attempt_id);
1247
1326
  const completedPayload = {
1248
1327
  task_id: taskId,
1249
1328
  attempt_id: attempt.attempt_id,
package/dist/types.d.ts CHANGED
@@ -168,7 +168,7 @@ export interface RequiredJobAction {
168
168
  packet_command: string;
169
169
  packet_argv: string[];
170
170
  }
171
- export type EventType = "transition_prepare" | "transition_commit" | "job_requested" | "job_invalidated" | "reopen" | "abandon" | "task_started" | "task_completed" | "task_abandoned" | "job_accepted" | "job_rejected" | "user_decision_recorded" | "test_run_recorded" | "task_activation_recorded" | "artifact_recorded";
171
+ export type EventType = "transition_prepare" | "transition_commit" | "job_requested" | "job_invalidated" | "reopen" | "abandon" | "task_started" | "task_completed" | "task_abandoned" | "job_accepted" | "job_rejected" | "user_question_presented" | "user_decision_recorded" | "test_run_recorded" | "task_activation_recorded" | "artifact_recorded";
172
172
  export interface Event {
173
173
  event_id: string;
174
174
  event_type: EventType;
@@ -192,6 +192,9 @@ export type OpenSpecValidationProfile = {
192
192
  export interface PlanningValidationProfile {
193
193
  version: 2;
194
194
  openspec: OpenSpecValidationProfile;
195
+ design?: {
196
+ schema_version: 1;
197
+ };
195
198
  }
196
199
  export interface TransitionCommitPayload {
197
200
  transition: string;
@@ -223,6 +226,8 @@ export interface TransitionCommitPayload {
223
226
  review_risk?: "minimal" | "normal" | "strict";
224
227
  };
225
228
  accepted_baseline_docs?: Record<string, string>;
229
+ /** Apply 开始时冻结的计划材料摘要;tasks.md 由状态机维护,不参与冻结。 */
230
+ apply_planning_baseline?: Record<string, string>;
226
231
  /** Propose-ready / start-apply 写入的本轮 workflow mode,后续阶段只读该快照。 */
227
232
  workflow_mode?: "minimal" | "normal" | "strict";
228
233
  /** v2 起所有普通任务必须有五字段执行依据;缺失表示旧 change,沿用旧规则回放。 */
@@ -318,6 +323,14 @@ export interface AskUser {
318
323
  allowed_answers: string[];
319
324
  scope: string;
320
325
  actions?: AskUserAction[];
326
+ /** 自由文本问题的直接登记入口;固定选项继续使用 actions。 */
327
+ record_argv?: string[];
328
+ record_input?: {
329
+ scope: string;
330
+ question: string;
331
+ answer: null;
332
+ };
333
+ required_fields?: Array<"answer">;
321
334
  }
322
335
  export interface AcceptedMaterialFollowupContinuation {
323
336
  kind: "accepted_material_followup";
@@ -331,6 +344,33 @@ export interface AcceptedMaterialFollowupContinuation {
331
344
  };
332
345
  plan_docs_changed_since_accept: boolean | null;
333
346
  }
347
+ export type WorkflowArtifactKind = "discovery" | "test_contract" | "tasks";
348
+ export interface RequiredWorkflowArtifact {
349
+ kind: WorkflowArtifactKind;
350
+ /** Repository-relative canonical path owned by the workflow engine. */
351
+ path: string;
352
+ operation: "create_or_update";
353
+ }
354
+ export interface ArtifactRequiredResume {
355
+ argv: string[];
356
+ }
357
+ export interface MaterialUpdateRequiredResume {
358
+ argv: string[];
359
+ }
360
+ export interface TestEvidenceAction {
361
+ kind: "test_run";
362
+ test_id: string;
363
+ record_argv: string[];
364
+ record_input: {
365
+ test_id: string;
366
+ attempt_id: string;
367
+ command: null;
368
+ cwd: null;
369
+ exit_code: null;
370
+ semantic_status: "expected_failure" | "expected_success" | "characterization_pass";
371
+ };
372
+ required_fields: Array<"command" | "cwd" | "exit_code">;
373
+ }
334
374
  export type NextOutput = {
335
375
  state: State;
336
376
  } & ({
@@ -342,6 +382,16 @@ export type NextOutput = {
342
382
  path: "required_job";
343
383
  required_jobs: RequiredJobAction[];
344
384
  reason: string;
385
+ } | {
386
+ path: "artifact_required";
387
+ artifact: RequiredWorkflowArtifact;
388
+ resume: ArtifactRequiredResume;
389
+ reason: string;
390
+ } | {
391
+ path: "material_update_required";
392
+ errors: string[];
393
+ resume: MaterialUpdateRequiredResume;
394
+ reason: string;
345
395
  } | {
346
396
  path: "ask_user";
347
397
  ask_user: AskUser;
@@ -6,7 +6,7 @@ const CURRENT_GATE_ROLES_BY_RISK = {
6
6
  "review.final_verifier": ["verifier"],
7
7
  },
8
8
  normal: {
9
- "explore.discovery_review": [],
9
+ "explore.discovery_review": ["critic"],
10
10
  "propose.final_review": ["critic"],
11
11
  "review.code_review": ["code-reviewer"],
12
12
  "review.final_verifier": ["verifier"],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peterxiaoyang/superspec",
3
- "version": "0.1.47",
3
+ "version": "0.1.49",
4
4
  "description": "SuperSpec 流程引擎 — transition engine with lightweight fact-sync",
5
5
  "type": "module",
6
6
  "engines": {
@@ -24,6 +24,12 @@
24
24
  "build": "node build.js",
25
25
  "typecheck": "tsc --noEmit",
26
26
  "test": "node --test tests/*.ts",
27
+ "eval:probe": "node evals/probe.mjs",
28
+ "eval:dynamic": "node evals/probe.mjs --scenario evals/scenarios/probe-dynamic-accepted.json",
29
+ "eval:m2": "node evals/m2.mjs",
30
+ "eval:m2:validate": "node evals/m2.mjs --validate-faults",
31
+ "eval:arena": "node evals/arena.mjs",
32
+ "eval:delegation": "node evals/delegation-probe.mjs",
27
33
  "prepack": "npm run build",
28
34
  "prepublishOnly": "npm run build"
29
35
  },