@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.
Files changed (39) hide show
  1. package/dist/cli.js +54 -9
  2. package/dist/code_review.d.ts +11 -1
  3. package/dist/code_review.js +40 -0
  4. package/dist/explore_round.d.ts +25 -0
  5. package/dist/explore_round.js +139 -0
  6. package/dist/format.d.ts +78 -2
  7. package/dist/format.js +227 -16
  8. package/dist/next.d.ts +1 -1
  9. package/dist/next.js +97 -12
  10. package/dist/openspec.js +26 -5
  11. package/dist/phase_confirmation.js +73 -2
  12. package/dist/phase_plan.d.ts +24 -1
  13. package/dist/phase_plan.js +194 -34
  14. package/dist/propose_round.d.ts +15 -0
  15. package/dist/propose_round.js +137 -0
  16. package/dist/record.js +313 -57
  17. package/dist/review.js +2 -0
  18. package/dist/review_job_gates.d.ts +1 -1
  19. package/dist/review_job_gates.js +12 -4
  20. package/dist/skill_loop.js +20 -0
  21. package/dist/task_evidence.js +5 -3
  22. package/dist/transition.d.ts +1 -0
  23. package/dist/transition.js +248 -31
  24. package/dist/types.d.ts +77 -1
  25. package/dist/workflow_profile.js +1 -1
  26. package/package.json +7 -1
  27. package/templates/workflow/AGENTS.md +17 -5
  28. package/templates/workflow/prompts/architect.md +4 -2
  29. package/templates/workflow/prompts/code-reviewer.md +3 -3
  30. package/templates/workflow/prompts/critic.md +13 -4
  31. package/templates/workflow/prompts/executor.md +5 -5
  32. package/templates/workflow/prompts/explore.md +2 -2
  33. package/templates/workflow/prompts/test-engineer.md +6 -5
  34. package/templates/workflow/prompts/test-runner.md +5 -5
  35. package/templates/workflow/prompts/verifier.md +4 -4
  36. package/templates/workflow/skills/superspec-apply/SKILL.md +26 -12
  37. package/templates/workflow/skills/superspec-explore/SKILL.md +25 -7
  38. package/templates/workflow/skills/superspec-propose/SKILL.md +33 -16
  39. 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 sectionBodyByHeadings(content, headings) {
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 nextHeadingMatch ? restContent.slice(0, nextHeadingMatch.index) : restContent;
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 countOpenChecklistItemsInSection(content, ["待确认问题", "Open Questions", "Pending Questions"]);
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
- return { ok: false, message: `链路五要素第 ${rowNum} 行缺少${col}`, present: true };
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
- return { ok: false, message: `链路五要素第 ${rowNum} 行状态必须是 已确认、未知阻塞 或 未知非阻塞`, present: true };
197
+ errors.push(`链路五要素第 ${rowNum} 行状态必须是 已确认、未知阻塞 或 未知非阻塞`);
101
198
  }
102
199
  if (status.includes("未知阻塞") && countDiscoveryOpenQuestions(content) === 0) {
103
- return { ok: false, message: "链路五要素存在未知阻塞,但待确认问题中没有未解决项", present: true };
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
- /** 完整校验 discovery.md:存在 + 非空 + 无未确认问题 */
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
- if (openCount > 0)
121
- return { ok: false, message: `discovery.md 有 ${openCount} 个未确认问题`, openCount };
122
- return { ok: true, message: "discovery.md 就绪", openCount: 0 };
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.startsWith("REVIEW-FIX-");
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
- errors.push(`${contract.taskId} 的引用锚点不存在:${parsed.path}#${anchor}`);
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 => !isReviewFixTaskId(task.taskId));
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 && !isReviewFixTaskId(task.taskId) && !contract) {
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 && !isReviewFixTaskId(task.taskId) && !contract) {
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 命令:读 snapshot,返回唯一可执行路径 */
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 命令:读 snapshot,返回唯一可执行路径 */
135
+ /** next 命令:读取当前状态,返回唯一可执行路径,并登记正式展示的用户问题。 */
56
136
  export function next(projectRoot, change, changeRoot, defaultRisk = workflowRiskForProject(projectRoot)) {
57
- const snapshot = rebuildSnapshot(projectRoot, change, changeRoot);
58
- const events = readEvents(projectRoot, change);
59
- const plannedNextStep = planNextStep({ projectRoot, change, changeRoot, events, snapshot, mode: { kind: "risk", risk: defaultRisk } });
60
- if (plannedNextStep)
61
- return toNextOutput(change, plannedNextStep);
62
- return {
63
- state: snapshot.state,
64
- path: "done",
65
- reason: `状态 ${snapshot.state} 没有可执行下一步`,
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 { execSync, execFileSync } from "node:child_process";
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 = execSync("openspec --version 2>/dev/null", { encoding: "utf8" }).trim();
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 修复:用 execFileSync 数组传参,彻底避免 shell 解析
30
- const result = execFileSync("openspec", ["status", "--change", change, "--json"], {
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
- execFileSync("openspec", ["validate", change, "--type", "change", "--strict", "--no-interactive"], {
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 actions = buildActions(snapshot.change_id, boundary, scope, spec.question, spec.actions, risk);
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: spec.question,
269
+ question,
199
270
  allowed_answers: actions.map(action => action.label),
200
271
  scope,
201
272
  actions,