@peterxiaoyang/superspec 0.1.47 → 0.1.48

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.
@@ -1,8 +1,9 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
- import { join } from "node:path";
2
+ import { join, relative } from "node:path";
3
3
  import { EXPLORE_DISCOVERY_REVIEW_GATE, PROPOSE_FINAL_REVIEW_GATE } from "./review_job_gates.js";
4
- import { currentExploreRoundId, exploreAnswerRegistrationPayload, unregisteredClosedExploreQuestions, } from "./explore_round.js";
5
- import { collectProposeOpenQuestions, discoveryOpenQuestionDisplayText, discoveryOpenQuestionScope, parseExecutionRequirements, parseDiscoveryOpenQuestions, parseTasksMd, pendingTasksInContent, validateDiscovery, validateExecutionRequirements, validateExecutionRequirementDocumentReferences, validateProposalImpact, validateTasksDocument, } from "./format.js";
4
+ import { currentExploreRoundId, exploreAnswerRegistrationPayload, unregisteredClosedExploreQuestions, unresolvedPresentedExploreQuestionScopes, } from "./explore_round.js";
5
+ import { currentProposeOpenQuestion, currentProposeRoundId, proposeAnswerRegistrationPayload, unregisteredClosedProposeQuestions, unresolvedPresentedProposeQuestionScopes, } from "./propose_round.js";
6
+ import { discoveryOpenQuestionDisplayText, discoveryOpenQuestionScope, proposeOpenQuestionDisplayText, proposeOpenQuestionScope, parseExecutionRequirements, parseDiscoveryOpenQuestions, parseTasksMd, pendingTasksInContent, validateDiscovery, validateExecutionRequirements, validateExecutionRequirementDocumentReferences, validateProposalImpact, validateTasksDocument, } from "./format.js";
6
7
  import { currentGitHead } from "./git_state.js";
7
8
  import { validateOpenSpecChange } from "./openspec.js";
8
9
  import { docRef, sha256File } from "./store.js";
@@ -14,6 +15,26 @@ import { workflowRiskForProposeRound, workflowRiskForState } from "./workflow_co
14
15
  function requiredJobs(state, jobs, reason) {
15
16
  return { kind: "required_jobs", state, jobs, reason };
16
17
  }
18
+ function freeTextDecisionAsk(change, question, scope) {
19
+ return {
20
+ question,
21
+ allowed_answers: [],
22
+ scope,
23
+ record_argv: ["superspec", "record", "user-decision", "--change", change, "--input", "-"],
24
+ record_input: { scope, question, answer: null },
25
+ required_fields: ["answer"],
26
+ };
27
+ }
28
+ function requiredArtifact(projectRoot, change, changeRoot, state, kind, fileName, risk) {
29
+ const artifactPath = relative(projectRoot, join(changeRoot, ".superspec", "artifacts", fileName)).replaceAll("\\", "/");
30
+ return {
31
+ kind: "artifact_required",
32
+ state,
33
+ artifact: { kind, path: artifactPath, operation: "create_or_update" },
34
+ resume: { argv: nextArgv(change, risk) },
35
+ reason: `${fileName} 不存在`,
36
+ };
37
+ }
17
38
  function phaseConfirmationStep(context, boundary, reason) {
18
39
  // 进入 propose_ready / apply 后,mode 来自本轮快照而非当前配置。
19
40
  const risk = workflowRiskForState(context.events, context.snapshot.state, context.mode.risk);
@@ -76,6 +97,7 @@ function reviewGatePlan(snapshot, events, changeRoot, gate, requiredRoles) {
76
97
  kind: "create_gate_jobs",
77
98
  gate,
78
99
  roles: missingRoles.map(item => item.role),
100
+ requiredRoles: [...requiredRoles],
79
101
  reason: missingRoles.map(item => item.reason).join("; "),
80
102
  };
81
103
  }
@@ -96,6 +118,42 @@ function validateTasksPlan(changeRoot, executionRequirementVersion) {
96
118
  return errors.join(";");
97
119
  return null;
98
120
  }
121
+ const REQUIRED_DESIGN_HEADINGS = [
122
+ "# 设计",
123
+ "## 背景",
124
+ "## 设计目标",
125
+ "## 非目标",
126
+ "## 总体方案",
127
+ "## 实现方案",
128
+ ];
129
+ function validateDesignPlan(changeRoot, profile) {
130
+ if (profile?.openspec.mode !== "strict" || profile.design?.schema_version !== 1)
131
+ return null;
132
+ const designPath = join(changeRoot, "design.md");
133
+ if (!existsSync(designPath))
134
+ return null;
135
+ const lines = readFileSync(designPath, "utf8").split(/\r?\n/).map(line => line.trimEnd());
136
+ const errors = [];
137
+ const headingIndexes = new Map();
138
+ for (const heading of REQUIRED_DESIGN_HEADINGS) {
139
+ const indexes = lines.flatMap((line, index) => line === heading ? [index] : []);
140
+ headingIndexes.set(heading, indexes);
141
+ if (indexes.length === 0)
142
+ errors.push(`design.md 缺少稳定结构标题:${heading}`);
143
+ if (indexes.length > 1)
144
+ errors.push(`design.md 稳定结构标题重复:${heading}`);
145
+ }
146
+ if (errors.length > 0)
147
+ return errors.join(";");
148
+ let previousIndex = -1;
149
+ for (const heading of REQUIRED_DESIGN_HEADINGS) {
150
+ const indexes = headingIndexes.get(heading);
151
+ if (indexes[0] <= previousIndex)
152
+ return `design.md 稳定结构标题顺序错误:${heading}`;
153
+ previousIndex = indexes[0];
154
+ }
155
+ return null;
156
+ }
99
157
  export function executionPolicyForRisk(risk) {
100
158
  return risk === "strict" ? "tdd" : "green_only";
101
159
  }
@@ -153,17 +211,27 @@ function validateOpenSpecPlanningDocuments(projectRoot, change, changeRoot, prof
153
211
  }
154
212
  function validatePlanningPreflight(projectRoot, change, changeRoot, risk, executionPolicy, profile) {
155
213
  const executionRequirementVersion = profile?.version ?? 1;
214
+ const errors = [];
156
215
  const tasksPlanError = validateTasksPlan(changeRoot, executionRequirementVersion);
157
216
  if (tasksPlanError)
158
- return { error: tasksPlanError, contractMode: false };
217
+ errors.push(tasksPlanError);
159
218
  const executionRequirementPlan = validateExecutionRequirementPlan(changeRoot, executionPolicy, executionRequirementVersion);
160
219
  if (!executionRequirementPlan.ok)
161
- return { error: executionRequirementPlan.message, contractMode: false };
220
+ errors.push(executionRequirementPlan.message);
162
221
  const missingArtifact = missingBaseArtifact(changeRoot, risk);
163
222
  if (missingArtifact)
164
- return { error: missingArtifact, contractMode: false };
223
+ errors.push(missingArtifact);
224
+ const designPlanError = validateDesignPlan(changeRoot, profile);
225
+ if (designPlanError)
226
+ errors.push(designPlanError);
227
+ if (existsSync(join(changeRoot, "tasks.md"))) {
228
+ const openSpecError = validateOpenSpecPlanningDocuments(projectRoot, change, changeRoot, profile);
229
+ if (openSpecError)
230
+ errors.push(openSpecError);
231
+ }
165
232
  return {
166
- error: validateOpenSpecPlanningDocuments(projectRoot, change, changeRoot, profile),
233
+ error: errors.length > 0 ? [...new Set(errors)].join(";") : null,
234
+ errors: [...new Set(errors)],
167
235
  contractMode: executionRequirementPlan.mode,
168
236
  };
169
237
  }
@@ -190,6 +258,9 @@ export function exploreAnswerRegistrationPayloadForChange(changeRoot) {
190
258
  const discoveryPath = join(changeRoot, ".superspec", "artifacts", "discovery.md");
191
259
  return exploreAnswerRegistrationPayload(existsSync(discoveryPath) ? readFileSync(discoveryPath, "utf8") : null);
192
260
  }
261
+ export function proposeAnswerRegistrationPayloadForChange(changeRoot) {
262
+ return proposeAnswerRegistrationPayload(changeRoot);
263
+ }
193
264
  function isDigestMap(value) {
194
265
  if (!value || typeof value !== "object" || Array.isArray(value))
195
266
  return false;
@@ -290,15 +361,16 @@ function isPlanningValidationProfile(value) {
290
361
  const profile = value;
291
362
  if (profile.version !== 2 || !profile.openspec || typeof profile.openspec !== "object")
292
363
  return false;
293
- return profile.openspec.mode === "disabled" ||
294
- profile.openspec.mode === "strict" && typeof profile.openspec.config_digest === "string";
364
+ const designValid = profile.design == null || profile.design.schema_version === 1;
365
+ return designValid && (profile.openspec.mode === "disabled" ||
366
+ profile.openspec.mode === "strict" && typeof profile.openspec.config_digest === "string");
295
367
  }
296
368
  /** 新 planning round 在进入 propose 时冻结当前 OpenSpec 校验契约。 */
297
369
  export function planningValidationProfileForNewRound(projectRoot) {
298
370
  const configDigest = sha256File(join(projectRoot, "openspec", "config.yaml"));
299
371
  return configDigest == null
300
- ? { version: 2, openspec: { mode: "disabled" } }
301
- : { version: 2, openspec: { mode: "strict", config_digest: configDigest } };
372
+ ? { version: 2, openspec: { mode: "disabled" }, design: { schema_version: 1 } }
373
+ : { version: 2, openspec: { mode: "strict", config_digest: configDigest }, design: { schema_version: 1 } };
302
374
  }
303
375
  /** propose 状态尚未 ready 时,从进入本 planning round 的事件读取冻结 profile。 */
304
376
  function planningValidationProfileForPendingProposeRound(events) {
@@ -441,7 +513,7 @@ function acceptedMaterialFollowup(change, risk, planDocsChangedSinceAccept) {
441
513
  };
442
514
  }
443
515
  export function planNextStep(context) {
444
- const { change, changeRoot, events, mode, snapshot } = context;
516
+ const { projectRoot, change, changeRoot, events, mode, snapshot } = context;
445
517
  switch (snapshot.state) {
446
518
  case "init":
447
519
  if (snapshot.open_jobs.length > 0) {
@@ -458,6 +530,10 @@ export function planNextStep(context) {
458
530
  reason: "回到 explore 后至少一个 discovery 材料必须变化",
459
531
  };
460
532
  }
533
+ const discoveryPath = join(changeRoot, ".superspec", "artifacts", "discovery.md");
534
+ if (!existsSync(discoveryPath)) {
535
+ return requiredArtifact(projectRoot, change, changeRoot, "explore", "discovery", "discovery.md", mode.risk);
536
+ }
461
537
  const discoveryCheck = validateDiscovery(changeRoot);
462
538
  if (!discoveryCheck.ok) {
463
539
  const ask = {
@@ -471,22 +547,27 @@ export function planNextStep(context) {
471
547
  const currentQuestion = parseDiscoveryOpenQuestions(content)[0];
472
548
  if (currentQuestion) {
473
549
  const questionText = discoveryOpenQuestionDisplayText(currentQuestion);
474
- const ask = {
475
- question: `现在有一件事需要你确认:${questionText}\n\n请只回答这一件事。主流程会先登记答复,再将结论回写 discovery.md;回写完成前会继续询问这一件事。`,
476
- // 用户可以接受建议、选择其他方向或补充事实;状态机不解释答案语义。
477
- allowed_answers: [],
478
- scope: discoveryOpenQuestionScope(currentQuestion, currentExploreRoundId(events)),
479
- };
550
+ const question = `现在有一件事需要你确认:${questionText}\n\n请只回答这一件事。主流程会先登记答复,再将结论回写 discovery.md;回写完成前会继续询问这一件事。`;
551
+ const scope = discoveryOpenQuestionScope(currentQuestion, currentExploreRoundId(events));
552
+ const ask = freeTextDecisionAsk(change, question, scope);
480
553
  return { kind: "ask_user", state: "explore", ask, reason: "等待用户确认" };
481
554
  }
482
555
  const unregisteredClosedQuestions = unregisteredClosedExploreQuestions(events, content);
483
556
  if (unregisteredClosedQuestions.length > 0) {
484
- const ask = {
485
- question: "发现一项已标记为已确认的事项没有对应的答复登记。请先将该项恢复为待确认,按主流程重新登记用户答复并回写 discovery.md 后继续。",
486
- allowed_answers: ["已处理"],
487
- scope: "explore_answer_registration",
557
+ return {
558
+ kind: "material_update_required",
559
+ state: "explore",
560
+ errors: ["发现一项已标记为已确认的事项没有对应答复登记;请恢复为待确认,通过 next 登记真实答复后再回写 discovery.md"],
561
+ reason: "存在未登记答复的已确认事项",
562
+ };
563
+ }
564
+ if (unresolvedPresentedExploreQuestionScopes(events).length > 0) {
565
+ return {
566
+ kind: "material_update_required",
567
+ state: "explore",
568
+ errors: ["此前已展示的 Explore 问题尚未登记答复但已从 discovery.md 消失;请恢复原问题,通过 next 登记答复后再回写结论"],
569
+ reason: "已展示的 Explore 问题缺少答复登记",
488
570
  };
489
- return { kind: "ask_user", state: "explore", ask, reason: "存在未登记答复的已确认事项" };
490
571
  }
491
572
  // 先让用户澄清当前 Discovery,再审查材料;否则 critic 会审查一份仍有
492
573
  // 关键业务未知的文档。正常创建的 job 已绑定 discovery 指纹,材料变化后
@@ -510,15 +591,42 @@ export function planNextStep(context) {
510
591
  };
511
592
  }
512
593
  case "propose": {
513
- const openQuestions = collectProposeOpenQuestions(changeRoot);
514
- if (openQuestions.openCount > 0) {
515
- const files = openQuestions.files.map(f => `${f.path}(${f.openCount})`).join(", ");
516
- const ask = {
517
- question: `计划文档有 ${openQuestions.openCount} 个待用户确认问题:${files}。请确认并更新计划文档后继续`,
518
- allowed_answers: ["所有问题已确认"],
519
- scope: "propose_open_questions",
594
+ const currentQuestion = currentProposeOpenQuestion(changeRoot);
595
+ if (currentQuestion) {
596
+ const question = `设计方案中有一件高影响取舍需要你决定:${proposeOpenQuestionDisplayText(currentQuestion)}\n\n请只回答这一件事。主流程会登记答复并将决定回写相关计划材料;回写完成前仍会停在当前事项。`;
597
+ const scope = proposeOpenQuestionScope(currentQuestion, currentProposeRoundId(events));
598
+ const ask = freeTextDecisionAsk(change, question, scope);
599
+ return { kind: "ask_user", state: "propose", ask, reason: "等待用户确认设计取舍" };
600
+ }
601
+ if (unregisteredClosedProposeQuestions(events, changeRoot).length > 0) {
602
+ return {
603
+ kind: "material_update_required",
604
+ state: "propose",
605
+ errors: ["发现一项已标记为确认的设计决定没有对应答复登记;请恢复为待确认,通过 next 登记真实答复后再回写计划材料"],
606
+ reason: "存在未登记答复的设计决定",
607
+ };
608
+ }
609
+ if (unresolvedPresentedProposeQuestionScopes(events).length > 0) {
610
+ return {
611
+ kind: "material_update_required",
612
+ state: "propose",
613
+ errors: ["此前已展示的设计问题尚未登记答复但已从计划材料消失;请恢复原问题,通过 next 登记答复后再回写结论"],
614
+ reason: "已展示的设计问题缺少答复登记",
615
+ };
616
+ }
617
+ const testContractPath = join(changeRoot, ".superspec", "artifacts", "test-contract.md");
618
+ if (!existsSync(testContractPath)) {
619
+ return requiredArtifact(projectRoot, change, changeRoot, "propose", "test_contract", "test-contract.md", mode.risk);
620
+ }
621
+ const planningProfile = planningValidationProfileForPendingProposeRound(events);
622
+ const preflight = validatePlanningPreflight(projectRoot, change, changeRoot, mode.risk, executionPolicyForRisk(mode.risk), planningProfile);
623
+ if (preflight.error) {
624
+ return {
625
+ kind: "material_update_required",
626
+ state: "propose",
627
+ errors: preflight.errors,
628
+ reason: preflight.error,
520
629
  };
521
- return { kind: "ask_user", state: "propose", ask, reason: `有 ${openQuestions.openCount} 个 propose 未确认问题` };
522
630
  }
523
631
  const proposalReviewJobs = PROPOSE_FINAL_REVIEW_GATE.openJobsForGate(snapshot);
524
632
  if (proposalReviewJobs.length > 0) {
@@ -860,6 +968,9 @@ function planExploreTransition(context) {
860
968
  if (unregisteredClosedExploreQuestions(events, discoveryContent).length > 0) {
861
969
  return { kind: "skip", message: "discovery.md 有已确认事项缺少对应答复登记,请先恢复为待确认并按主流程登记答复" };
862
970
  }
971
+ if (unresolvedPresentedExploreQuestionScopes(events).length > 0) {
972
+ return { kind: "skip", message: "此前展示的 Explore 问题缺少答复登记且已从 discovery.md 消失,请恢复原问题并完成登记" };
973
+ }
863
974
  const requiredRoles = EXPLORE_DISCOVERY_REVIEW_GATE.requiredRolesForRisk(mode.risk);
864
975
  const gatePlan = reviewGatePlan(snapshot, events, changeRoot, EXPLORE_DISCOVERY_REVIEW_GATE, requiredRoles);
865
976
  if (gatePlan)
@@ -878,6 +989,7 @@ function planExploreTransition(context) {
878
989
  ...phaseConfirmationCommitPayload(confirmation, decision),
879
990
  planning_validation_version: 2,
880
991
  planning_validation_profile: planningValidationProfileForNewRound(projectRoot),
992
+ ...proposeAnswerRegistrationPayloadForChange(changeRoot),
881
993
  },
882
994
  };
883
995
  }
@@ -890,10 +1002,14 @@ function planProposeReadyTransition(context) {
890
1002
  const preflight = validatePlanningPreflight(context.projectRoot, context.change, changeRoot, risk, executionPolicyForRisk(risk), planningProfile);
891
1003
  if (preflight.error)
892
1004
  return { kind: "skip", message: preflight.error };
893
- const openQuestions = collectProposeOpenQuestions(changeRoot);
894
- if (openQuestions.openCount > 0) {
895
- const files = openQuestions.files.map(f => `${f.path}(${f.openCount})`).join(", ");
896
- return { kind: "skip", message: `计划文档有 ${openQuestions.openCount} 个待用户确认问题:${files}` };
1005
+ if (currentProposeOpenQuestion(changeRoot)) {
1006
+ return { kind: "skip", message: "计划文档仍有待用户确认的设计决定,请先完成确认并回写计划材料" };
1007
+ }
1008
+ if (unregisteredClosedProposeQuestions(context.events, changeRoot).length > 0) {
1009
+ return { kind: "skip", message: "计划文档有已确认设计决定缺少对应答复登记,请先恢复为待确认并按主流程登记答复" };
1010
+ }
1011
+ if (unresolvedPresentedProposeQuestionScopes(context.events).length > 0) {
1012
+ return { kind: "skip", message: "此前展示的设计问题缺少答复登记且已从计划材料消失,请恢复原问题并完成登记" };
897
1013
  }
898
1014
  const requiredRoles = PROPOSE_FINAL_REVIEW_GATE.requiredRolesForRisk(risk);
899
1015
  const gatePlan = reviewGatePlan(snapshot, context.events, changeRoot, PROPOSE_FINAL_REVIEW_GATE, requiredRoles);
@@ -0,0 +1,15 @@
1
+ import { type ProposeQuestion } from "./format.ts";
2
+ import type { Event } from "./types.ts";
3
+ export declare function currentProposeRoundId(events: readonly Event[]): string;
4
+ export declare function proposeAnswerRegistrationPayload(changeRoot: string): {
5
+ propose_answer_registration: {
6
+ version: number;
7
+ baseline_closed_question_keys: string[];
8
+ };
9
+ };
10
+ export declare function proposeAnswerWasRecorded(events: readonly Event[], roundId: string, question: ProposeQuestion, content: string): boolean;
11
+ export declare function unregisteredClosedProposeQuestions(events: readonly Event[], changeRoot: string): ProposeQuestion[];
12
+ export declare function currentProposeOpenQuestion(changeRoot: string): ProposeQuestion | null;
13
+ export declare function currentProposeQuestionContent(changeRoot: string, question: ProposeQuestion): string | null;
14
+ /** 已正式展示但尚未登记答复的 Propose 问题不能通过删除问题行绕过。 */
15
+ export declare function unresolvedPresentedProposeQuestionScopes(events: readonly Event[]): string[];
@@ -0,0 +1,137 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { collectProposeQuestions, parseProposeQuestions, proposeQuestionContextFingerprint, proposeQuestionDecisionBasisDigest, proposeQuestionKey, } from "./format.js";
4
+ const PROPOSE_ANSWER_REGISTRATION_VERSION = 1;
5
+ function isProposeRoundEntry(event) {
6
+ if (event.event_type !== "transition_commit")
7
+ return false;
8
+ const payload = event.payload;
9
+ return payload.to_state === "propose" && (payload.transition === "explore" && payload.from_state === "explore" ||
10
+ payload.transition === "reopen" && payload.reopen_target === "propose");
11
+ }
12
+ function currentProposeRoundEntry(events) {
13
+ for (let index = events.length - 1; index >= 0; index--) {
14
+ const event = events[index];
15
+ if (isProposeRoundEntry(event))
16
+ return { event, roundId: event.event_id };
17
+ }
18
+ return null;
19
+ }
20
+ export function currentProposeRoundId(events) {
21
+ return currentProposeRoundEntry(events)?.roundId ?? "legacy-propose-round";
22
+ }
23
+ export function proposeAnswerRegistrationPayload(changeRoot) {
24
+ return {
25
+ propose_answer_registration: {
26
+ version: PROPOSE_ANSWER_REGISTRATION_VERSION,
27
+ baseline_closed_question_keys: collectProposeQuestions(changeRoot)
28
+ .filter(question => question.status === "closed")
29
+ .map(proposeQuestionKey)
30
+ .sort(),
31
+ },
32
+ };
33
+ }
34
+ function currentRegistration(events) {
35
+ const entry = currentProposeRoundEntry(events);
36
+ if (!entry)
37
+ return { enabled: false, roundId: "legacy-propose-round", baselineClosedQuestionKeys: new Set() };
38
+ const payload = entry.event.payload;
39
+ const registration = payload.propose_answer_registration;
40
+ if (registration?.version !== PROPOSE_ANSWER_REGISTRATION_VERSION || !Array.isArray(registration.baseline_closed_question_keys)) {
41
+ return { enabled: false, roundId: entry.roundId, baselineClosedQuestionKeys: new Set() };
42
+ }
43
+ return {
44
+ enabled: true,
45
+ roundId: entry.roundId,
46
+ baselineClosedQuestionKeys: new Set(registration.baseline_closed_question_keys.filter((key) => typeof key === "string")),
47
+ };
48
+ }
49
+ function questionContent(changeRoot, question) {
50
+ const path = join(changeRoot, question.path);
51
+ return existsSync(path) ? readFileSync(path, "utf8") : null;
52
+ }
53
+ export function proposeAnswerWasRecorded(events, roundId, question, content) {
54
+ const contextFingerprint = proposeQuestionContextFingerprint(content, question);
55
+ if (!contextFingerprint)
56
+ return false;
57
+ const currentBasisDigest = proposeQuestionDecisionBasisDigest(question);
58
+ return events.some(event => {
59
+ if (event.event_type !== "user_decision_recorded")
60
+ return false;
61
+ const payload = event.payload;
62
+ const recorded = payload.propose_open_question;
63
+ const revisionMatches = typeof recorded?.decision_basis_digest === "string"
64
+ ? recorded.decision_basis_digest === currentBasisDigest
65
+ : recorded?.context_fingerprint === contextFingerprint;
66
+ return payload.accepted === true &&
67
+ recorded?.round_id === roundId &&
68
+ recorded.path === question.path &&
69
+ recorded.question_id === question.id &&
70
+ (!question.id.startsWith("item-") || recorded.question_ordinal === question.ordinal) &&
71
+ revisionMatches;
72
+ });
73
+ }
74
+ export function unregisteredClosedProposeQuestions(events, changeRoot) {
75
+ const registration = currentRegistration(events);
76
+ if (!registration.enabled)
77
+ return [];
78
+ return collectProposeQuestions(changeRoot).filter(question => {
79
+ if (question.status !== "closed" || registration.baselineClosedQuestionKeys.has(proposeQuestionKey(question)))
80
+ return false;
81
+ const content = questionContent(changeRoot, question);
82
+ return content != null && !proposeAnswerWasRecorded(events, registration.roundId, question, content);
83
+ });
84
+ }
85
+ export function currentProposeOpenQuestion(changeRoot) {
86
+ return collectProposeQuestions(changeRoot).find(question => question.status === "open") ?? null;
87
+ }
88
+ export function currentProposeQuestionContent(changeRoot, question) {
89
+ const path = join(changeRoot, question.path);
90
+ if (!existsSync(path))
91
+ return null;
92
+ const content = readFileSync(path, "utf8");
93
+ return parseProposeQuestions(content, question.path).some(candidate => candidate.id === question.id && candidate.ordinal === question.ordinal)
94
+ ? content
95
+ : null;
96
+ }
97
+ /** 已正式展示但尚未登记答复的 Propose 问题不能通过删除问题行绕过。 */
98
+ export function unresolvedPresentedProposeQuestionScopes(events) {
99
+ const roundId = currentProposeRoundId(events);
100
+ const acceptedDecisions = events.flatMap(event => {
101
+ if (event.event_type !== "user_decision_recorded")
102
+ return [];
103
+ const payload = event.payload;
104
+ return payload.accepted === true ? [payload] : [];
105
+ });
106
+ const latestByQuestion = new Map();
107
+ for (const event of events) {
108
+ if (event.event_type !== "user_question_presented")
109
+ continue;
110
+ const payload = event.payload;
111
+ if (payload.phase !== "propose" || payload.round_id !== roundId || typeof payload.scope !== "string" || typeof payload.path !== "string" || typeof payload.question_id !== "string")
112
+ continue;
113
+ const ordinal = payload.question_id.startsWith("item-") ? `:${String(payload.question_ordinal)}` : "";
114
+ latestByQuestion.set(`${payload.path}:${payload.question_id}${ordinal}`, {
115
+ scope: payload.scope,
116
+ legacyScope: typeof payload.legacy_scope === "string" ? payload.legacy_scope : null,
117
+ path: payload.path,
118
+ questionId: payload.question_id,
119
+ questionOrdinal: payload.question_ordinal,
120
+ revisionDigest: payload.decision_basis_digest,
121
+ });
122
+ }
123
+ return [...latestByQuestion.values()].flatMap(presented => {
124
+ const answered = acceptedDecisions.some(decision => {
125
+ if (decision.scope === presented.scope || decision.scope === presented.legacyScope)
126
+ return true;
127
+ const recorded = decision.propose_open_question;
128
+ return recorded?.round_id === roundId &&
129
+ recorded.path === presented.path &&
130
+ recorded.question_id === presented.questionId &&
131
+ (!presented.questionId.startsWith("item-") || recorded.question_ordinal === presented.questionOrdinal) &&
132
+ typeof presented.revisionDigest === "string" &&
133
+ recorded.decision_basis_digest === presented.revisionDigest;
134
+ });
135
+ return answered ? [] : [presented.scope];
136
+ });
137
+ }