@peterxiaoyang/superspec 0.1.55 → 0.1.57

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.js CHANGED
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // 所有可机械判定的文档协议、格式定义和解析逻辑都在这里。skills 只指导
4
4
  // 生成与语义判断,不得自行充当格式校验器或在其它地方重复解析。
5
- import { readFileSync, existsSync, realpathSync } from "node:fs";
5
+ import { readFileSync, existsSync, realpathSync, statSync } from "node:fs";
6
6
  import { isAbsolute, join, relative, resolve, sep } from "node:path";
7
7
  import { sha256Text } from "./store.js";
8
8
  import { GREEN_ONLY_NO_TDD_REASON } from "./types.js";
@@ -321,6 +321,240 @@ export function collectProposeOpenQuestions(changeRoot) {
321
321
  files,
322
322
  };
323
323
  }
324
+ // ===== design.md 结构变更清单 =====
325
+ //
326
+ // 格式(状态机校验,propose skill 负责生成):
327
+ // ## 结构变更清单
328
+ // 无
329
+ // 或
330
+ // | ID | 类别 | 变更 | 需求依据 | 决定 |
331
+ // |---|---|---|---|---|
332
+ // | SC-001 | 新增持久化结构 | ... | specs/x/spec.md#Requirement: ... | DEC-001 |
333
+ //
334
+ // 需决定类别的依据只接受 specs Requirement 或 TEST;仅展示类别可另接受
335
+ // proposal(Impact 除外)与 discovery 的标题。所有依据都必须能在当前 change 中解析。
336
+ export const STRUCTURE_CHANGE_CATEGORIES = [
337
+ "新增持久化结构",
338
+ "迁移或回填",
339
+ "功能开关",
340
+ "新增公共接口",
341
+ "删除既有路径",
342
+ "改既有公共签名",
343
+ "改变既有数据语义",
344
+ "新增公共类型",
345
+ ];
346
+ export const STRUCTURE_DECISION_REQUIRED_CATEGORIES = new Set([
347
+ "新增持久化结构",
348
+ "迁移或回填",
349
+ "功能开关",
350
+ "新增公共接口",
351
+ "删除既有路径",
352
+ "改既有公共签名",
353
+ "改变既有数据语义",
354
+ ]);
355
+ const STRUCTURE_LEDGER_HEADINGS = ["结构变更清单"];
356
+ const STRUCTURE_LEDGER_COLUMNS = ["ID", "类别", "变更", "需求依据", "决定"];
357
+ const STRUCTURE_LEDGER_ID_RE = /^SC-[A-Za-z0-9_-]+$/;
358
+ const STRUCTURE_LEDGER_NONE_RE = /^\s*无\s*$/;
359
+ const STRUCTURE_TEST_ID_RE = /^TEST-[A-Za-z0-9_-]+$/;
360
+ const DEC_ID_RE = /^DEC-[A-Za-z0-9][A-Za-z0-9_-]*$/;
361
+ const DISPLAY_ONLY_DECISION_MARKERS = new Set(["—", "-", "–"]);
362
+ const STRUCTURE_LEDGER_MISSING_MESSAGE = 'design.md 缺少 ## 结构变更清单;没有结构变更时在该标题下写"无"';
363
+ const STRUCTURE_LEDGER_FORMAT_MESSAGE = `## 结构变更清单 一节只能是"无"或一张含 ${STRUCTURE_LEDGER_COLUMNS.join("、")} 列的表格,其他说明写到别的标题下`;
364
+ export function isStructureChangeCategory(value) {
365
+ return STRUCTURE_CHANGE_CATEGORIES.includes(value);
366
+ }
367
+ export function parseStructureChangeLedger(content) {
368
+ const sectionBody = sectionBodyByHeadings(content, STRUCTURE_LEDGER_HEADINGS);
369
+ if (sectionBody == null) {
370
+ return { present: false, none: false, entries: [] };
371
+ }
372
+ if (STRUCTURE_LEDGER_NONE_RE.test(sectionBody)) {
373
+ return { present: true, none: true, entries: [] };
374
+ }
375
+ const tableLines = sectionBody.split("\n").filter(line => line.trim().startsWith("|"));
376
+ if (tableLines.length < 2) {
377
+ return { present: true, none: false, entries: [], format_error: STRUCTURE_LEDGER_FORMAT_MESSAGE };
378
+ }
379
+ const header = splitMarkdownTableRow(tableLines[0]);
380
+ if (header.length === 0 || !isMarkdownTableSeparator(tableLines[1])) {
381
+ return { present: true, none: false, entries: [], format_error: STRUCTURE_LEDGER_FORMAT_MESSAGE };
382
+ }
383
+ const columnIndexes = Object.fromEntries(STRUCTURE_LEDGER_COLUMNS.map(col => [col, header.indexOf(col)]));
384
+ if (STRUCTURE_LEDGER_COLUMNS.some(col => columnIndexes[col] < 0)) {
385
+ return { present: true, none: false, entries: [], format_error: STRUCTURE_LEDGER_FORMAT_MESSAGE };
386
+ }
387
+ const entries = [];
388
+ for (const row of tableLines.slice(2).map(splitMarkdownTableRow).filter(cells => cells.length > 0)) {
389
+ entries.push({
390
+ id: (row[columnIndexes.ID] ?? "").trim(),
391
+ category: (row[columnIndexes["类别"]] ?? "").trim(),
392
+ change: (row[columnIndexes["变更"]] ?? "").trim(),
393
+ basis: (row[columnIndexes["需求依据"]] ?? "").trim(),
394
+ decision: (row[columnIndexes["决定"]] ?? "").trim(),
395
+ });
396
+ }
397
+ if (entries.length === 0) {
398
+ return { present: true, none: false, entries: [], format_error: STRUCTURE_LEDGER_FORMAT_MESSAGE };
399
+ }
400
+ return { present: true, none: false, entries };
401
+ }
402
+ function readChangeDocument(changeRoot, relPath) {
403
+ const root = resolve(changeRoot);
404
+ const target = resolve(root, relPath);
405
+ if (!isPathInside(root, target))
406
+ return null;
407
+ try {
408
+ if (!existsSync(target) || !statSync(target).isFile())
409
+ return null;
410
+ return readFileSync(target, "utf8");
411
+ }
412
+ catch {
413
+ return null;
414
+ }
415
+ }
416
+ /** Markdown 标题是否存在(任意层级,允许闭合 #)。供文档引用解析共用。 */
417
+ export function headingExists(content, title) {
418
+ const heading = new RegExp(`^#{1,6}[\\t ]+${escapeRegex(title)}(?:[\\t ]+#+)?[\\t ]*$`, "m");
419
+ return heading.test(content);
420
+ }
421
+ function splitBasisRef(basis) {
422
+ const separator = basis.indexOf("#");
423
+ if (separator <= 0 || separator === basis.length - 1)
424
+ return null;
425
+ return {
426
+ path: basis.slice(0, separator).replace(/\\/g, "/"),
427
+ anchor: basis.slice(separator + 1).trim(),
428
+ };
429
+ }
430
+ /**
431
+ * 结构变更清单"需求依据"的唯一解析入口。与 approved_refs 的解析分开:
432
+ * 这里接受 discovery 标题、拒绝 proposal.md#Impact,且按类别收紧允许集。
433
+ */
434
+ export function resolveStructureBasisRef(changeRoot, raw, category) {
435
+ if (typeof raw !== "string" || raw.trim() === "") {
436
+ return { ok: false, reason: "需求依据必须是非空字符串" };
437
+ }
438
+ const basis = raw.trim();
439
+ if (/[\u0000-\u001f\u007f]/.test(basis)) {
440
+ return { ok: false, reason: `需求依据不能包含换行等控制字符:${JSON.stringify(basis)}` };
441
+ }
442
+ const decisionRequired = STRUCTURE_DECISION_REQUIRED_CATEGORIES.has(category);
443
+ const allowedHint = decisionRequired
444
+ ? "需决定类别请引用 specs Requirement 或 TEST"
445
+ : "请引用 specs Requirement、TEST、proposal 标题(Impact 除外)或 discovery 标题";
446
+ if (STRUCTURE_TEST_ID_RE.test(basis)) {
447
+ const content = readChangeDocument(changeRoot, join(".superspec", "artifacts", "test-contract.md"));
448
+ if (content == null)
449
+ return { ok: false, reason: `无法读取 test-contract.md,无法校验 TEST 依据:${basis}` };
450
+ const parsed = parseTestContractEntries(content);
451
+ if (!parsed.ok || !parsed.entries.some(entry => entry.test_id === basis)) {
452
+ return { ok: false, reason: `test-contract.md 中不存在 ${basis}` };
453
+ }
454
+ return { ok: true, value: basis };
455
+ }
456
+ const ref = splitBasisRef(basis);
457
+ if (!ref)
458
+ return { ok: false, reason: `需求依据格式无法解析,应为 文件#标题 或 TEST-ID:${basis};${allowedHint}` };
459
+ if (/^specs\/[^/]+\/spec\.md$/.test(ref.path)) {
460
+ const requirementTitle = ref.anchor.startsWith("Requirement:")
461
+ ? ref.anchor.slice("Requirement:".length).trim()
462
+ : "";
463
+ if (!requirementTitle)
464
+ return { ok: false, reason: `spec 依据必须以 Requirement: 开头:${basis}` };
465
+ const content = readChangeDocument(changeRoot, ref.path);
466
+ if (content == null)
467
+ return { ok: false, reason: `${ref.path} 在当前 change 中不存在` };
468
+ if (!headingExists(content, `Requirement: ${requirementTitle}`)) {
469
+ return { ok: false, reason: `${ref.path} 中不存在 Requirement「${requirementTitle}」` };
470
+ }
471
+ return { ok: true, value: basis };
472
+ }
473
+ if (ref.path === "proposal.md" && ref.anchor === "Impact") {
474
+ return { ok: false, reason: `proposal.md#Impact 不能作为结构依据;${allowedHint}` };
475
+ }
476
+ if (decisionRequired) {
477
+ return { ok: false, reason: `需求依据 ${basis} 不满足类别要求;${allowedHint}` };
478
+ }
479
+ if (ref.path === "proposal.md") {
480
+ const content = readChangeDocument(changeRoot, "proposal.md");
481
+ if (content == null)
482
+ return { ok: false, reason: "proposal.md 在当前 change 中不存在" };
483
+ if (!headingExists(content, ref.anchor))
484
+ return { ok: false, reason: `proposal.md 中不存在标题「${ref.anchor}」` };
485
+ return { ok: true, value: basis };
486
+ }
487
+ const discoveryRel = ".superspec/artifacts/discovery.md";
488
+ if (ref.path === "discovery.md" || ref.path === discoveryRel) {
489
+ const content = readChangeDocument(changeRoot, join(".superspec", "artifacts", "discovery.md"));
490
+ if (content == null)
491
+ return { ok: false, reason: "discovery.md 在当前 change 中不存在" };
492
+ if (!headingExists(content, ref.anchor))
493
+ return { ok: false, reason: `discovery.md 中不存在标题「${ref.anchor}」` };
494
+ return { ok: true, value: basis };
495
+ }
496
+ return { ok: false, reason: `需求依据 ${basis} 无法解析;${allowedHint}` };
497
+ }
498
+ function isDisplayOnlyDecisionMarker(decision) {
499
+ return decision.trim() === "" || DISPLAY_ONLY_DECISION_MARKERS.has(decision.trim());
500
+ }
501
+ export function validateStructureChangeLedger(changeRoot, ledger) {
502
+ if (!ledger.present)
503
+ return { ok: false, errors: [STRUCTURE_LEDGER_MISSING_MESSAGE] };
504
+ if (ledger.none)
505
+ return { ok: true, errors: [] };
506
+ if (ledger.format_error)
507
+ return { ok: false, errors: [ledger.format_error] };
508
+ const errors = [];
509
+ const seenIds = new Set();
510
+ const knownDecIds = new Set(collectProposeQuestions(changeRoot).map(question => question.id));
511
+ for (const entry of ledger.entries) {
512
+ if (!STRUCTURE_LEDGER_ID_RE.test(entry.id)) {
513
+ errors.push(`结构变更清单 ${entry.id || "<空>"} 的 ID 格式无效,应为 SC-xxx`);
514
+ continue;
515
+ }
516
+ if (seenIds.has(entry.id)) {
517
+ errors.push(`结构变更清单 ID 重复:${entry.id}`);
518
+ continue;
519
+ }
520
+ seenIds.add(entry.id);
521
+ if (!isStructureChangeCategory(entry.category)) {
522
+ errors.push(`结构变更清单 ${entry.id} 的类别无效:${entry.category || "<空>"};可用类别:${STRUCTURE_CHANGE_CATEGORIES.join("、")}`);
523
+ continue;
524
+ }
525
+ if (!entry.change) {
526
+ errors.push(`结构变更清单 ${entry.id} 的变更不能为空`);
527
+ }
528
+ const basis = resolveStructureBasisRef(changeRoot, entry.basis, entry.category);
529
+ if (!basis.ok)
530
+ errors.push(`结构变更清单 ${entry.id} 的需求依据无效:${basis.reason}`);
531
+ const decisionValid = DEC_ID_RE.test(entry.decision) && knownDecIds.has(entry.decision);
532
+ if (STRUCTURE_DECISION_REQUIRED_CATEGORIES.has(entry.category)) {
533
+ if (!decisionValid) {
534
+ errors.push(`结构变更清单 ${entry.id} 属于 ${entry.category},必须在 决定 列引用一个 ## 待用户确认 中的 DEC`);
535
+ }
536
+ }
537
+ else if (!isDisplayOnlyDecisionMarker(entry.decision) && !decisionValid) {
538
+ errors.push(`结构变更清单 ${entry.id} 的决定列引用了无效的 DEC:${entry.decision}`);
539
+ }
540
+ }
541
+ return { ok: errors.length === 0, errors: [...new Set(errors)] };
542
+ }
543
+ export function formatStructureChangeLedgerSummary(ledger) {
544
+ if (!ledger.present || ledger.format_error)
545
+ return null;
546
+ if (ledger.none)
547
+ return "结构变更:无";
548
+ if (ledger.entries.length === 0)
549
+ return null;
550
+ const lines = ledger.entries.map(entry => {
551
+ const ref = splitBasisRef(entry.basis);
552
+ const basisLabel = ref && /^specs\//.test(ref.path) ? ref.anchor : entry.basis;
553
+ const decisionSuffix = DEC_ID_RE.test(entry.decision) ? ` — 决定 ${entry.decision}` : "";
554
+ return `- ${entry.id} [${entry.category}] ${entry.change} — 依据 ${basisLabel}${decisionSuffix}`;
555
+ });
556
+ return ["结构变更清单", "", ...lines].join("\n");
557
+ }
324
558
  const TASK_LINE_RE = /^(- \[([ xX])\])\s+(\S+)/;
325
559
  // 块头独占一行(允许全角/半角冒号);PREFIX 变体用于识别"块头带尾部内容/误加 bullet"的格式错误
326
560
  const EXECUTION_REQUIREMENT_LINE_RE = /^\s*执行依据[::]\s*$/;
package/dist/next.js CHANGED
@@ -115,14 +115,17 @@ function toNextOutput(change, plan) {
115
115
  resume: { argv: ["superspec", "transition", "next", "--change", change] },
116
116
  reason: plan.reason,
117
117
  };
118
- case "run_transition":
118
+ case "run_transition": {
119
+ const findingContext = plan.reopen?.reason === "review_fix" ? plan.reopen.findingContext : undefined;
119
120
  return {
120
121
  state: plan.state,
121
122
  path: "next_command",
122
123
  next_command: transitionCommand(change, plan.transition, formatTransitionArgs(plan)),
123
124
  reason: plan.reason,
124
125
  missing_inputs: [],
126
+ ...(findingContext ? { finding_context: findingContext } : {}),
125
127
  };
128
+ }
126
129
  case "done":
127
130
  return {
128
131
  state: plan.state,
@@ -5,7 +5,7 @@ import { historicalProposeReadyRoles, reviewEvidenceDigest, reviewGateRoleResolu
5
5
  import { EXPLORE_DISCOVERY_REVIEW_GATE, PROPOSE_FINAL_REVIEW_GATE } from "./review_job_gates.js";
6
6
  import { changeRoot as openspecChangeRoot } from "./openspec.js";
7
7
  import { findLatestEvent, sha256Text } from "./store.js";
8
- import { parseExecutionRequirements, parseTasksMd, parseTestContractEntries } from "./format.js";
8
+ import { parseExecutionRequirements, parseTasksMd, parseTestContractEntries, parseStructureChangeLedger, formatStructureChangeLedgerSummary } from "./format.js";
9
9
  import { hasFrozenWorkflowModeForProposeRound, workflowRiskForProject, workflowRiskForState, } from "./workflow_config.js";
10
10
  export const PHASE_CONFIRMATION_SCOPE_PREFIX = "phase_confirmation:";
11
11
  function taskTitle(content, task) {
@@ -68,7 +68,12 @@ function proposeTaskDeliverySummary(changeRoot) {
68
68
  const status = completedCount === 0
69
69
  ? ""
70
70
  : `已完成 ${completedCount} 项;${pendingTasks.length === 0 ? "当前没有待实施任务。" : `以下 ${pendingTasks.length} 项仍待实施或调整。`}\n\n`;
71
- return `执行计划概览\n\n${status}${items.join("\n\n")}`;
71
+ const taskSummary = `执行计划概览\n\n${status}${items.join("\n\n")}`;
72
+ const designPath = join(changeRoot, "design.md");
73
+ const ledgerSummary = existsSync(designPath)
74
+ ? formatStructureChangeLedgerSummary(parseStructureChangeLedger(readFileSync(designPath, "utf8")))
75
+ : null;
76
+ return ledgerSummary ? `${taskSummary}\n\n${ledgerSummary}` : taskSummary;
72
77
  }
73
78
  function latestTransition(events, predicate) {
74
79
  return findLatestEvent(events, "transition_commit", event => predicate(event.payload));
@@ -1,9 +1,12 @@
1
1
  import type { ReviewGateRule } from "./review_job_gates.ts";
2
2
  import { exploreAnswerRegistrationPayload } from "./explore_round.ts";
3
3
  import { proposeAnswerRegistrationPayload } from "./propose_round.ts";
4
- import type { AcceptedMaterialFollowupContinuation, AskUser, Event, ExecutionPolicy, Job, JobRole, PlanningValidationProfile, WorkflowArtifactKind, State } from "./types.ts";
4
+ import type { AcceptedMaterialFollowupContinuation, AskUser, Event, ExecutionPolicy, Job, JobRole, PlanningValidationProfile, ReviewFindingContext, WorkflowArtifactKind, State } from "./types.ts";
5
5
  import type { Snapshot } from "./types.ts";
6
6
  import type { ReviewRisk } from "./review.ts";
7
+ export declare const PLAN_SIZE_BUDGET_SCOPE_PREFIX = "plan_size_budget:";
8
+ export declare const PLAN_SIZE_BUDGET_CONFIRM_ANSWER = "\u786E\u8BA4\u89C4\u6A21\u5408\u7406\uFF0C\u7EE7\u7EED\u5BA1\u67E5";
9
+ export declare const PLAN_SIZE_BUDGET_SHRINK_ANSWER = "\u56DE\u53BB\u6536\u7F29\u8BA1\u5212";
7
10
  export type TransitionName = "explore" | "propose-ready" | "start-apply" | "task-start" | "task-complete" | "review-ready" | "reopen" | "accept";
8
11
  export type WorkflowMode = {
9
12
  kind: "risk";
@@ -29,6 +32,7 @@ export type ReopenNextStep = {
29
32
  jobId: string;
30
33
  findingId: string;
31
34
  reopenReason: string;
35
+ findingContext?: ReviewFindingContext;
32
36
  } | {
33
37
  to: "propose";
34
38
  reason: "review_finding";
@@ -118,6 +122,7 @@ export declare function latestReopenExploreBaseline(events: Event[]): Record<str
118
122
  export declare function proposalDocsChangedSinceBaseline(changeRoot: string, baseline: Record<string, string>): boolean;
119
123
  export declare function discoveryDocsChangedSinceBaseline(changeRoot: string, baseline: Record<string, string>): boolean;
120
124
  export declare function pendingTaskIds(changeRoot: string): string[];
125
+ export declare function planSizeBudgetScope(proposeRoundId: string, taskCount: number, testCount: number): string;
121
126
  /** 当前 Apply round 的规则版本;缺失版本的历史 event 保持 v1 回放。 */
122
127
  export declare function executionRequirementVersionForCurrentRound(events: Event[]): 1 | 2;
123
128
  /** 新 planning round 在进入 propose 时冻结当前 OpenSpec 校验契约。 */
@@ -2,16 +2,29 @@ import { existsSync, readFileSync } from "node:fs";
2
2
  import { join, relative } from "node:path";
3
3
  import { EXPLORE_DISCOVERY_REVIEW_GATE, PROPOSE_FINAL_REVIEW_GATE } from "./review_job_gates.js";
4
4
  import { currentExploreRoundId, exploreAnswerRegistrationPayload, unregisteredClosedExploreQuestions, unresolvedPresentedExploreQuestionScopes, } from "./explore_round.js";
5
- import { currentProposeOpenQuestion, currentProposeRoundId, proposeAnswerRegistrationPayload, unregisteredClosedProposeQuestions, unresolvedPresentedProposeQuestionScopes, } from "./propose_round.js";
6
- import { discoveryOpenQuestionDisplayText, discoveryOpenQuestionScope, proposeOpenQuestionDisplayText, proposeOpenQuestionScope, parseExecutionRequirements, parseDiscoveryOpenQuestions, parseTasksMd, pendingTasksInContent, validateDiscovery, validateExecutionRequirements, validateExecutionRequirementDocumentReferences, validateProposalImpact, validateTasksDocument, } from "./format.js";
5
+ import { currentProposeOpenQuestion, currentProposeRoundId, isPlanningValidationProfile, proposeAnswerRegistrationPayload, unregisteredClosedProposeQuestions, unresolvedPresentedProposeQuestionScopes, } from "./propose_round.js";
6
+ import { discoveryOpenQuestionDisplayText, discoveryOpenQuestionScope, proposeOpenQuestionDisplayText, proposeOpenQuestionScope, parseExecutionRequirements, parseDiscoveryOpenQuestions, parseTasksMd, parseTestContractEntries, isFixTaskId, pendingTasksInContent, validateDiscovery, validateExecutionRequirements, validateExecutionRequirementDocumentReferences, validateProposalImpact, validateTasksDocument, parseStructureChangeLedger, validateStructureChangeLedger, } from "./format.js";
7
7
  import { currentGitHead } from "./git_state.js";
8
8
  import { validateOpenSpecChange } from "./openspec.js";
9
- import { docRef, sha256File } from "./store.js";
9
+ import { docRef, sha256File, sha256Text, findLatestEvent } from "./store.js";
10
10
  import { isReviewReadyVerifier, isFreshReviewVerifier, historicalProposeReadyRoles, readReviewPolicyFromEvents, reviewGateRoleResolution, reviewRejectionOverrideScope, reviewEvidenceDigest, } from "./review.js";
11
- import { CODE_REVIEW_DECISION_ANSWER_LABELS, CODE_REVIEW_REPAIR_SCOPE_PREFIX, codeReviewDecisionScope, codeReviewJobStaleReason, collectCodeReviewGateFacts, currentCodeReviewWorkingPaths, latestCodeReviewFailedStatus, requiresFinalVerifierForCurrentReview, scanCodeChangesForReview, } from "./code_review.js";
11
+ import { CODE_REVIEW_DECISION_ANSWER_LABELS, CODE_REVIEW_REPAIR_SCOPE_PREFIX, codeReviewDecisionScope, codeReviewFindingNeedsUserDecision, codeReviewJobStaleReason, collectCodeReviewGateFacts, countReviewFixReopensSinceStartApply, currentCodeReviewWorkingPaths, isReviewFixCapReached, latestCodeReviewFailedStatus, requiresFinalVerifierForCurrentReview, scanCodeChangesForReview, } from "./code_review.js";
12
12
  import { isPhaseAdvanceAuthorized, latestAcceptedPhaseDecision, phaseConfirmationCommitPayload, phaseConfirmationForBoundary, phaseConfirmationMissingMessage, } from "./phase_confirmation.js";
13
13
  import { taskEvidenceReadiness } from "./task_evidence.js";
14
- import { workflowRiskForProposeRound, workflowRiskForState } from "./workflow_config.js";
14
+ import { workflowRiskForProposeRound, workflowRiskForState, workflowBudgetForRisk } from "./workflow_config.js";
15
+ export const PLAN_SIZE_BUDGET_SCOPE_PREFIX = "plan_size_budget:";
16
+ export const PLAN_SIZE_BUDGET_CONFIRM_ANSWER = "确认规模合理,继续审查";
17
+ export const PLAN_SIZE_BUDGET_SHRINK_ANSWER = "回去收缩计划";
18
+ /** 从失败 finding 提取定位上下文:只回传 evidence(位置事实),不回传 description——那是审查建议叙事,不进执行上下文。 */
19
+ function reviewFindingContext(finding) {
20
+ const evidence = typeof finding?.evidence === "string" ? finding.evidence.trim() : "";
21
+ if (!evidence)
22
+ return undefined;
23
+ return {
24
+ evidence,
25
+ note: "非授权上下文:仅用于定位问题代码;实现范围仍以任务行锚定的已批准行为为准",
26
+ };
27
+ }
15
28
  function requiredJobs(state, jobs, reason) {
16
29
  return { kind: "required_jobs", state, jobs, reason };
17
30
  }
@@ -127,32 +140,47 @@ const REQUIRED_DESIGN_HEADINGS = [
127
140
  "## 实现方案",
128
141
  ];
129
142
  function validateDesignPlan(changeRoot, profile) {
130
- if (profile?.openspec.mode !== "strict" || profile.design?.schema_version !== 1)
131
- return null;
132
- const designPath = join(changeRoot, "design.md");
133
- if (!existsSync(designPath))
134
- return null;
135
- const lines = readFileSync(designPath, "utf8").split(/\r?\n/).map(line => line.trimEnd());
136
143
  const errors = [];
137
- const headingIndexes = new Map();
138
- for (const heading of REQUIRED_DESIGN_HEADINGS) {
139
- const indexes = lines.flatMap((line, index) => line === heading ? [index] : []);
140
- headingIndexes.set(heading, indexes);
141
- if (indexes.length === 0)
142
- errors.push(`design.md 缺少稳定结构标题:${heading}`);
143
- if (indexes.length > 1)
144
- errors.push(`design.md 稳定结构标题重复:${heading}`);
144
+ // 稳定标题检查自 design.schema_version 引入起生效;更早的 strict round 没有 design 字段,保持不检查。
145
+ if (profile?.openspec.mode === "strict" && profile.design != null) {
146
+ const designPath = join(changeRoot, "design.md");
147
+ if (existsSync(designPath)) {
148
+ const lines = readFileSync(designPath, "utf8").split(/\r?\n/).map(line => line.trimEnd());
149
+ const headingIndexes = new Map();
150
+ for (const heading of REQUIRED_DESIGN_HEADINGS) {
151
+ const indexes = lines.flatMap((line, index) => line === heading ? [index] : []);
152
+ headingIndexes.set(heading, indexes);
153
+ if (indexes.length === 0)
154
+ errors.push(`design.md 缺少稳定结构标题:${heading}`);
155
+ if (indexes.length > 1)
156
+ errors.push(`design.md 稳定结构标题重复:${heading}`);
157
+ }
158
+ if (errors.length === 0) {
159
+ let previousIndex = -1;
160
+ for (const heading of REQUIRED_DESIGN_HEADINGS) {
161
+ const indexes = headingIndexes.get(heading);
162
+ if (indexes[0] <= previousIndex) {
163
+ errors.push(`design.md 稳定结构标题顺序错误:${heading}`);
164
+ break;
165
+ }
166
+ previousIndex = indexes[0];
167
+ }
168
+ }
169
+ }
145
170
  }
146
- if (errors.length > 0)
147
- return errors.join("");
148
- let previousIndex = -1;
149
- for (const heading of REQUIRED_DESIGN_HEADINGS) {
150
- const indexes = headingIndexes.get(heading);
151
- if (indexes[0] <= previousIndex)
152
- return `design.md 稳定结构标题顺序错误:${heading}`;
153
- previousIndex = indexes[0];
171
+ if (profile?.design?.schema_version === 2) {
172
+ const designPath = join(changeRoot, "design.md");
173
+ if (!existsSync(designPath)) {
174
+ errors.push('design.md 缺少 ## 结构变更清单;没有结构变更时在该标题下写"无"');
175
+ }
176
+ else {
177
+ const ledger = parseStructureChangeLedger(readFileSync(designPath, "utf8"));
178
+ const validation = validateStructureChangeLedger(changeRoot, ledger);
179
+ if (!validation.ok)
180
+ errors.push(...validation.errors);
181
+ }
154
182
  }
155
- return null;
183
+ return errors.length > 0 ? [...new Set(errors)].join(";") : null;
156
184
  }
157
185
  export function executionPolicyForRisk(risk) {
158
186
  return risk === "strict" ? "tdd" : "green_only";
@@ -343,6 +371,102 @@ function latestStartApplyIndex(events) {
343
371
  }
344
372
  return -1;
345
373
  }
374
+ function planSizeCountDigest(taskCount, testCount) {
375
+ return sha256Text(`${taskCount},${testCount}`).replace(/^sha256:/, "");
376
+ }
377
+ export function planSizeBudgetScope(proposeRoundId, taskCount, testCount) {
378
+ return `${PLAN_SIZE_BUDGET_SCOPE_PREFIX}${proposeRoundId}:${planSizeCountDigest(taskCount, testCount)}`;
379
+ }
380
+ function countNonFixPlanTasks(changeRoot) {
381
+ const tasksContent = readFileSync(join(changeRoot, "tasks.md"), "utf8");
382
+ return parseTasksMd(tasksContent).filter(task => !isFixTaskId(task.taskId)).length;
383
+ }
384
+ function countPlanTestEntries(changeRoot) {
385
+ const testContractPath = join(changeRoot, ".superspec", "artifacts", "test-contract.md");
386
+ if (!existsSync(testContractPath))
387
+ return 0;
388
+ const parsed = parseTestContractEntries(readFileSync(testContractPath, "utf8"));
389
+ return parsed.ok ? parsed.entries.length : 0;
390
+ }
391
+ function isOverPlanSizeBudget(budget, taskCount, testCount) {
392
+ const overTasks = budget.tasks !== null && taskCount > budget.tasks;
393
+ const overTests = budget.tests !== null && testCount > budget.tests;
394
+ return overTasks || overTests;
395
+ }
396
+ function latestPlanSizeBudgetAnswer(events, scope) {
397
+ const event = findLatestEvent(events, "user_decision_recorded", ev => {
398
+ const payload = ev.payload;
399
+ if (payload.scope !== scope)
400
+ return false;
401
+ if (payload.accepted === false)
402
+ return false;
403
+ return typeof payload.answer === "string" && payload.answer.trim() !== "";
404
+ });
405
+ if (!event)
406
+ return null;
407
+ const answer = event.payload.answer;
408
+ return typeof answer === "string" ? answer.trim() : null;
409
+ }
410
+ function planSizeBudgetOverageMessage(budget, taskCount, testCount) {
411
+ const parts = [];
412
+ if (budget.tasks !== null && taskCount > budget.tasks) {
413
+ parts.push(`任务 ${taskCount} 个(预算 ${budget.tasks})`);
414
+ }
415
+ if (budget.tests !== null && testCount > budget.tests) {
416
+ parts.push(`TEST ${testCount} 个(预算 ${budget.tests})`);
417
+ }
418
+ return parts.join(";");
419
+ }
420
+ function planSizeBudgetSkipReason(projectRoot, changeRoot, events, risk) {
421
+ const budget = workflowBudgetForRisk(projectRoot, risk);
422
+ if (!budget)
423
+ return null;
424
+ const taskCount = countNonFixPlanTasks(changeRoot);
425
+ const testCount = countPlanTestEntries(changeRoot);
426
+ if (!isOverPlanSizeBudget(budget, taskCount, testCount))
427
+ return null;
428
+ const scope = planSizeBudgetScope(currentProposeRoundId(events), taskCount, testCount);
429
+ const answer = latestPlanSizeBudgetAnswer(events, scope);
430
+ if (answer === PLAN_SIZE_BUDGET_CONFIRM_ANSWER)
431
+ return null;
432
+ if (answer === PLAN_SIZE_BUDGET_SHRINK_ANSWER) {
433
+ return `计划规模仍超过预算(${planSizeBudgetOverageMessage(budget, taskCount, testCount)}),请收缩 tasks 或 test-contract 后重试`;
434
+ }
435
+ return `计划规模超过预算(${planSizeBudgetOverageMessage(budget, taskCount, testCount)}),需要先确认规模或收缩计划`;
436
+ }
437
+ function planSizeBudgetNextStep(context) {
438
+ const { change, changeRoot, events, mode } = context;
439
+ const budget = workflowBudgetForRisk(context.projectRoot, mode.risk);
440
+ if (!budget)
441
+ return null;
442
+ const taskCount = countNonFixPlanTasks(changeRoot);
443
+ const testCount = countPlanTestEntries(changeRoot);
444
+ if (!isOverPlanSizeBudget(budget, taskCount, testCount))
445
+ return null;
446
+ const scope = planSizeBudgetScope(currentProposeRoundId(events), taskCount, testCount);
447
+ const answer = latestPlanSizeBudgetAnswer(events, scope);
448
+ if (answer === PLAN_SIZE_BUDGET_CONFIRM_ANSWER)
449
+ return null;
450
+ if (answer === PLAN_SIZE_BUDGET_SHRINK_ANSWER) {
451
+ return {
452
+ kind: "material_update_required",
453
+ state: "propose",
454
+ errors: [`计划规模仍超过预算:${planSizeBudgetOverageMessage(budget, taskCount, testCount)}`],
455
+ reason: "已选择收缩计划但规模未变化",
456
+ };
457
+ }
458
+ const overage = planSizeBudgetOverageMessage(budget, taskCount, testCount);
459
+ const question = `当前计划规模为 ${overage}。请确认是否按此规模继续进入审查,或先回去收缩 tasks / test-contract。`;
460
+ const ask = {
461
+ question,
462
+ allowed_answers: [PLAN_SIZE_BUDGET_CONFIRM_ANSWER, PLAN_SIZE_BUDGET_SHRINK_ANSWER],
463
+ scope,
464
+ record_argv: ["superspec", "record", "user-decision", "--change", change, "--input", "-"],
465
+ record_input: { scope, question, answer: null },
466
+ required_fields: ["answer"],
467
+ };
468
+ return { kind: "ask_user", state: "propose", ask, reason: "计划规模超过预算" };
469
+ }
346
470
  function executionRequirementVersionFromPayload(payload) {
347
471
  return payload.execution_requirement_version === 2 ? 2 : 1;
348
472
  }
@@ -364,22 +488,12 @@ function executionRequirementVersionForProposeRound(events) {
364
488
  }
365
489
  return 1;
366
490
  }
367
- function isPlanningValidationProfile(value) {
368
- if (!value || typeof value !== "object" || Array.isArray(value))
369
- return false;
370
- const profile = value;
371
- if (profile.version !== 2 || !profile.openspec || typeof profile.openspec !== "object")
372
- return false;
373
- const designValid = profile.design == null || profile.design.schema_version === 1;
374
- return designValid && (profile.openspec.mode === "disabled" ||
375
- profile.openspec.mode === "strict" && typeof profile.openspec.config_digest === "string");
376
- }
377
491
  /** 新 planning round 在进入 propose 时冻结当前 OpenSpec 校验契约。 */
378
492
  export function planningValidationProfileForNewRound(projectRoot) {
379
493
  const configDigest = sha256File(join(projectRoot, "openspec", "config.yaml"));
380
494
  return configDigest == null
381
- ? { version: 2, openspec: { mode: "disabled" }, design: { schema_version: 1 } }
382
- : { version: 2, openspec: { mode: "strict", config_digest: configDigest }, design: { schema_version: 1 } };
495
+ ? { version: 2, openspec: { mode: "disabled" }, design: { schema_version: 2 } }
496
+ : { version: 2, openspec: { mode: "strict", config_digest: configDigest }, design: { schema_version: 2 } };
383
497
  }
384
498
  /** propose 状态尚未 ready 时,从进入本 planning round 的事件读取冻结 profile。 */
385
499
  function planningValidationProfileForPendingProposeRound(events) {
@@ -654,6 +768,9 @@ export function planNextStep(context) {
654
768
  reason: preflight.error,
655
769
  };
656
770
  }
771
+ const budgetStep = planSizeBudgetNextStep(context);
772
+ if (budgetStep)
773
+ return budgetStep;
657
774
  const proposalReviewJobs = PROPOSE_FINAL_REVIEW_GATE.openJobsForGate(snapshot);
658
775
  if (proposalReviewJobs.length > 0) {
659
776
  return requiredJobs("propose", proposalReviewJobs, `有 ${proposalReviewJobs.length} 个待完成 proposal 审查工作项`);
@@ -826,7 +943,8 @@ function planApplyDoneNext(context) {
826
943
  const findingId = pendingFinding?.id ?? "";
827
944
  const type = pendingFinding?.type;
828
945
  const decision = pendingFinding?.decision;
829
- if (findingId && type === "implementation") {
946
+ const reviewFixCapReached = isReviewFixCapReached(context.projectRoot, events);
947
+ if (findingId && type === "implementation" && !reviewFixCapReached) {
830
948
  return {
831
949
  kind: "run_transition",
832
950
  state: "apply_done",
@@ -837,11 +955,12 @@ function planApplyDoneNext(context) {
837
955
  jobId: latest.job.job_id,
838
956
  findingId,
839
957
  reopenReason: `修复代码审查问题 ${findingId}`,
958
+ findingContext: reviewFindingContext(pendingFinding?.finding),
840
959
  },
841
960
  reason: `代码审查发现纯代码实现问题 ${findingId},回到实现阶段修复`,
842
961
  };
843
962
  }
844
- if (findingId && (type === "spec" || type === "mixed")) {
963
+ if (findingId && codeReviewFindingNeedsUserDecision(type, reviewFixCapReached)) {
845
964
  if (decision?.answer === "reopen_propose") {
846
965
  return {
847
966
  kind: "run_transition",
@@ -862,15 +981,21 @@ function planApplyDoneNext(context) {
862
981
  jobId: latest.job.job_id,
863
982
  findingId,
864
983
  reopenReason: `根据代码审查问题 ${findingId} 回到实现阶段修复`,
984
+ findingContext: reviewFindingContext(pendingFinding?.finding),
865
985
  },
866
986
  reason: `使用者已确认问题 ${findingId} 直接回到实现阶段修复`,
867
987
  };
868
988
  }
869
989
  const problemKind = type === "spec"
870
990
  ? "方案或需求文档可能需要调整"
871
- : "代码实现和方案文档都可能有关";
991
+ : type === "mixed"
992
+ ? "代码实现和方案文档都可能有关"
993
+ : "纯代码实现问题";
994
+ const capNote = reviewFixCapReached && type === "implementation"
995
+ ? `本轮 Apply 已自动修复 ${countReviewFixReopensSinceStartApply(events)} 次,`
996
+ : "";
872
997
  const ask = {
873
- question: `代码审查发现问题 ${findingId}:${problemKind}。请选择回到计划阶段修改文档、确认现有文档方向不变并回到实现阶段修代码,或驳回该问题;无论选择哪一项都必须写明原因。`,
998
+ question: `${capNote}代码审查发现问题 ${findingId}:${problemKind}。请选择回到计划阶段修改文档、确认现有文档方向不变并回到实现阶段修代码,或驳回该问题;无论选择哪一项都必须写明原因。`,
874
999
  allowed_answers: [
875
1000
  CODE_REVIEW_DECISION_ANSWER_LABELS.reopen_propose,
876
1001
  CODE_REVIEW_DECISION_ANSWER_LABELS.reopen_apply,
@@ -1043,6 +1168,9 @@ function planProposeReadyTransition(context) {
1043
1168
  if (unresolvedPresentedProposeQuestionScopes(context.events).length > 0) {
1044
1169
  return { kind: "skip", message: "此前展示的设计问题缺少答复登记且已从计划材料消失,请恢复原问题并完成登记" };
1045
1170
  }
1171
+ const budgetSkip = planSizeBudgetSkipReason(context.projectRoot, changeRoot, context.events, risk);
1172
+ if (budgetSkip)
1173
+ return { kind: "skip", message: budgetSkip };
1046
1174
  const requiredRoles = PROPOSE_FINAL_REVIEW_GATE.requiredRolesForRisk(risk);
1047
1175
  const gatePlan = reviewGatePlan(snapshot, context.events, changeRoot, PROPOSE_FINAL_REVIEW_GATE, requiredRoles);
1048
1176
  if (gatePlan)
@@ -1,5 +1,11 @@
1
1
  import { type ProposeQuestion } from "./format.ts";
2
- import type { Event } from "./types.ts";
2
+ import type { Event, PlanningValidationProfile } from "./types.ts";
3
+ export declare function isPlanningValidationProfile(value: unknown): value is PlanningValidationProfile;
4
+ /**
5
+ * 当前 planning round 的冻结 profile:优先取最近一次 propose-ready 写入的快照,
6
+ * 否则取进入 propose 的边界事件。两者都没有时是升级前的 v1 change。
7
+ */
8
+ export declare function planningValidationProfileForCurrentRound(events: readonly Event[]): PlanningValidationProfile | null;
3
9
  export declare function currentProposeRoundId(events: readonly Event[]): string;
4
10
  export declare function proposeAnswerRegistrationPayload(changeRoot: string): {
5
11
  propose_answer_registration: {