@peterxiaoyang/superspec 0.1.45 → 0.1.47
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 +2 -1
- package/dist/code_review.d.ts +11 -1
- package/dist/code_review.js +40 -0
- package/dist/explore_round.d.ts +23 -0
- package/dist/explore_round.js +94 -0
- package/dist/format.d.ts +67 -2
- package/dist/format.js +273 -22
- package/dist/openspec.d.ts +13 -0
- package/dist/openspec.js +53 -4
- package/dist/phase_confirmation.js +71 -2
- package/dist/phase_plan.d.ts +6 -1
- package/dist/phase_plan.js +180 -32
- package/dist/record.js +191 -57
- package/dist/review.js +2 -0
- package/dist/task_evidence.js +5 -3
- package/dist/transition.d.ts +1 -0
- package/dist/transition.js +222 -28
- package/dist/types.d.ts +42 -0
- package/package.json +1 -1
- package/templates/workflow/AGENTS.md +15 -5
- package/templates/workflow/agents/architect.toml +1 -1
- package/templates/workflow/agents/code-reviewer.toml +1 -1
- package/templates/workflow/agents/critic.toml +1 -1
- package/templates/workflow/agents/executor.toml +1 -1
- package/templates/workflow/agents/explore.toml +1 -1
- package/templates/workflow/agents/test-engineer.toml +1 -1
- package/templates/workflow/agents/test-runner.toml +1 -1
- package/templates/workflow/agents/verifier.toml +1 -1
- package/templates/workflow/prompts/architect.md +25 -33
- package/templates/workflow/prompts/code-reviewer.md +19 -67
- package/templates/workflow/prompts/critic.md +36 -86
- package/templates/workflow/prompts/executor.md +17 -19
- package/templates/workflow/prompts/explore.md +12 -46
- package/templates/workflow/prompts/test-engineer.md +22 -34
- package/templates/workflow/prompts/test-runner.md +11 -21
- package/templates/workflow/prompts/verifier.md +13 -37
- package/templates/workflow/skills/superspec-apply/SKILL.md +26 -26
- package/templates/workflow/skills/superspec-explore/SKILL.md +69 -60
- package/templates/workflow/skills/superspec-propose/SKILL.md +85 -133
- package/templates/workflow/skills/superspec-review/SKILL.md +14 -44
package/dist/format.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
// SuperSpec 流程引擎 — format.ts:文档格式解析的唯一权威源
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
import { readFileSync, existsSync } from "node:fs";
|
|
6
|
-
import { join } from "node:path";
|
|
3
|
+
// 所有可机械判定的文档协议、格式定义和解析逻辑都在这里。skills 只指导
|
|
4
|
+
// 生成与语义判断,不得自行充当格式校验器或在其它地方重复解析。
|
|
5
|
+
import { readFileSync, existsSync, realpathSync } from "node:fs";
|
|
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,90 @@ 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;
|
|
37
45
|
}
|
|
38
|
-
|
|
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
|
+
/**
|
|
56
|
+
* 计算某一确认事项之外的 Discovery 决策上下文。回写时该事项本身会从问题改为
|
|
57
|
+
* 结论,因此只归一化这一行;其余事实、证据和其它待确认项的改动都会使指纹失效。
|
|
58
|
+
*/
|
|
59
|
+
export function discoveryQuestionContextFingerprint(content, question) {
|
|
60
|
+
const range = sectionRangeByHeadings(content, DISCOVERY_QUESTION_HEADINGS);
|
|
61
|
+
if (!range)
|
|
62
|
+
return null;
|
|
63
|
+
const sectionBody = content.slice(range.start, range.end);
|
|
64
|
+
const checklist = /^\s*-\s+\[([ xX])\]\s+(.*?)\s*$/gm;
|
|
65
|
+
let ordinal = 0;
|
|
66
|
+
for (const match of sectionBody.matchAll(checklist)) {
|
|
67
|
+
ordinal += 1;
|
|
68
|
+
if (ordinal !== question.ordinal)
|
|
69
|
+
continue;
|
|
70
|
+
const lineStart = range.start + match.index;
|
|
71
|
+
const lineEnd = lineStart + match[0].length;
|
|
72
|
+
const placeholder = `- [ ] <discovery-question:${question.id}:${question.ordinal}>`;
|
|
73
|
+
return sha256Text(`${content.slice(0, lineStart)}${placeholder}${content.slice(lineEnd)}`);
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
/** 按文档顺序提取 discovery.md 的全部确认事项。 */
|
|
78
|
+
export function parseDiscoveryQuestions(content) {
|
|
79
|
+
const sectionBody = sectionBodyByHeadings(content, DISCOVERY_QUESTION_HEADINGS);
|
|
80
|
+
if (sectionBody == null)
|
|
81
|
+
return [];
|
|
82
|
+
const documentFingerprint = sha256Text(content);
|
|
83
|
+
const questions = [];
|
|
84
|
+
const checklist = /^\s*-\s+\[([ xX])\]\s+(.*?)\s*$/gm;
|
|
85
|
+
let ordinal = 0;
|
|
86
|
+
for (const match of sectionBody.matchAll(checklist)) {
|
|
87
|
+
ordinal += 1;
|
|
88
|
+
const text = match[2];
|
|
89
|
+
const idMatch = /^\s*(Q-[A-Za-z0-9][A-Za-z0-9_-]*)\b/.exec(text);
|
|
90
|
+
questions.push({
|
|
91
|
+
id: idMatch?.[1] ?? `item-${ordinal}`,
|
|
92
|
+
ordinal,
|
|
93
|
+
text,
|
|
94
|
+
raw: match[0],
|
|
95
|
+
documentFingerprint,
|
|
96
|
+
status: match[1] === " " ? "open" : "closed",
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
return questions;
|
|
100
|
+
}
|
|
101
|
+
/** 按文档顺序提取 discovery.md 中尚未确认的问题。 */
|
|
102
|
+
export function parseDiscoveryOpenQuestions(content) {
|
|
103
|
+
return parseDiscoveryQuestions(content)
|
|
104
|
+
.filter(question => question.status === "open")
|
|
105
|
+
.map(({ status: _status, ...question }) => question);
|
|
106
|
+
}
|
|
107
|
+
export function discoveryOpenQuestionScope(question, exploreRoundId) {
|
|
108
|
+
const fingerprint = sha256Text(`${exploreRoundId}\n${question.documentFingerprint}`);
|
|
109
|
+
return `${EXPLORE_OPEN_QUESTION_SCOPE_PREFIX}${fingerprint}:${question.id}`;
|
|
110
|
+
}
|
|
111
|
+
/** 面向用户展示时隐藏 Q-xxx 这一内部编号;历史无编号问题保持原文。 */
|
|
112
|
+
export function discoveryOpenQuestionDisplayText(question) {
|
|
113
|
+
if (question.id.startsWith("Q-") && question.text.startsWith(question.id)) {
|
|
114
|
+
return question.text.slice(question.id.length).replace(/^[\s::—–-]+/, "").trim();
|
|
115
|
+
}
|
|
116
|
+
return question.text.trim();
|
|
117
|
+
}
|
|
118
|
+
/** 从 discovery.md 提取“待确认问题”段内的未确认项数量。 */
|
|
39
119
|
export function countDiscoveryOpenQuestions(content) {
|
|
40
|
-
return
|
|
120
|
+
return parseDiscoveryOpenQuestions(content).length;
|
|
41
121
|
}
|
|
42
122
|
const DISCOVERY_CHAIN_HEADINGS = ["链路五要素"];
|
|
43
123
|
const DISCOVERY_CHAIN_REQUIRED_COLUMNS = [
|
|
@@ -52,6 +132,7 @@ const DISCOVERY_CHAIN_REQUIRED_COLUMNS = [
|
|
|
52
132
|
"证据",
|
|
53
133
|
"状态",
|
|
54
134
|
];
|
|
135
|
+
const DISCOVERY_CHAIN_STATUSES = new Set(["已确认", "未知阻塞", "未知非阻塞"]);
|
|
55
136
|
export function splitMarkdownTableRow(line) {
|
|
56
137
|
const trimmed = line.trim();
|
|
57
138
|
if (!trimmed.startsWith("|") || !trimmed.endsWith("|"))
|
|
@@ -95,13 +176,19 @@ export function validateDiscoveryChainCoverage(content) {
|
|
|
95
176
|
}
|
|
96
177
|
}
|
|
97
178
|
const status = cells[statusIdx]?.trim() ?? "";
|
|
179
|
+
if (!DISCOVERY_CHAIN_STATUSES.has(status)) {
|
|
180
|
+
return { ok: false, message: `链路五要素第 ${rowNum} 行状态必须是 已确认、未知阻塞 或 未知非阻塞`, present: true };
|
|
181
|
+
}
|
|
98
182
|
if (status.includes("未知阻塞") && countDiscoveryOpenQuestions(content) === 0) {
|
|
99
183
|
return { ok: false, message: "链路五要素存在未知阻塞,但待确认问题中没有未解决项", present: true };
|
|
100
184
|
}
|
|
101
185
|
}
|
|
102
186
|
return { ok: true, message: "链路五要素就绪", present: true };
|
|
103
187
|
}
|
|
104
|
-
/**
|
|
188
|
+
/**
|
|
189
|
+
* 校验 discovery.md 的可解析结构。未确认问题不是格式错误:next 会把第一个问题
|
|
190
|
+
* 作为当前用户决策返回;只有缺文档、空文档或已声明链路的结构错误才在此阻断。
|
|
191
|
+
*/
|
|
105
192
|
export function validateDiscovery(changeRoot) {
|
|
106
193
|
const path = join(changeRoot, ".superspec", "artifacts", "discovery.md");
|
|
107
194
|
if (!existsSync(path))
|
|
@@ -113,9 +200,11 @@ export function validateDiscovery(changeRoot) {
|
|
|
113
200
|
if (!chainCoverage.ok)
|
|
114
201
|
return { ok: false, message: chainCoverage.message, openCount: -1 };
|
|
115
202
|
const openCount = countDiscoveryOpenQuestions(content);
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
203
|
+
return {
|
|
204
|
+
ok: true,
|
|
205
|
+
message: openCount > 0 ? `discovery.md 结构有效,有 ${openCount} 个待确认问题` : "discovery.md 就绪",
|
|
206
|
+
openCount,
|
|
207
|
+
};
|
|
119
208
|
}
|
|
120
209
|
const PROPOSE_CONFIRMATION_DOCS = [
|
|
121
210
|
"proposal.md",
|
|
@@ -183,6 +272,28 @@ export function parseTasksMd(content) {
|
|
|
183
272
|
}
|
|
184
273
|
return tasks;
|
|
185
274
|
}
|
|
275
|
+
/**
|
|
276
|
+
* tasks.md 的机械结构校验。任务是否拆分合理、顺序是否符合真实依赖仍由
|
|
277
|
+
* Critic/Architect 判断;这里仅拒绝引擎无法可靠驱动的格式。
|
|
278
|
+
*/
|
|
279
|
+
export function validateTasksDocument(content) {
|
|
280
|
+
const errors = [];
|
|
281
|
+
if (!/^#\s+Tasks\s*$/m.test(content))
|
|
282
|
+
errors.push("tasks.md 缺少顶级 # Tasks 标题");
|
|
283
|
+
const tasks = parseTasksMd(content);
|
|
284
|
+
const seen = new Set();
|
|
285
|
+
for (const task of tasks) {
|
|
286
|
+
if (seen.has(task.taskId))
|
|
287
|
+
errors.push(`tasks.md task ID 重复:${task.taskId}`);
|
|
288
|
+
seen.add(task.taskId);
|
|
289
|
+
}
|
|
290
|
+
for (const [index, line] of content.split("\n").entries()) {
|
|
291
|
+
if (/^\s+-\s+\[[ xX]\]\s+/.test(line)) {
|
|
292
|
+
errors.push(`tasks.md 第 ${index + 1} 行存在缩进 checkbox;只有顶格 checkbox 可以作为可执行 task`);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return errors;
|
|
296
|
+
}
|
|
186
297
|
function isTopLevelTaskLine(line) {
|
|
187
298
|
return TASK_LINE_RE.test(line);
|
|
188
299
|
}
|
|
@@ -301,8 +412,17 @@ export function adoptedContractForTask(content, taskId, contractMode) {
|
|
|
301
412
|
const parsed = executionRequirementForTask(content, taskId);
|
|
302
413
|
return { parsed, contract: contractMode ? parsed?.contract ?? null : null };
|
|
303
414
|
}
|
|
415
|
+
/**
|
|
416
|
+
* Fix task 由状态机从已批准的实现范围派生;它没有 proposal 阶段执行依据块。
|
|
417
|
+
* REVIEW-FIX-* 是发布前已有的持久化 task ID,FIX-SELFTEST-* 是当前引擎生成的
|
|
418
|
+
* 自测修复 task。不能把通用 FIX-* 前缀保留为内部命名空间。
|
|
419
|
+
*/
|
|
420
|
+
export function isFixTaskId(taskId) {
|
|
421
|
+
return taskId.startsWith("REVIEW-FIX-") || taskId.startsWith("FIX-SELFTEST-");
|
|
422
|
+
}
|
|
423
|
+
/** @deprecated 新代码使用 isFixTaskId;保留给旧扩展和历史调用。 */
|
|
304
424
|
export function isReviewFixTaskId(taskId) {
|
|
305
|
-
return taskId
|
|
425
|
+
return isFixTaskId(taskId);
|
|
306
426
|
}
|
|
307
427
|
export function isCharacterizationTask(task) {
|
|
308
428
|
return task.tddRequired === false && task.noTddReason === "characterization";
|
|
@@ -329,15 +449,23 @@ export function parseTestContractEntries(content) {
|
|
|
329
449
|
if (row.length === 0)
|
|
330
450
|
break;
|
|
331
451
|
const testId = (row[testIdIdx] ?? "").trim();
|
|
332
|
-
|
|
333
|
-
|
|
452
|
+
const scenario = (row[scenarioIdx] ?? "").trim();
|
|
453
|
+
if (!testId) {
|
|
454
|
+
return { ok: false, entries: [], message: `test-contract.md 第 ${rowIndex + 1} 行缺少 test_id` };
|
|
455
|
+
}
|
|
456
|
+
if (!/^TEST-[A-Za-z0-9_-]+$/.test(testId)) {
|
|
457
|
+
return { ok: false, entries: [], message: `test-contract.md 中 TEST ID 格式无效:${testId}` };
|
|
458
|
+
}
|
|
334
459
|
if (seen.has(testId)) {
|
|
335
460
|
return { ok: false, entries: [], message: `test-contract.md 中 TEST ID 重复:${testId}` };
|
|
336
461
|
}
|
|
462
|
+
if (!scenario) {
|
|
463
|
+
return { ok: false, entries: [], message: `test-contract.md 中 ${testId} 缺少 scenario` };
|
|
464
|
+
}
|
|
337
465
|
seen.add(testId);
|
|
338
466
|
entries.push({
|
|
339
467
|
test_id: testId,
|
|
340
|
-
scenario
|
|
468
|
+
scenario,
|
|
341
469
|
});
|
|
342
470
|
}
|
|
343
471
|
}
|
|
@@ -347,6 +475,127 @@ export function parseTestContractEntries(content) {
|
|
|
347
475
|
return { ok: false, entries: [], message: "test-contract.md 没有 TEST-* 行" };
|
|
348
476
|
return { ok: true, entries };
|
|
349
477
|
}
|
|
478
|
+
/** OpenSpec 项目的 proposal 采用固定 Impact 表格,供状态机进行纯结构校验。 */
|
|
479
|
+
export function validateProposalImpact(content) {
|
|
480
|
+
const body = sectionBodyByHeadings(content, ["Impact"]);
|
|
481
|
+
if (body == null)
|
|
482
|
+
return { ok: false, message: "proposal.md 缺少 ## Impact" };
|
|
483
|
+
const tableLines = body.split("\n").filter(line => line.trim().startsWith("|"));
|
|
484
|
+
if (tableLines.length < 3 || !isMarkdownTableSeparator(tableLines[1])) {
|
|
485
|
+
return { ok: false, message: "proposal.md 的 Impact 必须包含 Area / Reason 表格" };
|
|
486
|
+
}
|
|
487
|
+
const header = splitMarkdownTableRow(tableLines[0]).map(cell => cell.toLowerCase());
|
|
488
|
+
const areaIdx = header.indexOf("area");
|
|
489
|
+
const reasonIdx = header.indexOf("reason");
|
|
490
|
+
if (areaIdx < 0 || reasonIdx < 0) {
|
|
491
|
+
return { ok: false, message: "proposal.md 的 Impact 表格缺少 Area 或 Reason 列" };
|
|
492
|
+
}
|
|
493
|
+
const rows = tableLines.slice(2).map(splitMarkdownTableRow).filter(cells => cells.length > 0);
|
|
494
|
+
if (rows.length === 0)
|
|
495
|
+
return { ok: false, message: "proposal.md 的 Impact 表格至少需要一行" };
|
|
496
|
+
for (const [index, row] of rows.entries()) {
|
|
497
|
+
if (!row[areaIdx]?.trim() || !row[reasonIdx]?.trim()) {
|
|
498
|
+
return { ok: false, message: `proposal.md 的 Impact 第 ${index + 1} 行缺少 Area 或 Reason` };
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
return { ok: true, message: "proposal.md Impact 结构有效" };
|
|
502
|
+
}
|
|
503
|
+
function isQualifiedDocumentRef(value) {
|
|
504
|
+
const ref = value.trim();
|
|
505
|
+
return /^(?!\/)(?!.*(?:^|\/)\.\.(?:\/|$))[^\s#]+\.md#[^\s].*$/.test(ref);
|
|
506
|
+
}
|
|
507
|
+
function executionRequirementReferenceErrors(contract) {
|
|
508
|
+
const errors = [];
|
|
509
|
+
if (contract.contract.design && !isQualifiedDocumentRef(contract.contract.design)) {
|
|
510
|
+
errors.push(`${contract.taskId} 的设计必须使用 文件.md#标题 的可定位引用`);
|
|
511
|
+
}
|
|
512
|
+
for (const source of contract.contract.source) {
|
|
513
|
+
if (!isQualifiedDocumentRef(source)) {
|
|
514
|
+
errors.push(`${contract.taskId} 的来源必须使用 文件.md#标题 的可定位引用:${source}`);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
return errors;
|
|
518
|
+
}
|
|
519
|
+
function parseQualifiedDocumentRef(value) {
|
|
520
|
+
const ref = value.trim();
|
|
521
|
+
const separator = ref.indexOf("#");
|
|
522
|
+
if (separator <= 0 || separator === ref.length - 1)
|
|
523
|
+
return null;
|
|
524
|
+
return { path: ref.slice(0, separator), anchor: ref.slice(separator + 1).trim() };
|
|
525
|
+
}
|
|
526
|
+
function isPathInside(root, target) {
|
|
527
|
+
const rel = relative(root, target);
|
|
528
|
+
return rel !== "" && !rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel);
|
|
529
|
+
}
|
|
530
|
+
function documentContainsAnchor(content, anchor) {
|
|
531
|
+
if (/^(?:TEST|CHAIN|IDC)-[A-Za-z0-9_-]+$/.test(anchor)) {
|
|
532
|
+
const token = new RegExp(`(?:^|[^A-Za-z0-9_-])${escapeRegex(anchor)}(?![A-Za-z0-9_-])`);
|
|
533
|
+
return token.test(content);
|
|
534
|
+
}
|
|
535
|
+
// 支持 Markdown ATX 标题可选的 closing sequence(`## Route ##`),但正文
|
|
536
|
+
// 中同名文字仍不能冒充可定位锚点。
|
|
537
|
+
const heading = new RegExp(`^#{1,6}[\\t ]+${escapeRegex(anchor)}(?:[\\t ]+#+)?[\\t ]*$`, "m");
|
|
538
|
+
return heading.test(content);
|
|
539
|
+
}
|
|
540
|
+
function documentAnchorParts(anchor) {
|
|
541
|
+
const parts = anchor.split(",").map(part => part.trim()).filter(Boolean);
|
|
542
|
+
return parts.length > 1 && parts.every(part => /^(?:TEST|CHAIN|IDC)-[A-Za-z0-9_-]+$/.test(part))
|
|
543
|
+
? parts
|
|
544
|
+
: [anchor];
|
|
545
|
+
}
|
|
546
|
+
function canonicalDocumentRefPath(path) {
|
|
547
|
+
if (path === "discovery.md" || path === "test-contract.md") {
|
|
548
|
+
return join(".superspec", "artifacts", path);
|
|
549
|
+
}
|
|
550
|
+
return path;
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* 在已初始化的当前工作流中,把执行依据的文件/锚点可解析性作为状态机协议。
|
|
554
|
+
* “该材料是否足以支撑 task”仍然是 Critic/Architect 的语义判断。
|
|
555
|
+
*/
|
|
556
|
+
export function validateExecutionRequirementDocumentReferences(changeRoot, contracts) {
|
|
557
|
+
const root = resolve(changeRoot);
|
|
558
|
+
const realRoot = realpathSync(root);
|
|
559
|
+
const errors = [];
|
|
560
|
+
for (const contract of contracts) {
|
|
561
|
+
const refs = [contract.contract.design, ...contract.contract.source].filter((value) => Boolean(value));
|
|
562
|
+
for (const ref of refs) {
|
|
563
|
+
const parsed = parseQualifiedDocumentRef(ref);
|
|
564
|
+
if (!parsed)
|
|
565
|
+
continue; // 语法错误由 executionRequirementReferenceErrors 报告。
|
|
566
|
+
const target = resolve(root, canonicalDocumentRefPath(parsed.path));
|
|
567
|
+
if (!isPathInside(root, target)) {
|
|
568
|
+
errors.push(`${contract.taskId} 的引用越出 change 目录:${ref}`);
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
if (!existsSync(target)) {
|
|
572
|
+
errors.push(`${contract.taskId} 的引用文件不存在:${parsed.path}`);
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
// resolve/relative 只能识别字面 `..`,不能阻止 change 内的符号链接指向
|
|
576
|
+
// 外部文件;按真实路径再次校验,确保引用材料仍属于当前 change。
|
|
577
|
+
let realTarget;
|
|
578
|
+
try {
|
|
579
|
+
realTarget = realpathSync(target);
|
|
580
|
+
}
|
|
581
|
+
catch {
|
|
582
|
+
errors.push(`${contract.taskId} 的引用文件无法解析:${parsed.path}`);
|
|
583
|
+
continue;
|
|
584
|
+
}
|
|
585
|
+
if (!isPathInside(realRoot, realTarget)) {
|
|
586
|
+
errors.push(`${contract.taskId} 的引用越出 change 目录:${ref}`);
|
|
587
|
+
continue;
|
|
588
|
+
}
|
|
589
|
+
const targetContent = readFileSync(target, "utf8");
|
|
590
|
+
for (const anchor of documentAnchorParts(parsed.anchor)) {
|
|
591
|
+
if (!documentContainsAnchor(targetContent, anchor)) {
|
|
592
|
+
errors.push(`${contract.taskId} 的引用锚点不存在:${parsed.path}#${anchor}`);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
return errors;
|
|
598
|
+
}
|
|
350
599
|
export function validateExecutionRequirements(content, testContractContent, executionPolicy = "tdd", executionRequirementVersion = 2) {
|
|
351
600
|
const tasks = parseTasksMd(content);
|
|
352
601
|
// 历史 green-only task 在旧版本中用标记强制进入契约模式。保留该入口,
|
|
@@ -355,7 +604,7 @@ export function validateExecutionRequirements(content, testContractContent, exec
|
|
|
355
604
|
const hasLegacyGreenOnlyTask = tasks.some(task => task.noTddReason === GREEN_ONLY_NO_TDD_REASON);
|
|
356
605
|
// v2 不允许“所有任务都没有执行依据”这一静默回退:新 Propose 的每个普通
|
|
357
606
|
// task 都必须显式声明五字段。v1 的缺失版本仍保留旧的按需契约语义。
|
|
358
|
-
const hasV2OrdinaryTask = executionRequirementVersion === 2 && tasks.some(task => !
|
|
607
|
+
const hasV2OrdinaryTask = executionRequirementVersion === 2 && tasks.some(task => !isFixTaskId(task.taskId));
|
|
359
608
|
const mode = hasTaskBoundExecutionRequirements(content) || hasLegacyGreenOnlyTask || hasV2OrdinaryTask;
|
|
360
609
|
const contracts = parseExecutionRequirements(content);
|
|
361
610
|
// 孤儿检测必须在 mode=false 的 early return 之前:全部块都悬空时 mode=false,
|
|
@@ -377,11 +626,11 @@ export function validateExecutionRequirements(content, testContractContent, exec
|
|
|
377
626
|
}
|
|
378
627
|
// v2 是本次改造后的新计划:每个普通 task 必须声明完整五字段,
|
|
379
628
|
// `测试:` 可显式为空以表达非行为任务。旧计划只保持原 TDD 契约要求。
|
|
380
|
-
if (executionRequirementVersion === 2 && !
|
|
629
|
+
if (executionRequirementVersion === 2 && !isFixTaskId(task.taskId) && !contract) {
|
|
381
630
|
errors.push(`${task.taskId} 缺少执行依据`);
|
|
382
631
|
continue;
|
|
383
632
|
}
|
|
384
|
-
if (executionRequirementVersion === 1 && task.tddRequired && !
|
|
633
|
+
if (executionRequirementVersion === 1 && task.tddRequired && !isFixTaskId(task.taskId) && !contract) {
|
|
385
634
|
errors.push(`${task.taskId} 缺少执行依据`);
|
|
386
635
|
continue;
|
|
387
636
|
}
|
|
@@ -396,6 +645,7 @@ export function validateExecutionRequirements(content, testContractContent, exec
|
|
|
396
645
|
errors.push(`${task.taskId} 的执行依据缺少验收目标`);
|
|
397
646
|
if (!contract.contract.guard)
|
|
398
647
|
errors.push(`${task.taskId} 的执行依据缺少边界`);
|
|
648
|
+
errors.push(...executionRequirementReferenceErrors(contract));
|
|
399
649
|
}
|
|
400
650
|
if (contract && (executionRequirementVersion === 1 && task.tddRequired || legacyGreenOnly) && contract.contract.tests.length === 0) {
|
|
401
651
|
errors.push(`${task.taskId} 的执行依据缺少测试`);
|
|
@@ -484,11 +734,12 @@ export function validateUserDecision(d) {
|
|
|
484
734
|
}
|
|
485
735
|
// ===== test-contract.md =====
|
|
486
736
|
//
|
|
487
|
-
//
|
|
737
|
+
// 格式(状态机校验,propose skill 负责生成):
|
|
488
738
|
// # Test Contract
|
|
489
739
|
// | test_id | scenario |
|
|
490
740
|
// |---|---|
|
|
491
741
|
// | TEST-001 | 注册时密码被加密 |
|
|
492
742
|
//
|
|
493
|
-
//
|
|
494
|
-
//
|
|
743
|
+
// 引擎行为:当 task 声明 TEST 时,状态机解析表格、TEST ID 和 scenario,
|
|
744
|
+
// 并在 propose-ready / start-apply 阶段拒绝无效引用;测试语义和证明力仍由
|
|
745
|
+
// Test Engineer 判断。
|
package/dist/openspec.d.ts
CHANGED
|
@@ -4,6 +4,19 @@ export interface OpenSpecProbe {
|
|
|
4
4
|
changeExists: boolean;
|
|
5
5
|
error?: string;
|
|
6
6
|
}
|
|
7
|
+
export interface OpenSpecStrictValidation {
|
|
8
|
+
/** 调用方已经按冻结 profile 决定是否执行 strict validation。 */
|
|
9
|
+
checked: boolean;
|
|
10
|
+
ok: boolean;
|
|
11
|
+
message: string;
|
|
12
|
+
}
|
|
7
13
|
export declare function probeOpenSpec(projectRoot: string, change?: string): OpenSpecProbe;
|
|
8
14
|
export declare function openspecStatus(projectRoot: string, change: string): string;
|
|
15
|
+
/**
|
|
16
|
+
* 计划阶段的原生 OpenSpec 结构 gate。
|
|
17
|
+
*
|
|
18
|
+
* 调用方已通过冻结的 planning profile 确认应执行 strict validation。这里不再
|
|
19
|
+
* 读取实时 config.yaml,避免计划就绪后环境变化导致准入标准漂移。
|
|
20
|
+
*/
|
|
21
|
+
export declare function validateOpenSpecChange(projectRoot: string, change: string): OpenSpecStrictValidation;
|
|
9
22
|
export declare function changeRoot(projectRoot: string, change: string): string;
|
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");
|
|
@@ -36,6 +57,34 @@ export function openspecStatus(projectRoot, change) {
|
|
|
36
57
|
return "sha256:unknown";
|
|
37
58
|
}
|
|
38
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* 计划阶段的原生 OpenSpec 结构 gate。
|
|
62
|
+
*
|
|
63
|
+
* 调用方已通过冻结的 planning profile 确认应执行 strict validation。这里不再
|
|
64
|
+
* 读取实时 config.yaml,避免计划就绪后环境变化导致准入标准漂移。
|
|
65
|
+
*/
|
|
66
|
+
export function validateOpenSpecChange(projectRoot, change) {
|
|
67
|
+
validateChange(change);
|
|
68
|
+
try {
|
|
69
|
+
execOpenSpec(["validate", change, "--type", "change", "--strict", "--no-interactive"], {
|
|
70
|
+
cwd: projectRoot,
|
|
71
|
+
encoding: "utf8",
|
|
72
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
73
|
+
});
|
|
74
|
+
return { checked: true, ok: true, message: "OpenSpec 原生结构校验通过" };
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
const failure = error;
|
|
78
|
+
const stdout = typeof failure.stdout === "string" ? failure.stdout : failure.stdout?.toString("utf8") ?? "";
|
|
79
|
+
const stderr = typeof failure.stderr === "string" ? failure.stderr : failure.stderr?.toString("utf8") ?? "";
|
|
80
|
+
const detail = [stdout, stderr].map(value => value.trim()).filter(Boolean).join(";");
|
|
81
|
+
return {
|
|
82
|
+
checked: true,
|
|
83
|
+
ok: false,
|
|
84
|
+
message: `OpenSpec strict 校验失败${detail ? `:${detail}` : ";请确认 openspec CLI 可用并修复 proposal/specs 结构"}`,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
}
|
|
39
88
|
export function changeRoot(projectRoot, change) {
|
|
40
89
|
return join(projectRoot, "openspec", "changes", change);
|
|
41
90
|
}
|
|
@@ -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
|
}
|
|
@@ -187,7 +252,11 @@ export function phaseConfirmationForBoundary(projectRoot, events, snapshot, boun
|
|
|
187
252
|
const epochEventId = epoch?.event_id ?? `legacy-${spec.state}`;
|
|
188
253
|
const digest = materialDigest(projectRoot, events, snapshot, boundary, risk);
|
|
189
254
|
const scope = `${spec.scopePrefix}:${epochEventId}:${digest}`;
|
|
190
|
-
const
|
|
255
|
+
const summary = boundary === "propose_to_apply"
|
|
256
|
+
? proposeTaskDeliverySummary(openspecChangeRoot(projectRoot, snapshot.change_id))
|
|
257
|
+
: null;
|
|
258
|
+
const question = summary ? `${summary}\n\n${spec.question}` : spec.question;
|
|
259
|
+
const actions = buildActions(snapshot.change_id, boundary, scope, question, spec.actions, risk);
|
|
191
260
|
return {
|
|
192
261
|
boundary,
|
|
193
262
|
epoch_event_id: epochEventId,
|
|
@@ -195,7 +264,7 @@ export function phaseConfirmationForBoundary(projectRoot, events, snapshot, boun
|
|
|
195
264
|
scope,
|
|
196
265
|
actions,
|
|
197
266
|
ask: {
|
|
198
|
-
question
|
|
267
|
+
question,
|
|
199
268
|
allowed_answers: actions.map(action => action.label),
|
|
200
269
|
scope,
|
|
201
270
|
actions,
|
package/dist/phase_plan.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ReviewGateRule } from "./review_job_gates.ts";
|
|
2
|
-
import
|
|
2
|
+
import { exploreAnswerRegistrationPayload } from "./explore_round.ts";
|
|
3
|
+
import type { AcceptedMaterialFollowupContinuation, AskUser, Event, ExecutionPolicy, Job, JobRole, PlanningValidationProfile, State } from "./types.ts";
|
|
3
4
|
import type { Snapshot } from "./types.ts";
|
|
4
5
|
import type { ReviewRisk } from "./review.ts";
|
|
5
6
|
export type TransitionName = "explore" | "propose-ready" | "start-apply" | "task-start" | "task-complete" | "review-ready" | "reopen" | "accept";
|
|
@@ -87,6 +88,8 @@ export declare function executionPolicyForRisk(risk: ReviewRisk): ExecutionPolic
|
|
|
87
88
|
export declare function executionPolicyForCurrentRound(events: Event[]): ExecutionPolicy;
|
|
88
89
|
export declare function proposalDocsBaseline(changeRoot: string): Record<string, string>;
|
|
89
90
|
export declare function discoveryDocsBaseline(changeRoot: string): Record<string, string>;
|
|
91
|
+
/** 新 Explore 轮次冻结已有已确认事项,避免把历史答复当作本轮遗漏。 */
|
|
92
|
+
export declare function exploreAnswerRegistrationPayloadForChange(changeRoot: string): ReturnType<typeof exploreAnswerRegistrationPayload>;
|
|
90
93
|
export declare function latestAcceptedProposalBaseline(events: Event[]): Record<string, string> | null;
|
|
91
94
|
export declare function latestReopenProposeBaseline(events: Event[]): Record<string, string> | null;
|
|
92
95
|
export declare function latestReopenExploreBaseline(events: Event[]): Record<string, string> | null;
|
|
@@ -95,6 +98,8 @@ export declare function discoveryDocsChangedSinceBaseline(changeRoot: string, ba
|
|
|
95
98
|
export declare function pendingTaskIds(changeRoot: string): string[];
|
|
96
99
|
/** 当前 Apply round 的规则版本;缺失版本的历史 event 保持 v1 回放。 */
|
|
97
100
|
export declare function executionRequirementVersionForCurrentRound(events: Event[]): 1 | 2;
|
|
101
|
+
/** 新 planning round 在进入 propose 时冻结当前 OpenSpec 校验契约。 */
|
|
102
|
+
export declare function planningValidationProfileForNewRound(projectRoot: string): PlanningValidationProfile;
|
|
98
103
|
export declare function applyRequirementModeForCurrentRound(events: Event[]): boolean;
|
|
99
104
|
export declare function pendingTaskStatusForApply(changeRoot: string, events: Event[]): ApplyPendingTaskStatus;
|
|
100
105
|
export declare function formatPendingTaskMessage(ids: string[], action: string): string;
|