@peterxiaoyang/superspec 0.1.47 → 0.1.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -66,7 +66,7 @@ function transitionExitCode(result) {
66
66
  function proposeReadyExitCode(result) {
67
67
  if (result.outcome === "blocked")
68
68
  return 0;
69
- return result.events_written === 0 && result.message.includes("不能") ? 1 : 0;
69
+ return result.events_written === 0 && result.outcome === "advanced" && result.from_state === result.to_state ? 1 : 0;
70
70
  }
71
71
  function ensureWorkflowMode(projectRoot, _opts) {
72
72
  try {
@@ -493,10 +493,8 @@ async function askToUpdateSelfIfNeeded(projectRoot, rerunArgs) {
493
493
  // ===== 初始化 transition init =====
494
494
  // ===== init/explore 现在在 transition.ts 中(走统一锁内路径)=====
495
495
  // ===== 主分发 =====
496
- async function main(argv) {
497
- // --help / 无参数 → 打印用法
498
- if (argv.length === 0 || argv.includes("--help") || argv.includes("-h")) {
499
- console.log(`SuperSpec 流程引擎 ${SUPERSPEC_VERSION}
496
+ function topLevelHelp() {
497
+ return `SuperSpec 流程引擎 ${SUPERSPEC_VERSION}
500
498
 
501
499
  用法:superspec <命令> [选项]
502
500
 
@@ -507,8 +505,8 @@ async function main(argv) {
507
505
  jobs <子命令> --change <C> 工作项管理(见下)
508
506
  install 安装项目工作流入口
509
507
  init --scope project install 的兼容别名
510
- update 升级 CLI 到 npm latest 并同步项目工作流模板
511
- version 版本号
508
+ update 升级 CLI 到 npm latest 并同步项目工作流模板
509
+ version 版本号
512
510
 
513
511
  transition 子命令:
514
512
  init / explore / sync / next / propose-ready / start-apply
@@ -525,7 +523,49 @@ record 子命令:
525
523
 
526
524
  jobs 子命令:
527
525
  list / packet --job <J>
528
- `);
526
+ `;
527
+ }
528
+ function commandHelp(command, subcommand) {
529
+ if (command === "record" && subcommand === "user-decision") {
530
+ return `用法:superspec record user-decision --change <C> --input <F|->
531
+
532
+ 从 JSON 文件读取用户决定;--input - 表示从 stdin 读取。
533
+ 输入至少包含 scope 和 answer,工作流给出的 question 可一并保留。
534
+
535
+ 示例:
536
+ printf '%s' '{"scope":"<next 返回的 scope>","question":"<原问题>","answer":"<用户答复>"}' | superspec record user-decision --change <C> --input -
537
+ `;
538
+ }
539
+ if (command === "record" && subcommand === "job-submit") {
540
+ return `用法:superspec record job-submit --change <C> --job <J> --report <F|->
541
+
542
+ 提交 reviewer JSON 报告;--report - 表示从 stdin 读取。报告契约以 jobs packet 返回的 report_schema 为准。
543
+
544
+ 示例:
545
+ superspec jobs packet --change <C> --job <J>
546
+ superspec record job-submit --change <C> --job <J> --report report.json
547
+ `;
548
+ }
549
+ if (command === "record" && subcommand === "test-run") {
550
+ return `用法:superspec record test-run --change <C> --input <F|->
551
+
552
+ 登记测试证据;--input - 表示从 stdin 读取。输入需包含 test_id、task_structure_digest、command、cwd、exit_code 和 semantic_status。
553
+
554
+ 示例:
555
+ printf '%s' '{"test_id":"TEST-001","task_structure_digest":"sha256:<digest>","command":"npm test","cwd":"<project>","exit_code":0,"semantic_status":"expected_success"}' | superspec record test-run --change <C> --input -
556
+ `;
557
+ }
558
+ if (command === "transition" && subcommand === "propose-ready") {
559
+ return `用法:superspec transition propose-ready --change <C>
560
+
561
+ 校验当前计划材料和审查门禁;满足条件时推进到 propose_ready。工作流模式来自项目配置,不接受 --risk。
562
+ `;
563
+ }
564
+ return topLevelHelp();
565
+ }
566
+ async function main(argv) {
567
+ if (argv.length === 0) {
568
+ console.log(topLevelHelp());
529
569
  return 0;
530
570
  }
531
571
  // version
@@ -534,6 +574,10 @@ jobs 子命令:
534
574
  return 0;
535
575
  }
536
576
  const { command, subcommand, opts } = parseArgs(argv);
577
+ if (argv.includes("--help") || argv.includes("-h") || command === "--help" || command === "-h") {
578
+ console.log(commandHelp(command, subcommand));
579
+ return 0;
580
+ }
537
581
  const projectRoot = process.cwd();
538
582
  // install / init / update 不需要 --change
539
583
  if (command === "install" || command === "init") {
@@ -15,9 +15,11 @@ export declare function exploreAnswerRegistrationPayload(discoveryContent: strin
15
15
  };
16
16
  };
17
17
  /** 当前确认事项已通过本轮主流程登记过答复。 */
18
- export declare function exploreAnswerWasRecorded(events: readonly Event[], roundId: string, question: Pick<DiscoveryQuestion, "id" | "ordinal">, discoveryContent: string): boolean;
18
+ export declare function exploreAnswerWasRecorded(events: readonly Event[], roundId: string, question: Pick<DiscoveryQuestion, "id" | "ordinal" | "text">, discoveryContent: string): boolean;
19
19
  /**
20
20
  * 新协议轮次中,被回写为已确认的事项必须有对应答复记录。
21
21
  * 进入本轮前已经关闭的事项作为基线保留,历史 change 因缺少协议标记整体兼容。
22
22
  */
23
23
  export declare function unregisteredClosedExploreQuestions(events: readonly Event[], discoveryContent: string): DiscoveryQuestion[];
24
+ /** 已正式展示但尚未登记答复的 Explore 问题不能通过删除问题行绕过。 */
25
+ export declare function unresolvedPresentedExploreQuestionScopes(events: readonly Event[]): string[];
@@ -1,4 +1,4 @@
1
- import { discoveryQuestionContextFingerprint, discoveryQuestionKey, parseDiscoveryQuestions, } from "./format.js";
1
+ import { discoveryQuestionContextFingerprint, discoveryQuestionDecisionBasisDigest, discoveryQuestionKey, parseDiscoveryQuestions, } from "./format.js";
2
2
  const EXPLORE_ANSWER_REGISTRATION_VERSION = 1;
3
3
  function isExploreRoundEntry(event) {
4
4
  if (event.event_type !== "transition_commit")
@@ -68,16 +68,20 @@ export function exploreAnswerWasRecorded(events, roundId, question, discoveryCon
68
68
  const currentContextFingerprint = discoveryQuestionContextFingerprint(discoveryContent, question);
69
69
  if (!currentContextFingerprint)
70
70
  return false;
71
+ const currentBasisDigest = discoveryQuestionDecisionBasisDigest(question);
71
72
  return events.some(event => {
72
73
  if (event.event_type !== "user_decision_recorded")
73
74
  return false;
74
75
  const payload = event.payload;
75
76
  const recorded = payload.explore_open_question;
77
+ const revisionMatches = typeof recorded?.decision_basis_digest === "string"
78
+ ? recorded.decision_basis_digest === currentBasisDigest
79
+ : recorded?.context_fingerprint === currentContextFingerprint;
76
80
  return payload.accepted === true &&
77
81
  recorded?.round_id === roundId &&
78
82
  recorded.question_id === question.id &&
79
- recorded.question_ordinal === question.ordinal &&
80
- recorded.context_fingerprint === currentContextFingerprint;
83
+ (!question.id.startsWith("item-") || recorded.question_ordinal === question.ordinal) &&
84
+ revisionMatches;
81
85
  });
82
86
  }
83
87
  /**
@@ -92,3 +96,44 @@ export function unregisteredClosedExploreQuestions(events, discoveryContent) {
92
96
  !registration.baselineClosedQuestionKeys.has(discoveryQuestionKey(question)) &&
93
97
  !exploreAnswerWasRecorded(events, registration.roundId, question, discoveryContent));
94
98
  }
99
+ /** 已正式展示但尚未登记答复的 Explore 问题不能通过删除问题行绕过。 */
100
+ export function unresolvedPresentedExploreQuestionScopes(events) {
101
+ const roundId = currentExploreRoundId(events);
102
+ const acceptedDecisions = events.flatMap(event => {
103
+ if (event.event_type !== "user_decision_recorded")
104
+ return [];
105
+ const payload = event.payload;
106
+ return payload.accepted === true ? [payload] : [];
107
+ });
108
+ const latestByQuestion = new Map();
109
+ for (const event of events) {
110
+ if (event.event_type !== "user_question_presented")
111
+ continue;
112
+ const payload = event.payload;
113
+ if (payload.phase !== "explore" || payload.round_id !== roundId || typeof payload.scope !== "string" || typeof payload.question_id !== "string")
114
+ continue;
115
+ const identity = payload.question_id.startsWith("item-")
116
+ ? `${payload.question_id}:${String(payload.question_ordinal)}`
117
+ : payload.question_id;
118
+ latestByQuestion.set(identity, {
119
+ scope: payload.scope,
120
+ legacyScope: typeof payload.legacy_scope === "string" ? payload.legacy_scope : null,
121
+ questionId: payload.question_id,
122
+ questionOrdinal: payload.question_ordinal,
123
+ revisionDigest: payload.decision_basis_digest,
124
+ });
125
+ }
126
+ return [...latestByQuestion.values()].flatMap(presented => {
127
+ const answered = acceptedDecisions.some(decision => {
128
+ if (decision.scope === presented.scope || decision.scope === presented.legacyScope)
129
+ return true;
130
+ const recorded = decision.explore_open_question;
131
+ return recorded?.round_id === roundId &&
132
+ recorded.question_id === presented.questionId &&
133
+ (!presented.questionId.startsWith("item-") || recorded.question_ordinal === presented.questionOrdinal) &&
134
+ typeof presented.revisionDigest === "string" &&
135
+ recorded.decision_basis_digest === presented.revisionDigest;
136
+ });
137
+ return answered ? [] : [presented.scope];
138
+ });
139
+ }
package/dist/format.d.ts CHANGED
@@ -26,6 +26,11 @@ export interface DiscoveryQuestion extends DiscoveryOpenQuestion {
26
26
  * 已经确认的旧事项误认为新答复。
27
27
  */
28
28
  export declare function discoveryQuestionKey(question: Pick<DiscoveryQuestion, "id" | "ordinal" | "text">): string;
29
+ /**
30
+ * 当前决定所依据的规范化问题内容。会改变选择、推荐或影响的事实必须写入该问题项;
31
+ * 状态机只绑定这份明确依据,不猜测文档其它段落与决定是否相关。
32
+ */
33
+ export declare function discoveryQuestionDecisionBasisDigest(question: Pick<DiscoveryQuestion, "id" | "ordinal" | "text">): string;
29
34
  /**
30
35
  * 计算某一确认事项之外的 Discovery 决策上下文。回写时该事项本身会从问题改为
31
36
  * 结论,因此只归一化这一行;其余事实、证据和其它待确认项的改动都会使指纹失效。
@@ -35,7 +40,9 @@ export declare function discoveryQuestionContextFingerprint(content: string, que
35
40
  export declare function parseDiscoveryQuestions(content: string): DiscoveryQuestion[];
36
41
  /** 按文档顺序提取 discovery.md 中尚未确认的问题。 */
37
42
  export declare function parseDiscoveryOpenQuestions(content: string): DiscoveryOpenQuestion[];
38
- export declare function discoveryOpenQuestionScope(question: Pick<DiscoveryOpenQuestion, "id" | "documentFingerprint">, exploreRoundId: string): string;
43
+ export declare function discoveryOpenQuestionScope(question: Pick<DiscoveryOpenQuestion, "id" | "ordinal" | "text">, exploreRoundId: string): string;
44
+ /** 兼容升级前已经展示给用户、但尚未登记的 scope。 */
45
+ export declare function legacyDiscoveryOpenQuestionScope(question: Pick<DiscoveryOpenQuestion, "id" | "documentFingerprint">, exploreRoundId: string): string;
39
46
  /** 面向用户展示时隐藏 Q-xxx 这一内部编号;历史无编号问题保持原文。 */
40
47
  export declare function discoveryOpenQuestionDisplayText(question: Pick<DiscoveryOpenQuestion, "id" | "text">): string;
41
48
  /** 从 discovery.md 提取“待确认问题”段内的未确认项数量。 */
@@ -61,6 +68,26 @@ export interface ProposeOpenQuestionFile {
61
68
  path: string;
62
69
  openCount: number;
63
70
  }
71
+ export declare const PROPOSE_OPEN_QUESTION_SCOPE_PREFIX = "propose_open_question:";
72
+ export interface ProposeQuestion {
73
+ path: string;
74
+ id: string;
75
+ ordinal: number;
76
+ text: string;
77
+ raw: string;
78
+ documentFingerprint: string;
79
+ status: "open" | "closed";
80
+ }
81
+ export declare function parseProposeQuestions(content: string, path: string): ProposeQuestion[];
82
+ export declare function proposeQuestionKey(question: Pick<ProposeQuestion, "path" | "id" | "ordinal" | "text">): string;
83
+ /** Propose 决定的明确依据;其它计划材料变化不会自动使已展示决定失效。 */
84
+ export declare function proposeQuestionDecisionBasisDigest(question: Pick<ProposeQuestion, "path" | "id" | "ordinal" | "text">): string;
85
+ export declare function proposeQuestionContextFingerprint(content: string, question: Pick<ProposeQuestion, "id" | "ordinal">): string | null;
86
+ export declare function proposeOpenQuestionScope(question: ProposeQuestion, proposeRoundId: string): string;
87
+ /** 兼容升级前已经展示给用户、但尚未登记的 scope。 */
88
+ export declare function legacyProposeOpenQuestionScope(question: ProposeQuestion, proposeRoundId: string): string;
89
+ export declare function proposeOpenQuestionDisplayText(question: Pick<ProposeQuestion, "id" | "text">): string;
90
+ export declare function collectProposeQuestions(changeRoot: string): ProposeQuestion[];
64
91
  export declare function countProposeOpenQuestionsInContent(content: string): number;
65
92
  export declare function collectProposeOpenQuestions(changeRoot: string): {
66
93
  openCount: number;
package/dist/format.js CHANGED
@@ -52,6 +52,17 @@ export const EXPLORE_OPEN_QUESTION_SCOPE_PREFIX = "explore_open_question:";
52
52
  export function discoveryQuestionKey(question) {
53
53
  return sha256Text(`${question.id}\n${question.ordinal}\n${question.text}`);
54
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
+ }
55
66
  /**
56
67
  * 计算某一确认事项之外的 Discovery 决策上下文。回写时该事项本身会从问题改为
57
68
  * 结论,因此只归一化这一行;其余事实、证据和其它待确认项的改动都会使指纹失效。
@@ -105,6 +116,11 @@ export function parseDiscoveryOpenQuestions(content) {
105
116
  .map(({ status: _status, ...question }) => question);
106
117
  }
107
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) {
108
124
  const fingerprint = sha256Text(`${exploreRoundId}\n${question.documentFingerprint}`);
109
125
  return `${EXPLORE_OPEN_QUESTION_SCOPE_PREFIX}${fingerprint}:${question.id}`;
110
126
  }
@@ -168,21 +184,24 @@ export function validateDiscoveryChainCoverage(content) {
168
184
  }
169
185
  const statusIdx = header.indexOf("状态");
170
186
  const requiredColumnIndexes = DISCOVERY_CHAIN_REQUIRED_COLUMNS.map(col => ({ col, idx: header.indexOf(col) }));
187
+ const errors = [];
171
188
  for (const [idx, cells] of rows.entries()) {
172
189
  const rowNum = idx + 1;
173
190
  for (const { col, idx: colIdx } of requiredColumnIndexes) {
174
191
  if (!(cells[colIdx]?.trim())) {
175
- return { ok: false, message: `链路五要素第 ${rowNum} 行缺少${col}`, present: true };
192
+ errors.push(`链路五要素第 ${rowNum} 行缺少${col}`);
176
193
  }
177
194
  }
178
195
  const status = cells[statusIdx]?.trim() ?? "";
179
196
  if (!DISCOVERY_CHAIN_STATUSES.has(status)) {
180
- return { ok: false, message: `链路五要素第 ${rowNum} 行状态必须是 已确认、未知阻塞 或 未知非阻塞`, present: true };
197
+ errors.push(`链路五要素第 ${rowNum} 行状态必须是 已确认、未知阻塞 或 未知非阻塞`);
181
198
  }
182
199
  if (status.includes("未知阻塞") && countDiscoveryOpenQuestions(content) === 0) {
183
- return { ok: false, message: "链路五要素存在未知阻塞,但待确认问题中没有未解决项", present: true };
200
+ errors.push("链路五要素存在未知阻塞,但待确认问题中没有未解决项");
184
201
  }
185
202
  }
203
+ if (errors.length > 0)
204
+ return { ok: false, message: [...new Set(errors)].join(";"), present: true };
186
205
  return { ok: true, message: "链路五要素就绪", present: true };
187
206
  }
188
207
  /**
@@ -206,12 +225,84 @@ export function validateDiscovery(changeRoot) {
206
225
  openCount,
207
226
  };
208
227
  }
228
+ export const PROPOSE_OPEN_QUESTION_SCOPE_PREFIX = "propose_open_question:";
209
229
  const PROPOSE_CONFIRMATION_DOCS = [
210
230
  "proposal.md",
211
231
  "design.md",
212
232
  ".superspec/artifacts/test-contract.md",
213
233
  ];
214
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
+ }
215
306
  export function countProposeOpenQuestionsInContent(content) {
216
307
  return countOpenChecklistItemsInSection(content, PROPOSE_CONFIRMATION_HEADINGS);
217
308
  }
@@ -543,6 +634,31 @@ function documentAnchorParts(anchor) {
543
634
  ? parts
544
635
  : [anchor];
545
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
+ }
546
662
  function canonicalDocumentRefPath(path) {
547
663
  if (path === "discovery.md" || path === "test-contract.md") {
548
664
  return join(".superspec", "artifacts", path);
@@ -589,7 +705,8 @@ export function validateExecutionRequirementDocumentReferences(changeRoot, contr
589
705
  const targetContent = readFileSync(target, "utf8");
590
706
  for (const anchor of documentAnchorParts(parsed.anchor)) {
591
707
  if (!documentContainsAnchor(targetContent, anchor)) {
592
- 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("、")}` : ""}`);
593
710
  }
594
711
  }
595
712
  }
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
  }
@@ -123,6 +123,7 @@ const SPECS = {
123
123
  epoch: events => latestTransition(events, payload => payload.from_state === "apply" && payload.to_state === "apply_done"),
124
124
  },
125
125
  };
126
+ const CURRENT_USER_DECISION_NOTICE = "请向用户展示本次选择并等待当前明确答复;不得用启动工作流、要求推进、一般授权、历史偏好或模型推断代替本次回答。";
126
127
  function boundaryForState(state) {
127
128
  switch (state) {
128
129
  case "explore": return "explore_to_propose";
@@ -255,7 +256,8 @@ export function phaseConfirmationForBoundary(projectRoot, events, snapshot, boun
255
256
  const summary = boundary === "propose_to_apply"
256
257
  ? proposeTaskDeliverySummary(openspecChangeRoot(projectRoot, snapshot.change_id))
257
258
  : null;
258
- const question = summary ? `${summary}\n\n${spec.question}` : spec.question;
259
+ const boundaryQuestion = `${spec.question}\n\n${CURRENT_USER_DECISION_NOTICE}`;
260
+ const question = summary ? `${summary}\n\n${boundaryQuestion}` : boundaryQuestion;
259
261
  const actions = buildActions(snapshot.change_id, boundary, scope, question, spec.actions, risk);
260
262
  return {
261
263
  boundary,
@@ -1,6 +1,7 @@
1
1
  import type { ReviewGateRule } from "./review_job_gates.ts";
2
2
  import { exploreAnswerRegistrationPayload } from "./explore_round.ts";
3
- import type { AcceptedMaterialFollowupContinuation, AskUser, Event, ExecutionPolicy, Job, JobRole, PlanningValidationProfile, State } from "./types.ts";
3
+ import { proposeAnswerRegistrationPayload } from "./propose_round.ts";
4
+ import type { AcceptedMaterialFollowupContinuation, AskUser, Event, ExecutionPolicy, Job, JobRole, PlanningValidationProfile, WorkflowArtifactKind, State } from "./types.ts";
4
5
  import type { Snapshot } from "./types.ts";
5
6
  import type { ReviewRisk } from "./review.ts";
6
7
  export type TransitionName = "explore" | "propose-ready" | "start-apply" | "task-start" | "task-complete" | "review-ready" | "reopen" | "accept";
@@ -39,11 +40,28 @@ export type NextStepPlan = {
39
40
  state: State;
40
41
  jobs: Job[];
41
42
  reason: string;
43
+ } | {
44
+ kind: "artifact_required";
45
+ state: State;
46
+ artifact: {
47
+ kind: WorkflowArtifactKind;
48
+ path: string;
49
+ operation: "create_or_update";
50
+ };
51
+ resume: {
52
+ argv: string[];
53
+ };
54
+ reason: string;
42
55
  } | {
43
56
  kind: "ask_user";
44
57
  state: State;
45
58
  ask: AskUser;
46
59
  reason: string;
60
+ } | {
61
+ kind: "material_update_required";
62
+ state: State;
63
+ errors: string[];
64
+ reason: string;
47
65
  } | {
48
66
  kind: "run_transition";
49
67
  state: State;
@@ -70,6 +88,7 @@ export type TransitionDecisionPlan = {
70
88
  kind: "create_gate_jobs";
71
89
  gate: ReviewGateRule;
72
90
  roles: JobRole[];
91
+ requiredRoles: JobRole[];
73
92
  reason: string;
74
93
  } | {
75
94
  kind: "advance";
@@ -87,9 +106,12 @@ export interface ApplyPendingTaskStatus {
87
106
  export declare function executionPolicyForRisk(risk: ReviewRisk): ExecutionPolicy;
88
107
  export declare function executionPolicyForCurrentRound(events: Event[]): ExecutionPolicy;
89
108
  export declare function proposalDocsBaseline(changeRoot: string): Record<string, string>;
109
+ export declare function applyPlanningBaseline(changeRoot: string): Record<string, string>;
110
+ export declare function applyPlanningDocsChangedSinceBaseline(changeRoot: string, baseline: Record<string, string>): boolean;
90
111
  export declare function discoveryDocsBaseline(changeRoot: string): Record<string, string>;
91
112
  /** 新 Explore 轮次冻结已有已确认事项,避免把历史答复当作本轮遗漏。 */
92
113
  export declare function exploreAnswerRegistrationPayloadForChange(changeRoot: string): ReturnType<typeof exploreAnswerRegistrationPayload>;
114
+ export declare function proposeAnswerRegistrationPayloadForChange(changeRoot: string): ReturnType<typeof proposeAnswerRegistrationPayload>;
93
115
  export declare function latestAcceptedProposalBaseline(events: Event[]): Record<string, string> | null;
94
116
  export declare function latestReopenProposeBaseline(events: Event[]): Record<string, string> | null;
95
117
  export declare function latestReopenExploreBaseline(events: Event[]): Record<string, string> | null;