@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/README.md CHANGED
@@ -4,15 +4,26 @@
4
4
  [![Node.js](https://img.shields.io/badge/node-%3E%3D20.19.0-brightgreen?style=flat-square)](https://nodejs.org)
5
5
  [![OpenSpec](https://img.shields.io/badge/OpenSpec-compatible-6f42c1?style=flat-square)](https://github.com/Fission-AI/OpenSpec)
6
6
 
7
- SuperSpec 是一套面向 AI 编程代理的需求变更工作流。它在 OpenSpec 材料之上增加阶段控制、审查和验证记录,帮助 AI 从需求理解推进到可回放的交付结果。
7
+ AI 写代码很快。真正慢的是之后:范围悄悄变大、完成全凭一张嘴、review 时问一句「这里为什么要改」它才承认不该改。
8
+
9
+ SuperSpec 是一套跑在 AI 编程代理(Codex 等)下的需求变更工作流引擎。它把「不要乱改、别过度设计、做完要有证据」从 prompt 劝说升级成**机器门禁**——约束写在 CLI 和数据契约里,不靠模型自觉。
8
10
 
9
11
  ```text
10
12
  Explore → Propose → Apply → Review → Accepted
11
13
  ```
12
14
 
13
- SuperSpec 适合跨模块、涉及接口或需要方案审查的变更。简单的单文件修改不必强行使用完整工作流。普通请求也不会自动进入 SuperSpec,只有明确调用对应入口或继续已有 change 时才会启动。
15
+ ## 它拦得住什么
16
+
17
+ | 你遇到的问题 | SuperSpec 的机制 |
18
+ | --- | --- |
19
+ | 「顺手」改了不该改的,加了不该加的方法 | 每个任务绑定五字段执行依据:测试 / 设计 / 来源 / 验收 / 边界,引用可解析性由状态机校验;实现越界必须走结构化的范围说明,而不是一句道歉 |
20
+ | 说自己做完了,测过没有无从考证 | 任务启动即冻结证据要求:该 RED 的先失败、该 GREEN 的真通过,测试证据绑定任务尝试;全程事件日志带摘要,可回放、防篡改 |
21
+ | 审查抓到了问题,修复时又把新架构夹带回来 | 阻塞问题必须标注类型(漏做 / 破坏已有 / 计划外新增)并**锚定到已批准材料**才能受理;修复任务的指令只兑现锚点,审查建议里的架构永远不是授权 |
22
+ | 审查形同虚设,「看起来没问题」就通过 | critic / architect / test-engineer / code-reviewer / verifier 独立角色持密封工作项审查,pass 必须说明覆盖了什么,fail 必须给出可追溯的证据 |
23
+
24
+ 这些约束全部由引擎在提交口强制执行:答不出锚点的阻塞问题直接拒收,而不是等你在对话里追问。
14
25
 
15
- ## 安装
26
+ ## 快速开始
16
27
 
17
28
  要求 Node.js `>=20.19.0`。
18
29
 
@@ -22,18 +33,17 @@ cd <your-project>
22
33
  superspec install
23
34
  ```
24
35
 
25
- `superspec install` 会把工作流入口、角色配置和运行时目录同步到当前项目,并准备 OpenSpec 依赖。`superspec init --scope project` 是兼容别名。
36
+ `superspec install` 会把工作流入口、角色配置和运行时目录同步到当前项目,并准备 [OpenSpec](https://github.com/Fission-AI/OpenSpec) 依赖。
26
37
 
27
- 检查安装:
38
+ 然后在 Codex 里对它说话:
28
39
 
29
- ```bash
30
- superspec version
31
- superspec status
40
+ ```text
41
+ superspec-explore change <change-name>。<你的需求>
32
42
  ```
33
43
 
34
- ## 工作流入口
44
+ 工作流接管之后,每一步该做什么由 `superspec transition next` 决定——AI 不自由发挥流程,只在被授权的范围内干活。
35
45
 
36
- Codex 中显式调用对应 Skill。新需求通常从 `superspec-explore` 开始;已有 change 则从当前阶段继续。
46
+ ## 工作流入口
37
47
 
38
48
  | Skill | 作用 |
39
49
  | --- | --- |
@@ -42,35 +52,21 @@ superspec status
42
52
  | `superspec-apply` | 按已批准任务修改代码并验证 |
43
53
  | `superspec-review` | 审查实现并完成最终验证 |
44
54
 
45
- 工作流会根据问题性质留在当前阶段修复,或回到计划阶段重新确认需求、验收和技术取舍。
46
-
47
- ## 配置
55
+ 修不动会自己拐弯:能由既有任务解释的问题留在 Apply 内闭环;需要改需求、验收或技术取舍的,回到计划阶段重新确认。
48
56
 
49
- 项目级配置位于 `.superspec/config.json`。未创建配置或未声明模式时,默认使用 `normal`:
50
-
51
- ```json
52
- {
53
- "workflow": {
54
- "mode": "normal",
55
- "hosts": ["codex"]
56
- }
57
- }
58
- ```
57
+ ## 轻量,且知道自己的边界
59
58
 
60
- 可选模式为 `minimal`、`normal` `strict`。模式主要影响计划阶段的审查强度;最终代码审查和验证仍由 Review 阶段负责。
59
+ - **三档强度**:`minimal` / `normal` / `strict`(`.superspec/config.json`),按变更风险选择计划阶段的审查强度。
60
+ - **不绑架日常**:简单的单文件修改不必走完整工作流;普通请求也不会自动进入 SuperSpec,只有显式调用入口才启动。
61
+ - **诚实的能力边界**:它是流程与审计辅助,不是安全隔离或发布审批系统;不能替代代码审计、权限控制、合规检查和人工判断。
61
62
 
62
63
  ## 与 OpenSpec 的关系
63
64
 
64
- OpenSpec 负责变更材料和规格结构;SuperSpec 负责组织 AI 的探索、计划、实现、审查与证据记录。两者互补:
65
-
66
- ```text
67
- OpenSpec:这次要改变什么
68
- SuperSpec:如何在边界内把它交付
69
- ```
65
+ OpenSpec 负责「这次要改变什么」,提供变更材料与规格结构;SuperSpec 负责「如何在边界内把它交付」,组织 AI 的探索、计划、实现、审查与证据记录。
70
66
 
71
- ## 设计边界
67
+ ## 评测
72
68
 
73
- SuperSpec 是流程和审计辅助工具,不是安全隔离或发布审批系统。它不能替代代码审计、权限控制、合规检查和人工判断。
69
+ 仓库自带密封评测考场(隔离工作区 + 真实 Agent 运行 + 硬门禁 + 双模型语义复盘),用于验证工作流行为本身,而不只是 Prompt 文案。见 [`evals/README.md`](evals/README.md)。
74
70
 
75
71
  ## 致谢
76
72
 
@@ -0,0 +1,26 @@
1
+ import type { CodeReviewClaimKind } from "./types.ts";
2
+ export declare const CODE_REVIEW_CLAIM_KINDS: readonly ["missing_approved", "breaks_existing", "unjustified_addition"];
3
+ export type ApprovedRefKind = "test" | "requirement" | "task" | "design" | "proposal" | "structure";
4
+ export interface ResolvedApprovedRef {
5
+ raw: string;
6
+ kind: ApprovedRefKind;
7
+ short: string;
8
+ }
9
+ export declare function isCodeReviewClaimKind(value: unknown): value is CodeReviewClaimKind;
10
+ export declare function shortApprovedRef(raw: string): string;
11
+ export declare function resolveApprovedRef(changeRoot: string, raw: unknown): {
12
+ ok: true;
13
+ value: ResolvedApprovedRef;
14
+ } | {
15
+ ok: false;
16
+ reason: string;
17
+ };
18
+ export declare function resolveApprovedRefs(changeRoot: string, refs: unknown): {
19
+ ok: true;
20
+ values: ResolvedApprovedRef[];
21
+ } | {
22
+ ok: false;
23
+ reasons: string[];
24
+ };
25
+ export declare function hasBehaviorAnchor(values: readonly ResolvedApprovedRef[]): boolean;
26
+ export declare function reviewFixReason(claimKind: CodeReviewClaimKind, refs: readonly string[]): string;
@@ -0,0 +1,159 @@
1
+ // SuperSpec 代码审查 approved_refs:只做存在性解析,不做语义匹配。
2
+ import { readFileSync, statSync } from "node:fs";
3
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
4
+ import { headingExists, parseTasksMd, parseTestContractEntries, parseStructureChangeLedger } from "./format.js";
5
+ export const CODE_REVIEW_CLAIM_KINDS = [
6
+ "missing_approved",
7
+ "breaks_existing",
8
+ "unjustified_addition",
9
+ ];
10
+ const TEST_ID_RE = /^TEST-[A-Za-z0-9_-]+$/;
11
+ const STRUCTURE_LEDGER_ID_RE = /^SC-[A-Za-z0-9_-]+$/;
12
+ const TEST_CONTRACT_REL = join(".superspec", "artifacts", "test-contract.md");
13
+ function isPathInside(root, target) {
14
+ const rel = relative(root, target);
15
+ return rel !== "" && !rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel);
16
+ }
17
+ function shortTestId(raw) {
18
+ const trimmed = raw.trim();
19
+ if (TEST_ID_RE.test(trimmed))
20
+ return trimmed;
21
+ const hash = trimmed.lastIndexOf("#");
22
+ if (hash >= 0) {
23
+ const id = trimmed.slice(hash + 1).trim();
24
+ if (TEST_ID_RE.test(id))
25
+ return id;
26
+ }
27
+ return null;
28
+ }
29
+ function readChangeFile(changeRoot, relPath) {
30
+ const target = resolve(changeRoot, relPath);
31
+ if (!isPathInside(resolve(changeRoot), target) && resolve(changeRoot) !== target)
32
+ return null;
33
+ try {
34
+ if (!statSync(target).isFile())
35
+ return null;
36
+ return readFileSync(target, "utf8");
37
+ }
38
+ catch {
39
+ return null;
40
+ }
41
+ }
42
+ export function isCodeReviewClaimKind(value) {
43
+ return typeof value === "string" && CODE_REVIEW_CLAIM_KINDS.includes(value);
44
+ }
45
+ export function shortApprovedRef(raw) {
46
+ const testId = shortTestId(raw);
47
+ if (testId)
48
+ return testId;
49
+ const req = /#Requirement:\s*(.+)$/.exec(raw.trim());
50
+ if (req)
51
+ return `Requirement: ${req[1].trim()}`;
52
+ const hash = raw.lastIndexOf("#");
53
+ if (hash >= 0 && hash < raw.length - 1)
54
+ return raw.slice(hash + 1).trim();
55
+ return raw.trim();
56
+ }
57
+ export function resolveApprovedRef(changeRoot, raw) {
58
+ if (typeof raw !== "string" || raw.trim() === "") {
59
+ return { ok: false, reason: "approved_refs 条目必须是非空字符串" };
60
+ }
61
+ const ref = raw.trim();
62
+ if (/[\u0000-\u001f\u007f]/.test(ref)) {
63
+ return { ok: false, reason: `approved_refs 条目不能包含换行等控制字符:${JSON.stringify(ref)}` };
64
+ }
65
+ const testId = shortTestId(ref);
66
+ if (testId) {
67
+ if (ref.includes("#") && !ref.endsWith(`#${testId}`)) {
68
+ return { ok: false, reason: `TEST 引用只能是裸 TEST-ID 或以 #TEST-ID 结尾指向 test-contract.md:${ref}` };
69
+ }
70
+ if (ref.includes("#")) {
71
+ const path = ref.slice(0, ref.lastIndexOf("#")).replace(/\\/g, "/");
72
+ const allowed = path === TEST_CONTRACT_REL.replace(/\\/g, "/")
73
+ || path === "test-contract.md"
74
+ || path.endsWith("/test-contract.md");
75
+ if (!allowed)
76
+ return { ok: false, reason: `TEST 引用只能指向 test-contract.md:${ref}` };
77
+ }
78
+ const content = readChangeFile(changeRoot, TEST_CONTRACT_REL);
79
+ if (content == null)
80
+ return { ok: false, reason: `无法读取 ${TEST_CONTRACT_REL.replace(/\\/g, "/")},无法校验 TEST 引用:${ref}` };
81
+ const parsed = parseTestContractEntries(content);
82
+ if (!parsed.ok || !parsed.entries.some(entry => entry.test_id === testId)) {
83
+ return { ok: false, reason: `${TEST_CONTRACT_REL.replace(/\\/g, "/")} 中不存在 ${testId}` };
84
+ }
85
+ return { ok: true, value: { raw: ref, kind: "test", short: testId } };
86
+ }
87
+ const separator = ref.indexOf("#");
88
+ if (separator <= 0 || separator === ref.length - 1) {
89
+ return { ok: false, reason: `锚点格式无法解析,应为 文件#标题 或 TEST-ID:${ref}` };
90
+ }
91
+ const path = ref.slice(0, separator).replace(/\\/g, "/");
92
+ const anchor = ref.slice(separator + 1).trim();
93
+ const content = readChangeFile(changeRoot, path);
94
+ if (content == null)
95
+ return { ok: false, reason: `${path} 在当前 change 中不存在` };
96
+ if (path === "tasks.md") {
97
+ const tasks = parseTasksMd(content);
98
+ if (!tasks.some(task => task.taskId === anchor))
99
+ return { ok: false, reason: `tasks.md 中不存在任务 ${anchor}` };
100
+ return { ok: true, value: { raw: ref, kind: "task", short: anchor } };
101
+ }
102
+ if (path === "design.md") {
103
+ if (STRUCTURE_LEDGER_ID_RE.test(anchor)) {
104
+ const ledger = parseStructureChangeLedger(content);
105
+ if (ledger.present && ledger.entries.some(entry => entry.id === anchor)) {
106
+ return { ok: true, value: { raw: ref, kind: "structure", short: anchor } };
107
+ }
108
+ return { ok: false, reason: `design.md 结构变更清单中不存在 ${anchor}` };
109
+ }
110
+ if (!headingExists(content, anchor))
111
+ return { ok: false, reason: `design.md 中不存在标题「${anchor}」` };
112
+ return { ok: true, value: { raw: ref, kind: "design", short: anchor } };
113
+ }
114
+ if (path === "proposal.md") {
115
+ if (!headingExists(content, anchor))
116
+ return { ok: false, reason: `proposal.md 中不存在标题「${anchor}」` };
117
+ return { ok: true, value: { raw: ref, kind: "proposal", short: anchor } };
118
+ }
119
+ if (/^specs\/[^/]+\/spec\.md$/.test(path)) {
120
+ const requirementTitle = anchor.startsWith("Requirement:")
121
+ ? anchor.slice("Requirement:".length).trim()
122
+ : "";
123
+ if (!requirementTitle)
124
+ return { ok: false, reason: `spec 锚点必须以 Requirement: 开头:${ref}` };
125
+ if (!headingExists(content, `Requirement: ${requirementTitle}`)) {
126
+ return { ok: false, reason: `${path} 中不存在 Requirement「${requirementTitle}」` };
127
+ }
128
+ return {
129
+ ok: true,
130
+ value: { raw: ref, kind: "requirement", short: `Requirement: ${requirementTitle}` },
131
+ };
132
+ }
133
+ return { ok: false, reason: `只支持 tasks.md、design.md、proposal.md、specs/*/spec.md 与 test-contract.md 的锚点:${ref}` };
134
+ }
135
+ export function resolveApprovedRefs(changeRoot, refs) {
136
+ if (!Array.isArray(refs) || refs.length === 0) {
137
+ return { ok: false, reasons: ["approved_refs 必须是非空字符串数组"] };
138
+ }
139
+ const values = [];
140
+ const reasons = [];
141
+ for (const raw of refs) {
142
+ const resolved = resolveApprovedRef(changeRoot, raw);
143
+ if (!resolved.ok)
144
+ reasons.push(resolved.reason);
145
+ else
146
+ values.push(resolved.value);
147
+ }
148
+ if (reasons.length > 0)
149
+ return { ok: false, reasons };
150
+ return { ok: true, values };
151
+ }
152
+ export function hasBehaviorAnchor(values) {
153
+ return values.some(value => value.kind === "test" || value.kind === "requirement");
154
+ }
155
+ export function reviewFixReason(claimKind, refs) {
156
+ const shorts = refs.map(shortApprovedRef).filter(Boolean);
157
+ const target = shorts.length > 0 ? shorts.join("、") : "已批准行为";
158
+ return `兑现 ${target}(${claimKind})`;
159
+ }
package/dist/cli.js CHANGED
@@ -557,6 +557,8 @@ function topLevelHelp() {
557
557
  update [--hosts codex,omp] 升级 CLI 到 npm latest 并同步已选宿主入口
558
558
  version 版本号
559
559
 
560
+ 工作流配置见 .superspec/config.json(workflow.mode、workflow.budget)。
561
+
560
562
  transition 子命令:
561
563
  init / explore / sync / next / propose-ready / start-apply
562
564
  task-start --task <T> / task-complete --task <T> [--input -]
@@ -69,12 +69,19 @@ export declare function codeReviewPacketDigest(input: {
69
69
  packet_context?: JobPacketContext;
70
70
  previous_rejection?: ReviewPreviousRejection;
71
71
  }): string;
72
+ export declare function addedCodePathsForScope(projectRoot: string, scope: CodeReviewScope): string[];
72
73
  export declare function codeReviewPacketContext(changeRoot: string, projectRoot: string, scope: CodeReviewScope, events: Event[]): JobPacketContext;
73
74
  export declare function effectiveCoverageExemptionRefsFromEvents(events: Event[]): CoverageExemptionRef[];
74
75
  export declare function missingCoverageExemptionTestIds(changeRoot: string, events: Event[]): string[];
75
76
  /** Read-only execution evidence projected for code review and final verification. */
76
77
  export declare function taskExecutionIndexForReview(projectRoot: string, events: Event[]): TaskExecutionIndexEntry[];
77
78
  export declare function codeReviewDecisionScope(jobId: string, findingId: string): string;
79
+ /** 自当前 Apply round 的 start-apply 起,带 review_fix_of 的 reopen 次数。 */
80
+ export declare function countReviewFixReopensSinceStartApply(events: readonly Event[]): number;
81
+ /** 上限是否生效按 Apply round 在 start-apply 冻结的档位判断(minimal 忽略),未冻结时回落项目配置。 */
82
+ export declare function isReviewFixCapReached(projectRoot: string, events: readonly Event[]): boolean;
83
+ /** spec / mixed 问题始终交使用者决策;implementation 问题只在自动修复触顶后交使用者决策。 */
84
+ export declare function codeReviewFindingNeedsUserDecision(type: string | undefined, reviewFixCapReached: boolean): boolean;
78
85
  export interface CodeReviewDecisionScopeRef {
79
86
  jobId: string;
80
87
  findingId: string;
@@ -4,7 +4,9 @@ import { join } from "node:path";
4
4
  import { findLatestEvent, sha256File, sha256Text } from "./store.js";
5
5
  import { REVIEW_CODE_REVIEW_GATE } from "./review_job_gates.js";
6
6
  import { codeFileContentSha, currentGitHead, diffFingerprints, dirtyCodeFiles, dirtyCodePaths, gitLines, isCodeLikePath, projectHasReadableDirectory, walkCodeFiles, } from "./git_state.js";
7
- import { parseExecutionRequirements, parseTestContractEntries } from "./format.js";
7
+ import { parseExecutionRequirements, parseTestContractEntries, parseStructureChangeLedger } from "./format.js";
8
+ import { planningValidationProfileForCurrentRound } from "./propose_round.js";
9
+ import { workflowBudgetForRisk, workflowRiskForApplyRound, workflowRiskForProject } from "./workflow_config.js";
8
10
  export const CODE_REVIEW_REPAIR_SCOPE_PREFIX = "code_reviewer_report_repair:";
9
11
  export const CODE_REVIEW_DECISION_SCOPE_PREFIX = "code_review_decision:";
10
12
  export const TEST_COVERAGE_EXEMPTION_SCOPE_PREFIX = "test_coverage_exemption:";
@@ -360,6 +362,51 @@ export function codeReviewJobStaleReason(projectRoot, job, currentPaths, events,
360
362
  export function codeReviewPacketDigest(input) {
361
363
  return sha256Text(JSON.stringify(input));
362
364
  }
365
+ export function addedCodePathsForScope(projectRoot, scope) {
366
+ if (!scope.scope_reliable)
367
+ return [];
368
+ const added = new Set();
369
+ let baseHead = scope.base_head;
370
+ if (!baseHead && scope.current_head) {
371
+ // 首轮 start-apply 前仓库还没有提交:以空树为基点,让 Apply 期间产生的首个提交也进入新增清单。
372
+ const emptyTree = gitLines(projectRoot, ["hash-object", "-t", "tree", "/dev/null"]);
373
+ if (emptyTree.ok)
374
+ baseHead = emptyTree.lines[0] ?? null;
375
+ }
376
+ if (baseHead && scope.current_head) {
377
+ const committed = gitLines(projectRoot, [
378
+ "diff", "--no-renames", "--diff-filter=A", "--name-only", `${baseHead}..${scope.current_head}`,
379
+ ]);
380
+ if (committed.ok) {
381
+ for (const path of committed.lines) {
382
+ if (isCodeLikePath(path))
383
+ added.add(path);
384
+ }
385
+ }
386
+ }
387
+ const dirty = dirtyCodeFiles(projectRoot);
388
+ if (dirty.ok) {
389
+ for (const file of dirty.files) {
390
+ if (file.status === "added" && isCodeLikePath(file.path))
391
+ added.add(file.path);
392
+ }
393
+ }
394
+ for (const path of scope.untracked_paths) {
395
+ if (isCodeLikePath(path))
396
+ added.add(path);
397
+ }
398
+ return [...added].sort();
399
+ }
400
+ function structureLedgerForCodeReview(changeRoot, events) {
401
+ // 只有 v2 planning round 才有清单契约;v1 change 不写该字段,保持旧 packet 形态。
402
+ if (planningValidationProfileForCurrentRound(events)?.design?.schema_version !== 2)
403
+ return undefined;
404
+ const designPath = join(changeRoot, "design.md");
405
+ if (!existsSync(designPath))
406
+ return undefined;
407
+ const ledger = parseStructureChangeLedger(readFileSync(designPath, "utf8"));
408
+ return ledger.present ? ledger : undefined;
409
+ }
363
410
  export function codeReviewPacketContext(changeRoot, projectRoot, scope, events) {
364
411
  const taskExecutionIndex = taskExecutionIndexFromEvents(projectRoot, events);
365
412
  // changed_paths 未知(快照缺失)或不完整(committed 段 diff 失败)的 task
@@ -373,12 +420,15 @@ export function codeReviewPacketContext(changeRoot, projectRoot, scope, events)
373
420
  for (const path of item.changed_paths ?? [])
374
421
  attributedPaths.add(path);
375
422
  }
423
+ const structureLedger = structureLedgerForCodeReview(changeRoot, events);
376
424
  return {
377
425
  code_review_scope: scope,
378
426
  coverage_exemption_refs: coverageExemptionRefs(changeRoot, events),
379
427
  task_execution_index: taskExecutionIndex,
380
428
  unattributed_paths: scope.review_paths.filter(path => !attributedPaths.has(path)).sort(),
381
429
  unknown_attribution_tasks: unknownAttributionTasks,
430
+ added_code_paths: addedCodePathsForScope(projectRoot, scope),
431
+ ...(structureLedger ? { structure_ledger: structureLedger } : {}),
382
432
  };
383
433
  }
384
434
  export function effectiveCoverageExemptionRefsFromEvents(events) {
@@ -571,6 +621,47 @@ function codeReviewResultKind(value) {
571
621
  export function codeReviewDecisionScope(jobId, findingId) {
572
622
  return `${CODE_REVIEW_DECISION_SCOPE_PREFIX}${jobId}#${findingId}`;
573
623
  }
624
+ // ===== review-fix 自动修复上限 =====
625
+ //
626
+ // next / reopen --review-fix / record user-decision 三处必须得出同一个结论,
627
+ // 否则会出现 next 要求使用者决策而 CLI 直调仍自动放行的分叉。这里是唯一判定入口。
628
+ /** 自当前 Apply round 的 start-apply 起,带 review_fix_of 的 reopen 次数。 */
629
+ export function countReviewFixReopensSinceStartApply(events) {
630
+ let startIndex = -1;
631
+ for (let i = events.length - 1; i >= 0; i--) {
632
+ const ev = events[i];
633
+ if (ev.event_type !== "transition_commit")
634
+ continue;
635
+ const payload = ev.payload;
636
+ if (payload.transition === "start-apply" && payload.to_state === "apply") {
637
+ startIndex = i;
638
+ break;
639
+ }
640
+ }
641
+ if (startIndex < 0)
642
+ return 0;
643
+ let count = 0;
644
+ for (const ev of events.slice(startIndex + 1)) {
645
+ if (ev.event_type !== "transition_commit")
646
+ continue;
647
+ const payload = ev.payload;
648
+ if (typeof payload.review_fix_of === "string" && payload.review_fix_of.trim() !== "")
649
+ count++;
650
+ }
651
+ return count;
652
+ }
653
+ /** 上限是否生效按 Apply round 在 start-apply 冻结的档位判断(minimal 忽略),未冻结时回落项目配置。 */
654
+ export function isReviewFixCapReached(projectRoot, events) {
655
+ const risk = workflowRiskForApplyRound(events, workflowRiskForProject(projectRoot));
656
+ const budget = workflowBudgetForRisk(projectRoot, risk);
657
+ if (!budget || budget.review_fix_rounds === null)
658
+ return false;
659
+ return countReviewFixReopensSinceStartApply(events) >= budget.review_fix_rounds;
660
+ }
661
+ /** spec / mixed 问题始终交使用者决策;implementation 问题只在自动修复触顶后交使用者决策。 */
662
+ export function codeReviewFindingNeedsUserDecision(type, reviewFixCapReached) {
663
+ return type === "spec" || type === "mixed" || (type === "implementation" && reviewFixCapReached);
664
+ }
574
665
  /** 只接受状态机生成的 <job>#<finding> 决策范围。 */
575
666
  export function parseCodeReviewDecisionScope(scope) {
576
667
  if (!scope.startsWith(CODE_REVIEW_DECISION_SCOPE_PREFIX))
package/dist/format.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type ExecutionContract, type ExecutionPolicy } from "./types.ts";
1
+ import { type ExecutionContract, type ExecutionPolicy, type StructureChangeLedger } from "./types.ts";
2
2
  export declare const EXPLORE_OPEN_QUESTION_SCOPE_PREFIX = "explore_open_question:";
3
3
  /**
4
4
  * Discovery 的未确认项是用户决策的唯一候选来源。这里保留原有的“仅指定段落内
@@ -93,6 +93,30 @@ export declare function collectProposeOpenQuestions(changeRoot: string): {
93
93
  openCount: number;
94
94
  files: ProposeOpenQuestionFile[];
95
95
  };
96
+ export declare const STRUCTURE_CHANGE_CATEGORIES: readonly ["新增持久化结构", "迁移或回填", "功能开关", "新增公共接口", "删除既有路径", "改既有公共签名", "改变既有数据语义", "新增公共类型"];
97
+ export type StructureChangeCategory = (typeof STRUCTURE_CHANGE_CATEGORIES)[number];
98
+ export declare const STRUCTURE_DECISION_REQUIRED_CATEGORIES: ReadonlySet<string>;
99
+ export declare function isStructureChangeCategory(value: string): value is StructureChangeCategory;
100
+ export declare function parseStructureChangeLedger(content: string): StructureChangeLedger;
101
+ export interface StructureChangeLedgerValidation {
102
+ ok: boolean;
103
+ errors: string[];
104
+ }
105
+ /** Markdown 标题是否存在(任意层级,允许闭合 #)。供文档引用解析共用。 */
106
+ export declare function headingExists(content: string, title: string): boolean;
107
+ /**
108
+ * 结构变更清单"需求依据"的唯一解析入口。与 approved_refs 的解析分开:
109
+ * 这里接受 discovery 标题、拒绝 proposal.md#Impact,且按类别收紧允许集。
110
+ */
111
+ export declare function resolveStructureBasisRef(changeRoot: string, raw: unknown, category: string): {
112
+ ok: true;
113
+ value: string;
114
+ } | {
115
+ ok: false;
116
+ reason: string;
117
+ };
118
+ export declare function validateStructureChangeLedger(changeRoot: string, ledger: StructureChangeLedger): StructureChangeLedgerValidation;
119
+ export declare function formatStructureChangeLedgerSummary(ledger: StructureChangeLedger): string | null;
96
120
  export interface ParsedTask {
97
121
  taskId: string;
98
122
  lineIdx: number;