@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/format.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// 生成与语义判断,不得自行充当格式校验器或在其它地方重复解析。
|
|
5
5
|
import { readFileSync, existsSync, realpathSync } from "node:fs";
|
|
6
6
|
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
7
|
+
import { sha256Text } from "./store.js";
|
|
7
8
|
import { GREEN_ONLY_NO_TDD_REASON } from "./types.js";
|
|
8
9
|
// ===== discovery.md =====
|
|
9
10
|
//
|
|
@@ -24,7 +25,7 @@ function countOpenChecklistItemsInSection(content, headings) {
|
|
|
24
25
|
const matches = sectionBody.match(/^\s*-\s+\[ \]/gm);
|
|
25
26
|
return matches ? matches.length : 0;
|
|
26
27
|
}
|
|
27
|
-
function
|
|
28
|
+
function sectionRangeByHeadings(content, headings) {
|
|
28
29
|
const headingPattern = headings.map(escapeRegex).join("|");
|
|
29
30
|
const sectionMatch = new RegExp(`^#{1,6}\\s*(?:${headingPattern})\\s*$`, "im").exec(content);
|
|
30
31
|
if (!sectionMatch)
|
|
@@ -33,11 +34,106 @@ function sectionBodyByHeadings(content, headings) {
|
|
|
33
34
|
// 截取到下一个标题或文件末尾
|
|
34
35
|
const restContent = content.slice(sectionStart);
|
|
35
36
|
const nextHeadingMatch = restContent.match(/^#{1,6}\s+/m);
|
|
36
|
-
return
|
|
37
|
+
return {
|
|
38
|
+
start: sectionStart,
|
|
39
|
+
end: nextHeadingMatch ? sectionStart + nextHeadingMatch.index : content.length,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function sectionBodyByHeadings(content, headings) {
|
|
43
|
+
const range = sectionRangeByHeadings(content, headings);
|
|
44
|
+
return range ? content.slice(range.start, range.end) : null;
|
|
45
|
+
}
|
|
46
|
+
const DISCOVERY_QUESTION_HEADINGS = ["待确认问题", "Open Questions", "Pending Questions"];
|
|
47
|
+
export const EXPLORE_OPEN_QUESTION_SCOPE_PREFIX = "explore_open_question:";
|
|
48
|
+
/**
|
|
49
|
+
* 当前文档中同一确认事项的稳定键。结合 Q-ID、顺序和原文,避免把本轮开始前
|
|
50
|
+
* 已经确认的旧事项误认为新答复。
|
|
51
|
+
*/
|
|
52
|
+
export function discoveryQuestionKey(question) {
|
|
53
|
+
return sha256Text(`${question.id}\n${question.ordinal}\n${question.text}`);
|
|
54
|
+
}
|
|
55
|
+
function normalizedDecisionQuestionText(text) {
|
|
56
|
+
return text.replace(/\s+/g, " ").trim();
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* 当前决定所依据的规范化问题内容。会改变选择、推荐或影响的事实必须写入该问题项;
|
|
60
|
+
* 状态机只绑定这份明确依据,不猜测文档其它段落与决定是否相关。
|
|
61
|
+
*/
|
|
62
|
+
export function discoveryQuestionDecisionBasisDigest(question) {
|
|
63
|
+
const legacyOrdinal = question.id.startsWith("item-") ? String(question.ordinal) : "explicit-id";
|
|
64
|
+
return sha256Text(`decision-basis:v1\nexplore\n${question.id}\n${legacyOrdinal}\n${normalizedDecisionQuestionText(question.text)}`);
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* 计算某一确认事项之外的 Discovery 决策上下文。回写时该事项本身会从问题改为
|
|
68
|
+
* 结论,因此只归一化这一行;其余事实、证据和其它待确认项的改动都会使指纹失效。
|
|
69
|
+
*/
|
|
70
|
+
export function discoveryQuestionContextFingerprint(content, question) {
|
|
71
|
+
const range = sectionRangeByHeadings(content, DISCOVERY_QUESTION_HEADINGS);
|
|
72
|
+
if (!range)
|
|
73
|
+
return null;
|
|
74
|
+
const sectionBody = content.slice(range.start, range.end);
|
|
75
|
+
const checklist = /^\s*-\s+\[([ xX])\]\s+(.*?)\s*$/gm;
|
|
76
|
+
let ordinal = 0;
|
|
77
|
+
for (const match of sectionBody.matchAll(checklist)) {
|
|
78
|
+
ordinal += 1;
|
|
79
|
+
if (ordinal !== question.ordinal)
|
|
80
|
+
continue;
|
|
81
|
+
const lineStart = range.start + match.index;
|
|
82
|
+
const lineEnd = lineStart + match[0].length;
|
|
83
|
+
const placeholder = `- [ ] <discovery-question:${question.id}:${question.ordinal}>`;
|
|
84
|
+
return sha256Text(`${content.slice(0, lineStart)}${placeholder}${content.slice(lineEnd)}`);
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
/** 按文档顺序提取 discovery.md 的全部确认事项。 */
|
|
89
|
+
export function parseDiscoveryQuestions(content) {
|
|
90
|
+
const sectionBody = sectionBodyByHeadings(content, DISCOVERY_QUESTION_HEADINGS);
|
|
91
|
+
if (sectionBody == null)
|
|
92
|
+
return [];
|
|
93
|
+
const documentFingerprint = sha256Text(content);
|
|
94
|
+
const questions = [];
|
|
95
|
+
const checklist = /^\s*-\s+\[([ xX])\]\s+(.*?)\s*$/gm;
|
|
96
|
+
let ordinal = 0;
|
|
97
|
+
for (const match of sectionBody.matchAll(checklist)) {
|
|
98
|
+
ordinal += 1;
|
|
99
|
+
const text = match[2];
|
|
100
|
+
const idMatch = /^\s*(Q-[A-Za-z0-9][A-Za-z0-9_-]*)\b/.exec(text);
|
|
101
|
+
questions.push({
|
|
102
|
+
id: idMatch?.[1] ?? `item-${ordinal}`,
|
|
103
|
+
ordinal,
|
|
104
|
+
text,
|
|
105
|
+
raw: match[0],
|
|
106
|
+
documentFingerprint,
|
|
107
|
+
status: match[1] === " " ? "open" : "closed",
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
return questions;
|
|
111
|
+
}
|
|
112
|
+
/** 按文档顺序提取 discovery.md 中尚未确认的问题。 */
|
|
113
|
+
export function parseDiscoveryOpenQuestions(content) {
|
|
114
|
+
return parseDiscoveryQuestions(content)
|
|
115
|
+
.filter(question => question.status === "open")
|
|
116
|
+
.map(({ status: _status, ...question }) => question);
|
|
117
|
+
}
|
|
118
|
+
export function discoveryOpenQuestionScope(question, exploreRoundId) {
|
|
119
|
+
const fingerprint = sha256Text(`${exploreRoundId}\n${discoveryQuestionDecisionBasisDigest(question)}`);
|
|
120
|
+
return `${EXPLORE_OPEN_QUESTION_SCOPE_PREFIX}${fingerprint}:${question.id}`;
|
|
121
|
+
}
|
|
122
|
+
/** 兼容升级前已经展示给用户、但尚未登记的 scope。 */
|
|
123
|
+
export function legacyDiscoveryOpenQuestionScope(question, exploreRoundId) {
|
|
124
|
+
const fingerprint = sha256Text(`${exploreRoundId}\n${question.documentFingerprint}`);
|
|
125
|
+
return `${EXPLORE_OPEN_QUESTION_SCOPE_PREFIX}${fingerprint}:${question.id}`;
|
|
126
|
+
}
|
|
127
|
+
/** 面向用户展示时隐藏 Q-xxx 这一内部编号;历史无编号问题保持原文。 */
|
|
128
|
+
export function discoveryOpenQuestionDisplayText(question) {
|
|
129
|
+
if (question.id.startsWith("Q-") && question.text.startsWith(question.id)) {
|
|
130
|
+
return question.text.slice(question.id.length).replace(/^[\s::—–-]+/, "").trim();
|
|
131
|
+
}
|
|
132
|
+
return question.text.trim();
|
|
37
133
|
}
|
|
38
|
-
/** 从 discovery.md
|
|
134
|
+
/** 从 discovery.md 提取“待确认问题”段内的未确认项数量。 */
|
|
39
135
|
export function countDiscoveryOpenQuestions(content) {
|
|
40
|
-
return
|
|
136
|
+
return parseDiscoveryOpenQuestions(content).length;
|
|
41
137
|
}
|
|
42
138
|
const DISCOVERY_CHAIN_HEADINGS = ["链路五要素"];
|
|
43
139
|
const DISCOVERY_CHAIN_REQUIRED_COLUMNS = [
|
|
@@ -88,24 +184,30 @@ export function validateDiscoveryChainCoverage(content) {
|
|
|
88
184
|
}
|
|
89
185
|
const statusIdx = header.indexOf("状态");
|
|
90
186
|
const requiredColumnIndexes = DISCOVERY_CHAIN_REQUIRED_COLUMNS.map(col => ({ col, idx: header.indexOf(col) }));
|
|
187
|
+
const errors = [];
|
|
91
188
|
for (const [idx, cells] of rows.entries()) {
|
|
92
189
|
const rowNum = idx + 1;
|
|
93
190
|
for (const { col, idx: colIdx } of requiredColumnIndexes) {
|
|
94
191
|
if (!(cells[colIdx]?.trim())) {
|
|
95
|
-
|
|
192
|
+
errors.push(`链路五要素第 ${rowNum} 行缺少${col}`);
|
|
96
193
|
}
|
|
97
194
|
}
|
|
98
195
|
const status = cells[statusIdx]?.trim() ?? "";
|
|
99
196
|
if (!DISCOVERY_CHAIN_STATUSES.has(status)) {
|
|
100
|
-
|
|
197
|
+
errors.push(`链路五要素第 ${rowNum} 行状态必须是 已确认、未知阻塞 或 未知非阻塞`);
|
|
101
198
|
}
|
|
102
199
|
if (status.includes("未知阻塞") && countDiscoveryOpenQuestions(content) === 0) {
|
|
103
|
-
|
|
200
|
+
errors.push("链路五要素存在未知阻塞,但待确认问题中没有未解决项");
|
|
104
201
|
}
|
|
105
202
|
}
|
|
203
|
+
if (errors.length > 0)
|
|
204
|
+
return { ok: false, message: [...new Set(errors)].join(";"), present: true };
|
|
106
205
|
return { ok: true, message: "链路五要素就绪", present: true };
|
|
107
206
|
}
|
|
108
|
-
/**
|
|
207
|
+
/**
|
|
208
|
+
* 校验 discovery.md 的可解析结构。未确认问题不是格式错误:next 会把第一个问题
|
|
209
|
+
* 作为当前用户决策返回;只有缺文档、空文档或已声明链路的结构错误才在此阻断。
|
|
210
|
+
*/
|
|
109
211
|
export function validateDiscovery(changeRoot) {
|
|
110
212
|
const path = join(changeRoot, ".superspec", "artifacts", "discovery.md");
|
|
111
213
|
if (!existsSync(path))
|
|
@@ -117,16 +219,90 @@ export function validateDiscovery(changeRoot) {
|
|
|
117
219
|
if (!chainCoverage.ok)
|
|
118
220
|
return { ok: false, message: chainCoverage.message, openCount: -1 };
|
|
119
221
|
const openCount = countDiscoveryOpenQuestions(content);
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
222
|
+
return {
|
|
223
|
+
ok: true,
|
|
224
|
+
message: openCount > 0 ? `discovery.md 结构有效,有 ${openCount} 个待确认问题` : "discovery.md 就绪",
|
|
225
|
+
openCount,
|
|
226
|
+
};
|
|
123
227
|
}
|
|
228
|
+
export const PROPOSE_OPEN_QUESTION_SCOPE_PREFIX = "propose_open_question:";
|
|
124
229
|
const PROPOSE_CONFIRMATION_DOCS = [
|
|
125
230
|
"proposal.md",
|
|
126
231
|
"design.md",
|
|
127
232
|
".superspec/artifacts/test-contract.md",
|
|
128
233
|
];
|
|
129
234
|
const PROPOSE_CONFIRMATION_HEADINGS = ["待用户确认", "待确认问题", "Open Questions", "Pending Questions"];
|
|
235
|
+
export function parseProposeQuestions(content, path) {
|
|
236
|
+
const sectionBody = sectionBodyByHeadings(content, PROPOSE_CONFIRMATION_HEADINGS);
|
|
237
|
+
if (sectionBody == null)
|
|
238
|
+
return [];
|
|
239
|
+
const documentFingerprint = sha256Text(content);
|
|
240
|
+
const questions = [];
|
|
241
|
+
const checklist = /^\s*-\s+\[([ xX])\]\s+(.*?)\s*$/gm;
|
|
242
|
+
let ordinal = 0;
|
|
243
|
+
for (const match of sectionBody.matchAll(checklist)) {
|
|
244
|
+
ordinal += 1;
|
|
245
|
+
const text = match[2];
|
|
246
|
+
const idMatch = /^\s*(DEC-[A-Za-z0-9][A-Za-z0-9_-]*)\b/.exec(text);
|
|
247
|
+
questions.push({
|
|
248
|
+
path,
|
|
249
|
+
id: idMatch?.[1] ?? `item-${ordinal}`,
|
|
250
|
+
ordinal,
|
|
251
|
+
text,
|
|
252
|
+
raw: match[0],
|
|
253
|
+
documentFingerprint,
|
|
254
|
+
status: match[1] === " " ? "open" : "closed",
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
return questions;
|
|
258
|
+
}
|
|
259
|
+
export function proposeQuestionKey(question) {
|
|
260
|
+
return sha256Text(`${question.path}\n${question.id}\n${question.ordinal}\n${question.text}`);
|
|
261
|
+
}
|
|
262
|
+
/** Propose 决定的明确依据;其它计划材料变化不会自动使已展示决定失效。 */
|
|
263
|
+
export function proposeQuestionDecisionBasisDigest(question) {
|
|
264
|
+
const legacyOrdinal = question.id.startsWith("item-") ? String(question.ordinal) : "explicit-id";
|
|
265
|
+
return sha256Text(`decision-basis:v1\npropose\n${question.path}\n${question.id}\n${legacyOrdinal}\n${normalizedDecisionQuestionText(question.text)}`);
|
|
266
|
+
}
|
|
267
|
+
export function proposeQuestionContextFingerprint(content, question) {
|
|
268
|
+
const range = sectionRangeByHeadings(content, PROPOSE_CONFIRMATION_HEADINGS);
|
|
269
|
+
if (!range)
|
|
270
|
+
return null;
|
|
271
|
+
const sectionBody = content.slice(range.start, range.end);
|
|
272
|
+
const checklist = /^\s*-\s+\[([ xX])\]\s+(.*?)\s*$/gm;
|
|
273
|
+
let ordinal = 0;
|
|
274
|
+
for (const match of sectionBody.matchAll(checklist)) {
|
|
275
|
+
ordinal += 1;
|
|
276
|
+
if (ordinal !== question.ordinal)
|
|
277
|
+
continue;
|
|
278
|
+
const lineStart = range.start + match.index;
|
|
279
|
+
const lineEnd = lineStart + match[0].length;
|
|
280
|
+
const placeholder = `- [ ] <propose-question:${question.id}:${question.ordinal}>`;
|
|
281
|
+
return sha256Text(`${content.slice(0, lineStart)}${placeholder}${content.slice(lineEnd)}`);
|
|
282
|
+
}
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
export function proposeOpenQuestionScope(question, proposeRoundId) {
|
|
286
|
+
const fingerprint = sha256Text(`${proposeRoundId}\n${proposeQuestionDecisionBasisDigest(question)}`);
|
|
287
|
+
return `${PROPOSE_OPEN_QUESTION_SCOPE_PREFIX}${fingerprint}:${question.id}`;
|
|
288
|
+
}
|
|
289
|
+
/** 兼容升级前已经展示给用户、但尚未登记的 scope。 */
|
|
290
|
+
export function legacyProposeOpenQuestionScope(question, proposeRoundId) {
|
|
291
|
+
const fingerprint = sha256Text(`${proposeRoundId}\n${question.path}\n${question.documentFingerprint}`);
|
|
292
|
+
return `${PROPOSE_OPEN_QUESTION_SCOPE_PREFIX}${fingerprint}:${question.id}`;
|
|
293
|
+
}
|
|
294
|
+
export function proposeOpenQuestionDisplayText(question) {
|
|
295
|
+
if (question.id.startsWith("DEC-") && question.text.startsWith(question.id)) {
|
|
296
|
+
return question.text.slice(question.id.length).replace(/^[\s::—–-]+/, "").trim();
|
|
297
|
+
}
|
|
298
|
+
return question.text.trim();
|
|
299
|
+
}
|
|
300
|
+
export function collectProposeQuestions(changeRoot) {
|
|
301
|
+
return PROPOSE_CONFIRMATION_DOCS.flatMap(path => {
|
|
302
|
+
const fullPath = join(changeRoot, path);
|
|
303
|
+
return existsSync(fullPath) ? parseProposeQuestions(readFileSync(fullPath, "utf8"), path) : [];
|
|
304
|
+
});
|
|
305
|
+
}
|
|
130
306
|
export function countProposeOpenQuestionsInContent(content) {
|
|
131
307
|
return countOpenChecklistItemsInSection(content, PROPOSE_CONFIRMATION_HEADINGS);
|
|
132
308
|
}
|
|
@@ -327,8 +503,17 @@ export function adoptedContractForTask(content, taskId, contractMode) {
|
|
|
327
503
|
const parsed = executionRequirementForTask(content, taskId);
|
|
328
504
|
return { parsed, contract: contractMode ? parsed?.contract ?? null : null };
|
|
329
505
|
}
|
|
506
|
+
/**
|
|
507
|
+
* Fix task 由状态机从已批准的实现范围派生;它没有 proposal 阶段执行依据块。
|
|
508
|
+
* REVIEW-FIX-* 是发布前已有的持久化 task ID,FIX-SELFTEST-* 是当前引擎生成的
|
|
509
|
+
* 自测修复 task。不能把通用 FIX-* 前缀保留为内部命名空间。
|
|
510
|
+
*/
|
|
511
|
+
export function isFixTaskId(taskId) {
|
|
512
|
+
return taskId.startsWith("REVIEW-FIX-") || taskId.startsWith("FIX-SELFTEST-");
|
|
513
|
+
}
|
|
514
|
+
/** @deprecated 新代码使用 isFixTaskId;保留给旧扩展和历史调用。 */
|
|
330
515
|
export function isReviewFixTaskId(taskId) {
|
|
331
|
-
return taskId
|
|
516
|
+
return isFixTaskId(taskId);
|
|
332
517
|
}
|
|
333
518
|
export function isCharacterizationTask(task) {
|
|
334
519
|
return task.tddRequired === false && task.noTddReason === "characterization";
|
|
@@ -449,6 +634,31 @@ function documentAnchorParts(anchor) {
|
|
|
449
634
|
? parts
|
|
450
635
|
: [anchor];
|
|
451
636
|
}
|
|
637
|
+
function documentAnchorCandidates(content, path, requested) {
|
|
638
|
+
const candidates = [];
|
|
639
|
+
for (const match of content.matchAll(/^#{1,6}[\t ]+(.+?)(?:[\t ]+#+)?[\t ]*$/gm)) {
|
|
640
|
+
const anchor = match[1]?.trim();
|
|
641
|
+
if (anchor)
|
|
642
|
+
candidates.push({ anchor, index: match.index ?? candidates.length });
|
|
643
|
+
}
|
|
644
|
+
for (const match of content.matchAll(/(?:^|[^A-Za-z0-9_-])((?:TEST|CHAIN|IDC)-[A-Za-z0-9_-]+)(?![A-Za-z0-9_-])/gm)) {
|
|
645
|
+
candidates.push({ anchor: match[1], index: match.index ?? candidates.length });
|
|
646
|
+
}
|
|
647
|
+
const normalizedRequested = requested.toLocaleLowerCase();
|
|
648
|
+
const score = (anchor) => {
|
|
649
|
+
const normalized = anchor.toLocaleLowerCase();
|
|
650
|
+
if (normalized === normalizedRequested)
|
|
651
|
+
return 100;
|
|
652
|
+
if (normalized.includes(normalizedRequested) || normalizedRequested.includes(normalized))
|
|
653
|
+
return 50;
|
|
654
|
+
const requestedTokens = new Set(normalizedRequested.split(/[^\p{L}\p{N}_-]+/u).filter(Boolean));
|
|
655
|
+
return normalized.split(/[^\p{L}\p{N}_-]+/u).filter(Boolean).filter(token => requestedTokens.has(token)).length * 10;
|
|
656
|
+
};
|
|
657
|
+
return [...new Map(candidates.map(candidate => [candidate.anchor, candidate])).values()]
|
|
658
|
+
.sort((left, right) => score(right.anchor) - score(left.anchor) || left.index - right.index)
|
|
659
|
+
.slice(0, 8)
|
|
660
|
+
.map(candidate => `${path}#${candidate.anchor}`);
|
|
661
|
+
}
|
|
452
662
|
function canonicalDocumentRefPath(path) {
|
|
453
663
|
if (path === "discovery.md" || path === "test-contract.md") {
|
|
454
664
|
return join(".superspec", "artifacts", path);
|
|
@@ -495,7 +705,8 @@ export function validateExecutionRequirementDocumentReferences(changeRoot, contr
|
|
|
495
705
|
const targetContent = readFileSync(target, "utf8");
|
|
496
706
|
for (const anchor of documentAnchorParts(parsed.anchor)) {
|
|
497
707
|
if (!documentContainsAnchor(targetContent, anchor)) {
|
|
498
|
-
|
|
708
|
+
const candidates = documentAnchorCandidates(targetContent, parsed.path, anchor);
|
|
709
|
+
errors.push(`${contract.taskId} 的引用锚点不存在:${parsed.path}#${anchor}${candidates.length > 0 ? `;可用锚点:${candidates.join("、")}` : ""}`);
|
|
499
710
|
}
|
|
500
711
|
}
|
|
501
712
|
}
|
|
@@ -510,7 +721,7 @@ export function validateExecutionRequirements(content, testContractContent, exec
|
|
|
510
721
|
const hasLegacyGreenOnlyTask = tasks.some(task => task.noTddReason === GREEN_ONLY_NO_TDD_REASON);
|
|
511
722
|
// v2 不允许“所有任务都没有执行依据”这一静默回退:新 Propose 的每个普通
|
|
512
723
|
// task 都必须显式声明五字段。v1 的缺失版本仍保留旧的按需契约语义。
|
|
513
|
-
const hasV2OrdinaryTask = executionRequirementVersion === 2 && tasks.some(task => !
|
|
724
|
+
const hasV2OrdinaryTask = executionRequirementVersion === 2 && tasks.some(task => !isFixTaskId(task.taskId));
|
|
514
725
|
const mode = hasTaskBoundExecutionRequirements(content) || hasLegacyGreenOnlyTask || hasV2OrdinaryTask;
|
|
515
726
|
const contracts = parseExecutionRequirements(content);
|
|
516
727
|
// 孤儿检测必须在 mode=false 的 early return 之前:全部块都悬空时 mode=false,
|
|
@@ -532,11 +743,11 @@ export function validateExecutionRequirements(content, testContractContent, exec
|
|
|
532
743
|
}
|
|
533
744
|
// v2 是本次改造后的新计划:每个普通 task 必须声明完整五字段,
|
|
534
745
|
// `测试:` 可显式为空以表达非行为任务。旧计划只保持原 TDD 契约要求。
|
|
535
|
-
if (executionRequirementVersion === 2 && !
|
|
746
|
+
if (executionRequirementVersion === 2 && !isFixTaskId(task.taskId) && !contract) {
|
|
536
747
|
errors.push(`${task.taskId} 缺少执行依据`);
|
|
537
748
|
continue;
|
|
538
749
|
}
|
|
539
|
-
if (executionRequirementVersion === 1 && task.tddRequired && !
|
|
750
|
+
if (executionRequirementVersion === 1 && task.tddRequired && !isFixTaskId(task.taskId) && !contract) {
|
|
540
751
|
errors.push(`${task.taskId} 缺少执行依据`);
|
|
541
752
|
continue;
|
|
542
753
|
}
|
package/dist/next.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
import type { NextOutput } from "./types.ts";
|
|
2
|
-
/** next
|
|
2
|
+
/** next 命令:读取当前状态,返回唯一可执行路径,并登记正式展示的用户问题。 */
|
|
3
3
|
export declare function next(projectRoot: string, change: string, changeRoot: string, defaultRisk?: import("./review.ts").ReviewRisk): NextOutput;
|
package/dist/next.js
CHANGED
|
@@ -1,9 +1,73 @@
|
|
|
1
1
|
// SuperSpec 流程引擎 — next:返回可执行路径
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
2
4
|
import { rebuildSnapshot } from "./sync.js";
|
|
3
|
-
import { readEvents } from "./store.js";
|
|
5
|
+
import { appendEvent, makeEvent, readEvents, withLock } from "./store.js";
|
|
4
6
|
import { requiredJobActions } from "./job_action.js";
|
|
5
7
|
import { planNextStep } from "./phase_plan.js";
|
|
6
8
|
import { workflowRiskForProject } from "./workflow_config.js";
|
|
9
|
+
import { currentExploreRoundId } from "./explore_round.js";
|
|
10
|
+
import { currentProposeRoundId } from "./propose_round.js";
|
|
11
|
+
import { discoveryQuestionDecisionBasisDigest, legacyDiscoveryOpenQuestionScope, parseDiscoveryOpenQuestions, collectProposeQuestions, proposeQuestionDecisionBasisDigest, legacyProposeOpenQuestionScope, } from "./format.js";
|
|
12
|
+
function recordPresentedQuestion(projectRoot, change, changeRoot, output) {
|
|
13
|
+
if (output.path !== "ask_user")
|
|
14
|
+
return;
|
|
15
|
+
const isExplore = output.ask_user.scope.startsWith("explore_open_question:");
|
|
16
|
+
const isPropose = output.ask_user.scope.startsWith("propose_open_question:");
|
|
17
|
+
if (!isExplore && !isPropose)
|
|
18
|
+
return;
|
|
19
|
+
const events = readEvents(projectRoot, change);
|
|
20
|
+
if (isExplore) {
|
|
21
|
+
const current = parseDiscoveryOpenQuestions(readFileSync(join(changeRoot, ".superspec", "artifacts", "discovery.md"), "utf8"))[0];
|
|
22
|
+
if (!current)
|
|
23
|
+
return;
|
|
24
|
+
const roundId = currentExploreRoundId(events);
|
|
25
|
+
const latest = [...events].reverse().find(event => {
|
|
26
|
+
if (event.event_type !== "user_question_presented")
|
|
27
|
+
return false;
|
|
28
|
+
const payload = event.payload;
|
|
29
|
+
return payload.phase === "explore" && payload.round_id === roundId && payload.question_id === current.id &&
|
|
30
|
+
(!current.id.startsWith("item-") || payload.question_ordinal === current.ordinal);
|
|
31
|
+
});
|
|
32
|
+
if (latest?.payload?.scope === output.ask_user.scope)
|
|
33
|
+
return;
|
|
34
|
+
appendEvent(projectRoot, change, makeEvent(change, "user_question_presented", {
|
|
35
|
+
phase: "explore",
|
|
36
|
+
round_id: roundId,
|
|
37
|
+
scope: output.ask_user.scope,
|
|
38
|
+
legacy_scope: legacyDiscoveryOpenQuestionScope(current, roundId),
|
|
39
|
+
question: output.ask_user.question,
|
|
40
|
+
question_id: current.id,
|
|
41
|
+
question_ordinal: current.ordinal,
|
|
42
|
+
decision_basis_digest: discoveryQuestionDecisionBasisDigest(current),
|
|
43
|
+
}));
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const current = collectProposeQuestions(changeRoot).find(question => question.status === "open");
|
|
47
|
+
if (!current)
|
|
48
|
+
return;
|
|
49
|
+
const roundId = currentProposeRoundId(events);
|
|
50
|
+
const latest = [...events].reverse().find(event => {
|
|
51
|
+
if (event.event_type !== "user_question_presented")
|
|
52
|
+
return false;
|
|
53
|
+
const payload = event.payload;
|
|
54
|
+
return payload.phase === "propose" && payload.round_id === roundId && payload.path === current.path && payload.question_id === current.id &&
|
|
55
|
+
(!current.id.startsWith("item-") || payload.question_ordinal === current.ordinal);
|
|
56
|
+
});
|
|
57
|
+
if (latest?.payload?.scope === output.ask_user.scope)
|
|
58
|
+
return;
|
|
59
|
+
appendEvent(projectRoot, change, makeEvent(change, "user_question_presented", {
|
|
60
|
+
phase: "propose",
|
|
61
|
+
round_id: roundId,
|
|
62
|
+
scope: output.ask_user.scope,
|
|
63
|
+
legacy_scope: legacyProposeOpenQuestionScope(current, roundId),
|
|
64
|
+
question: output.ask_user.question,
|
|
65
|
+
path: current.path,
|
|
66
|
+
question_id: current.id,
|
|
67
|
+
question_ordinal: current.ordinal,
|
|
68
|
+
decision_basis_digest: proposeQuestionDecisionBasisDigest(current),
|
|
69
|
+
}));
|
|
70
|
+
}
|
|
7
71
|
function requiredJobsOutput(state, change, jobs, reason) {
|
|
8
72
|
return {
|
|
9
73
|
state,
|
|
@@ -33,8 +97,24 @@ function toNextOutput(change, plan) {
|
|
|
33
97
|
switch (plan.kind) {
|
|
34
98
|
case "required_jobs":
|
|
35
99
|
return requiredJobsOutput(plan.state, change, plan.jobs, plan.reason);
|
|
100
|
+
case "artifact_required":
|
|
101
|
+
return {
|
|
102
|
+
state: plan.state,
|
|
103
|
+
path: "artifact_required",
|
|
104
|
+
artifact: plan.artifact,
|
|
105
|
+
resume: plan.resume,
|
|
106
|
+
reason: plan.reason,
|
|
107
|
+
};
|
|
36
108
|
case "ask_user":
|
|
37
109
|
return { state: plan.state, path: "ask_user", ask_user: plan.ask, reason: plan.reason };
|
|
110
|
+
case "material_update_required":
|
|
111
|
+
return {
|
|
112
|
+
state: plan.state,
|
|
113
|
+
path: "material_update_required",
|
|
114
|
+
errors: plan.errors,
|
|
115
|
+
resume: { argv: ["superspec", "transition", "next", "--change", change] },
|
|
116
|
+
reason: plan.reason,
|
|
117
|
+
};
|
|
38
118
|
case "run_transition":
|
|
39
119
|
return {
|
|
40
120
|
state: plan.state,
|
|
@@ -52,16 +132,21 @@ function toNextOutput(change, plan) {
|
|
|
52
132
|
};
|
|
53
133
|
}
|
|
54
134
|
}
|
|
55
|
-
/** next
|
|
135
|
+
/** next 命令:读取当前状态,返回唯一可执行路径,并登记正式展示的用户问题。 */
|
|
56
136
|
export function next(projectRoot, change, changeRoot, defaultRisk = workflowRiskForProject(projectRoot)) {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
137
|
+
return withLock(projectRoot, change, () => {
|
|
138
|
+
const snapshot = rebuildSnapshot(projectRoot, change, changeRoot);
|
|
139
|
+
const events = readEvents(projectRoot, change);
|
|
140
|
+
const plannedNextStep = planNextStep({ projectRoot, change, changeRoot, events, snapshot, mode: { kind: "risk", risk: defaultRisk } });
|
|
141
|
+
if (plannedNextStep) {
|
|
142
|
+
const output = toNextOutput(change, plannedNextStep);
|
|
143
|
+
recordPresentedQuestion(projectRoot, change, changeRoot, output);
|
|
144
|
+
return output;
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
state: snapshot.state,
|
|
148
|
+
path: "done",
|
|
149
|
+
reason: `状态 ${snapshot.state} 没有可执行下一步`,
|
|
150
|
+
};
|
|
151
|
+
});
|
|
67
152
|
}
|
package/dist/openspec.js
CHANGED
|
@@ -1,8 +1,26 @@
|
|
|
1
1
|
// SuperSpec 流程引擎 — OpenSpec 探测
|
|
2
|
-
import {
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
3
|
import { existsSync } from "node:fs";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { createHash } from "node:crypto";
|
|
6
|
+
function isTestMode() {
|
|
7
|
+
return process.env.SUPERSPEC_TEST_MODE === "1" || process.env.NODE_ENV === "test";
|
|
8
|
+
}
|
|
9
|
+
function runtimePlatform() {
|
|
10
|
+
return isTestMode() ? process.env.SUPERSPEC_TEST_PLATFORM ?? process.platform : process.platform;
|
|
11
|
+
}
|
|
12
|
+
function windowsCommandHost() {
|
|
13
|
+
return process.env.ComSpec ?? "cmd.exe";
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* 在 Windows 上,npm 安装的 CLI 是 .cmd 启动器,不能被 execFileSync 直接执行。
|
|
17
|
+
* 保留数组参数传递;只有启动器这一层通过 cmd.exe 运行。
|
|
18
|
+
*/
|
|
19
|
+
function execOpenSpec(args, options) {
|
|
20
|
+
if (runtimePlatform() !== "win32")
|
|
21
|
+
return execFileSync("openspec", args, options);
|
|
22
|
+
return execFileSync(windowsCommandHost(), ["/d", "/s", "/c", "openspec.cmd", ...args], options);
|
|
23
|
+
}
|
|
6
24
|
/** B2 修复:校验 change 字符集,防 shell 注入 */
|
|
7
25
|
function validateChange(change) {
|
|
8
26
|
if (!/^[A-Za-z0-9._-]+$/.test(change)) {
|
|
@@ -11,7 +29,10 @@ function validateChange(change) {
|
|
|
11
29
|
}
|
|
12
30
|
export function probeOpenSpec(projectRoot, change) {
|
|
13
31
|
try {
|
|
14
|
-
const version =
|
|
32
|
+
const version = execOpenSpec(["--version"], {
|
|
33
|
+
encoding: "utf8",
|
|
34
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
35
|
+
}).trim();
|
|
15
36
|
let changeExists = false;
|
|
16
37
|
if (change) {
|
|
17
38
|
validateChange(change);
|
|
@@ -26,8 +47,8 @@ export function probeOpenSpec(projectRoot, change) {
|
|
|
26
47
|
export function openspecStatus(projectRoot, change) {
|
|
27
48
|
validateChange(change);
|
|
28
49
|
try {
|
|
29
|
-
// B2
|
|
30
|
-
const result =
|
|
50
|
+
// B2 修复:参数始终走数组;Windows 仅通过 cmd.exe 运行 .cmd 启动器。
|
|
51
|
+
const result = execOpenSpec(["status", "--change", change, "--json"], {
|
|
31
52
|
encoding: "utf8", cwd: projectRoot, stdio: ["pipe", "pipe", "ignore"],
|
|
32
53
|
});
|
|
33
54
|
return "sha256:" + createHash("sha256").update(result).digest("hex");
|
|
@@ -45,7 +66,7 @@ export function openspecStatus(projectRoot, change) {
|
|
|
45
66
|
export function validateOpenSpecChange(projectRoot, change) {
|
|
46
67
|
validateChange(change);
|
|
47
68
|
try {
|
|
48
|
-
|
|
69
|
+
execOpenSpec(["validate", change, "--type", "change", "--strict", "--no-interactive"], {
|
|
49
70
|
cwd: projectRoot,
|
|
50
71
|
encoding: "utf8",
|
|
51
72
|
stdio: ["pipe", "pipe", "pipe"],
|
|
@@ -1,10 +1,75 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
1
3
|
import { currentGitHead, dirtyCodeFiles } from "./git_state.js";
|
|
2
4
|
import { historicalProposeReadyRoles, reviewEvidenceDigest, reviewGateRoleResolution } from "./review.js";
|
|
3
5
|
import { EXPLORE_DISCOVERY_REVIEW_GATE, PROPOSE_FINAL_REVIEW_GATE } from "./review_job_gates.js";
|
|
4
6
|
import { changeRoot as openspecChangeRoot } from "./openspec.js";
|
|
5
7
|
import { findLatestEvent, sha256Text } from "./store.js";
|
|
8
|
+
import { parseExecutionRequirements, parseTasksMd, parseTestContractEntries } from "./format.js";
|
|
6
9
|
import { hasFrozenWorkflowModeForProposeRound, workflowRiskForProject, workflowRiskForState, } from "./workflow_config.js";
|
|
7
10
|
export const PHASE_CONFIRMATION_SCOPE_PREFIX = "phase_confirmation:";
|
|
11
|
+
function taskTitle(content, task) {
|
|
12
|
+
const line = content.split("\n")[task.lineIdx] ?? "";
|
|
13
|
+
return line.replace(/^-\s+\[[ xX]\]\s+\S+\s*/, "").trim() || task.taskId;
|
|
14
|
+
}
|
|
15
|
+
function taskSummaryField(lines, task, labels) {
|
|
16
|
+
for (let index = task.lineIdx + 1; index < lines.length; index++) {
|
|
17
|
+
const line = lines[index];
|
|
18
|
+
if (/^-\s+\[[ xX]\]\s+\S+/.test(line) || /^#{1,6}\s+/.test(line))
|
|
19
|
+
break;
|
|
20
|
+
const match = /^\s*-\s*([^::]+)\s*[::]\s*(.+?)\s*$/.exec(line);
|
|
21
|
+
if (match && labels.includes(match[1].trim()))
|
|
22
|
+
return match[2].trim();
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* 这是阶段确认的临时阅读面,不是新的计划格式或 gate。优先读取 task 已声明的
|
|
28
|
+
* 交付/依赖/验收;缺省项如实标为未声明,避免将任务顺序臆造成真实依赖。
|
|
29
|
+
*/
|
|
30
|
+
function proposeTaskDeliverySummary(changeRoot) {
|
|
31
|
+
const tasksPath = join(changeRoot, "tasks.md");
|
|
32
|
+
if (!existsSync(tasksPath))
|
|
33
|
+
return null;
|
|
34
|
+
const tasksContent = readFileSync(tasksPath, "utf8");
|
|
35
|
+
const tasks = parseTasksMd(tasksContent);
|
|
36
|
+
if (tasks.length === 0)
|
|
37
|
+
return null;
|
|
38
|
+
const contracts = new Map(parseExecutionRequirements(tasksContent).map(item => [item.taskId, item.contract]));
|
|
39
|
+
const testContractPath = join(changeRoot, ".superspec", "artifacts", "test-contract.md");
|
|
40
|
+
const parsedTests = existsSync(testContractPath)
|
|
41
|
+
? parseTestContractEntries(readFileSync(testContractPath, "utf8"))
|
|
42
|
+
: null;
|
|
43
|
+
const scenarios = parsedTests?.ok
|
|
44
|
+
? new Map(parsedTests.entries.map(entry => [entry.test_id, entry.scenario]))
|
|
45
|
+
: new Map();
|
|
46
|
+
const lines = tasksContent.split("\n");
|
|
47
|
+
// Propose 展示的是当前计划,不是旧 Apply 轮的执行历史。重新打开同一 task 时,
|
|
48
|
+
// 当前 tasks.md 中的未勾选状态明确表示它已重新纳入本轮待实施范围。
|
|
49
|
+
const pendingTasks = tasks.filter(task => !task.done);
|
|
50
|
+
const completedCount = tasks.length - pendingTasks.length;
|
|
51
|
+
const items = pendingTasks.map((task, index) => {
|
|
52
|
+
const contract = contracts.get(task.taskId);
|
|
53
|
+
const explicitDelivery = taskSummaryField(lines, task, ["交付", "Delivery"]);
|
|
54
|
+
const explicitDependency = taskSummaryField(lines, task, ["依赖", "Dependencies", "Depends On"]);
|
|
55
|
+
const firstTest = contract?.tests[0];
|
|
56
|
+
const testScenario = firstTest ? scenarios.get(firstTest) : null;
|
|
57
|
+
const delivery = explicitDelivery ?? contract?.acceptance ?? taskTitle(tasksContent, task);
|
|
58
|
+
const dependency = explicitDependency ?? "无明确前置交付";
|
|
59
|
+
const acceptance = contract?.acceptance
|
|
60
|
+
?? (testScenario ? `${firstTest}:${testScenario}` : "计划未单独声明验收");
|
|
61
|
+
return [
|
|
62
|
+
`${index + 1}. ${task.taskId} ${taskTitle(tasksContent, task)}`,
|
|
63
|
+
` - 交付:${delivery}`,
|
|
64
|
+
` - 依赖:${dependency}`,
|
|
65
|
+
` - 验收:${acceptance}`,
|
|
66
|
+
].join("\n");
|
|
67
|
+
});
|
|
68
|
+
const status = completedCount === 0
|
|
69
|
+
? ""
|
|
70
|
+
: `已完成 ${completedCount} 项;${pendingTasks.length === 0 ? "当前没有待实施任务。" : `以下 ${pendingTasks.length} 项仍待实施或调整。`}\n\n`;
|
|
71
|
+
return `执行计划概览\n\n${status}${items.join("\n\n")}`;
|
|
72
|
+
}
|
|
8
73
|
function latestTransition(events, predicate) {
|
|
9
74
|
return findLatestEvent(events, "transition_commit", event => predicate(event.payload));
|
|
10
75
|
}
|
|
@@ -58,6 +123,7 @@ const SPECS = {
|
|
|
58
123
|
epoch: events => latestTransition(events, payload => payload.from_state === "apply" && payload.to_state === "apply_done"),
|
|
59
124
|
},
|
|
60
125
|
};
|
|
126
|
+
const CURRENT_USER_DECISION_NOTICE = "请向用户展示本次选择并等待当前明确答复;不得用启动工作流、要求推进、一般授权、历史偏好或模型推断代替本次回答。";
|
|
61
127
|
function boundaryForState(state) {
|
|
62
128
|
switch (state) {
|
|
63
129
|
case "explore": return "explore_to_propose";
|
|
@@ -187,7 +253,12 @@ export function phaseConfirmationForBoundary(projectRoot, events, snapshot, boun
|
|
|
187
253
|
const epochEventId = epoch?.event_id ?? `legacy-${spec.state}`;
|
|
188
254
|
const digest = materialDigest(projectRoot, events, snapshot, boundary, risk);
|
|
189
255
|
const scope = `${spec.scopePrefix}:${epochEventId}:${digest}`;
|
|
190
|
-
const
|
|
256
|
+
const summary = boundary === "propose_to_apply"
|
|
257
|
+
? proposeTaskDeliverySummary(openspecChangeRoot(projectRoot, snapshot.change_id))
|
|
258
|
+
: null;
|
|
259
|
+
const boundaryQuestion = `${spec.question}\n\n${CURRENT_USER_DECISION_NOTICE}`;
|
|
260
|
+
const question = summary ? `${summary}\n\n${boundaryQuestion}` : boundaryQuestion;
|
|
261
|
+
const actions = buildActions(snapshot.change_id, boundary, scope, question, spec.actions, risk);
|
|
191
262
|
return {
|
|
192
263
|
boundary,
|
|
193
264
|
epoch_event_id: epochEventId,
|
|
@@ -195,7 +266,7 @@ export function phaseConfirmationForBoundary(projectRoot, events, snapshot, boun
|
|
|
195
266
|
scope,
|
|
196
267
|
actions,
|
|
197
268
|
ask: {
|
|
198
|
-
question
|
|
269
|
+
question,
|
|
199
270
|
allowed_answers: actions.map(action => action.label),
|
|
200
271
|
scope,
|
|
201
272
|
actions,
|