@peterxiaoyang/superspec 0.1.33 → 0.1.35
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 +4 -37
- package/dist/cli.js +6 -2
- package/dist/code_review.d.ts +74 -0
- package/dist/code_review.js +323 -0
- package/dist/format.js +11 -1
- package/dist/next.js +80 -2
- package/dist/record.js +385 -58
- package/dist/review.js +17 -7
- package/dist/store.d.ts +1 -0
- package/dist/store.js +10 -0
- package/dist/sync.js +12 -6
- package/dist/task.js +19 -0
- package/dist/transition.d.ts +4 -1
- package/dist/transition.js +244 -26
- package/dist/types.d.ts +15 -1
- package/package.json +1 -1
- package/templates/workflow/agents/architect.toml +2 -2
- package/templates/workflow/agents/code-reviewer.toml +5 -5
- package/templates/workflow/agents/critic.toml +2 -2
- package/templates/workflow/agents/test-engineer.toml +3 -3
- package/templates/workflow/agents/verifier.toml +4 -4
- package/templates/workflow/prompts/architect.md +11 -21
- package/templates/workflow/prompts/code-reviewer.md +56 -16
- package/templates/workflow/prompts/critic.md +23 -34
- package/templates/workflow/prompts/test-engineer.md +16 -26
- package/templates/workflow/prompts/test-runner.md +1 -0
- package/templates/workflow/prompts/verifier.md +27 -45
- package/templates/workflow/skills/superspec-apply/SKILL.md +46 -40
- package/templates/workflow/skills/superspec-propose/SKILL.md +2 -0
- package/templates/workflow/skills/superspec-review/SKILL.md +65 -28
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
SuperSpec 会把一次需求变更拆成 5 步:
|
|
12
12
|
|
|
13
13
|
```text
|
|
14
|
-
探索需求 -> 写方案 -> 做实现 ->
|
|
14
|
+
探索需求 -> 写方案 -> 做实现 -> 代码审查与最终验证 -> 归档收尾
|
|
15
15
|
```
|
|
16
16
|
|
|
17
17
|
这样做的目的很简单:
|
|
@@ -112,10 +112,10 @@ superspec.cmd install
|
|
|
112
112
|
使用 superspec-apply,按任务实现。
|
|
113
113
|
```
|
|
114
114
|
|
|
115
|
-
|
|
115
|
+
实现完成后,代码审查与最终验证:
|
|
116
116
|
|
|
117
117
|
```text
|
|
118
|
-
使用 superspec-review
|
|
118
|
+
使用 superspec-review,完成代码审查、问题处理和最终验证。
|
|
119
119
|
```
|
|
120
120
|
|
|
121
121
|
审查通过后,归档:
|
|
@@ -131,7 +131,7 @@ superspec.cmd install
|
|
|
131
131
|
| `superspec-explore` | 需求刚开始时 | 读代码、查现状、整理范围和风险;这一步不改业务代码 |
|
|
132
132
|
| `superspec-propose` | 需求已经清楚后 | 写正式方案、规格、设计和任务,并提前规划测试 |
|
|
133
133
|
| `superspec-apply` | 方案通过后 | 按任务实现代码,记录测试或验证结果 |
|
|
134
|
-
| `superspec-review` | 实现完成后 |
|
|
134
|
+
| `superspec-review` | 实现完成后 | 检查代码实现是否符合方案,处理审查问题,并完成最终验证 |
|
|
135
135
|
| `superspec-archive` | 审查通过后 | 用 OpenSpec 完成归档,并检查关键记录是否保留 |
|
|
136
136
|
|
|
137
137
|
你日常主要记住这五个入口就够了。
|
|
@@ -158,27 +158,6 @@ openspec/changes/<变更ID>/.superspec/
|
|
|
158
158
|
|
|
159
159
|
这里的 `<变更ID>` 就是一次需求变更的名字。
|
|
160
160
|
|
|
161
|
-
`.superspec/` 要不要提交到 git,由你的团队决定。
|
|
162
|
-
如果不提交,删掉后就没有 git 历史可以恢复。
|
|
163
|
-
|
|
164
|
-
## 流程门禁
|
|
165
|
-
|
|
166
|
-
SuperSpec 的阶段入口由 `superspec transition next --change <变更ID>` 驱动。
|
|
167
|
-
|
|
168
|
-
当当前阶段还有未完成的用户确认、审查、验证或工作项时,`next` 会先返回这些事项,不会把下一阶段命令作为推荐路径。重复运行会创建审查工作项的 transition 时,如果同阶段工作项已经存在,CLI 会返回正常的门禁结果,不会写入新事件,也不会把它当作程序错误。
|
|
169
|
-
|
|
170
|
-
这仍然是轻量流程控制,不是写入拦截。它约束按 SuperSpec 正常入口执行时的下一步建议和状态提交结果,不承诺阻止绕过流程的手动编辑。
|
|
171
|
-
|
|
172
|
-
## Hook 会做什么
|
|
173
|
-
|
|
174
|
-
SuperSpec 默认安装的 hook 只在子智能体启动和停止时运行:
|
|
175
|
-
|
|
176
|
-
- 子智能体启动和停止时:记录这次子智能体运行的基本信息
|
|
177
|
-
|
|
178
|
-
这些 hook 的默认超时时间是 `120` 秒。这个时间限制的是 hook 自己的检查过程,不限制 `npm test`、构建命令或子智能体本身能运行多久。
|
|
179
|
-
|
|
180
|
-
hook 不是安全沙箱。默认 hook 只留下子智能体审计线索;它不会机械阻止写入,也不能把记录变成不可伪造的安全证明。
|
|
181
|
-
|
|
182
161
|
## 重要边界
|
|
183
162
|
|
|
184
163
|
SuperSpec 能让流程更规范,但它不是安全锁。
|
|
@@ -215,18 +194,6 @@ superspec install
|
|
|
215
194
|
|
|
216
195
|
`superspec init --scope project` 是兼容别名,也会执行同一套安装逻辑。
|
|
217
196
|
|
|
218
|
-
### OpenSpec 中文输出
|
|
219
|
-
|
|
220
|
-
OpenSpec 生成文档的语言应通过官方项目配置控制。在 `openspec/config.yaml` 中使用 `context`:
|
|
221
|
-
|
|
222
|
-
```yaml
|
|
223
|
-
schema: spec-driven
|
|
224
|
-
|
|
225
|
-
context: |
|
|
226
|
-
语言:中文(简体)
|
|
227
|
-
所有产出物必须用简体中文撰写。
|
|
228
|
-
```
|
|
229
|
-
|
|
230
197
|
`superspec install` 会创建缺失的 `openspec/config.yaml`,或在没有顶层 `context` 时追加这段官方中文 context。如果文件已经有顶层 `context`,SuperSpec 不会覆盖它。
|
|
231
198
|
|
|
232
199
|
可以用下面的命令检查生成的 instructions 是否包含语言上下文:
|
package/dist/cli.js
CHANGED
|
@@ -501,7 +501,8 @@ async function main(argv) {
|
|
|
501
501
|
transition 子命令:
|
|
502
502
|
init / explore / sync / next / propose-ready / start-apply
|
|
503
503
|
task-start --task <T> / task-complete --task <T>
|
|
504
|
-
reopen --to apply --reason <TEXT>
|
|
504
|
+
reopen --to apply --reason <TEXT> [--review-fix <JOB#FINDING>]
|
|
505
|
+
reopen --to propose --reason <TEXT> --review-finding <JOB#FINDING>
|
|
505
506
|
review-ready / accept / archive
|
|
506
507
|
|
|
507
508
|
record 子命令:
|
|
@@ -693,7 +694,10 @@ jobs 子命令:
|
|
|
693
694
|
console.error("reopen 需要 --reason");
|
|
694
695
|
return 1;
|
|
695
696
|
}
|
|
696
|
-
const result = reopen(projectRoot, change, cr, to, reason
|
|
697
|
+
const result = reopen(projectRoot, change, cr, to, reason, {
|
|
698
|
+
...(opts["review-fix"] ? { reviewFix: opts["review-fix"] } : {}),
|
|
699
|
+
...(opts["review-finding"] ? { reviewFinding: opts["review-finding"] } : {}),
|
|
700
|
+
});
|
|
697
701
|
console.log(JSON.stringify(result, null, 2));
|
|
698
702
|
return transitionExitCode(result);
|
|
699
703
|
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { CodeReviewResultKind, Event, Job, Ref } from "./types.ts";
|
|
2
|
+
export declare const CODE_REVIEW_REPAIR_SCOPE_PREFIX = "code_reviewer_report_repair:";
|
|
3
|
+
export declare const CODE_REVIEW_DECISION_SCOPE_PREFIX = "code_review_decision:";
|
|
4
|
+
export type CodeReviewDecisionAnswer = "reopen_propose" | "reopen_apply" | "dismiss";
|
|
5
|
+
export declare const CODE_REVIEW_DECISION_ANSWER_LABELS: Record<CodeReviewDecisionAnswer, string>;
|
|
6
|
+
export interface CodeChangeScan {
|
|
7
|
+
reliable: boolean;
|
|
8
|
+
hasCodeChanges: boolean;
|
|
9
|
+
paths: string[];
|
|
10
|
+
reason: string;
|
|
11
|
+
}
|
|
12
|
+
export interface CodeReviewTerminalResult {
|
|
13
|
+
job: Job;
|
|
14
|
+
event: Event;
|
|
15
|
+
state: "accepted" | "rejected";
|
|
16
|
+
result_kind?: CodeReviewResultKind;
|
|
17
|
+
reason?: string;
|
|
18
|
+
}
|
|
19
|
+
export interface CodeReviewDecision {
|
|
20
|
+
answer: CodeReviewDecisionAnswer;
|
|
21
|
+
reason: string;
|
|
22
|
+
event: Event;
|
|
23
|
+
}
|
|
24
|
+
export interface CodeReviewFindingStatus {
|
|
25
|
+
id: string;
|
|
26
|
+
type: "implementation" | "spec" | "mixed";
|
|
27
|
+
finding: Record<string, unknown>;
|
|
28
|
+
decision: CodeReviewDecision | null;
|
|
29
|
+
}
|
|
30
|
+
export interface CodeReviewFailedStatus {
|
|
31
|
+
terminal: CodeReviewTerminalResult;
|
|
32
|
+
findings: CodeReviewFindingStatus[];
|
|
33
|
+
unresolved: CodeReviewFindingStatus[];
|
|
34
|
+
dismissed: CodeReviewFindingStatus[];
|
|
35
|
+
}
|
|
36
|
+
export interface CodeReviewGateFacts {
|
|
37
|
+
cycleStartIndex: number;
|
|
38
|
+
jobs: Job[];
|
|
39
|
+
openJobs: Job[];
|
|
40
|
+
terminalResults: CodeReviewTerminalResult[];
|
|
41
|
+
latestTerminal: CodeReviewTerminalResult | null;
|
|
42
|
+
latestRejected: CodeReviewTerminalResult | null;
|
|
43
|
+
consecutiveRejected: number;
|
|
44
|
+
}
|
|
45
|
+
export declare function isCodeLikePath(path: string): boolean;
|
|
46
|
+
export declare function scanCodeChanges(projectRoot: string): CodeChangeScan;
|
|
47
|
+
export declare function codeReviewBoundFiles(projectRoot: string, paths: string[]): Ref[];
|
|
48
|
+
export declare function codeReviewJobStaleReason(projectRoot: string, job: Job, currentPaths?: string[]): string | null;
|
|
49
|
+
export declare function codeReviewPacketDigest(input: {
|
|
50
|
+
role: "code-reviewer";
|
|
51
|
+
boundFiles: Ref[];
|
|
52
|
+
checkedDocs: string[];
|
|
53
|
+
created_from_transition: string;
|
|
54
|
+
previous_rejection?: {
|
|
55
|
+
result_kind: CodeReviewResultKind;
|
|
56
|
+
reason: string;
|
|
57
|
+
job_id: string;
|
|
58
|
+
};
|
|
59
|
+
}): string;
|
|
60
|
+
export declare function codeReviewDecisionScope(jobId: string, findingId: string): string;
|
|
61
|
+
export declare function isCodeReviewDecisionAnswer(value: unknown): value is CodeReviewDecisionAnswer;
|
|
62
|
+
export declare function codeReviewDecisionAnswerLabel(answer: CodeReviewDecisionAnswer): string;
|
|
63
|
+
export declare function normalizeCodeReviewDecisionAnswer(value: unknown): CodeReviewDecisionAnswer | null;
|
|
64
|
+
export declare function latestCodeReviewDecision(events: Event[], scope: string): CodeReviewDecision | null;
|
|
65
|
+
export declare function latestCodeReviewFailedStatus(events: Event[]): CodeReviewFailedStatus | null;
|
|
66
|
+
export declare function dismissedCodeReviewSummary(status: CodeReviewFailedStatus): string;
|
|
67
|
+
export declare function currentApplyDoneCycleStart(events: Event[]): number;
|
|
68
|
+
export declare function collectCodeReviewGateFacts(events: Event[]): CodeReviewGateFacts;
|
|
69
|
+
export declare function latestApplyDoneToReviewGate(events: Event[]): {
|
|
70
|
+
decision: "passed" | "skipped";
|
|
71
|
+
job_id?: string;
|
|
72
|
+
reason?: string;
|
|
73
|
+
} | null;
|
|
74
|
+
export declare function requiresFinalVerifierForCurrentReview(events: Event[]): boolean;
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
// SuperSpec code-reviewer gate helpers.
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
4
|
+
import { join, extname } from "node:path";
|
|
5
|
+
import { findLatestEvent, sha256File, sha256Text } from "./store.js";
|
|
6
|
+
export const CODE_REVIEW_REPAIR_SCOPE_PREFIX = "code_reviewer_report_repair:";
|
|
7
|
+
export const CODE_REVIEW_DECISION_SCOPE_PREFIX = "code_review_decision:";
|
|
8
|
+
export const CODE_REVIEW_DECISION_ANSWER_LABELS = {
|
|
9
|
+
reopen_propose: "回到计划阶段",
|
|
10
|
+
reopen_apply: "回到实现阶段",
|
|
11
|
+
dismiss: "驳回该问题",
|
|
12
|
+
};
|
|
13
|
+
const PROCESS_DOC_RE = /^(?:openspec\/changes\/[^/]+\/)?(?:proposal|design|tasks)\.md$/;
|
|
14
|
+
const PROCESS_ARTIFACT_RE = /^(?:openspec\/changes\/[^/]+\/)?\.superspec\/artifacts\/(?:discovery|business-invariants|test-contract)\.md$/;
|
|
15
|
+
const CODE_EXTENSIONS = new Set([
|
|
16
|
+
".c", ".cc", ".cpp", ".cs", ".css", ".go", ".h", ".hpp", ".html", ".java", ".js", ".jsx",
|
|
17
|
+
".json", ".kt", ".mjs", ".mts", ".php", ".py", ".rb", ".rs", ".scss", ".sh", ".sql",
|
|
18
|
+
".swift", ".toml", ".ts", ".tsx", ".yaml", ".yml",
|
|
19
|
+
]);
|
|
20
|
+
const CODE_BASENAMES = new Set([
|
|
21
|
+
"Dockerfile", "Makefile", "package.json", "package-lock.json", "pnpm-lock.yaml", "yarn.lock",
|
|
22
|
+
"tsconfig.json", "tsconfig.build.json", "eslint.config.js", "vite.config.ts", "webpack.config.js",
|
|
23
|
+
]);
|
|
24
|
+
const WALK_SKIP_DIRS = new Set([".git", "node_modules", "dist", "build", ".superspec", ".omx"]);
|
|
25
|
+
function normalizeGitPath(rawPath) {
|
|
26
|
+
const trimmed = rawPath.trim();
|
|
27
|
+
const unquoted = trimmed.startsWith('"') && trimmed.endsWith('"')
|
|
28
|
+
? trimmed.slice(1, -1).replace(/\\"/g, '"')
|
|
29
|
+
: trimmed;
|
|
30
|
+
const renamed = unquoted.includes(" -> ") ? unquoted.split(" -> ").pop() ?? unquoted : unquoted;
|
|
31
|
+
return renamed.replace(/\\/g, "/");
|
|
32
|
+
}
|
|
33
|
+
function gitChangedPaths(projectRoot) {
|
|
34
|
+
try {
|
|
35
|
+
const output = execFileSync("git", ["-C", projectRoot, "status", "--porcelain", "--untracked-files=all"], {
|
|
36
|
+
encoding: "utf8",
|
|
37
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
38
|
+
});
|
|
39
|
+
const paths = output
|
|
40
|
+
.split("\n")
|
|
41
|
+
.map(line => line.trimEnd())
|
|
42
|
+
.filter(Boolean)
|
|
43
|
+
.map(line => normalizeGitPath(line.slice(3)))
|
|
44
|
+
.filter(Boolean);
|
|
45
|
+
return { ok: true, paths: [...new Set(paths)].sort() };
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
return { ok: false, reason: err instanceof Error ? err.message : "git status failed" };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function isProcessOrOrdinaryDoc(path) {
|
|
52
|
+
if (path.startsWith(".superspec/") || path.startsWith(".omx/"))
|
|
53
|
+
return true;
|
|
54
|
+
if (path.includes("/.superspec/") || path.includes("/.omx/"))
|
|
55
|
+
return true;
|
|
56
|
+
if (PROCESS_DOC_RE.test(path) || PROCESS_ARTIFACT_RE.test(path))
|
|
57
|
+
return true;
|
|
58
|
+
return extname(path).toLowerCase() === ".md";
|
|
59
|
+
}
|
|
60
|
+
export function isCodeLikePath(path) {
|
|
61
|
+
const normalized = path.replace(/\\/g, "/");
|
|
62
|
+
if (!normalized || isProcessOrOrdinaryDoc(normalized))
|
|
63
|
+
return false;
|
|
64
|
+
const base = normalized.split("/").pop() ?? normalized;
|
|
65
|
+
if (CODE_BASENAMES.has(base))
|
|
66
|
+
return true;
|
|
67
|
+
return CODE_EXTENSIONS.has(extname(base).toLowerCase());
|
|
68
|
+
}
|
|
69
|
+
function walkCodeFiles(root, dir = root, out = []) {
|
|
70
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
71
|
+
if (entry.isDirectory()) {
|
|
72
|
+
if (WALK_SKIP_DIRS.has(entry.name))
|
|
73
|
+
continue;
|
|
74
|
+
walkCodeFiles(root, join(dir, entry.name), out);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (!entry.isFile())
|
|
78
|
+
continue;
|
|
79
|
+
const full = join(dir, entry.name);
|
|
80
|
+
const rel = full.slice(root.length + 1).replace(/\\/g, "/");
|
|
81
|
+
if (isCodeLikePath(rel))
|
|
82
|
+
out.push(rel);
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
export function scanCodeChanges(projectRoot) {
|
|
87
|
+
const git = gitChangedPaths(projectRoot);
|
|
88
|
+
if (!git.ok) {
|
|
89
|
+
const paths = existsSync(projectRoot) && statSync(projectRoot).isDirectory()
|
|
90
|
+
? walkCodeFiles(projectRoot).sort()
|
|
91
|
+
: [];
|
|
92
|
+
return {
|
|
93
|
+
reliable: false,
|
|
94
|
+
hasCodeChanges: true,
|
|
95
|
+
paths,
|
|
96
|
+
reason: `无法读取 git 状态,已按当前代码文件范围发起审查:${git.reason}`,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
const paths = git.paths.filter(isCodeLikePath);
|
|
100
|
+
return {
|
|
101
|
+
reliable: true,
|
|
102
|
+
hasCodeChanges: paths.length > 0,
|
|
103
|
+
paths,
|
|
104
|
+
reason: paths.length > 0 ? "检测到代码类改动" : "没有代码类改动",
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
export function codeReviewBoundFiles(projectRoot, paths) {
|
|
108
|
+
return paths.map(path => ({ path, sha: sha256File(join(projectRoot, path)) ?? "sha256:missing" }));
|
|
109
|
+
}
|
|
110
|
+
function samePathSet(left, right) {
|
|
111
|
+
const a = [...new Set(left)].sort();
|
|
112
|
+
const b = [...new Set(right)].sort();
|
|
113
|
+
return a.length === b.length && a.every((path, index) => path === b[index]);
|
|
114
|
+
}
|
|
115
|
+
export function codeReviewJobStaleReason(projectRoot, job, currentPaths) {
|
|
116
|
+
if (!isCodeReviewerJob(job))
|
|
117
|
+
return null;
|
|
118
|
+
const scanPaths = currentPaths ?? scanCodeChanges(projectRoot).paths;
|
|
119
|
+
const boundPaths = job.boundFiles.map(file => file.path);
|
|
120
|
+
if (!samePathSet(boundPaths, scanPaths)) {
|
|
121
|
+
return `代码审查范围已变化(原范围:${boundPaths.join(", ") || "<none>"};当前范围:${scanPaths.join(", ") || "<none>"})`;
|
|
122
|
+
}
|
|
123
|
+
for (const bound of job.boundFiles) {
|
|
124
|
+
const currentSha = sha256File(join(projectRoot, bound.path)) ?? "sha256:missing";
|
|
125
|
+
if (currentSha !== bound.sha) {
|
|
126
|
+
return `代码审查范围内的文件 ${bound.path} 已变化(原记录:${bound.sha};当前:${currentSha})`;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
export function codeReviewPacketDigest(input) {
|
|
132
|
+
return sha256Text(JSON.stringify(input));
|
|
133
|
+
}
|
|
134
|
+
function isCodeReviewerJob(job) {
|
|
135
|
+
return job.role === "code-reviewer" && job.created_from_transition === "review-ready";
|
|
136
|
+
}
|
|
137
|
+
function codeReviewResultKind(value) {
|
|
138
|
+
return value === "invalid_report" || value === "non_actionable_report" || value === "review_failed"
|
|
139
|
+
? value
|
|
140
|
+
: undefined;
|
|
141
|
+
}
|
|
142
|
+
export function codeReviewDecisionScope(jobId, findingId) {
|
|
143
|
+
return `${CODE_REVIEW_DECISION_SCOPE_PREFIX}${jobId}#${findingId}`;
|
|
144
|
+
}
|
|
145
|
+
export function isCodeReviewDecisionAnswer(value) {
|
|
146
|
+
return value === "reopen_propose" || value === "reopen_apply" || value === "dismiss";
|
|
147
|
+
}
|
|
148
|
+
export function codeReviewDecisionAnswerLabel(answer) {
|
|
149
|
+
return CODE_REVIEW_DECISION_ANSWER_LABELS[answer];
|
|
150
|
+
}
|
|
151
|
+
export function normalizeCodeReviewDecisionAnswer(value) {
|
|
152
|
+
if (isCodeReviewDecisionAnswer(value))
|
|
153
|
+
return value;
|
|
154
|
+
if (value === CODE_REVIEW_DECISION_ANSWER_LABELS.reopen_propose)
|
|
155
|
+
return "reopen_propose";
|
|
156
|
+
if (value === CODE_REVIEW_DECISION_ANSWER_LABELS.reopen_apply)
|
|
157
|
+
return "reopen_apply";
|
|
158
|
+
if (value === CODE_REVIEW_DECISION_ANSWER_LABELS.dismiss)
|
|
159
|
+
return "dismiss";
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
export function latestCodeReviewDecision(events, scope) {
|
|
163
|
+
const event = findLatestEvent(events, "user_decision_recorded", ev => {
|
|
164
|
+
const payload = ev.payload;
|
|
165
|
+
if (payload.scope !== scope)
|
|
166
|
+
return false;
|
|
167
|
+
if (payload.accepted === false)
|
|
168
|
+
return false;
|
|
169
|
+
if (!normalizeCodeReviewDecisionAnswer(payload.answer))
|
|
170
|
+
return false;
|
|
171
|
+
if (typeof payload.reason !== "string" || payload.reason.trim() === "")
|
|
172
|
+
return false;
|
|
173
|
+
return true;
|
|
174
|
+
});
|
|
175
|
+
if (!event)
|
|
176
|
+
return null;
|
|
177
|
+
const payload = event.payload;
|
|
178
|
+
const answer = normalizeCodeReviewDecisionAnswer(payload.answer);
|
|
179
|
+
if (!answer)
|
|
180
|
+
return null;
|
|
181
|
+
return {
|
|
182
|
+
answer,
|
|
183
|
+
reason: typeof payload.reason === "string" ? payload.reason.trim() : "",
|
|
184
|
+
event,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
function blockingFindingsFromReviewFailed(event, jobId, events) {
|
|
188
|
+
const payload = event.payload;
|
|
189
|
+
const findings = Array.isArray(payload.findings) ? payload.findings : [];
|
|
190
|
+
const result = [];
|
|
191
|
+
for (const raw of findings) {
|
|
192
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
193
|
+
continue;
|
|
194
|
+
const finding = raw;
|
|
195
|
+
if (finding.blocking !== true)
|
|
196
|
+
continue;
|
|
197
|
+
if (typeof finding.id !== "string" || finding.id.trim() === "")
|
|
198
|
+
continue;
|
|
199
|
+
if (finding.type !== "implementation" && finding.type !== "spec" && finding.type !== "mixed")
|
|
200
|
+
continue;
|
|
201
|
+
const id = finding.id;
|
|
202
|
+
result.push({
|
|
203
|
+
id,
|
|
204
|
+
type: finding.type,
|
|
205
|
+
finding,
|
|
206
|
+
decision: latestCodeReviewDecision(events, codeReviewDecisionScope(jobId, id)),
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
return result;
|
|
210
|
+
}
|
|
211
|
+
export function latestCodeReviewFailedStatus(events) {
|
|
212
|
+
const facts = collectCodeReviewGateFacts(events);
|
|
213
|
+
const terminal = facts.latestTerminal;
|
|
214
|
+
if (!terminal || terminal.state !== "rejected" || terminal.result_kind !== "review_failed")
|
|
215
|
+
return null;
|
|
216
|
+
const findings = blockingFindingsFromReviewFailed(terminal.event, terminal.job.job_id, events);
|
|
217
|
+
const dismissed = findings.filter(item => item.decision?.answer === "dismiss");
|
|
218
|
+
const unresolved = findings.filter(item => item.decision?.answer !== "dismiss");
|
|
219
|
+
return { terminal, findings, unresolved, dismissed };
|
|
220
|
+
}
|
|
221
|
+
export function dismissedCodeReviewSummary(status) {
|
|
222
|
+
const details = status.dismissed
|
|
223
|
+
.map(item => `${item.id}:${item.decision?.reason || "主流程已驳回该问题"}`)
|
|
224
|
+
.join(";");
|
|
225
|
+
return details
|
|
226
|
+
? `上一次代码审查提出的问题已被主流程复核驳回:${details}。没有新的具体证据时,不要重复提出同一问题。`
|
|
227
|
+
: "上一次代码审查提出的问题已被主流程复核驳回。没有新的具体证据时,不要重复提出同一问题。";
|
|
228
|
+
}
|
|
229
|
+
export function currentApplyDoneCycleStart(events) {
|
|
230
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
231
|
+
const ev = events[i];
|
|
232
|
+
if (ev.event_type !== "transition_commit")
|
|
233
|
+
continue;
|
|
234
|
+
const payload = ev.payload;
|
|
235
|
+
if (payload.to_state === "apply_done" && payload.from_state !== "apply_done")
|
|
236
|
+
return i;
|
|
237
|
+
}
|
|
238
|
+
return 0;
|
|
239
|
+
}
|
|
240
|
+
export function collectCodeReviewGateFacts(events) {
|
|
241
|
+
const cycleStartIndex = currentApplyDoneCycleStart(events);
|
|
242
|
+
const jobs = [];
|
|
243
|
+
const jobsById = new Map();
|
|
244
|
+
const terminalResults = [];
|
|
245
|
+
const terminalJobIds = new Set();
|
|
246
|
+
for (let i = cycleStartIndex; i < events.length; i++) {
|
|
247
|
+
const ev = events[i];
|
|
248
|
+
if (ev.event_type !== "transition_commit")
|
|
249
|
+
continue;
|
|
250
|
+
const newJobs = ev.payload.new_jobs ?? [];
|
|
251
|
+
for (const job of newJobs) {
|
|
252
|
+
if (!isCodeReviewerJob(job))
|
|
253
|
+
continue;
|
|
254
|
+
jobs.push(job);
|
|
255
|
+
jobsById.set(job.job_id, job);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
for (let i = cycleStartIndex; i < events.length; i++) {
|
|
259
|
+
const ev = events[i];
|
|
260
|
+
if (ev.event_type !== "job_accepted" && ev.event_type !== "job_rejected")
|
|
261
|
+
continue;
|
|
262
|
+
const payload = ev.payload;
|
|
263
|
+
if (typeof payload.job_id !== "string")
|
|
264
|
+
continue;
|
|
265
|
+
const job = jobsById.get(payload.job_id);
|
|
266
|
+
if (!job)
|
|
267
|
+
continue;
|
|
268
|
+
terminalJobIds.add(job.job_id);
|
|
269
|
+
terminalResults.push({
|
|
270
|
+
job,
|
|
271
|
+
event: ev,
|
|
272
|
+
state: ev.event_type === "job_accepted" ? "accepted" : "rejected",
|
|
273
|
+
result_kind: ev.event_type === "job_rejected"
|
|
274
|
+
? codeReviewResultKind(payload.result_kind) ?? "invalid_report"
|
|
275
|
+
: undefined,
|
|
276
|
+
reason: typeof payload.reason === "string" ? payload.reason : undefined,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
let consecutiveRejected = 0;
|
|
280
|
+
for (let i = terminalResults.length - 1; i >= 0; i--) {
|
|
281
|
+
const result = terminalResults[i];
|
|
282
|
+
if (result.state !== "rejected")
|
|
283
|
+
break;
|
|
284
|
+
if (result.result_kind !== "invalid_report" && result.result_kind !== "non_actionable_report")
|
|
285
|
+
break;
|
|
286
|
+
consecutiveRejected += 1;
|
|
287
|
+
}
|
|
288
|
+
const latestTerminal = terminalResults.at(-1) ?? null;
|
|
289
|
+
const latestRejected = [...terminalResults].reverse().find(result => result.state === "rejected") ?? null;
|
|
290
|
+
return {
|
|
291
|
+
cycleStartIndex,
|
|
292
|
+
jobs,
|
|
293
|
+
openJobs: jobs.filter(job => !terminalJobIds.has(job.job_id)),
|
|
294
|
+
terminalResults,
|
|
295
|
+
latestTerminal,
|
|
296
|
+
latestRejected,
|
|
297
|
+
consecutiveRejected,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
export function latestApplyDoneToReviewGate(events) {
|
|
301
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
302
|
+
const ev = events[i];
|
|
303
|
+
if (ev.event_type !== "transition_commit")
|
|
304
|
+
continue;
|
|
305
|
+
const payload = ev.payload;
|
|
306
|
+
if (payload.transition !== "review-ready")
|
|
307
|
+
continue;
|
|
308
|
+
if (payload.from_state !== "apply_done" || payload.to_state !== "review")
|
|
309
|
+
continue;
|
|
310
|
+
const gate = payload.code_review_gate;
|
|
311
|
+
if (!gate || (gate.decision !== "passed" && gate.decision !== "skipped"))
|
|
312
|
+
return null;
|
|
313
|
+
return {
|
|
314
|
+
decision: gate.decision,
|
|
315
|
+
...(typeof gate.job_id === "string" ? { job_id: gate.job_id } : {}),
|
|
316
|
+
...(typeof gate.reason === "string" ? { reason: gate.reason } : {}),
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
export function requiresFinalVerifierForCurrentReview(events) {
|
|
322
|
+
return latestApplyDoneToReviewGate(events) != null;
|
|
323
|
+
}
|
package/dist/format.js
CHANGED
|
@@ -127,15 +127,25 @@ export function tasksStructureDigest(content, sha256Text) {
|
|
|
127
127
|
// "cwd": "/path",
|
|
128
128
|
// "exit_code": 1,
|
|
129
129
|
// "semantic_status": "expected_failure",
|
|
130
|
+
// "covers_task_ids": ["TASK-001"],
|
|
130
131
|
// "target_fingerprint": "sha256:..."
|
|
131
132
|
// }
|
|
132
133
|
//
|
|
133
|
-
// 引擎校验:test_id + task_structure_digest
|
|
134
|
+
// 引擎校验:test_id + task_structure_digest 必填,covers_task_ids 如存在必须是非空字符串数组,其余可选
|
|
134
135
|
export function validateTestRunInput(tr) {
|
|
135
136
|
if (!tr.test_id)
|
|
136
137
|
return { ok: false, message: "缺少 test_id" };
|
|
137
138
|
if (!tr.task_structure_digest)
|
|
138
139
|
return { ok: false, message: "缺少 task_structure_digest" };
|
|
140
|
+
if (tr.covers_task_ids !== undefined) {
|
|
141
|
+
if (!Array.isArray(tr.covers_task_ids))
|
|
142
|
+
return { ok: false, message: "covers_task_ids 必须是字符串数组" };
|
|
143
|
+
if (tr.covers_task_ids.length === 0)
|
|
144
|
+
return { ok: false, message: "covers_task_ids 不能是空数组" };
|
|
145
|
+
if (!tr.covers_task_ids.every(item => typeof item === "string" && item.trim().length > 0)) {
|
|
146
|
+
return { ok: false, message: "covers_task_ids 不能包含空字符串或非字符串" };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
139
149
|
return { ok: true, message: "" };
|
|
140
150
|
}
|
|
141
151
|
// ===== user-decision JSON =====
|
package/dist/next.js
CHANGED
|
@@ -4,6 +4,7 @@ import { readFileSync } from "node:fs";
|
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { readEvents, sha256Text } from "./store.js";
|
|
6
6
|
import { isFreshReviewVerifier, isReviewReadyVerifier, readReviewPolicyFromEvents, reviewEvidenceDigest } from "./review.js";
|
|
7
|
+
import { CODE_REVIEW_DECISION_ANSWER_LABELS, CODE_REVIEW_REPAIR_SCOPE_PREFIX, codeReviewDecisionScope, collectCodeReviewGateFacts, latestCodeReviewFailedStatus, requiresFinalVerifierForCurrentReview, } from "./code_review.js";
|
|
7
8
|
import { requiredJobActions } from "./job_action.js";
|
|
8
9
|
import { validateDiscovery, countDiscoveryOpenQuestions, collectProposeOpenQuestions, parseTasksMd, pendingTasksInContent } from "./format.js";
|
|
9
10
|
const ACTIVE_PROPOSAL_REVIEW_ROLES = new Set(["critic", "architect", "test-engineer"]);
|
|
@@ -212,6 +213,83 @@ export function next(projectRoot, change, changeRoot, defaultRisk = "strict") {
|
|
|
212
213
|
if (snapshot.open_jobs.length > 0) {
|
|
213
214
|
return requiredJobsOutput("apply_done", change, snapshot.open_jobs, `有 ${snapshot.open_jobs.length} 个待完成工作项`);
|
|
214
215
|
}
|
|
216
|
+
const events = readEvents(projectRoot, change);
|
|
217
|
+
const facts = collectCodeReviewGateFacts(events);
|
|
218
|
+
const latest = facts.latestTerminal;
|
|
219
|
+
if (latest?.state === "rejected" && latest.result_kind === "review_failed") {
|
|
220
|
+
const status = latestCodeReviewFailedStatus(events);
|
|
221
|
+
const pending = status?.unresolved[0] ?? null;
|
|
222
|
+
if (status && status.findings.length > 0 && !pending) {
|
|
223
|
+
return {
|
|
224
|
+
state: "apply_done",
|
|
225
|
+
path: "next_command",
|
|
226
|
+
next_command: transitionCommand(change, "review-ready", riskFlag(defaultRisk)),
|
|
227
|
+
reason: "代码审查问题已被主流程复核驳回,重新发起代码审查",
|
|
228
|
+
missing_inputs: [],
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
const findingId = pending?.id ?? "";
|
|
232
|
+
const type = pending?.type;
|
|
233
|
+
const decision = pending?.decision;
|
|
234
|
+
if (findingId && type === "implementation") {
|
|
235
|
+
return {
|
|
236
|
+
state: "apply_done",
|
|
237
|
+
path: "next_command",
|
|
238
|
+
next_command: transitionCommand(change, "reopen", `--to apply --review-fix ${latest.job.job_id}#${findingId} --reason "修复代码审查问题 ${findingId}"`),
|
|
239
|
+
reason: `代码审查发现纯代码实现问题 ${findingId},回到实现阶段修复`,
|
|
240
|
+
missing_inputs: [],
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
if (findingId && (type === "spec" || type === "mixed")) {
|
|
244
|
+
if (decision?.answer === "reopen_propose") {
|
|
245
|
+
return {
|
|
246
|
+
state: "apply_done",
|
|
247
|
+
path: "next_command",
|
|
248
|
+
next_command: transitionCommand(change, "reopen", `--to propose --review-finding ${latest.job.job_id}#${findingId} --reason "根据代码审查问题 ${findingId} 回到计划阶段"`),
|
|
249
|
+
reason: `使用者已确认问题 ${findingId} 需要回到计划阶段`,
|
|
250
|
+
missing_inputs: [],
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
if (decision?.answer === "reopen_apply") {
|
|
254
|
+
return {
|
|
255
|
+
state: "apply_done",
|
|
256
|
+
path: "next_command",
|
|
257
|
+
next_command: transitionCommand(change, "reopen", `--to apply --review-fix ${latest.job.job_id}#${findingId} --reason "根据代码审查问题 ${findingId} 回到实现阶段修复"`),
|
|
258
|
+
reason: `使用者已确认问题 ${findingId} 直接回到实现阶段修复`,
|
|
259
|
+
missing_inputs: [],
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
const problemKind = type === "spec"
|
|
263
|
+
? "方案或需求文档可能需要调整"
|
|
264
|
+
: "代码实现和方案文档都可能有关";
|
|
265
|
+
const ask = {
|
|
266
|
+
question: `代码审查发现问题 ${findingId}:${problemKind}。请选择回到计划阶段修改文档、确认现有文档方向不变并回到实现阶段修代码,或驳回该问题;无论选择哪一项都必须写明原因。`,
|
|
267
|
+
allowed_answers: [
|
|
268
|
+
CODE_REVIEW_DECISION_ANSWER_LABELS.reopen_propose,
|
|
269
|
+
CODE_REVIEW_DECISION_ANSWER_LABELS.reopen_apply,
|
|
270
|
+
CODE_REVIEW_DECISION_ANSWER_LABELS.dismiss,
|
|
271
|
+
],
|
|
272
|
+
scope: codeReviewDecisionScope(latest.job.job_id, findingId),
|
|
273
|
+
};
|
|
274
|
+
return { state: "apply_done", path: "ask_user", ask_user: ask, reason: `代码审查发现需要使用者判断的问题 ${findingId}` };
|
|
275
|
+
}
|
|
276
|
+
const ask = {
|
|
277
|
+
question: "代码审查报告里缺少可用于处理问题的编号或分类。请修正审查报告后重新执行 review-ready。",
|
|
278
|
+
allowed_answers: ["报告已修正"],
|
|
279
|
+
scope: `${CODE_REVIEW_REPAIR_SCOPE_PREFIX}${change}`,
|
|
280
|
+
};
|
|
281
|
+
return { state: "apply_done", path: "ask_user", ask_user: ask, reason: "代码审查报告中的阻塞问题无法处理" };
|
|
282
|
+
}
|
|
283
|
+
if (latest?.state === "rejected" &&
|
|
284
|
+
(latest.result_kind === "invalid_report" || latest.result_kind === "non_actionable_report") &&
|
|
285
|
+
facts.consecutiveRejected >= 2) {
|
|
286
|
+
const ask = {
|
|
287
|
+
question: "代码审查报告连续两次不符合要求,或者没有给出可处理的问题。请先修正报告生成方式、模板或审查口径;修正后仍可显式执行 review-ready。",
|
|
288
|
+
allowed_answers: ["已修正"],
|
|
289
|
+
scope: `${CODE_REVIEW_REPAIR_SCOPE_PREFIX}${change}`,
|
|
290
|
+
};
|
|
291
|
+
return { state: "apply_done", path: "ask_user", ask_user: ask, reason: "代码审查报告连续不符合要求或没有可处理问题" };
|
|
292
|
+
}
|
|
215
293
|
return {
|
|
216
294
|
state: "apply_done",
|
|
217
295
|
path: "next_command",
|
|
@@ -246,7 +324,7 @@ export function next(projectRoot, change, changeRoot, defaultRisk = "strict") {
|
|
|
246
324
|
missing_inputs: [],
|
|
247
325
|
};
|
|
248
326
|
}
|
|
249
|
-
if (policy.requires_verifier) {
|
|
327
|
+
if (requiresFinalVerifierForCurrentReview(events) || policy.requires_verifier) {
|
|
250
328
|
const currentEvidenceDigest = reviewEvidenceDigest(events);
|
|
251
329
|
const verifierAccepted = snapshot.accepted_jobs.find(job => isFreshReviewVerifier(job, changeRoot, currentEvidenceDigest));
|
|
252
330
|
if (!verifierAccepted) {
|
|
@@ -254,7 +332,7 @@ export function next(projectRoot, change, changeRoot, defaultRisk = "strict") {
|
|
|
254
332
|
state: "review",
|
|
255
333
|
path: "next_command",
|
|
256
334
|
next_command: transitionCommand(change, "review-ready", riskFlag(defaultRisk)),
|
|
257
|
-
reason: "
|
|
335
|
+
reason: "最终验证已经缺失或不再匹配当前证据,先补最终验证",
|
|
258
336
|
missing_inputs: [],
|
|
259
337
|
};
|
|
260
338
|
}
|