@peterxiaoyang/superspec 0.1.56 → 0.1.58
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 +28 -32
- package/dist/approved_ref.d.ts +1 -1
- package/dist/approved_ref.js +9 -8
- package/dist/cli.js +5 -1
- package/dist/code_review.d.ts +6 -0
- package/dist/code_review.js +56 -1
- package/dist/format.d.ts +25 -1
- package/dist/format.js +235 -1
- package/dist/phase_confirmation.js +7 -2
- package/dist/phase_plan.d.ts +4 -0
- package/dist/phase_plan.js +160 -44
- package/dist/propose_round.d.ts +7 -1
- package/dist/propose_round.js +35 -0
- package/dist/record.d.ts +2 -0
- package/dist/record.js +23 -5
- package/dist/transition.js +3 -3
- package/dist/types.d.ts +19 -1
- package/dist/workflow_config.d.ts +15 -0
- package/dist/workflow_config.js +47 -0
- package/package.json +1 -1
- package/templates/workflow/prompts/architect.md +1 -0
- package/templates/workflow/prompts/code-reviewer.md +2 -1
- package/templates/workflow/prompts/critic.md +2 -1
- package/templates/workflow/prompts/executor.md +1 -0
- package/templates/workflow/prompts/explore.md +1 -1
- package/templates/workflow/skills/superspec-explore/SKILL.md +16 -12
- package/templates/workflow/skills/superspec-propose/SKILL.md +28 -5
package/README.md
CHANGED
|
@@ -4,15 +4,26 @@
|
|
|
4
4
|
[](https://nodejs.org)
|
|
5
5
|
[](https://github.com/Fission-AI/OpenSpec)
|
|
6
6
|
|
|
7
|
-
|
|
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
|
-
|
|
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
|
|
36
|
+
`superspec install` 会把工作流入口、角色配置和运行时目录同步到当前项目,并准备 [OpenSpec](https://github.com/Fission-AI/OpenSpec) 依赖。
|
|
26
37
|
|
|
27
|
-
|
|
38
|
+
然后在 Codex 里对它说话:
|
|
28
39
|
|
|
29
|
-
```
|
|
30
|
-
superspec
|
|
31
|
-
superspec status
|
|
40
|
+
```text
|
|
41
|
+
superspec-explore change <change-name>。<你的需求>
|
|
32
42
|
```
|
|
33
43
|
|
|
34
|
-
|
|
44
|
+
工作流接管之后,每一步该做什么由 `superspec transition next` 决定——AI 不自由发挥流程,只在被授权的范围内干活。
|
|
35
45
|
|
|
36
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
```json
|
|
52
|
-
{
|
|
53
|
-
"workflow": {
|
|
54
|
-
"mode": "normal",
|
|
55
|
-
"hosts": ["codex"]
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
```
|
|
57
|
+
## 轻量,且知道自己的边界
|
|
59
58
|
|
|
60
|
-
|
|
59
|
+
- **三档强度**:`minimal` / `normal` / `strict`(`.superspec/config.json`),按变更风险选择计划阶段的审查强度。
|
|
60
|
+
- **不绑架日常**:简单的单文件修改不必走完整工作流;普通请求也不会自动进入 SuperSpec,只有显式调用入口才启动。
|
|
61
|
+
- **诚实的能力边界**:它是流程与审计辅助,不是安全隔离或发布审批系统;不能替代代码审计、权限控制、合规检查和人工判断。
|
|
61
62
|
|
|
62
63
|
## 与 OpenSpec 的关系
|
|
63
64
|
|
|
64
|
-
OpenSpec
|
|
65
|
-
|
|
66
|
-
```text
|
|
67
|
-
OpenSpec:这次要改变什么
|
|
68
|
-
SuperSpec:如何在边界内把它交付
|
|
69
|
-
```
|
|
65
|
+
OpenSpec 负责「这次要改变什么」,提供变更材料与规格结构;SuperSpec 负责「如何在边界内把它交付」,组织 AI 的探索、计划、实现、审查与证据记录。
|
|
70
66
|
|
|
71
|
-
##
|
|
67
|
+
## 评测
|
|
72
68
|
|
|
73
|
-
|
|
69
|
+
仓库自带密封评测考场(隔离工作区 + 真实 Agent 运行 + 硬门禁 + 双模型语义复盘),用于验证工作流行为本身,而不只是 Prompt 文案。见 [`evals/README.md`](evals/README.md)。
|
|
74
70
|
|
|
75
71
|
## 致谢
|
|
76
72
|
|
package/dist/approved_ref.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { CodeReviewClaimKind } from "./types.ts";
|
|
2
2
|
export declare const CODE_REVIEW_CLAIM_KINDS: readonly ["missing_approved", "breaks_existing", "unjustified_addition"];
|
|
3
|
-
export type ApprovedRefKind = "test" | "requirement" | "task" | "design" | "proposal";
|
|
3
|
+
export type ApprovedRefKind = "test" | "requirement" | "task" | "design" | "proposal" | "structure";
|
|
4
4
|
export interface ResolvedApprovedRef {
|
|
5
5
|
raw: string;
|
|
6
6
|
kind: ApprovedRefKind;
|
package/dist/approved_ref.js
CHANGED
|
@@ -1,25 +1,19 @@
|
|
|
1
1
|
// SuperSpec 代码审查 approved_refs:只做存在性解析,不做语义匹配。
|
|
2
2
|
import { readFileSync, statSync } from "node:fs";
|
|
3
3
|
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
|
-
import { parseTasksMd, parseTestContractEntries } from "./format.js";
|
|
4
|
+
import { headingExists, parseTasksMd, parseTestContractEntries, parseStructureChangeLedger } from "./format.js";
|
|
5
5
|
export const CODE_REVIEW_CLAIM_KINDS = [
|
|
6
6
|
"missing_approved",
|
|
7
7
|
"breaks_existing",
|
|
8
8
|
"unjustified_addition",
|
|
9
9
|
];
|
|
10
10
|
const TEST_ID_RE = /^TEST-[A-Za-z0-9_-]+$/;
|
|
11
|
+
const STRUCTURE_LEDGER_ID_RE = /^SC-[A-Za-z0-9_-]+$/;
|
|
11
12
|
const TEST_CONTRACT_REL = join(".superspec", "artifacts", "test-contract.md");
|
|
12
|
-
function escapeRegex(value) {
|
|
13
|
-
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
14
|
-
}
|
|
15
13
|
function isPathInside(root, target) {
|
|
16
14
|
const rel = relative(root, target);
|
|
17
15
|
return rel !== "" && !rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel);
|
|
18
16
|
}
|
|
19
|
-
function headingExists(content, title) {
|
|
20
|
-
const heading = new RegExp(`^#{1,6}[\\t ]+${escapeRegex(title)}(?:[\\t ]+#+)?[\\t ]*$`, "m");
|
|
21
|
-
return heading.test(content);
|
|
22
|
-
}
|
|
23
17
|
function shortTestId(raw) {
|
|
24
18
|
const trimmed = raw.trim();
|
|
25
19
|
if (TEST_ID_RE.test(trimmed))
|
|
@@ -106,6 +100,13 @@ export function resolveApprovedRef(changeRoot, raw) {
|
|
|
106
100
|
return { ok: true, value: { raw: ref, kind: "task", short: anchor } };
|
|
107
101
|
}
|
|
108
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
|
+
}
|
|
109
110
|
if (!headingExists(content, anchor))
|
|
110
111
|
return { ok: false, reason: `design.md 中不存在标题「${anchor}」` };
|
|
111
112
|
return { ok: true, value: { raw: ref, kind: "design", short: anchor } };
|
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 -]
|
|
@@ -589,10 +591,12 @@ function commandHelp(command, subcommand) {
|
|
|
589
591
|
return `用法:superspec record job-submit --change <C> --job <J> --report <F|->
|
|
590
592
|
|
|
591
593
|
提交 reviewer JSON 报告;--report - 表示从 stdin 读取。报告契约以 jobs packet 返回的 report_schema 为准。
|
|
594
|
+
需要落盘时写到 packet 的 report_file_path(.superspec/changes/<C>/jobs/<J>.report.json),不要放进 openspec/changes 计划材料目录。
|
|
592
595
|
|
|
593
596
|
示例:
|
|
594
597
|
superspec jobs packet --change <C> --job <J>
|
|
595
|
-
superspec record job-submit --change <C> --job <J> --report
|
|
598
|
+
superspec record job-submit --change <C> --job <J> --report -
|
|
599
|
+
superspec record job-submit --change <C> --job <J> --report .superspec/changes/<C>/jobs/<J>.report.json
|
|
596
600
|
`;
|
|
597
601
|
}
|
|
598
602
|
if (command === "record" && subcommand === "test-run") {
|
package/dist/code_review.d.ts
CHANGED
|
@@ -76,6 +76,12 @@ export declare function missingCoverageExemptionTestIds(changeRoot: string, even
|
|
|
76
76
|
/** Read-only execution evidence projected for code review and final verification. */
|
|
77
77
|
export declare function taskExecutionIndexForReview(projectRoot: string, events: Event[]): TaskExecutionIndexEntry[];
|
|
78
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;
|
|
79
85
|
export interface CodeReviewDecisionScopeRef {
|
|
80
86
|
jobId: string;
|
|
81
87
|
findingId: string;
|
package/dist/code_review.js
CHANGED
|
@@ -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:";
|
|
@@ -395,6 +397,16 @@ export function addedCodePathsForScope(projectRoot, scope) {
|
|
|
395
397
|
}
|
|
396
398
|
return [...added].sort();
|
|
397
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
|
+
}
|
|
398
410
|
export function codeReviewPacketContext(changeRoot, projectRoot, scope, events) {
|
|
399
411
|
const taskExecutionIndex = taskExecutionIndexFromEvents(projectRoot, events);
|
|
400
412
|
// changed_paths 未知(快照缺失)或不完整(committed 段 diff 失败)的 task
|
|
@@ -408,6 +420,7 @@ export function codeReviewPacketContext(changeRoot, projectRoot, scope, events)
|
|
|
408
420
|
for (const path of item.changed_paths ?? [])
|
|
409
421
|
attributedPaths.add(path);
|
|
410
422
|
}
|
|
423
|
+
const structureLedger = structureLedgerForCodeReview(changeRoot, events);
|
|
411
424
|
return {
|
|
412
425
|
code_review_scope: scope,
|
|
413
426
|
coverage_exemption_refs: coverageExemptionRefs(changeRoot, events),
|
|
@@ -415,6 +428,7 @@ export function codeReviewPacketContext(changeRoot, projectRoot, scope, events)
|
|
|
415
428
|
unattributed_paths: scope.review_paths.filter(path => !attributedPaths.has(path)).sort(),
|
|
416
429
|
unknown_attribution_tasks: unknownAttributionTasks,
|
|
417
430
|
added_code_paths: addedCodePathsForScope(projectRoot, scope),
|
|
431
|
+
...(structureLedger ? { structure_ledger: structureLedger } : {}),
|
|
418
432
|
};
|
|
419
433
|
}
|
|
420
434
|
export function effectiveCoverageExemptionRefsFromEvents(events) {
|
|
@@ -607,6 +621,47 @@ function codeReviewResultKind(value) {
|
|
|
607
621
|
export function codeReviewDecisionScope(jobId, findingId) {
|
|
608
622
|
return `${CODE_REVIEW_DECISION_SCOPE_PREFIX}${jobId}#${findingId}`;
|
|
609
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
|
+
}
|
|
610
665
|
/** 只接受状态机生成的 <job>#<finding> 决策范围。 */
|
|
611
666
|
export function parseCodeReviewDecisionScope(scope) {
|
|
612
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;
|
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*$/;
|
|
@@ -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
|
-
|
|
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));
|
package/dist/phase_plan.d.ts
CHANGED
|
@@ -4,6 +4,9 @@ import { proposeAnswerRegistrationPayload } from "./propose_round.ts";
|
|
|
4
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";
|
|
@@ -119,6 +122,7 @@ export declare function latestReopenExploreBaseline(events: Event[]): Record<str
|
|
|
119
122
|
export declare function proposalDocsChangedSinceBaseline(changeRoot: string, baseline: Record<string, string>): boolean;
|
|
120
123
|
export declare function discoveryDocsChangedSinceBaseline(changeRoot: string, baseline: Record<string, string>): boolean;
|
|
121
124
|
export declare function pendingTaskIds(changeRoot: string): string[];
|
|
125
|
+
export declare function planSizeBudgetScope(proposeRoundId: string, taskCount: number, testCount: number): string;
|
|
122
126
|
/** 当前 Apply round 的规则版本;缺失版本的历史 event 保持 v1 回放。 */
|
|
123
127
|
export declare function executionRequirementVersionForCurrentRound(events: Event[]): 1 | 2;
|
|
124
128
|
/** 新 planning round 在进入 propose 时冻结当前 OpenSpec 校验契约。 */
|