@peterxiaoyang/superspec 0.1.46 → 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.
- package/dist/cli.js +54 -9
- package/dist/code_review.d.ts +11 -1
- package/dist/code_review.js +40 -0
- package/dist/explore_round.d.ts +25 -0
- package/dist/explore_round.js +139 -0
- package/dist/format.d.ts +78 -2
- package/dist/format.js +227 -16
- package/dist/next.d.ts +1 -1
- package/dist/next.js +97 -12
- package/dist/openspec.js +26 -5
- package/dist/phase_confirmation.js +73 -2
- package/dist/phase_plan.d.ts +24 -1
- package/dist/phase_plan.js +194 -34
- package/dist/propose_round.d.ts +15 -0
- package/dist/propose_round.js +137 -0
- package/dist/record.js +313 -57
- package/dist/review.js +2 -0
- 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/task_evidence.js +5 -3
- package/dist/transition.d.ts +1 -0
- package/dist/transition.js +248 -31
- package/dist/types.d.ts +77 -1
- package/dist/workflow_profile.js +1 -1
- package/package.json +7 -1
- package/templates/workflow/AGENTS.md +17 -5
- package/templates/workflow/prompts/architect.md +4 -2
- package/templates/workflow/prompts/code-reviewer.md +3 -3
- package/templates/workflow/prompts/critic.md +13 -4
- package/templates/workflow/prompts/executor.md +5 -5
- package/templates/workflow/prompts/explore.md +2 -2
- package/templates/workflow/prompts/test-engineer.md +6 -5
- package/templates/workflow/prompts/test-runner.md +5 -5
- package/templates/workflow/prompts/verifier.md +4 -4
- package/templates/workflow/skills/superspec-apply/SKILL.md +26 -12
- package/templates/workflow/skills/superspec-explore/SKILL.md +25 -7
- package/templates/workflow/skills/superspec-propose/SKILL.md +33 -16
- package/templates/workflow/skills/superspec-review/SKILL.md +9 -9
package/dist/phase_plan.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { ReviewGateRule } from "./review_job_gates.ts";
|
|
2
|
-
import
|
|
2
|
+
import { exploreAnswerRegistrationPayload } from "./explore_round.ts";
|
|
3
|
+
import { proposeAnswerRegistrationPayload } from "./propose_round.ts";
|
|
4
|
+
import type { AcceptedMaterialFollowupContinuation, AskUser, Event, ExecutionPolicy, Job, JobRole, PlanningValidationProfile, WorkflowArtifactKind, State } from "./types.ts";
|
|
3
5
|
import type { Snapshot } from "./types.ts";
|
|
4
6
|
import type { ReviewRisk } from "./review.ts";
|
|
5
7
|
export type TransitionName = "explore" | "propose-ready" | "start-apply" | "task-start" | "task-complete" | "review-ready" | "reopen" | "accept";
|
|
@@ -38,11 +40,28 @@ export type NextStepPlan = {
|
|
|
38
40
|
state: State;
|
|
39
41
|
jobs: Job[];
|
|
40
42
|
reason: string;
|
|
43
|
+
} | {
|
|
44
|
+
kind: "artifact_required";
|
|
45
|
+
state: State;
|
|
46
|
+
artifact: {
|
|
47
|
+
kind: WorkflowArtifactKind;
|
|
48
|
+
path: string;
|
|
49
|
+
operation: "create_or_update";
|
|
50
|
+
};
|
|
51
|
+
resume: {
|
|
52
|
+
argv: string[];
|
|
53
|
+
};
|
|
54
|
+
reason: string;
|
|
41
55
|
} | {
|
|
42
56
|
kind: "ask_user";
|
|
43
57
|
state: State;
|
|
44
58
|
ask: AskUser;
|
|
45
59
|
reason: string;
|
|
60
|
+
} | {
|
|
61
|
+
kind: "material_update_required";
|
|
62
|
+
state: State;
|
|
63
|
+
errors: string[];
|
|
64
|
+
reason: string;
|
|
46
65
|
} | {
|
|
47
66
|
kind: "run_transition";
|
|
48
67
|
state: State;
|
|
@@ -69,6 +88,7 @@ export type TransitionDecisionPlan = {
|
|
|
69
88
|
kind: "create_gate_jobs";
|
|
70
89
|
gate: ReviewGateRule;
|
|
71
90
|
roles: JobRole[];
|
|
91
|
+
requiredRoles: JobRole[];
|
|
72
92
|
reason: string;
|
|
73
93
|
} | {
|
|
74
94
|
kind: "advance";
|
|
@@ -87,6 +107,9 @@ export declare function executionPolicyForRisk(risk: ReviewRisk): ExecutionPolic
|
|
|
87
107
|
export declare function executionPolicyForCurrentRound(events: Event[]): ExecutionPolicy;
|
|
88
108
|
export declare function proposalDocsBaseline(changeRoot: string): Record<string, string>;
|
|
89
109
|
export declare function discoveryDocsBaseline(changeRoot: string): Record<string, string>;
|
|
110
|
+
/** 新 Explore 轮次冻结已有已确认事项,避免把历史答复当作本轮遗漏。 */
|
|
111
|
+
export declare function exploreAnswerRegistrationPayloadForChange(changeRoot: string): ReturnType<typeof exploreAnswerRegistrationPayload>;
|
|
112
|
+
export declare function proposeAnswerRegistrationPayloadForChange(changeRoot: string): ReturnType<typeof proposeAnswerRegistrationPayload>;
|
|
90
113
|
export declare function latestAcceptedProposalBaseline(events: Event[]): Record<string, string> | null;
|
|
91
114
|
export declare function latestReopenProposeBaseline(events: Event[]): Record<string, string> | null;
|
|
92
115
|
export declare function latestReopenExploreBaseline(events: Event[]): Record<string, string> | null;
|
package/dist/phase_plan.js
CHANGED
|
@@ -1,7 +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 {
|
|
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";
|
|
5
7
|
import { currentGitHead } from "./git_state.js";
|
|
6
8
|
import { validateOpenSpecChange } from "./openspec.js";
|
|
7
9
|
import { docRef, sha256File } from "./store.js";
|
|
@@ -13,6 +15,26 @@ import { workflowRiskForProposeRound, workflowRiskForState } from "./workflow_co
|
|
|
13
15
|
function requiredJobs(state, jobs, reason) {
|
|
14
16
|
return { kind: "required_jobs", state, jobs, reason };
|
|
15
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
|
+
}
|
|
16
38
|
function phaseConfirmationStep(context, boundary, reason) {
|
|
17
39
|
// 进入 propose_ready / apply 后,mode 来自本轮快照而非当前配置。
|
|
18
40
|
const risk = workflowRiskForState(context.events, context.snapshot.state, context.mode.risk);
|
|
@@ -75,6 +97,7 @@ function reviewGatePlan(snapshot, events, changeRoot, gate, requiredRoles) {
|
|
|
75
97
|
kind: "create_gate_jobs",
|
|
76
98
|
gate,
|
|
77
99
|
roles: missingRoles.map(item => item.role),
|
|
100
|
+
requiredRoles: [...requiredRoles],
|
|
78
101
|
reason: missingRoles.map(item => item.reason).join("; "),
|
|
79
102
|
};
|
|
80
103
|
}
|
|
@@ -95,6 +118,42 @@ function validateTasksPlan(changeRoot, executionRequirementVersion) {
|
|
|
95
118
|
return errors.join(";");
|
|
96
119
|
return null;
|
|
97
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
|
+
}
|
|
98
157
|
export function executionPolicyForRisk(risk) {
|
|
99
158
|
return risk === "strict" ? "tdd" : "green_only";
|
|
100
159
|
}
|
|
@@ -152,17 +211,27 @@ function validateOpenSpecPlanningDocuments(projectRoot, change, changeRoot, prof
|
|
|
152
211
|
}
|
|
153
212
|
function validatePlanningPreflight(projectRoot, change, changeRoot, risk, executionPolicy, profile) {
|
|
154
213
|
const executionRequirementVersion = profile?.version ?? 1;
|
|
214
|
+
const errors = [];
|
|
155
215
|
const tasksPlanError = validateTasksPlan(changeRoot, executionRequirementVersion);
|
|
156
216
|
if (tasksPlanError)
|
|
157
|
-
|
|
217
|
+
errors.push(tasksPlanError);
|
|
158
218
|
const executionRequirementPlan = validateExecutionRequirementPlan(changeRoot, executionPolicy, executionRequirementVersion);
|
|
159
219
|
if (!executionRequirementPlan.ok)
|
|
160
|
-
|
|
220
|
+
errors.push(executionRequirementPlan.message);
|
|
161
221
|
const missingArtifact = missingBaseArtifact(changeRoot, risk);
|
|
162
222
|
if (missingArtifact)
|
|
163
|
-
|
|
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
|
+
}
|
|
164
232
|
return {
|
|
165
|
-
error:
|
|
233
|
+
error: errors.length > 0 ? [...new Set(errors)].join(";") : null,
|
|
234
|
+
errors: [...new Set(errors)],
|
|
166
235
|
contractMode: executionRequirementPlan.mode,
|
|
167
236
|
};
|
|
168
237
|
}
|
|
@@ -184,6 +253,14 @@ export function discoveryDocsBaseline(changeRoot) {
|
|
|
184
253
|
}
|
|
185
254
|
return baseline;
|
|
186
255
|
}
|
|
256
|
+
/** 新 Explore 轮次冻结已有已确认事项,避免把历史答复当作本轮遗漏。 */
|
|
257
|
+
export function exploreAnswerRegistrationPayloadForChange(changeRoot) {
|
|
258
|
+
const discoveryPath = join(changeRoot, ".superspec", "artifacts", "discovery.md");
|
|
259
|
+
return exploreAnswerRegistrationPayload(existsSync(discoveryPath) ? readFileSync(discoveryPath, "utf8") : null);
|
|
260
|
+
}
|
|
261
|
+
export function proposeAnswerRegistrationPayloadForChange(changeRoot) {
|
|
262
|
+
return proposeAnswerRegistrationPayload(changeRoot);
|
|
263
|
+
}
|
|
187
264
|
function isDigestMap(value) {
|
|
188
265
|
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
189
266
|
return false;
|
|
@@ -284,15 +361,16 @@ function isPlanningValidationProfile(value) {
|
|
|
284
361
|
const profile = value;
|
|
285
362
|
if (profile.version !== 2 || !profile.openspec || typeof profile.openspec !== "object")
|
|
286
363
|
return false;
|
|
287
|
-
|
|
288
|
-
|
|
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");
|
|
289
367
|
}
|
|
290
368
|
/** 新 planning round 在进入 propose 时冻结当前 OpenSpec 校验契约。 */
|
|
291
369
|
export function planningValidationProfileForNewRound(projectRoot) {
|
|
292
370
|
const configDigest = sha256File(join(projectRoot, "openspec", "config.yaml"));
|
|
293
371
|
return configDigest == null
|
|
294
|
-
? { version: 2, openspec: { mode: "disabled" } }
|
|
295
|
-
: { 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 } };
|
|
296
374
|
}
|
|
297
375
|
/** propose 状态尚未 ready 时,从进入本 planning round 的事件读取冻结 profile。 */
|
|
298
376
|
function planningValidationProfileForPendingProposeRound(events) {
|
|
@@ -435,7 +513,7 @@ function acceptedMaterialFollowup(change, risk, planDocsChangedSinceAccept) {
|
|
|
435
513
|
};
|
|
436
514
|
}
|
|
437
515
|
export function planNextStep(context) {
|
|
438
|
-
const { change, changeRoot, events, mode, snapshot } = context;
|
|
516
|
+
const { projectRoot, change, changeRoot, events, mode, snapshot } = context;
|
|
439
517
|
switch (snapshot.state) {
|
|
440
518
|
case "init":
|
|
441
519
|
if (snapshot.open_jobs.length > 0) {
|
|
@@ -452,9 +530,9 @@ export function planNextStep(context) {
|
|
|
452
530
|
reason: "回到 explore 后至少一个 discovery 材料必须变化",
|
|
453
531
|
};
|
|
454
532
|
}
|
|
455
|
-
const
|
|
456
|
-
if (
|
|
457
|
-
return
|
|
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);
|
|
458
536
|
}
|
|
459
537
|
const discoveryCheck = validateDiscovery(changeRoot);
|
|
460
538
|
if (!discoveryCheck.ok) {
|
|
@@ -466,14 +544,37 @@ export function planNextStep(context) {
|
|
|
466
544
|
return { kind: "ask_user", state: "explore", ask, reason: discoveryCheck.message };
|
|
467
545
|
}
|
|
468
546
|
const content = readFileSync(join(changeRoot, ".superspec", "artifacts", "discovery.md"), "utf8");
|
|
469
|
-
const
|
|
470
|
-
if (
|
|
471
|
-
const
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
547
|
+
const currentQuestion = parseDiscoveryOpenQuestions(content)[0];
|
|
548
|
+
if (currentQuestion) {
|
|
549
|
+
const questionText = discoveryOpenQuestionDisplayText(currentQuestion);
|
|
550
|
+
const question = `现在有一件事需要你确认:${questionText}\n\n请只回答这一件事。主流程会先登记答复,再将结论回写 discovery.md;回写完成前会继续询问这一件事。`;
|
|
551
|
+
const scope = discoveryOpenQuestionScope(currentQuestion, currentExploreRoundId(events));
|
|
552
|
+
const ask = freeTextDecisionAsk(change, question, scope);
|
|
553
|
+
return { kind: "ask_user", state: "explore", ask, reason: "等待用户确认" };
|
|
554
|
+
}
|
|
555
|
+
const unregisteredClosedQuestions = unregisteredClosedExploreQuestions(events, content);
|
|
556
|
+
if (unregisteredClosedQuestions.length > 0) {
|
|
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 问题缺少答复登记",
|
|
475
570
|
};
|
|
476
|
-
|
|
571
|
+
}
|
|
572
|
+
// 先让用户澄清当前 Discovery,再审查材料;否则 critic 会审查一份仍有
|
|
573
|
+
// 关键业务未知的文档。正常创建的 job 已绑定 discovery 指纹,材料变化后
|
|
574
|
+
// 会由 snapshot freshness 自动失效。
|
|
575
|
+
const exploreReviewJobs = EXPLORE_DISCOVERY_REVIEW_GATE.openJobsForGate(snapshot);
|
|
576
|
+
if (exploreReviewJobs.length > 0) {
|
|
577
|
+
return requiredJobs("explore", exploreReviewJobs, `有 ${exploreReviewJobs.length} 个待完成探索审查工作项`);
|
|
477
578
|
}
|
|
478
579
|
const requiredRoles = EXPLORE_DISCOVERY_REVIEW_GATE.requiredRolesForRisk(mode.risk);
|
|
479
580
|
if (!reviewGatePlan(snapshot, events, changeRoot, EXPLORE_DISCOVERY_REVIEW_GATE, requiredRoles)) {
|
|
@@ -490,15 +591,42 @@ export function planNextStep(context) {
|
|
|
490
591
|
};
|
|
491
592
|
}
|
|
492
593
|
case "propose": {
|
|
493
|
-
const
|
|
494
|
-
if (
|
|
495
|
-
const
|
|
496
|
-
const
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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,
|
|
500
629
|
};
|
|
501
|
-
return { kind: "ask_user", state: "propose", ask, reason: `有 ${openQuestions.openCount} 个 propose 未确认问题` };
|
|
502
630
|
}
|
|
503
631
|
const proposalReviewJobs = PROPOSE_FINAL_REVIEW_GATE.openJobsForGate(snapshot);
|
|
504
632
|
if (proposalReviewJobs.length > 0) {
|
|
@@ -642,6 +770,16 @@ function planApplyDoneNext(context) {
|
|
|
642
770
|
}
|
|
643
771
|
const latest = facts.latestTerminal;
|
|
644
772
|
if (latest?.state === "rejected" && latest.result_kind === "review_failed") {
|
|
773
|
+
const staleReason = codeReviewJobStaleReason(context.projectRoot, latest.job, currentWorkingPaths);
|
|
774
|
+
if (staleReason) {
|
|
775
|
+
return {
|
|
776
|
+
kind: "run_transition",
|
|
777
|
+
state: "apply_done",
|
|
778
|
+
transition: "review-ready",
|
|
779
|
+
risk: mode.risk,
|
|
780
|
+
reason: `代码审查结论已过期,重新发起审查:${staleReason}`,
|
|
781
|
+
};
|
|
782
|
+
}
|
|
645
783
|
const status = latestCodeReviewFailedStatus(events);
|
|
646
784
|
const pendingFinding = status?.unresolved[0] ?? null;
|
|
647
785
|
if (status && status.findings.length > 0 && !pendingFinding) {
|
|
@@ -804,7 +942,13 @@ export function planTransition(name, context) {
|
|
|
804
942
|
function planExploreTransition(context) {
|
|
805
943
|
const { changeRoot, events, mode, projectRoot, snapshot } = context;
|
|
806
944
|
if (snapshot.state === "init") {
|
|
807
|
-
return {
|
|
945
|
+
return {
|
|
946
|
+
kind: "advance",
|
|
947
|
+
fromState: "init",
|
|
948
|
+
toState: "explore",
|
|
949
|
+
reason: "进入探索阶段",
|
|
950
|
+
payload: exploreAnswerRegistrationPayloadForChange(changeRoot),
|
|
951
|
+
};
|
|
808
952
|
}
|
|
809
953
|
if (snapshot.state !== "explore") {
|
|
810
954
|
return { kind: "skip", message: `当前状态 ${snapshot.state},explore 不适用` };
|
|
@@ -816,6 +960,17 @@ function planExploreTransition(context) {
|
|
|
816
960
|
const discoveryCheck = validateDiscovery(changeRoot);
|
|
817
961
|
if (!discoveryCheck.ok)
|
|
818
962
|
return { kind: "skip", message: discoveryCheck.message };
|
|
963
|
+
const discoveryContent = readFileSync(join(changeRoot, ".superspec", "artifacts", "discovery.md"), "utf8");
|
|
964
|
+
const currentQuestion = parseDiscoveryOpenQuestions(discoveryContent)[0];
|
|
965
|
+
if (currentQuestion) {
|
|
966
|
+
return { kind: "skip", message: "discovery.md 仍有需要确认的事项,请先完成确认并回写 discovery.md" };
|
|
967
|
+
}
|
|
968
|
+
if (unregisteredClosedExploreQuestions(events, discoveryContent).length > 0) {
|
|
969
|
+
return { kind: "skip", message: "discovery.md 有已确认事项缺少对应答复登记,请先恢复为待确认并按主流程登记答复" };
|
|
970
|
+
}
|
|
971
|
+
if (unresolvedPresentedExploreQuestionScopes(events).length > 0) {
|
|
972
|
+
return { kind: "skip", message: "此前展示的 Explore 问题缺少答复登记且已从 discovery.md 消失,请恢复原问题并完成登记" };
|
|
973
|
+
}
|
|
819
974
|
const requiredRoles = EXPLORE_DISCOVERY_REVIEW_GATE.requiredRolesForRisk(mode.risk);
|
|
820
975
|
const gatePlan = reviewGatePlan(snapshot, events, changeRoot, EXPLORE_DISCOVERY_REVIEW_GATE, requiredRoles);
|
|
821
976
|
if (gatePlan)
|
|
@@ -834,6 +989,7 @@ function planExploreTransition(context) {
|
|
|
834
989
|
...phaseConfirmationCommitPayload(confirmation, decision),
|
|
835
990
|
planning_validation_version: 2,
|
|
836
991
|
planning_validation_profile: planningValidationProfileForNewRound(projectRoot),
|
|
992
|
+
...proposeAnswerRegistrationPayloadForChange(changeRoot),
|
|
837
993
|
},
|
|
838
994
|
};
|
|
839
995
|
}
|
|
@@ -846,10 +1002,14 @@ function planProposeReadyTransition(context) {
|
|
|
846
1002
|
const preflight = validatePlanningPreflight(context.projectRoot, context.change, changeRoot, risk, executionPolicyForRisk(risk), planningProfile);
|
|
847
1003
|
if (preflight.error)
|
|
848
1004
|
return { kind: "skip", message: preflight.error };
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
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: "此前展示的设计问题缺少答复登记且已从计划材料消失,请恢复原问题并完成登记" };
|
|
853
1013
|
}
|
|
854
1014
|
const requiredRoles = PROPOSE_FINAL_REVIEW_GATE.requiredRolesForRisk(risk);
|
|
855
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
|
+
}
|