@peterxiaoyang/superspec 0.1.45 → 0.1.46

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/format.d.ts CHANGED
@@ -53,6 +53,11 @@ export type TestContractParseResult = {
53
53
  };
54
54
  /** 解析 tasks.md 的全部任务行 */
55
55
  export declare function parseTasksMd(content: string): ParsedTask[];
56
+ /**
57
+ * tasks.md 的机械结构校验。任务是否拆分合理、顺序是否符合真实依赖仍由
58
+ * Critic/Architect 判断;这里仅拒绝引擎无法可靠驱动的格式。
59
+ */
60
+ export declare function validateTasksDocument(content: string): string[];
56
61
  export declare function hasTaskBoundExecutionRequirements(content: string): boolean;
57
62
  export declare function parseExecutionRequirements(content: string): ParsedExecutionRequirement[];
58
63
  export declare function orphanExecutionRequirementErrors(content: string): string[];
@@ -64,12 +69,23 @@ export declare function adoptedContractForTask(content: string, taskId: string,
64
69
  export declare function isReviewFixTaskId(taskId: string): boolean;
65
70
  export declare function isCharacterizationTask(task: ParsedTask): boolean;
66
71
  export declare function parseTestContractEntries(content: string): TestContractParseResult;
72
+ export interface ProposalImpactValidation {
73
+ ok: boolean;
74
+ message: string;
75
+ }
76
+ /** OpenSpec 项目的 proposal 采用固定 Impact 表格,供状态机进行纯结构校验。 */
77
+ export declare function validateProposalImpact(content: string): ProposalImpactValidation;
67
78
  export interface ExecutionRequirementValidation {
68
79
  ok: boolean;
69
80
  mode: boolean;
70
81
  contracts: ParsedExecutionRequirement[];
71
82
  errors: string[];
72
83
  }
84
+ /**
85
+ * 在已初始化的当前工作流中,把执行依据的文件/锚点可解析性作为状态机协议。
86
+ * “该材料是否足以支撑 task”仍然是 Critic/Architect 的语义判断。
87
+ */
88
+ export declare function validateExecutionRequirementDocumentReferences(changeRoot: string, contracts: readonly ParsedExecutionRequirement[]): string[];
73
89
  export declare function validateExecutionRequirements(content: string, testContractContent: string | null, executionPolicy?: ExecutionPolicy, executionRequirementVersion?: 1 | 2): ExecutionRequirementValidation;
74
90
  /** 返回未完成任务 */
75
91
  export declare function pendingTasksInContent(content: string): ParsedTask[];
package/dist/format.js CHANGED
@@ -1,9 +1,9 @@
1
1
  // SuperSpec 流程引擎 — format.ts:文档格式解析的唯一权威源
2
2
  //
3
- // 所有文档的格式定义和解析逻辑都在这里。skills 文案引用这里的格式。
4
- // 修改格式 = 修改这里 + 对应 skill。禁止在其它地方重复解析。
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
7
  import { GREEN_ONLY_NO_TDD_REASON } from "./types.js";
8
8
  // ===== discovery.md =====
9
9
  //
@@ -52,6 +52,7 @@ const DISCOVERY_CHAIN_REQUIRED_COLUMNS = [
52
52
  "证据",
53
53
  "状态",
54
54
  ];
55
+ const DISCOVERY_CHAIN_STATUSES = new Set(["已确认", "未知阻塞", "未知非阻塞"]);
55
56
  export function splitMarkdownTableRow(line) {
56
57
  const trimmed = line.trim();
57
58
  if (!trimmed.startsWith("|") || !trimmed.endsWith("|"))
@@ -95,6 +96,9 @@ export function validateDiscoveryChainCoverage(content) {
95
96
  }
96
97
  }
97
98
  const status = cells[statusIdx]?.trim() ?? "";
99
+ if (!DISCOVERY_CHAIN_STATUSES.has(status)) {
100
+ return { ok: false, message: `链路五要素第 ${rowNum} 行状态必须是 已确认、未知阻塞 或 未知非阻塞`, present: true };
101
+ }
98
102
  if (status.includes("未知阻塞") && countDiscoveryOpenQuestions(content) === 0) {
99
103
  return { ok: false, message: "链路五要素存在未知阻塞,但待确认问题中没有未解决项", present: true };
100
104
  }
@@ -183,6 +187,28 @@ export function parseTasksMd(content) {
183
187
  }
184
188
  return tasks;
185
189
  }
190
+ /**
191
+ * tasks.md 的机械结构校验。任务是否拆分合理、顺序是否符合真实依赖仍由
192
+ * Critic/Architect 判断;这里仅拒绝引擎无法可靠驱动的格式。
193
+ */
194
+ export function validateTasksDocument(content) {
195
+ const errors = [];
196
+ if (!/^#\s+Tasks\s*$/m.test(content))
197
+ errors.push("tasks.md 缺少顶级 # Tasks 标题");
198
+ const tasks = parseTasksMd(content);
199
+ const seen = new Set();
200
+ for (const task of tasks) {
201
+ if (seen.has(task.taskId))
202
+ errors.push(`tasks.md task ID 重复:${task.taskId}`);
203
+ seen.add(task.taskId);
204
+ }
205
+ for (const [index, line] of content.split("\n").entries()) {
206
+ if (/^\s+-\s+\[[ xX]\]\s+/.test(line)) {
207
+ errors.push(`tasks.md 第 ${index + 1} 行存在缩进 checkbox;只有顶格 checkbox 可以作为可执行 task`);
208
+ }
209
+ }
210
+ return errors;
211
+ }
186
212
  function isTopLevelTaskLine(line) {
187
213
  return TASK_LINE_RE.test(line);
188
214
  }
@@ -329,15 +355,23 @@ export function parseTestContractEntries(content) {
329
355
  if (row.length === 0)
330
356
  break;
331
357
  const testId = (row[testIdIdx] ?? "").trim();
332
- if (!testId.startsWith("TEST-"))
333
- continue;
358
+ const scenario = (row[scenarioIdx] ?? "").trim();
359
+ if (!testId) {
360
+ return { ok: false, entries: [], message: `test-contract.md 第 ${rowIndex + 1} 行缺少 test_id` };
361
+ }
362
+ if (!/^TEST-[A-Za-z0-9_-]+$/.test(testId)) {
363
+ return { ok: false, entries: [], message: `test-contract.md 中 TEST ID 格式无效:${testId}` };
364
+ }
334
365
  if (seen.has(testId)) {
335
366
  return { ok: false, entries: [], message: `test-contract.md 中 TEST ID 重复:${testId}` };
336
367
  }
368
+ if (!scenario) {
369
+ return { ok: false, entries: [], message: `test-contract.md 中 ${testId} 缺少 scenario` };
370
+ }
337
371
  seen.add(testId);
338
372
  entries.push({
339
373
  test_id: testId,
340
- scenario: (row[scenarioIdx] ?? "").trim(),
374
+ scenario,
341
375
  });
342
376
  }
343
377
  }
@@ -347,6 +381,127 @@ export function parseTestContractEntries(content) {
347
381
  return { ok: false, entries: [], message: "test-contract.md 没有 TEST-* 行" };
348
382
  return { ok: true, entries };
349
383
  }
384
+ /** OpenSpec 项目的 proposal 采用固定 Impact 表格,供状态机进行纯结构校验。 */
385
+ export function validateProposalImpact(content) {
386
+ const body = sectionBodyByHeadings(content, ["Impact"]);
387
+ if (body == null)
388
+ return { ok: false, message: "proposal.md 缺少 ## Impact" };
389
+ const tableLines = body.split("\n").filter(line => line.trim().startsWith("|"));
390
+ if (tableLines.length < 3 || !isMarkdownTableSeparator(tableLines[1])) {
391
+ return { ok: false, message: "proposal.md 的 Impact 必须包含 Area / Reason 表格" };
392
+ }
393
+ const header = splitMarkdownTableRow(tableLines[0]).map(cell => cell.toLowerCase());
394
+ const areaIdx = header.indexOf("area");
395
+ const reasonIdx = header.indexOf("reason");
396
+ if (areaIdx < 0 || reasonIdx < 0) {
397
+ return { ok: false, message: "proposal.md 的 Impact 表格缺少 Area 或 Reason 列" };
398
+ }
399
+ const rows = tableLines.slice(2).map(splitMarkdownTableRow).filter(cells => cells.length > 0);
400
+ if (rows.length === 0)
401
+ return { ok: false, message: "proposal.md 的 Impact 表格至少需要一行" };
402
+ for (const [index, row] of rows.entries()) {
403
+ if (!row[areaIdx]?.trim() || !row[reasonIdx]?.trim()) {
404
+ return { ok: false, message: `proposal.md 的 Impact 第 ${index + 1} 行缺少 Area 或 Reason` };
405
+ }
406
+ }
407
+ return { ok: true, message: "proposal.md Impact 结构有效" };
408
+ }
409
+ function isQualifiedDocumentRef(value) {
410
+ const ref = value.trim();
411
+ return /^(?!\/)(?!.*(?:^|\/)\.\.(?:\/|$))[^\s#]+\.md#[^\s].*$/.test(ref);
412
+ }
413
+ function executionRequirementReferenceErrors(contract) {
414
+ const errors = [];
415
+ if (contract.contract.design && !isQualifiedDocumentRef(contract.contract.design)) {
416
+ errors.push(`${contract.taskId} 的设计必须使用 文件.md#标题 的可定位引用`);
417
+ }
418
+ for (const source of contract.contract.source) {
419
+ if (!isQualifiedDocumentRef(source)) {
420
+ errors.push(`${contract.taskId} 的来源必须使用 文件.md#标题 的可定位引用:${source}`);
421
+ }
422
+ }
423
+ return errors;
424
+ }
425
+ function parseQualifiedDocumentRef(value) {
426
+ const ref = value.trim();
427
+ const separator = ref.indexOf("#");
428
+ if (separator <= 0 || separator === ref.length - 1)
429
+ return null;
430
+ return { path: ref.slice(0, separator), anchor: ref.slice(separator + 1).trim() };
431
+ }
432
+ function isPathInside(root, target) {
433
+ const rel = relative(root, target);
434
+ return rel !== "" && !rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel);
435
+ }
436
+ function documentContainsAnchor(content, anchor) {
437
+ if (/^(?:TEST|CHAIN|IDC)-[A-Za-z0-9_-]+$/.test(anchor)) {
438
+ const token = new RegExp(`(?:^|[^A-Za-z0-9_-])${escapeRegex(anchor)}(?![A-Za-z0-9_-])`);
439
+ return token.test(content);
440
+ }
441
+ // 支持 Markdown ATX 标题可选的 closing sequence(`## Route ##`),但正文
442
+ // 中同名文字仍不能冒充可定位锚点。
443
+ const heading = new RegExp(`^#{1,6}[\\t ]+${escapeRegex(anchor)}(?:[\\t ]+#+)?[\\t ]*$`, "m");
444
+ return heading.test(content);
445
+ }
446
+ function documentAnchorParts(anchor) {
447
+ const parts = anchor.split(",").map(part => part.trim()).filter(Boolean);
448
+ return parts.length > 1 && parts.every(part => /^(?:TEST|CHAIN|IDC)-[A-Za-z0-9_-]+$/.test(part))
449
+ ? parts
450
+ : [anchor];
451
+ }
452
+ function canonicalDocumentRefPath(path) {
453
+ if (path === "discovery.md" || path === "test-contract.md") {
454
+ return join(".superspec", "artifacts", path);
455
+ }
456
+ return path;
457
+ }
458
+ /**
459
+ * 在已初始化的当前工作流中,把执行依据的文件/锚点可解析性作为状态机协议。
460
+ * “该材料是否足以支撑 task”仍然是 Critic/Architect 的语义判断。
461
+ */
462
+ export function validateExecutionRequirementDocumentReferences(changeRoot, contracts) {
463
+ const root = resolve(changeRoot);
464
+ const realRoot = realpathSync(root);
465
+ const errors = [];
466
+ for (const contract of contracts) {
467
+ const refs = [contract.contract.design, ...contract.contract.source].filter((value) => Boolean(value));
468
+ for (const ref of refs) {
469
+ const parsed = parseQualifiedDocumentRef(ref);
470
+ if (!parsed)
471
+ continue; // 语法错误由 executionRequirementReferenceErrors 报告。
472
+ const target = resolve(root, canonicalDocumentRefPath(parsed.path));
473
+ if (!isPathInside(root, target)) {
474
+ errors.push(`${contract.taskId} 的引用越出 change 目录:${ref}`);
475
+ continue;
476
+ }
477
+ if (!existsSync(target)) {
478
+ errors.push(`${contract.taskId} 的引用文件不存在:${parsed.path}`);
479
+ continue;
480
+ }
481
+ // resolve/relative 只能识别字面 `..`,不能阻止 change 内的符号链接指向
482
+ // 外部文件;按真实路径再次校验,确保引用材料仍属于当前 change。
483
+ let realTarget;
484
+ try {
485
+ realTarget = realpathSync(target);
486
+ }
487
+ catch {
488
+ errors.push(`${contract.taskId} 的引用文件无法解析:${parsed.path}`);
489
+ continue;
490
+ }
491
+ if (!isPathInside(realRoot, realTarget)) {
492
+ errors.push(`${contract.taskId} 的引用越出 change 目录:${ref}`);
493
+ continue;
494
+ }
495
+ const targetContent = readFileSync(target, "utf8");
496
+ for (const anchor of documentAnchorParts(parsed.anchor)) {
497
+ if (!documentContainsAnchor(targetContent, anchor)) {
498
+ errors.push(`${contract.taskId} 的引用锚点不存在:${parsed.path}#${anchor}`);
499
+ }
500
+ }
501
+ }
502
+ }
503
+ return errors;
504
+ }
350
505
  export function validateExecutionRequirements(content, testContractContent, executionPolicy = "tdd", executionRequirementVersion = 2) {
351
506
  const tasks = parseTasksMd(content);
352
507
  // 历史 green-only task 在旧版本中用标记强制进入契约模式。保留该入口,
@@ -396,6 +551,7 @@ export function validateExecutionRequirements(content, testContractContent, exec
396
551
  errors.push(`${task.taskId} 的执行依据缺少验收目标`);
397
552
  if (!contract.contract.guard)
398
553
  errors.push(`${task.taskId} 的执行依据缺少边界`);
554
+ errors.push(...executionRequirementReferenceErrors(contract));
399
555
  }
400
556
  if (contract && (executionRequirementVersion === 1 && task.tddRequired || legacyGreenOnly) && contract.contract.tests.length === 0) {
401
557
  errors.push(`${task.taskId} 的执行依据缺少测试`);
@@ -484,11 +640,12 @@ export function validateUserDecision(d) {
484
640
  }
485
641
  // ===== test-contract.md =====
486
642
  //
487
- // 格式(propose skill 定义):
643
+ // 格式(状态机校验,propose skill 负责生成):
488
644
  // # Test Contract
489
645
  // | test_id | scenario |
490
646
  // |---|---|
491
647
  // | TEST-001 | 注册时密码被加密 |
492
648
  //
493
- // 引擎行为:Phase 1-5 只校验文件存在性(轻量)。
494
- // 表格结构是 agent 指引,引擎不逐行解析。
649
+ // 引擎行为:当 task 声明 TEST 时,状态机解析表格、TEST ID 和 scenario,
650
+ // 并在 propose-ready / start-apply 阶段拒绝无效引用;测试语义和证明力仍由
651
+ // Test Engineer 判断。
@@ -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
@@ -36,6 +36,34 @@ export function openspecStatus(projectRoot, change) {
36
36
  return "sha256:unknown";
37
37
  }
38
38
  }
39
+ /**
40
+ * 计划阶段的原生 OpenSpec 结构 gate。
41
+ *
42
+ * 调用方已通过冻结的 planning profile 确认应执行 strict validation。这里不再
43
+ * 读取实时 config.yaml,避免计划就绪后环境变化导致准入标准漂移。
44
+ */
45
+ export function validateOpenSpecChange(projectRoot, change) {
46
+ validateChange(change);
47
+ try {
48
+ execFileSync("openspec", ["validate", change, "--type", "change", "--strict", "--no-interactive"], {
49
+ cwd: projectRoot,
50
+ encoding: "utf8",
51
+ stdio: ["pipe", "pipe", "pipe"],
52
+ });
53
+ return { checked: true, ok: true, message: "OpenSpec 原生结构校验通过" };
54
+ }
55
+ catch (error) {
56
+ const failure = error;
57
+ const stdout = typeof failure.stdout === "string" ? failure.stdout : failure.stdout?.toString("utf8") ?? "";
58
+ const stderr = typeof failure.stderr === "string" ? failure.stderr : failure.stderr?.toString("utf8") ?? "";
59
+ const detail = [stdout, stderr].map(value => value.trim()).filter(Boolean).join(";");
60
+ return {
61
+ checked: true,
62
+ ok: false,
63
+ message: `OpenSpec strict 校验失败${detail ? `:${detail}` : ";请确认 openspec CLI 可用并修复 proposal/specs 结构"}`,
64
+ };
65
+ }
66
+ }
39
67
  export function changeRoot(projectRoot, change) {
40
68
  return join(projectRoot, "openspec", "changes", change);
41
69
  }
@@ -1,5 +1,5 @@
1
1
  import type { ReviewGateRule } from "./review_job_gates.ts";
2
- import type { AcceptedMaterialFollowupContinuation, AskUser, Event, ExecutionPolicy, Job, JobRole, State } from "./types.ts";
2
+ import type { AcceptedMaterialFollowupContinuation, AskUser, Event, ExecutionPolicy, Job, JobRole, PlanningValidationProfile, State } from "./types.ts";
3
3
  import type { Snapshot } from "./types.ts";
4
4
  import type { ReviewRisk } from "./review.ts";
5
5
  export type TransitionName = "explore" | "propose-ready" | "start-apply" | "task-start" | "task-complete" | "review-ready" | "reopen" | "accept";
@@ -95,6 +95,8 @@ export declare function discoveryDocsChangedSinceBaseline(changeRoot: string, ba
95
95
  export declare function pendingTaskIds(changeRoot: string): string[];
96
96
  /** 当前 Apply round 的规则版本;缺失版本的历史 event 保持 v1 回放。 */
97
97
  export declare function executionRequirementVersionForCurrentRound(events: Event[]): 1 | 2;
98
+ /** 新 planning round 在进入 propose 时冻结当前 OpenSpec 校验契约。 */
99
+ export declare function planningValidationProfileForNewRound(projectRoot: string): PlanningValidationProfile;
98
100
  export declare function applyRequirementModeForCurrentRound(events: Event[]): boolean;
99
101
  export declare function pendingTaskStatusForApply(changeRoot: string, events: Event[]): ApplyPendingTaskStatus;
100
102
  export declare function formatPendingTaskMessage(ids: string[], action: string): string;
@@ -1,8 +1,9 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { EXPLORE_DISCOVERY_REVIEW_GATE, PROPOSE_FINAL_REVIEW_GATE } from "./review_job_gates.js";
4
- import { collectProposeOpenQuestions, countDiscoveryOpenQuestions, parseTasksMd, pendingTasksInContent, validateDiscovery, validateExecutionRequirements, } from "./format.js";
4
+ import { collectProposeOpenQuestions, countDiscoveryOpenQuestions, parseExecutionRequirements, parseTasksMd, pendingTasksInContent, validateDiscovery, validateExecutionRequirements, validateExecutionRequirementDocumentReferences, validateProposalImpact, validateTasksDocument, } from "./format.js";
5
5
  import { currentGitHead } from "./git_state.js";
6
+ import { validateOpenSpecChange } from "./openspec.js";
6
7
  import { docRef, sha256File } from "./store.js";
7
8
  import { isReviewReadyVerifier, isFreshReviewVerifier, historicalProposeReadyRoles, readReviewPolicyFromEvents, reviewGateRoleResolution, reviewRejectionOverrideScope, reviewEvidenceDigest, } from "./review.js";
8
9
  import { CODE_REVIEW_DECISION_ANSWER_LABELS, CODE_REVIEW_REPAIR_SCOPE_PREFIX, codeReviewDecisionScope, codeReviewJobStaleReason, collectCodeReviewGateFacts, currentCodeReviewWorkingPaths, latestCodeReviewFailedStatus, requiresFinalVerifierForCurrentReview, scanCodeChangesForReview, } from "./code_review.js";
@@ -79,13 +80,19 @@ function reviewGatePlan(snapshot, events, changeRoot, gate, requiredRoles) {
79
80
  }
80
81
  return null;
81
82
  }
82
- function validateTasksPlan(changeRoot) {
83
+ function validateTasksPlan(changeRoot, executionRequirementVersion) {
83
84
  const tasksPath = join(changeRoot, "tasks.md");
84
85
  if (!existsSync(tasksPath))
85
86
  return "tasks.md 不存在";
86
87
  const tasksContent = readFileSync(tasksPath, "utf8");
87
- if (!tasksContent.includes("# Tasks") && !tasksContent.includes("- [ ]"))
88
- return "tasks.md 内容不像任务计划文档";
88
+ if (executionRequirementVersion === 1) {
89
+ return tasksContent.includes("# Tasks") || tasksContent.includes("- [ ]")
90
+ ? null
91
+ : "tasks.md 内容不像任务计划文档";
92
+ }
93
+ const errors = validateTasksDocument(tasksContent);
94
+ if (errors.length > 0)
95
+ return errors.join(";");
89
96
  return null;
90
97
  }
91
98
  export function executionPolicyForRisk(risk) {
@@ -122,6 +129,43 @@ function missingBaseArtifact(changeRoot, risk) {
122
129
  }
123
130
  return null;
124
131
  }
132
+ /** OpenSpec strict gate 只在当前 planning round 冻结为 strict 时执行。 */
133
+ function validateOpenSpecPlanningDocuments(projectRoot, change, changeRoot, profile) {
134
+ if (profile == null || profile.openspec.mode !== "strict")
135
+ return null;
136
+ const currentConfigDigest = sha256File(join(projectRoot, "openspec", "config.yaml"));
137
+ if (currentConfigDigest !== profile.openspec.config_digest) {
138
+ return "OpenSpec 配置自本 planning round 起已变化;请恢复原配置或 reopen --to propose 创建新的计划轮";
139
+ }
140
+ const tasksPath = join(changeRoot, "tasks.md");
141
+ const referenceErrors = validateExecutionRequirementDocumentReferences(changeRoot, parseExecutionRequirements(readFileSync(tasksPath, "utf8")));
142
+ if (referenceErrors.length > 0)
143
+ return referenceErrors.join(";");
144
+ const proposalPath = join(changeRoot, "proposal.md");
145
+ if (!existsSync(proposalPath))
146
+ return "proposal.md 不存在";
147
+ const impact = validateProposalImpact(readFileSync(proposalPath, "utf8"));
148
+ if (!impact.ok)
149
+ return impact.message;
150
+ const native = validateOpenSpecChange(projectRoot, change);
151
+ return native.ok ? null : native.message;
152
+ }
153
+ function validatePlanningPreflight(projectRoot, change, changeRoot, risk, executionPolicy, profile) {
154
+ const executionRequirementVersion = profile?.version ?? 1;
155
+ const tasksPlanError = validateTasksPlan(changeRoot, executionRequirementVersion);
156
+ if (tasksPlanError)
157
+ return { error: tasksPlanError, contractMode: false };
158
+ const executionRequirementPlan = validateExecutionRequirementPlan(changeRoot, executionPolicy, executionRequirementVersion);
159
+ if (!executionRequirementPlan.ok)
160
+ return { error: executionRequirementPlan.message, contractMode: false };
161
+ const missingArtifact = missingBaseArtifact(changeRoot, risk);
162
+ if (missingArtifact)
163
+ return { error: missingArtifact, contractMode: false };
164
+ return {
165
+ error: validateOpenSpecPlanningDocuments(projectRoot, change, changeRoot, profile),
166
+ contractMode: executionRequirementPlan.mode,
167
+ };
168
+ }
125
169
  export function proposalDocsBaseline(changeRoot) {
126
170
  // 与 Propose gate 的可修改审查目标保持同一来源:specs/ 用目录聚合指纹,避免 reopen 基线漏掉任一个可修改的计划材料。
127
171
  const docs = PROPOSE_FINAL_REVIEW_GATE.reviewTargets;
@@ -234,6 +278,62 @@ function executionRequirementVersionForProposeRound(events) {
234
278
  }
235
279
  return 1;
236
280
  }
281
+ function isPlanningValidationProfile(value) {
282
+ if (!value || typeof value !== "object" || Array.isArray(value))
283
+ return false;
284
+ const profile = value;
285
+ if (profile.version !== 2 || !profile.openspec || typeof profile.openspec !== "object")
286
+ return false;
287
+ return profile.openspec.mode === "disabled" ||
288
+ profile.openspec.mode === "strict" && typeof profile.openspec.config_digest === "string";
289
+ }
290
+ /** 新 planning round 在进入 propose 时冻结当前 OpenSpec 校验契约。 */
291
+ export function planningValidationProfileForNewRound(projectRoot) {
292
+ const configDigest = sha256File(join(projectRoot, "openspec", "config.yaml"));
293
+ return configDigest == null
294
+ ? { version: 2, openspec: { mode: "disabled" } }
295
+ : { version: 2, openspec: { mode: "strict", config_digest: configDigest } };
296
+ }
297
+ /** propose 状态尚未 ready 时,从进入本 planning round 的事件读取冻结 profile。 */
298
+ function planningValidationProfileForPendingProposeRound(events) {
299
+ for (let i = events.length - 1; i >= 0; i--) {
300
+ const event = events[i];
301
+ if (event.event_type !== "transition_commit")
302
+ continue;
303
+ const payload = event.payload;
304
+ // 只读取“进入 propose”的边界事件。propose-ready 创建审查 job 时也会
305
+ // 保持在 propose;若把它误当作新的 planning round,便会覆盖此前冻结的
306
+ // profile 并把当前轮错误降级为 v1。
307
+ const entersPropose = payload.to_state === "propose" && (payload.transition === "explore" ||
308
+ payload.transition === "propose" ||
309
+ payload.transition === "reopen" && payload.reopen_target === "propose");
310
+ if (!entersPropose)
311
+ continue;
312
+ return isPlanningValidationProfile(payload.planning_validation_profile)
313
+ ? payload.planning_validation_profile
314
+ : null;
315
+ }
316
+ return null;
317
+ }
318
+ /** 已完成 propose-ready 的 round 只回放当时冻结的 profile。 */
319
+ function planningValidationProfileForReadyProposeRound(events) {
320
+ for (let i = events.length - 1; i >= 0; i--) {
321
+ const event = events[i];
322
+ if (event.event_type !== "transition_commit")
323
+ continue;
324
+ const payload = event.payload;
325
+ if (payload.transition !== "propose-ready" || payload.to_state !== "propose_ready")
326
+ continue;
327
+ if (isPlanningValidationProfile(payload.planning_validation_profile))
328
+ return payload.planning_validation_profile;
329
+ // 过渡期已写 v2 执行依据、但尚未带 profile 的事件保持 v2 tasks 契约,
330
+ // 但不在 start-apply 追溯新增 strict gate。
331
+ return executionRequirementVersionFromPayload(event.payload) === 2
332
+ ? { version: 2, openspec: { mode: "disabled" } }
333
+ : null;
334
+ }
335
+ return null;
336
+ }
237
337
  export function applyRequirementModeForCurrentRound(events) {
238
338
  const index = latestStartApplyIndex(events);
239
339
  if (index < 0)
@@ -730,7 +830,11 @@ function planExploreTransition(context) {
730
830
  fromState: "explore",
731
831
  toState: "propose",
732
832
  reason: "探索完成",
733
- payload: phaseConfirmationCommitPayload(confirmation, decision),
833
+ payload: {
834
+ ...phaseConfirmationCommitPayload(confirmation, decision),
835
+ planning_validation_version: 2,
836
+ planning_validation_profile: planningValidationProfileForNewRound(projectRoot),
837
+ },
734
838
  };
735
839
  }
736
840
  function planProposeReadyTransition(context) {
@@ -738,20 +842,15 @@ function planProposeReadyTransition(context) {
738
842
  const risk = mode.risk;
739
843
  if (snapshot.state !== "propose")
740
844
  return { kind: "skip", message: `当前状态 ${snapshot.state},不能 propose-ready` };
741
- const tasksPlanError = validateTasksPlan(changeRoot);
742
- if (tasksPlanError)
743
- return { kind: "skip", message: tasksPlanError };
744
- const executionRequirementPlan = validateExecutionRequirementPlan(changeRoot, executionPolicyForRisk(risk), 2);
745
- if (!executionRequirementPlan.ok)
746
- return { kind: "skip", message: executionRequirementPlan.message };
845
+ const planningProfile = planningValidationProfileForPendingProposeRound(context.events);
846
+ const preflight = validatePlanningPreflight(context.projectRoot, context.change, changeRoot, risk, executionPolicyForRisk(risk), planningProfile);
847
+ if (preflight.error)
848
+ return { kind: "skip", message: preflight.error };
747
849
  const openQuestions = collectProposeOpenQuestions(changeRoot);
748
850
  if (openQuestions.openCount > 0) {
749
851
  const files = openQuestions.files.map(f => `${f.path}(${f.openCount})`).join(", ");
750
852
  return { kind: "skip", message: `计划文档有 ${openQuestions.openCount} 个待用户确认问题:${files}` };
751
853
  }
752
- const missingArtifact = missingBaseArtifact(changeRoot, risk);
753
- if (missingArtifact)
754
- return { kind: "skip", message: missingArtifact };
755
854
  const requiredRoles = PROPOSE_FINAL_REVIEW_GATE.requiredRolesForRisk(risk);
756
855
  const gatePlan = reviewGatePlan(snapshot, context.events, changeRoot, PROPOSE_FINAL_REVIEW_GATE, requiredRoles);
757
856
  if (gatePlan)
@@ -763,7 +862,11 @@ function planProposeReadyTransition(context) {
763
862
  reason: `risk=${risk},所有需求已满足`,
764
863
  payload: {
765
864
  workflow_mode: risk,
766
- execution_requirement_version: 2,
865
+ ...(planningProfile ? {
866
+ execution_requirement_version: 2,
867
+ planning_validation_version: 2,
868
+ planning_validation_profile: planningProfile,
869
+ } : {}),
767
870
  },
768
871
  };
769
872
  }
@@ -782,7 +885,12 @@ function planStartApplyTransition(context, enforceConfirmation = true) {
782
885
  return { kind: "skip", message: `回到 propose 后至少一个计划文档必须变化(基线绑定:${Object.keys(reopenBaseline).join("、")})` };
783
886
  }
784
887
  const risk = workflowRiskForProposeRound(events, context.mode.risk);
785
- const executionRequirementVersion = executionRequirementVersionForProposeRound(events);
888
+ const planningProfile = planningValidationProfileForReadyProposeRound(events);
889
+ const executionRequirementVersion = planningProfile?.version ?? executionRequirementVersionForProposeRound(events);
890
+ const executionPolicy = executionPolicyForRisk(risk);
891
+ const preflight = validatePlanningPreflight(projectRoot, context.change, changeRoot, risk, executionPolicy, planningProfile);
892
+ if (preflight.error)
893
+ return { kind: "skip", message: preflight.error };
786
894
  const requiredRoles = executionRequirementVersion === 2
787
895
  ? PROPOSE_FINAL_REVIEW_GATE.requiredRolesForRisk(risk)
788
896
  : historicalProposeReadyRoles(events);
@@ -796,10 +904,6 @@ function planStartApplyTransition(context, enforceConfirmation = true) {
796
904
  const acceptedConfirmation = enforceConfirmation
797
905
  ? acceptedProposeToApplyConfirmation(context, risk)
798
906
  : null;
799
- const executionPolicy = executionPolicyForRisk(risk);
800
- const executionRequirementPlan = validateExecutionRequirementPlan(changeRoot, executionPolicy, executionRequirementVersion);
801
- if (!executionRequirementPlan.ok)
802
- return { kind: "skip", message: executionRequirementPlan.message };
803
907
  if (enforceConfirmation && !acceptedConfirmation) {
804
908
  const confirmation = phaseConfirmationForBoundary(projectRoot, events, snapshot, "propose_to_apply", risk);
805
909
  return {
@@ -816,7 +920,7 @@ function planStartApplyTransition(context, enforceConfirmation = true) {
816
920
  payload: {
817
921
  apply_start_head: gitHead.head,
818
922
  apply_start_head_reason: gitHead.reason,
819
- apply_contract_mode: executionRequirementPlan.mode,
923
+ apply_contract_mode: preflight.contractMode,
820
924
  ...(executionRequirementVersion === 2 ? { execution_requirement_version: 2 } : {}),
821
925
  execution_policy: executionPolicy,
822
926
  workflow_mode: risk,
@@ -9,7 +9,7 @@ import { REVIEW_CODE_REVIEW_GATE_ID, REVIEW_FINAL_VERIFIER_GATE_ID, reviewScopeF
9
9
  import { codeReviewBoundFiles, codeReviewDecisionScope, codeReviewJobStaleReason, codeReviewPacketContext, codeReviewPacketDigest, collectCodeReviewGateFacts, computeCodeStateCheck, currentCodeReviewWorkingPaths, dismissedCodeReviewSummary, latestCodeReviewDecision, latestCodeReviewFailedStatus, missingCoverageExemptionTestIds, requiresFinalVerifierForCurrentReview, scanCodeChangesForReview, } from "./code_review.js";
10
10
  import { taskEvidenceReadiness } from "./task_evidence.js";
11
11
  import { adoptedContractForTask, findTaskInLines, isReviewFixTaskId, parseTasksMd, parseTestContractEntries, } from "./format.js";
12
- import { applyRequirementModeForCurrentRound, executionRequirementVersionForCurrentRound, blockingJobsForApplyDone, executionPolicyForCurrentRound, formatPendingTaskMessage, latestAcceptedProposalBaseline, pendingTaskStatusForApply, planTransition, discoveryDocsBaseline, proposalDocsBaseline, } from "./phase_plan.js";
12
+ import { applyRequirementModeForCurrentRound, executionRequirementVersionForCurrentRound, blockingJobsForApplyDone, executionPolicyForCurrentRound, formatPendingTaskMessage, latestAcceptedProposalBaseline, pendingTaskStatusForApply, planningValidationProfileForNewRound, planTransition, discoveryDocsBaseline, proposalDocsBaseline, } from "./phase_plan.js";
13
13
  import { latestAcceptedPhaseDecision, phaseConfirmationCommitPayload, phaseConfirmationForBoundary, phaseConfirmationMissingMessage, } from "./phase_confirmation.js";
14
14
  import { currentGitHead, dirtyCodeFiles, stageProductionJavaFilesSince } from "./git_state.js";
15
15
  import { workflowRiskForProject } from "./workflow_config.js";
@@ -826,6 +826,8 @@ export function reopen(projectRoot, change, changeRoot, to, reason, opts = {}) {
826
826
  finding_id: ref.findingId,
827
827
  decision_scope: scope,
828
828
  baseline_docs: proposalDocsBaseline(changeRoot),
829
+ planning_validation_version: 2,
830
+ planning_validation_profile: planningValidationProfileForNewRound(projectRoot),
829
831
  },
830
832
  };
831
833
  }
@@ -915,6 +917,8 @@ export function reopen(projectRoot, change, changeRoot, to, reason, opts = {}) {
915
917
  reopen_source: snapshot.state,
916
918
  baseline_source: acceptedBaseline ? (baselineNeedsBackfill ? "accepted_backfill" : "accepted") : "reopen_fallback",
917
919
  baseline_docs: baselineDocs,
920
+ planning_validation_version: 2,
921
+ planning_validation_profile: planningValidationProfileForNewRound(projectRoot),
918
922
  },
919
923
  extraEvents: planningReopenExtraEvents(snapshot, "propose", reason.trim()),
920
924
  };
package/dist/types.d.ts CHANGED
@@ -156,6 +156,16 @@ export interface Event {
156
156
  payload: Record<string, unknown>;
157
157
  event_digest: string;
158
158
  }
159
+ export type OpenSpecValidationProfile = {
160
+ mode: "disabled";
161
+ } | {
162
+ mode: "strict";
163
+ config_digest: string;
164
+ };
165
+ export interface PlanningValidationProfile {
166
+ version: 2;
167
+ openspec: OpenSpecValidationProfile;
168
+ }
159
169
  export interface TransitionCommitPayload {
160
170
  transition: string;
161
171
  from_state: State;
@@ -190,6 +200,10 @@ export interface TransitionCommitPayload {
190
200
  workflow_mode?: "minimal" | "normal" | "strict";
191
201
  /** v2 起所有普通任务必须有五字段执行依据;缺失表示旧 change,沿用旧规则回放。 */
192
202
  execution_requirement_version?: 2;
203
+ /** 新 planning round 的格式协议版本;缺失表示升级前的 v1 change。 */
204
+ planning_validation_version?: 2;
205
+ /** propose-ready 成功时冻结的 OpenSpec 校验 profile,start-apply 只回放此快照。 */
206
+ planning_validation_profile?: PlanningValidationProfile;
193
207
  execution_policy?: ExecutionPolicy;
194
208
  }
195
209
  export interface Snapshot {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peterxiaoyang/superspec",
3
- "version": "0.1.45",
3
+ "version": "0.1.46",
4
4
  "description": "SuperSpec 流程引擎 — transition engine with lightweight fact-sync",
5
5
  "type": "module",
6
6
  "engines": {
@@ -1,7 +1,7 @@
1
1
  # SuperSpec Codex agent: architect
2
2
  name = "architect"
3
3
  description = "System design, boundaries, interfaces, long-horizon tradeoffs"
4
- model_reasoning_effort = "high"
4
+ model_reasoning_effort = "medium"
5
5
  developer_instructions = """
6
6
  Role: Architect. Review system boundaries, interface contracts, data flow, maintenance risk, rollback risk, and design tradeoffs.
7
7
 
@@ -1,7 +1,7 @@
1
1
  # SuperSpec Codex agent: code-reviewer
2
2
  name = "code-reviewer"
3
3
  description = "Code-level review for spec fit, bugs, safety, and test gaps"
4
- model_reasoning_effort = "high"
4
+ model_reasoning_effort = "medium"
5
5
  developer_instructions = """
6
6
  Role: Code Reviewer. Check spec fit, correctness, security, test adequacy, code quality, performance, and maintainability without making the workflow heavy.
7
7