@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/cli.js +52 -8
- package/dist/explore_round.d.ts +3 -1
- package/dist/explore_round.js +48 -3
- package/dist/format.d.ts +28 -1
- package/dist/format.js +121 -4
- package/dist/next.d.ts +1 -1
- package/dist/next.js +97 -12
- package/dist/phase_confirmation.js +3 -1
- package/dist/phase_plan.d.ts +23 -1
- package/dist/phase_plan.js +190 -39
- package/dist/propose_round.d.ts +15 -0
- package/dist/propose_round.js +137 -0
- package/dist/record.js +133 -11
- package/dist/review_job_gates.d.ts +1 -1
- package/dist/review_job_gates.js +12 -4
- package/dist/skill_loop.js +20 -0
- package/dist/transition.js +86 -7
- package/dist/types.d.ts +51 -1
- package/dist/workflow_profile.js +1 -1
- package/package.json +7 -1
- package/templates/workflow/AGENTS.md +6 -0
- package/templates/workflow/prompts/architect.md +2 -0
- package/templates/workflow/prompts/critic.md +11 -2
- package/templates/workflow/prompts/test-engineer.md +1 -0
- package/templates/workflow/skills/superspec-apply/SKILL.md +7 -3
- package/templates/workflow/skills/superspec-explore/SKILL.md +7 -3
- package/templates/workflow/skills/superspec-propose/SKILL.md +18 -4
package/dist/phase_plan.js
CHANGED
|
@@ -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 {
|
|
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, canonicalPath, risk) {
|
|
29
|
+
const artifactPath = relative(projectRoot, join(changeRoot, canonicalPath)).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: `${canonicalPath.split("/").at(-1)} 不存在`,
|
|
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
|
-
|
|
217
|
+
errors.push(tasksPlanError);
|
|
159
218
|
const executionRequirementPlan = validateExecutionRequirementPlan(changeRoot, executionPolicy, executionRequirementVersion);
|
|
160
219
|
if (!executionRequirementPlan.ok)
|
|
161
|
-
|
|
220
|
+
errors.push(executionRequirementPlan.message);
|
|
162
221
|
const missingArtifact = missingBaseArtifact(changeRoot, risk);
|
|
163
222
|
if (missingArtifact)
|
|
164
|
-
|
|
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:
|
|
233
|
+
error: errors.length > 0 ? [...new Set(errors)].join(";") : null,
|
|
234
|
+
errors: [...new Set(errors)],
|
|
167
235
|
contractMode: executionRequirementPlan.mode,
|
|
168
236
|
};
|
|
169
237
|
}
|
|
@@ -176,6 +244,15 @@ export function proposalDocsBaseline(changeRoot) {
|
|
|
176
244
|
}
|
|
177
245
|
return baseline;
|
|
178
246
|
}
|
|
247
|
+
export function applyPlanningBaseline(changeRoot) {
|
|
248
|
+
const baseline = proposalDocsBaseline(changeRoot);
|
|
249
|
+
delete baseline["tasks.md"];
|
|
250
|
+
return baseline;
|
|
251
|
+
}
|
|
252
|
+
export function applyPlanningDocsChangedSinceBaseline(changeRoot, baseline) {
|
|
253
|
+
const current = applyPlanningBaseline(changeRoot);
|
|
254
|
+
return Object.entries(baseline).some(([path, digest]) => current[path] !== digest);
|
|
255
|
+
}
|
|
179
256
|
export function discoveryDocsBaseline(changeRoot) {
|
|
180
257
|
// 与 Explore gate 使用同一组审查目标。回退到 explore 后,至少要更新一项
|
|
181
258
|
// discovery 材料,才允许重新进入 propose,避免把一次纯状态回退误当作新探索轮次。
|
|
@@ -190,6 +267,9 @@ export function exploreAnswerRegistrationPayloadForChange(changeRoot) {
|
|
|
190
267
|
const discoveryPath = join(changeRoot, ".superspec", "artifacts", "discovery.md");
|
|
191
268
|
return exploreAnswerRegistrationPayload(existsSync(discoveryPath) ? readFileSync(discoveryPath, "utf8") : null);
|
|
192
269
|
}
|
|
270
|
+
export function proposeAnswerRegistrationPayloadForChange(changeRoot) {
|
|
271
|
+
return proposeAnswerRegistrationPayload(changeRoot);
|
|
272
|
+
}
|
|
193
273
|
function isDigestMap(value) {
|
|
194
274
|
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
195
275
|
return false;
|
|
@@ -290,15 +370,16 @@ function isPlanningValidationProfile(value) {
|
|
|
290
370
|
const profile = value;
|
|
291
371
|
if (profile.version !== 2 || !profile.openspec || typeof profile.openspec !== "object")
|
|
292
372
|
return false;
|
|
293
|
-
|
|
294
|
-
|
|
373
|
+
const designValid = profile.design == null || profile.design.schema_version === 1;
|
|
374
|
+
return designValid && (profile.openspec.mode === "disabled" ||
|
|
375
|
+
profile.openspec.mode === "strict" && typeof profile.openspec.config_digest === "string");
|
|
295
376
|
}
|
|
296
377
|
/** 新 planning round 在进入 propose 时冻结当前 OpenSpec 校验契约。 */
|
|
297
378
|
export function planningValidationProfileForNewRound(projectRoot) {
|
|
298
379
|
const configDigest = sha256File(join(projectRoot, "openspec", "config.yaml"));
|
|
299
380
|
return configDigest == null
|
|
300
|
-
? { version: 2, openspec: { mode: "disabled" } }
|
|
301
|
-
: { version: 2, openspec: { mode: "strict", config_digest: configDigest } };
|
|
381
|
+
? { version: 2, openspec: { mode: "disabled" }, design: { schema_version: 1 } }
|
|
382
|
+
: { version: 2, openspec: { mode: "strict", config_digest: configDigest }, design: { schema_version: 1 } };
|
|
302
383
|
}
|
|
303
384
|
/** propose 状态尚未 ready 时,从进入本 planning round 的事件读取冻结 profile。 */
|
|
304
385
|
function planningValidationProfileForPendingProposeRound(events) {
|
|
@@ -404,6 +485,20 @@ export function pendingTaskStatusForApply(changeRoot, events) {
|
|
|
404
485
|
completedByEvent,
|
|
405
486
|
};
|
|
406
487
|
}
|
|
488
|
+
function isPostApplyPlanOnlyRepair(changeRoot, events) {
|
|
489
|
+
// Propose 返工发生在新 Apply round 之前,旧 round 的 task_completed 不能覆盖
|
|
490
|
+
// 当前计划明确重新打开的 checkbox;这里以当前 tasks.md 为准。
|
|
491
|
+
if (pendingTaskIds(changeRoot).length > 0)
|
|
492
|
+
return false;
|
|
493
|
+
const roundId = currentProposeRoundId(events);
|
|
494
|
+
const roundEvent = events.find(event => event.event_id === roundId);
|
|
495
|
+
if (roundEvent?.event_type !== "transition_commit")
|
|
496
|
+
return false;
|
|
497
|
+
const payload = roundEvent.payload;
|
|
498
|
+
return payload.transition === "reopen"
|
|
499
|
+
&& payload.reopen_target === "propose"
|
|
500
|
+
&& ["apply", "apply_done", "review", "accepted"].includes(String(payload.reopen_source));
|
|
501
|
+
}
|
|
407
502
|
export function formatPendingTaskMessage(ids, action) {
|
|
408
503
|
return `尚有未完成任务:${ids.join(", ")};${action}`;
|
|
409
504
|
}
|
|
@@ -441,7 +536,7 @@ function acceptedMaterialFollowup(change, risk, planDocsChangedSinceAccept) {
|
|
|
441
536
|
};
|
|
442
537
|
}
|
|
443
538
|
export function planNextStep(context) {
|
|
444
|
-
const { change, changeRoot, events, mode, snapshot } = context;
|
|
539
|
+
const { projectRoot, change, changeRoot, events, mode, snapshot } = context;
|
|
445
540
|
switch (snapshot.state) {
|
|
446
541
|
case "init":
|
|
447
542
|
if (snapshot.open_jobs.length > 0) {
|
|
@@ -458,6 +553,10 @@ export function planNextStep(context) {
|
|
|
458
553
|
reason: "回到 explore 后至少一个 discovery 材料必须变化",
|
|
459
554
|
};
|
|
460
555
|
}
|
|
556
|
+
const discoveryPath = join(changeRoot, ".superspec", "artifacts", "discovery.md");
|
|
557
|
+
if (!existsSync(discoveryPath)) {
|
|
558
|
+
return requiredArtifact(projectRoot, change, changeRoot, "explore", "discovery", ".superspec/artifacts/discovery.md", mode.risk);
|
|
559
|
+
}
|
|
461
560
|
const discoveryCheck = validateDiscovery(changeRoot);
|
|
462
561
|
if (!discoveryCheck.ok) {
|
|
463
562
|
const ask = {
|
|
@@ -471,22 +570,27 @@ export function planNextStep(context) {
|
|
|
471
570
|
const currentQuestion = parseDiscoveryOpenQuestions(content)[0];
|
|
472
571
|
if (currentQuestion) {
|
|
473
572
|
const questionText = discoveryOpenQuestionDisplayText(currentQuestion);
|
|
474
|
-
const
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
allowed_answers: [],
|
|
478
|
-
scope: discoveryOpenQuestionScope(currentQuestion, currentExploreRoundId(events)),
|
|
479
|
-
};
|
|
573
|
+
const question = `现在有一件事需要你确认:${questionText}\n\n请只回答这一件事。主流程会先登记答复,再将结论回写 discovery.md;回写完成前会继续询问这一件事。`;
|
|
574
|
+
const scope = discoveryOpenQuestionScope(currentQuestion, currentExploreRoundId(events));
|
|
575
|
+
const ask = freeTextDecisionAsk(change, question, scope);
|
|
480
576
|
return { kind: "ask_user", state: "explore", ask, reason: "等待用户确认" };
|
|
481
577
|
}
|
|
482
578
|
const unregisteredClosedQuestions = unregisteredClosedExploreQuestions(events, content);
|
|
483
579
|
if (unregisteredClosedQuestions.length > 0) {
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
580
|
+
return {
|
|
581
|
+
kind: "material_update_required",
|
|
582
|
+
state: "explore",
|
|
583
|
+
errors: ["发现一项已标记为已确认的事项没有对应答复登记;请恢复为待确认,通过 next 登记真实答复后再回写 discovery.md"],
|
|
584
|
+
reason: "存在未登记答复的已确认事项",
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
if (unresolvedPresentedExploreQuestionScopes(events).length > 0) {
|
|
588
|
+
return {
|
|
589
|
+
kind: "material_update_required",
|
|
590
|
+
state: "explore",
|
|
591
|
+
errors: ["此前已展示的 Explore 问题尚未登记答复但已从 discovery.md 消失;请恢复原问题,通过 next 登记答复后再回写结论"],
|
|
592
|
+
reason: "已展示的 Explore 问题缺少答复登记",
|
|
488
593
|
};
|
|
489
|
-
return { kind: "ask_user", state: "explore", ask, reason: "存在未登记答复的已确认事项" };
|
|
490
594
|
}
|
|
491
595
|
// 先让用户澄清当前 Discovery,再审查材料;否则 critic 会审查一份仍有
|
|
492
596
|
// 关键业务未知的文档。正常创建的 job 已绑定 discovery 指纹,材料变化后
|
|
@@ -510,15 +614,45 @@ export function planNextStep(context) {
|
|
|
510
614
|
};
|
|
511
615
|
}
|
|
512
616
|
case "propose": {
|
|
513
|
-
const
|
|
514
|
-
if (
|
|
515
|
-
const
|
|
516
|
-
const
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
617
|
+
const currentQuestion = currentProposeOpenQuestion(changeRoot);
|
|
618
|
+
if (currentQuestion) {
|
|
619
|
+
const question = `设计方案中有一件高影响取舍需要你决定:${proposeOpenQuestionDisplayText(currentQuestion)}\n\n请只回答这一件事。主流程会登记答复并将决定回写相关计划材料;回写完成前仍会停在当前事项。`;
|
|
620
|
+
const scope = proposeOpenQuestionScope(currentQuestion, currentProposeRoundId(events));
|
|
621
|
+
const ask = freeTextDecisionAsk(change, question, scope);
|
|
622
|
+
return { kind: "ask_user", state: "propose", ask, reason: "等待用户确认设计取舍" };
|
|
623
|
+
}
|
|
624
|
+
if (unregisteredClosedProposeQuestions(events, changeRoot).length > 0) {
|
|
625
|
+
return {
|
|
626
|
+
kind: "material_update_required",
|
|
627
|
+
state: "propose",
|
|
628
|
+
errors: ["发现一项已标记为确认的设计决定没有对应答复登记;请恢复为待确认,通过 next 登记真实答复后再回写计划材料"],
|
|
629
|
+
reason: "存在未登记答复的设计决定",
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
if (unresolvedPresentedProposeQuestionScopes(events).length > 0) {
|
|
633
|
+
return {
|
|
634
|
+
kind: "material_update_required",
|
|
635
|
+
state: "propose",
|
|
636
|
+
errors: ["此前已展示的设计问题尚未登记答复但已从计划材料消失;请恢复原问题,通过 next 登记答复后再回写结论"],
|
|
637
|
+
reason: "已展示的设计问题缺少答复登记",
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
const testContractPath = join(changeRoot, ".superspec", "artifacts", "test-contract.md");
|
|
641
|
+
if (!existsSync(testContractPath)) {
|
|
642
|
+
return requiredArtifact(projectRoot, change, changeRoot, "propose", "test_contract", ".superspec/artifacts/test-contract.md", mode.risk);
|
|
643
|
+
}
|
|
644
|
+
if (!existsSync(join(changeRoot, "tasks.md"))) {
|
|
645
|
+
return requiredArtifact(projectRoot, change, changeRoot, "propose", "tasks", "tasks.md", mode.risk);
|
|
646
|
+
}
|
|
647
|
+
const planningProfile = planningValidationProfileForPendingProposeRound(events);
|
|
648
|
+
const preflight = validatePlanningPreflight(projectRoot, change, changeRoot, mode.risk, executionPolicyForRisk(mode.risk), planningProfile);
|
|
649
|
+
if (preflight.error) {
|
|
650
|
+
return {
|
|
651
|
+
kind: "material_update_required",
|
|
652
|
+
state: "propose",
|
|
653
|
+
errors: preflight.errors,
|
|
654
|
+
reason: preflight.error,
|
|
520
655
|
};
|
|
521
|
-
return { kind: "ask_user", state: "propose", ask, reason: `有 ${openQuestions.openCount} 个 propose 未确认问题` };
|
|
522
656
|
}
|
|
523
657
|
const proposalReviewJobs = PROPOSE_FINAL_REVIEW_GATE.openJobsForGate(snapshot);
|
|
524
658
|
if (proposalReviewJobs.length > 0) {
|
|
@@ -538,7 +672,8 @@ export function planNextStep(context) {
|
|
|
538
672
|
return requiredJobs("propose_ready", proposalReviewJobs, `有 ${proposalReviewJobs.length} 个待完成 proposal 审查工作项`);
|
|
539
673
|
}
|
|
540
674
|
const startApplyPlan = planStartApplyTransition(context, false);
|
|
541
|
-
|
|
675
|
+
const planOnlyRepair = isPostApplyPlanOnlyRepair(changeRoot, events);
|
|
676
|
+
if (startApplyPlan.kind === "advance" && !planOnlyRepair) {
|
|
542
677
|
const confirmation = phaseConfirmationStep(context, "propose_to_apply", "计划阶段完成,等待用户确认开始实现");
|
|
543
678
|
if (confirmation)
|
|
544
679
|
return confirmation;
|
|
@@ -553,7 +688,12 @@ export function planNextStep(context) {
|
|
|
553
688
|
};
|
|
554
689
|
return { kind: "ask_user", state: "propose_ready", ask, reason: startApplyPlan.message };
|
|
555
690
|
}
|
|
556
|
-
return {
|
|
691
|
+
return {
|
|
692
|
+
kind: "run_transition",
|
|
693
|
+
state: "propose_ready",
|
|
694
|
+
transition: "start-apply",
|
|
695
|
+
reason: planOnlyRepair ? "计划材料已修正且没有待实施任务,跳过重复的 Apply 确认" : "计划就绪,开始执行",
|
|
696
|
+
};
|
|
557
697
|
}
|
|
558
698
|
case "apply":
|
|
559
699
|
return planApplyNext(context);
|
|
@@ -860,6 +1000,9 @@ function planExploreTransition(context) {
|
|
|
860
1000
|
if (unregisteredClosedExploreQuestions(events, discoveryContent).length > 0) {
|
|
861
1001
|
return { kind: "skip", message: "discovery.md 有已确认事项缺少对应答复登记,请先恢复为待确认并按主流程登记答复" };
|
|
862
1002
|
}
|
|
1003
|
+
if (unresolvedPresentedExploreQuestionScopes(events).length > 0) {
|
|
1004
|
+
return { kind: "skip", message: "此前展示的 Explore 问题缺少答复登记且已从 discovery.md 消失,请恢复原问题并完成登记" };
|
|
1005
|
+
}
|
|
863
1006
|
const requiredRoles = EXPLORE_DISCOVERY_REVIEW_GATE.requiredRolesForRisk(mode.risk);
|
|
864
1007
|
const gatePlan = reviewGatePlan(snapshot, events, changeRoot, EXPLORE_DISCOVERY_REVIEW_GATE, requiredRoles);
|
|
865
1008
|
if (gatePlan)
|
|
@@ -878,6 +1021,7 @@ function planExploreTransition(context) {
|
|
|
878
1021
|
...phaseConfirmationCommitPayload(confirmation, decision),
|
|
879
1022
|
planning_validation_version: 2,
|
|
880
1023
|
planning_validation_profile: planningValidationProfileForNewRound(projectRoot),
|
|
1024
|
+
...proposeAnswerRegistrationPayloadForChange(changeRoot),
|
|
881
1025
|
},
|
|
882
1026
|
};
|
|
883
1027
|
}
|
|
@@ -890,10 +1034,14 @@ function planProposeReadyTransition(context) {
|
|
|
890
1034
|
const preflight = validatePlanningPreflight(context.projectRoot, context.change, changeRoot, risk, executionPolicyForRisk(risk), planningProfile);
|
|
891
1035
|
if (preflight.error)
|
|
892
1036
|
return { kind: "skip", message: preflight.error };
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
1037
|
+
if (currentProposeOpenQuestion(changeRoot)) {
|
|
1038
|
+
return { kind: "skip", message: "计划文档仍有待用户确认的设计决定,请先完成确认并回写计划材料" };
|
|
1039
|
+
}
|
|
1040
|
+
if (unregisteredClosedProposeQuestions(context.events, changeRoot).length > 0) {
|
|
1041
|
+
return { kind: "skip", message: "计划文档有已确认设计决定缺少对应答复登记,请先恢复为待确认并按主流程登记答复" };
|
|
1042
|
+
}
|
|
1043
|
+
if (unresolvedPresentedProposeQuestionScopes(context.events).length > 0) {
|
|
1044
|
+
return { kind: "skip", message: "此前展示的设计问题缺少答复登记且已从计划材料消失,请恢复原问题并完成登记" };
|
|
897
1045
|
}
|
|
898
1046
|
const requiredRoles = PROPOSE_FINAL_REVIEW_GATE.requiredRolesForRisk(risk);
|
|
899
1047
|
const gatePlan = reviewGatePlan(snapshot, context.events, changeRoot, PROPOSE_FINAL_REVIEW_GATE, requiredRoles);
|
|
@@ -945,10 +1093,12 @@ function planStartApplyTransition(context, enforceConfirmation = true) {
|
|
|
945
1093
|
reason: `进入执行阶段前需要重新完成计划文档审查:${gatePlan.reason}`,
|
|
946
1094
|
};
|
|
947
1095
|
}
|
|
1096
|
+
const planOnlyRepair = isPostApplyPlanOnlyRepair(changeRoot, events);
|
|
948
1097
|
const acceptedConfirmation = enforceConfirmation
|
|
1098
|
+
&& !planOnlyRepair
|
|
949
1099
|
? acceptedProposeToApplyConfirmation(context, risk)
|
|
950
1100
|
: null;
|
|
951
|
-
if (enforceConfirmation && !acceptedConfirmation) {
|
|
1101
|
+
if (enforceConfirmation && !acceptedConfirmation && !planOnlyRepair) {
|
|
952
1102
|
const confirmation = phaseConfirmationForBoundary(projectRoot, events, snapshot, "propose_to_apply", risk);
|
|
953
1103
|
return {
|
|
954
1104
|
kind: "skip",
|
|
@@ -960,7 +1110,7 @@ function planStartApplyTransition(context, enforceConfirmation = true) {
|
|
|
960
1110
|
kind: "advance",
|
|
961
1111
|
fromState: "propose_ready",
|
|
962
1112
|
toState: "apply",
|
|
963
|
-
reason: "进入执行阶段",
|
|
1113
|
+
reason: planOnlyRepair ? "计划材料修正完成且没有待实施任务" : "进入执行阶段",
|
|
964
1114
|
payload: {
|
|
965
1115
|
apply_start_head: gitHead.head,
|
|
966
1116
|
apply_start_head_reason: gitHead.reason,
|
|
@@ -972,6 +1122,7 @@ function planStartApplyTransition(context, enforceConfirmation = true) {
|
|
|
972
1122
|
review_risk: risk,
|
|
973
1123
|
requires_verifier: risk !== "minimal",
|
|
974
1124
|
},
|
|
1125
|
+
apply_planning_baseline: applyPlanningBaseline(changeRoot),
|
|
975
1126
|
...(acceptedConfirmation ? phaseConfirmationCommitPayload(acceptedConfirmation.confirmation, acceptedConfirmation.decision) : {}),
|
|
976
1127
|
},
|
|
977
1128
|
};
|
|
@@ -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
|
+
}
|