@gordon.gan/specflow 1.4.6-beta → 1.7.0-beta
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 +2 -2
- package/dist/cli/commands/approval-assemble.d.ts +48 -5
- package/dist/cli/commands/approval-assemble.js +347 -34
- package/dist/core/approval/assemble.js +64 -17
- package/dist/core/approval/bundle.d.ts +9 -0
- package/dist/core/approval/bundle.js +172 -0
- package/dist/core/approval/forbidden-patterns.d.ts +6 -0
- package/dist/core/approval/forbidden-patterns.js +37 -0
- package/dist/core/approval/index-schema.d.ts +432 -0
- package/dist/core/approval/index-schema.js +103 -0
- package/dist/core/approval/index.d.ts +10 -2
- package/dist/core/approval/index.js +7 -1
- package/dist/core/approval/lint.d.ts +10 -0
- package/dist/core/approval/lint.js +302 -0
- package/dist/core/approval/paths.d.ts +5 -0
- package/dist/core/approval/paths.js +15 -0
- package/dist/core/approval/pipeline.d.ts +28 -0
- package/dist/core/approval/pipeline.js +146 -0
- package/dist/core/approval/playbook-schema.d.ts +182 -0
- package/dist/core/approval/playbook-schema.js +51 -0
- package/dist/core/approval/render.d.ts +20 -0
- package/dist/core/approval/render.js +210 -0
- package/dist/core/approval/review-pack.d.ts +26 -0
- package/dist/core/approval/review-pack.js +205 -0
- package/dist/core/approval/types.d.ts +131 -0
- package/dist/integrations/shared/capability-evidence.js +2 -0
- package/dist/integrations/shared/parity-manifest.js +2 -0
- package/package.json +2 -1
- package/prompts/approval/acp-pipeline.md +104 -0
- package/prompts/approval/ai-review.md +145 -0
- package/prompts/approval/api-guidance.md +179 -0
- package/prompts/approval/generate.md +164 -13
- package/prompts/approval/multi-repo-guidance.md +238 -0
- package/prompts/approval/project-conventions-guidance.md +1 -1
- package/prompts/approval/runtime-guidance.md +64 -0
- package/prompts/approval/segmented-generation.md +23 -11
- package/skills/GUIDANCE_PACKS.md +2 -2
- package/skills/specflow-approval/SKILL.md +80 -18
- package/templates/approval-index.yaml +41 -0
- package/templates/approval-part.md +1 -1
- package/templates/approval-playbook.yaml +28 -0
package/README.md
CHANGED
|
@@ -126,7 +126,7 @@ npm install -g @gordon.gan/specflow
|
|
|
126
126
|
npm install -g github:Gordon-Gan-Jiang/specflow
|
|
127
127
|
|
|
128
128
|
# 验证
|
|
129
|
-
specflow --version # 以 npm / package.json 为准(当前 1.
|
|
129
|
+
specflow --version # 以 npm / package.json 为准(当前 1.7.0-beta)
|
|
130
130
|
specflow --help
|
|
131
131
|
```
|
|
132
132
|
|
|
@@ -282,7 +282,7 @@ specflow init
|
|
|
282
282
|
| **explore** | 读代码、比方案、定边界;产出 `explore.md`,confirmed 后 handoff 到 propose |
|
|
283
283
|
| **propose** | 一次产出 proposal、delta specs、design、tasks(第一轮深度思考,非占位骨架);叙述语言跟 `artifacts.language` |
|
|
284
284
|
| **refine** | 内部多轮循环(≥2 轮,AI 判断收敛);可更新任意 artifact |
|
|
285
|
-
| **approval** | refine 后可选:AI
|
|
285
|
+
| **approval** | refine 后可选:AI 7 维闭环 + 设计质量 + 可实施性 → `approval.md`;非轻量变更走 **ACP**:分片 `approval/parts/` → Stage 12e AI Review → **`specflow approval finalize`**(lint / review-check / assemble / 多仓 bundle);不改 phase,不阻塞 apply |
|
|
286
286
|
| **apply** | Phase A 重写 tasks.md;Phase B 按语言路由 ECC 审查 + TDD 逐任务执行;可选 `--yes` |
|
|
287
287
|
| **review** | 代码审查,对照 specs 检查回归 |
|
|
288
288
|
| **test** | 单元 + 集成 + E2E + 回归测试 |
|
|
@@ -1,21 +1,64 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* CLI: specflow approval assemble | check
|
|
2
|
+
* CLI: specflow approval assemble | check | lint | bundle | review-pack | review-check | finalize
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* ACP (Approval Compiler Pipeline) — deterministic Reduce/Verify; Map is LLM in skill.
|
|
5
|
+
* Prefer `specflow approval finalize` for the full Verify → Reduce → bundle chain.
|
|
6
6
|
*/
|
|
7
7
|
import type { Command } from 'commander';
|
|
8
|
-
export interface
|
|
8
|
+
export interface ApprovalCommandOptions {
|
|
9
9
|
readonly store?: string;
|
|
10
10
|
readonly cwd?: string;
|
|
11
11
|
readonly force?: boolean;
|
|
12
12
|
readonly json?: boolean;
|
|
13
13
|
readonly noLazyCheck?: boolean;
|
|
14
|
+
readonly strict?: boolean;
|
|
15
|
+
readonly playbook?: string;
|
|
16
|
+
readonly workspaceRoot?: string;
|
|
17
|
+
readonly dryRun?: boolean;
|
|
18
|
+
readonly requireReview?: boolean;
|
|
19
|
+
readonly skipReview?: boolean;
|
|
20
|
+
readonly bundle?: boolean;
|
|
21
|
+
readonly noBundle?: boolean;
|
|
22
|
+
readonly render?: boolean;
|
|
23
|
+
readonly renderInput?: string;
|
|
24
|
+
readonly renderOutput?: string;
|
|
14
25
|
}
|
|
15
|
-
export declare function runApprovalAssemble(changeName: string, options:
|
|
26
|
+
export declare function runApprovalAssemble(changeName: string, options: ApprovalCommandOptions & {
|
|
16
27
|
checkOnly?: boolean;
|
|
17
28
|
}): Promise<{
|
|
18
29
|
exitCode: number;
|
|
19
30
|
payload: unknown;
|
|
20
31
|
}>;
|
|
32
|
+
export declare function runApprovalLint(changeName: string, options: ApprovalCommandOptions): Promise<{
|
|
33
|
+
exitCode: number;
|
|
34
|
+
payload: unknown;
|
|
35
|
+
}>;
|
|
36
|
+
export declare function runApprovalBundle(changeName: string, options: ApprovalCommandOptions): Promise<{
|
|
37
|
+
exitCode: number;
|
|
38
|
+
payload: unknown;
|
|
39
|
+
}>;
|
|
40
|
+
export declare function runApprovalReviewPack(changeName: string, options: ApprovalCommandOptions): Promise<{
|
|
41
|
+
exitCode: number;
|
|
42
|
+
payload: unknown;
|
|
43
|
+
}>;
|
|
44
|
+
export declare function runApprovalReviewCheck(changeName: string, options: ApprovalCommandOptions): Promise<{
|
|
45
|
+
exitCode: number;
|
|
46
|
+
payload: unknown;
|
|
47
|
+
}>;
|
|
48
|
+
export declare function runApprovalReviewHashes(changeName: string, options: ApprovalCommandOptions): Promise<{
|
|
49
|
+
exitCode: number;
|
|
50
|
+
payload: unknown;
|
|
51
|
+
}>;
|
|
52
|
+
export declare function runApprovalRender(changeName: string, options: ApprovalCommandOptions & {
|
|
53
|
+
input?: string;
|
|
54
|
+
output?: string;
|
|
55
|
+
title?: string;
|
|
56
|
+
}): Promise<{
|
|
57
|
+
exitCode: number;
|
|
58
|
+
payload: unknown;
|
|
59
|
+
}>;
|
|
60
|
+
export declare function runApprovalFinalize(changeName: string, options: ApprovalCommandOptions): Promise<{
|
|
61
|
+
exitCode: number;
|
|
62
|
+
payload: unknown;
|
|
63
|
+
}>;
|
|
21
64
|
export declare function registerApprovalAssembleCommand(program: Command): void;
|
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* CLI: specflow approval assemble | check
|
|
2
|
+
* CLI: specflow approval assemble | check | lint | bundle | review-pack | review-check | finalize
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* ACP (Approval Compiler Pipeline) — deterministic Reduce/Verify; Map is LLM in skill.
|
|
5
|
+
* Prefer `specflow approval finalize` for the full Verify → Reduce → bundle chain.
|
|
6
6
|
*/
|
|
7
7
|
import { promises as fs } from 'node:fs';
|
|
8
8
|
import { addStoreOption, resolveRootFromCommandOptions } from '../shared/store-option.js';
|
|
9
9
|
import { validateChangeName } from '../../utils/change-utils.js';
|
|
10
|
-
import { assembleApprovalDocument,
|
|
10
|
+
import { assembleApprovalDocument, bundleApprovalDocument, checkApprovalReview, generateReviewPacket, getApprovalOutputPath, getChangeDirectory, lintApprovalDocument, finalizeApprovalDocument, computeReviewPartHashes, readApprovalIndex, renderApprovalDocument, resolveApprovalRenderPaths, resolvePlaybookPath, } from '../../core/approval/index.js';
|
|
11
|
+
function printDiagnostics(diagnostics) {
|
|
12
|
+
for (const d of diagnostics) {
|
|
13
|
+
const prefix = d.severity === 'error' ? 'ERROR' : 'WARN';
|
|
14
|
+
const part = d.part ? ` [${d.part}]` : '';
|
|
15
|
+
console.error(`${prefix}${part}: ${d.message}`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
11
18
|
export async function runApprovalAssemble(changeName, options) {
|
|
12
19
|
validateChangeName(changeName);
|
|
13
20
|
const projectRoot = await resolveRootFromCommandOptions({
|
|
@@ -21,6 +28,21 @@ export async function runApprovalAssemble(changeName, options) {
|
|
|
21
28
|
catch {
|
|
22
29
|
throw new Error(`Change "${changeName}" not found at ${changeDir}`);
|
|
23
30
|
}
|
|
31
|
+
if (options.requireReview) {
|
|
32
|
+
const reviewCheck = await checkApprovalReview({
|
|
33
|
+
changeDir,
|
|
34
|
+
requirePass: true,
|
|
35
|
+
forceRequire: true,
|
|
36
|
+
});
|
|
37
|
+
if (!reviewCheck.ok) {
|
|
38
|
+
const payload = { ok: false, stage: 'review-check', diagnostics: reviewCheck.diagnostics };
|
|
39
|
+
if (options.json) {
|
|
40
|
+
return { exitCode: 1, payload };
|
|
41
|
+
}
|
|
42
|
+
printDiagnostics(reviewCheck.diagnostics);
|
|
43
|
+
return { exitCode: 1, payload };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
24
46
|
const result = await assembleApprovalDocument({
|
|
25
47
|
changeDir,
|
|
26
48
|
checkOnly: options.checkOnly ?? false,
|
|
@@ -38,11 +60,7 @@ export async function runApprovalAssemble(changeName, options) {
|
|
|
38
60
|
return { exitCode: result.ok ? 0 : 1, payload };
|
|
39
61
|
}
|
|
40
62
|
if (result.diagnostics.length > 0) {
|
|
41
|
-
|
|
42
|
-
const prefix = d.severity === 'error' ? 'ERROR' : 'WARN';
|
|
43
|
-
const part = d.part ? ` [${d.part}]` : '';
|
|
44
|
-
console.error(`${prefix}${part}: ${d.message}`);
|
|
45
|
-
}
|
|
63
|
+
printDiagnostics(result.diagnostics);
|
|
46
64
|
}
|
|
47
65
|
if (result.ok) {
|
|
48
66
|
if (options.checkOnly) {
|
|
@@ -55,41 +73,336 @@ export async function runApprovalAssemble(changeName, options) {
|
|
|
55
73
|
}
|
|
56
74
|
return { exitCode: 1, payload };
|
|
57
75
|
}
|
|
76
|
+
export async function runApprovalLint(changeName, options) {
|
|
77
|
+
validateChangeName(changeName);
|
|
78
|
+
const projectRoot = await resolveRootFromCommandOptions({
|
|
79
|
+
store: options.store,
|
|
80
|
+
cwd: options.cwd,
|
|
81
|
+
});
|
|
82
|
+
const changeDir = getChangeDirectory(projectRoot, changeName);
|
|
83
|
+
const result = await lintApprovalDocument({
|
|
84
|
+
changeDir,
|
|
85
|
+
strict: options.strict ?? false,
|
|
86
|
+
});
|
|
87
|
+
const payload = { ok: result.ok, change: changeName, diagnostics: result.diagnostics };
|
|
88
|
+
if (options.json) {
|
|
89
|
+
return { exitCode: result.ok ? 0 : 1, payload };
|
|
90
|
+
}
|
|
91
|
+
printDiagnostics(result.diagnostics);
|
|
92
|
+
if (result.ok) {
|
|
93
|
+
console.info(`Lint passed for change "${changeName}".`);
|
|
94
|
+
return { exitCode: 0, payload };
|
|
95
|
+
}
|
|
96
|
+
return { exitCode: 1, payload };
|
|
97
|
+
}
|
|
98
|
+
export async function runApprovalBundle(changeName, options) {
|
|
99
|
+
validateChangeName(changeName);
|
|
100
|
+
const projectRoot = await resolveRootFromCommandOptions({
|
|
101
|
+
store: options.store,
|
|
102
|
+
cwd: options.cwd,
|
|
103
|
+
});
|
|
104
|
+
const workspaceRoot = options.workspaceRoot ?? projectRoot;
|
|
105
|
+
const changeDir = getChangeDirectory(projectRoot, changeName);
|
|
106
|
+
const playbookPath = await resolvePlaybookPath(changeDir, options.playbook);
|
|
107
|
+
const result = await bundleApprovalDocument({
|
|
108
|
+
workspaceRoot,
|
|
109
|
+
changeDir,
|
|
110
|
+
playbookPath,
|
|
111
|
+
write: !options.dryRun,
|
|
112
|
+
});
|
|
113
|
+
const payload = {
|
|
114
|
+
ok: result.ok,
|
|
115
|
+
change: changeName,
|
|
116
|
+
output: result.outputPath,
|
|
117
|
+
playbook: playbookPath,
|
|
118
|
+
dry_run: options.dryRun ?? false,
|
|
119
|
+
diagnostics: result.diagnostics,
|
|
120
|
+
};
|
|
121
|
+
if (options.json) {
|
|
122
|
+
return { exitCode: result.ok ? 0 : 1, payload };
|
|
123
|
+
}
|
|
124
|
+
printDiagnostics(result.diagnostics);
|
|
125
|
+
if (result.ok) {
|
|
126
|
+
if (options.dryRun) {
|
|
127
|
+
console.info(`Bundle dry-run OK for "${changeName}" → ${result.outputPath}`);
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
console.info(`Wrote bundle ${result.outputPath}`);
|
|
131
|
+
}
|
|
132
|
+
return { exitCode: 0, payload };
|
|
133
|
+
}
|
|
134
|
+
return { exitCode: 1, payload };
|
|
135
|
+
}
|
|
136
|
+
export async function runApprovalReviewPack(changeName, options) {
|
|
137
|
+
validateChangeName(changeName);
|
|
138
|
+
const projectRoot = await resolveRootFromCommandOptions({
|
|
139
|
+
store: options.store,
|
|
140
|
+
cwd: options.cwd,
|
|
141
|
+
});
|
|
142
|
+
const changeDir = getChangeDirectory(projectRoot, changeName);
|
|
143
|
+
const result = await generateReviewPacket({ changeDir });
|
|
144
|
+
const payload = {
|
|
145
|
+
ok: result.ok,
|
|
146
|
+
change: changeName,
|
|
147
|
+
packet_path: result.packetPath,
|
|
148
|
+
diagnostics: result.diagnostics,
|
|
149
|
+
};
|
|
150
|
+
if (options.json) {
|
|
151
|
+
return { exitCode: result.ok ? 0 : 1, payload };
|
|
152
|
+
}
|
|
153
|
+
printDiagnostics(result.diagnostics);
|
|
154
|
+
if (result.ok) {
|
|
155
|
+
console.info(`Wrote review packet ${result.packetPath}`);
|
|
156
|
+
console.info('Run AI review (Stage 12e), then: specflow approval review-check');
|
|
157
|
+
return { exitCode: 0, payload };
|
|
158
|
+
}
|
|
159
|
+
return { exitCode: 1, payload };
|
|
160
|
+
}
|
|
161
|
+
export async function runApprovalReviewCheck(changeName, options) {
|
|
162
|
+
validateChangeName(changeName);
|
|
163
|
+
const projectRoot = await resolveRootFromCommandOptions({
|
|
164
|
+
store: options.store,
|
|
165
|
+
cwd: options.cwd,
|
|
166
|
+
});
|
|
167
|
+
const changeDir = getChangeDirectory(projectRoot, changeName);
|
|
168
|
+
const result = await checkApprovalReview({
|
|
169
|
+
changeDir,
|
|
170
|
+
requirePass: options.requireReview !== false,
|
|
171
|
+
});
|
|
172
|
+
const payload = {
|
|
173
|
+
ok: result.ok,
|
|
174
|
+
change: changeName,
|
|
175
|
+
verdict: result.review?.verdict,
|
|
176
|
+
diagnostics: result.diagnostics,
|
|
177
|
+
};
|
|
178
|
+
if (options.json) {
|
|
179
|
+
return { exitCode: result.ok ? 0 : 1, payload };
|
|
180
|
+
}
|
|
181
|
+
printDiagnostics(result.diagnostics);
|
|
182
|
+
if (result.ok) {
|
|
183
|
+
console.info(`Review check passed for "${changeName}" (verdict=${result.review?.verdict ?? 'n/a'}).`);
|
|
184
|
+
return { exitCode: 0, payload };
|
|
185
|
+
}
|
|
186
|
+
return { exitCode: 1, payload };
|
|
187
|
+
}
|
|
188
|
+
export async function runApprovalReviewHashes(changeName, options) {
|
|
189
|
+
validateChangeName(changeName);
|
|
190
|
+
const projectRoot = await resolveRootFromCommandOptions({
|
|
191
|
+
store: options.store,
|
|
192
|
+
cwd: options.cwd,
|
|
193
|
+
});
|
|
194
|
+
const changeDir = getChangeDirectory(projectRoot, changeName);
|
|
195
|
+
let index;
|
|
196
|
+
try {
|
|
197
|
+
index = await readApprovalIndex(changeDir);
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
201
|
+
throw new Error(`Cannot read index: ${message}`);
|
|
202
|
+
}
|
|
203
|
+
const part_hashes = await computeReviewPartHashes(changeDir, index.parts_order);
|
|
204
|
+
const payload = { change: changeName, part_hashes };
|
|
205
|
+
if (options.json) {
|
|
206
|
+
return { exitCode: 0, payload };
|
|
207
|
+
}
|
|
208
|
+
console.info(JSON.stringify(part_hashes, null, 2));
|
|
209
|
+
return { exitCode: 0, payload };
|
|
210
|
+
}
|
|
211
|
+
export async function runApprovalRender(changeName, options) {
|
|
212
|
+
validateChangeName(changeName);
|
|
213
|
+
const projectRoot = await resolveRootFromCommandOptions({
|
|
214
|
+
store: options.store,
|
|
215
|
+
cwd: options.cwd,
|
|
216
|
+
});
|
|
217
|
+
const changeDir = getChangeDirectory(projectRoot, changeName);
|
|
218
|
+
const paths = resolveApprovalRenderPaths(changeDir, options.input, options.output);
|
|
219
|
+
const result = await renderApprovalDocument({
|
|
220
|
+
changeDir,
|
|
221
|
+
inputPath: paths.inputPath,
|
|
222
|
+
outputPath: paths.outputPath,
|
|
223
|
+
title: options.title,
|
|
224
|
+
write: !options.dryRun,
|
|
225
|
+
});
|
|
226
|
+
const payload = {
|
|
227
|
+
ok: result.ok,
|
|
228
|
+
change: changeName,
|
|
229
|
+
input: paths.inputPath,
|
|
230
|
+
output: result.outputPath,
|
|
231
|
+
sha256: result.sha256,
|
|
232
|
+
dry_run: options.dryRun ?? false,
|
|
233
|
+
diagnostics: result.diagnostics,
|
|
234
|
+
};
|
|
235
|
+
if (options.json) {
|
|
236
|
+
return { exitCode: result.ok ? 0 : 1, payload };
|
|
237
|
+
}
|
|
238
|
+
if (result.diagnostics.length > 0) {
|
|
239
|
+
printDiagnostics(result.diagnostics);
|
|
240
|
+
}
|
|
241
|
+
if (result.ok) {
|
|
242
|
+
if (options.dryRun) {
|
|
243
|
+
console.info(`Render dry-run OK for "${changeName}" → ${result.outputPath}`);
|
|
244
|
+
}
|
|
245
|
+
else {
|
|
246
|
+
console.info(`Wrote ${result.outputPath}`);
|
|
247
|
+
}
|
|
248
|
+
return { exitCode: 0, payload };
|
|
249
|
+
}
|
|
250
|
+
return { exitCode: 1, payload };
|
|
251
|
+
}
|
|
252
|
+
export async function runApprovalFinalize(changeName, options) {
|
|
253
|
+
validateChangeName(changeName);
|
|
254
|
+
const projectRoot = await resolveRootFromCommandOptions({
|
|
255
|
+
store: options.store,
|
|
256
|
+
cwd: options.cwd,
|
|
257
|
+
});
|
|
258
|
+
const changeDir = getChangeDirectory(projectRoot, changeName);
|
|
259
|
+
const bundleMode = options.noBundle ? false : options.bundle ? true : 'auto';
|
|
260
|
+
const result = await finalizeApprovalDocument({
|
|
261
|
+
changeDir,
|
|
262
|
+
workspaceRoot: options.workspaceRoot ?? projectRoot,
|
|
263
|
+
strict: options.strict ?? true,
|
|
264
|
+
skipReview: options.skipReview ?? false,
|
|
265
|
+
force: options.force ?? true,
|
|
266
|
+
bundle: bundleMode,
|
|
267
|
+
playbook: options.playbook,
|
|
268
|
+
dryRun: options.dryRun ?? false,
|
|
269
|
+
validateLazy: !options.noLazyCheck,
|
|
270
|
+
});
|
|
271
|
+
const payload = {
|
|
272
|
+
ok: result.ok,
|
|
273
|
+
paused: result.paused ?? false,
|
|
274
|
+
stage: result.stage,
|
|
275
|
+
change: changeName,
|
|
276
|
+
output: result.outputPath,
|
|
277
|
+
bundle: result.bundlePath,
|
|
278
|
+
review_packet: result.reviewPacketPath,
|
|
279
|
+
diagnostics: result.diagnostics,
|
|
280
|
+
};
|
|
281
|
+
if (options.json) {
|
|
282
|
+
const exitCode = result.ok ? 0 : result.paused ? 2 : 1;
|
|
283
|
+
return { exitCode, payload };
|
|
284
|
+
}
|
|
285
|
+
printDiagnostics(result.diagnostics);
|
|
286
|
+
if (result.paused) {
|
|
287
|
+
console.info(`Paused at AI review for "${changeName}". Complete Stage 12e in-session, then re-run finalize.`);
|
|
288
|
+
if (result.reviewPacketPath) {
|
|
289
|
+
console.info(`Review packet: ${result.reviewPacketPath}`);
|
|
290
|
+
}
|
|
291
|
+
return { exitCode: 2, payload };
|
|
292
|
+
}
|
|
293
|
+
if (result.ok) {
|
|
294
|
+
console.info(`Finalized approval for "${changeName}" (stage=${result.stage}).`);
|
|
295
|
+
if (result.outputPath) {
|
|
296
|
+
console.info(`Wrote ${result.outputPath}`);
|
|
297
|
+
}
|
|
298
|
+
if (result.bundlePath) {
|
|
299
|
+
console.info(`Wrote bundle ${result.bundlePath}`);
|
|
300
|
+
}
|
|
301
|
+
if (options.render && !options.dryRun) {
|
|
302
|
+
const paths = resolveApprovalRenderPaths(changeDir, options.renderInput, options.renderOutput);
|
|
303
|
+
const renderResult = await renderApprovalDocument({
|
|
304
|
+
changeDir,
|
|
305
|
+
inputPath: paths.inputPath,
|
|
306
|
+
outputPath: paths.outputPath,
|
|
307
|
+
write: true,
|
|
308
|
+
});
|
|
309
|
+
if (renderResult.ok && renderResult.outputPath) {
|
|
310
|
+
console.info(`Wrote ${renderResult.outputPath}`);
|
|
311
|
+
}
|
|
312
|
+
else {
|
|
313
|
+
printDiagnostics(renderResult.diagnostics);
|
|
314
|
+
return { exitCode: 1, payload: { ...payload, render: renderResult } };
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return { exitCode: 0, payload };
|
|
318
|
+
}
|
|
319
|
+
console.error(`Finalize failed at stage "${result.stage}" for "${changeName}".`);
|
|
320
|
+
return { exitCode: 1, payload };
|
|
321
|
+
}
|
|
322
|
+
function bindJsonAction(action) {
|
|
323
|
+
return async (change, opts) => {
|
|
324
|
+
const { exitCode, payload } = await action(change, opts);
|
|
325
|
+
if (opts.json) {
|
|
326
|
+
console.info(JSON.stringify(payload, null, 2));
|
|
327
|
+
}
|
|
328
|
+
if (exitCode !== 0) {
|
|
329
|
+
process.exitCode = exitCode;
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
}
|
|
58
333
|
export function registerApprovalAssembleCommand(program) {
|
|
59
|
-
const approval = program.command('approval').description('Approval document utilities');
|
|
334
|
+
const approval = program.command('approval').description('Approval document utilities (ACP)');
|
|
335
|
+
addStoreOption(approval
|
|
336
|
+
.command('finalize <change>')
|
|
337
|
+
.description('Run full ACP finalize: lint → review-check → check → assemble → bundle (preferred entry)')
|
|
338
|
+
.option('--skip-review', 'Skip AI review gate even when acp.review is enabled')
|
|
339
|
+
.option('--bundle', 'Force multi-repo bundle after assemble')
|
|
340
|
+
.option('--no-bundle', 'Skip bundle even when multi_repo.bundle.enabled')
|
|
341
|
+
.option('--playbook <path>', 'Path to playbook YAML (bundle step)')
|
|
342
|
+
.option('--workspace-root <path>', 'Workspace root for multi-repo bundle')
|
|
343
|
+
.option('--force', 'Overwrite existing approval.md (default: true)', true)
|
|
344
|
+
.option('--dry-run', 'Validate through check without writing approval.md or bundle')
|
|
345
|
+
.option('--strict', 'Strict lint (default: true)', true)
|
|
346
|
+
.option('--json', 'Output result as JSON')
|
|
347
|
+
.option('--no-lazy-check', 'Skip stub/lazy content validation on parts')
|
|
348
|
+
.option('--render', 'Also render approval.md to approval.html after finalize')
|
|
349
|
+
.option('--render-input <path>', 'Markdown input for --render (default: approval.md)')
|
|
350
|
+
.option('--render-output <path>', 'HTML output for --render (default: approval.html)')
|
|
351
|
+
.action(bindJsonAction(runApprovalFinalize)));
|
|
352
|
+
addStoreOption(approval
|
|
353
|
+
.command('render <change>')
|
|
354
|
+
.description('Render approval.md (or bundle markdown) to standalone HTML')
|
|
355
|
+
.option('--input <path>', 'Input markdown (default: approval.md)')
|
|
356
|
+
.option('--output <path>', 'Output HTML (default: same name with .html)')
|
|
357
|
+
.option('--title <text>', 'HTML document title override')
|
|
358
|
+
.option('--dry-run', 'Validate render without writing HTML file')
|
|
359
|
+
.option('--json', 'Output result as JSON')
|
|
360
|
+
.action(bindJsonAction((change, opts) => runApprovalRender(change, opts))));
|
|
60
361
|
addStoreOption(approval
|
|
61
362
|
.command('assemble <change>')
|
|
62
363
|
.description('Merge approval/parts into approval.md (deterministic, no LLM)')
|
|
63
364
|
.option('--force', 'Overwrite existing approval.md')
|
|
365
|
+
.option('--require-review', 'Require approval/review-result.json pass before assemble')
|
|
64
366
|
.option('--json', 'Output result as JSON')
|
|
65
367
|
.option('--no-lazy-check', 'Skip stub/lazy content validation on parts')
|
|
66
|
-
.action(
|
|
67
|
-
const { exitCode, payload } = await runApprovalAssemble(change, {
|
|
68
|
-
...opts,
|
|
69
|
-
checkOnly: false,
|
|
70
|
-
});
|
|
71
|
-
if (opts.json) {
|
|
72
|
-
console.info(JSON.stringify(payload, null, 2));
|
|
73
|
-
}
|
|
74
|
-
if (exitCode !== 0) {
|
|
75
|
-
process.exitCode = exitCode;
|
|
76
|
-
}
|
|
77
|
-
}));
|
|
368
|
+
.action(bindJsonAction((change, opts) => runApprovalAssemble(change, { ...opts, checkOnly: false }))));
|
|
78
369
|
addStoreOption(approval
|
|
79
370
|
.command('check <change>')
|
|
80
371
|
.description('Validate approval parts and index without writing approval.md')
|
|
81
372
|
.option('--json', 'Output result as JSON')
|
|
82
373
|
.option('--no-lazy-check', 'Skip stub/lazy content validation on parts')
|
|
83
|
-
.action(
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
374
|
+
.action(bindJsonAction((change, opts) => runApprovalAssemble(change, { ...opts, checkOnly: true }))));
|
|
375
|
+
addStoreOption(approval
|
|
376
|
+
.command('lint <change>')
|
|
377
|
+
.description('Cross-part consistency lint (IR freeze, pairs_with, page apis)')
|
|
378
|
+
.option('--strict', 'Treat inconsistency hints as errors')
|
|
379
|
+
.option('--json', 'Output result as JSON')
|
|
380
|
+
.action(bindJsonAction(runApprovalLint)));
|
|
381
|
+
addStoreOption(approval
|
|
382
|
+
.command('bundle <change>')
|
|
383
|
+
.description('Deterministic multi-repo bundle view from approval-playbook.yaml')
|
|
384
|
+
.option('--playbook <path>', 'Path to playbook YAML')
|
|
385
|
+
.option('--workspace-root <path>', 'Workspace root containing multiple repo planning roots')
|
|
386
|
+
.option('--dry-run', 'Validate bundle without writing output file')
|
|
387
|
+
.option('--json', 'Output result as JSON')
|
|
388
|
+
.action(bindJsonAction(runApprovalBundle)));
|
|
389
|
+
addStoreOption(approval
|
|
390
|
+
.command('review-pack <change>')
|
|
391
|
+
.description('Generate AI review packet (review-packet.json) for Stage 12e')
|
|
392
|
+
.option('--json', 'Output result as JSON')
|
|
393
|
+
.action(bindJsonAction(runApprovalReviewPack)));
|
|
394
|
+
addStoreOption(approval
|
|
395
|
+
.command('review-hashes <change>')
|
|
396
|
+
.description('Print part_hashes[] JSON for review-result.json after Stage 12e fixes')
|
|
397
|
+
.option('--json', 'Output result as JSON')
|
|
398
|
+
.action(bindJsonAction(runApprovalReviewHashes)));
|
|
399
|
+
addStoreOption(approval
|
|
400
|
+
.command('review-check <change>')
|
|
401
|
+
.description('Validate review-result.json + strict lint before assemble')
|
|
402
|
+
.option('--no-require-review', 'Skip requiring review-result when acp.review not enabled')
|
|
403
|
+
.option('--json', 'Output result as JSON')
|
|
404
|
+
.action(bindJsonAction((change, opts) => runApprovalReviewCheck(change, {
|
|
405
|
+
...opts,
|
|
406
|
+
requireReview: opts.noRequireReview ? false : true,
|
|
407
|
+
}))));
|
|
95
408
|
}
|
|
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
|
|
|
2
2
|
import { promises as fs } from 'node:fs';
|
|
3
3
|
import yaml from 'js-yaml';
|
|
4
4
|
import { parseApprovalIndex } from './index-schema.js';
|
|
5
|
+
import { findForbiddenMatch } from './forbidden-patterns.js';
|
|
5
6
|
import { getApprovalIndexPath, getApprovalManifestPath, getApprovalOutputPath, getApprovalPartPath, getApprovalPartsDir, } from './paths.js';
|
|
6
7
|
const OPTIONAL_PREFIXES = {
|
|
7
8
|
s5: ['05-', '05.'],
|
|
@@ -21,17 +22,6 @@ const CHAPTER_HEADERS = {
|
|
|
21
22
|
'appendix-a': '## 附录 A: 产物溯源',
|
|
22
23
|
};
|
|
23
24
|
const SECTION4_HEADER = '## 4. 方案详细设计 (Detailed Design)';
|
|
24
|
-
const LAZY_PATTERNS = [
|
|
25
|
-
/\bTODO\b/i,
|
|
26
|
-
/待补充/,
|
|
27
|
-
/待 refine 澄清(?!.*\[)/,
|
|
28
|
-
/此处省略/,
|
|
29
|
-
/详见\s*(?:上文|下文|附件)(?!.*§)/,
|
|
30
|
-
/略\s*[。.]?$/,
|
|
31
|
-
/TBD\s*[。.]?$/,
|
|
32
|
-
/(?:^|\n)\s*\.{3}\s*(?:\n|$)/,
|
|
33
|
-
/(?:^|\n)\s*(?:同上|同前)\s*[。.]?\s*(?:\n|$)/,
|
|
34
|
-
];
|
|
35
25
|
const MIN_PART_BYTES = {
|
|
36
26
|
'04.4': 200,
|
|
37
27
|
'04.5': 200,
|
|
@@ -80,10 +70,9 @@ function detectLazyContent(partId, content) {
|
|
|
80
70
|
return `part content below minimum for ${prefix} (${trimmed.length} bytes)`;
|
|
81
71
|
}
|
|
82
72
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
}
|
|
73
|
+
const forbidden = findForbiddenMatch(trimmed);
|
|
74
|
+
if (forbidden) {
|
|
75
|
+
return `lazy/stub pattern detected: ${forbidden.source}`;
|
|
87
76
|
}
|
|
88
77
|
if (/^#+\s*4\.\s/m.test(trimmed)) {
|
|
89
78
|
return 'part must not contain top-level "## 4." heading (assemble injects section 4)';
|
|
@@ -96,14 +85,36 @@ function detectLazyContent(partId, content) {
|
|
|
96
85
|
}
|
|
97
86
|
return null;
|
|
98
87
|
}
|
|
88
|
+
function partHasAppendixA(partContents) {
|
|
89
|
+
for (const content of partContents.values()) {
|
|
90
|
+
if (/^#{2,3}\s+附录\s*A/m.test(content)) {
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
99
96
|
function buildDocumentHeader(index) {
|
|
100
97
|
const meta = index.meta ?? {};
|
|
101
98
|
const lang = meta.language ?? 'en';
|
|
102
99
|
const stack = meta.tech_stack ?? 'unknown';
|
|
103
100
|
const projectMode = meta.project_mode ?? 'brownfield';
|
|
104
101
|
const modeNote = index.mode === 'segmented' ? 'segmented+assemble' : 'monolithic';
|
|
102
|
+
let multiRepoBlock = '';
|
|
103
|
+
const mr = index.multi_repo;
|
|
104
|
+
if (mr?.enabled && mr.repos.length >= 2) {
|
|
105
|
+
const mode = mr.document_mode ?? 'pending';
|
|
106
|
+
const primary = mr.primary_repo ?? '—';
|
|
107
|
+
const bundleNote = mr.bundle?.enabled ? ' | 合订: approval bundle (只读)' : '';
|
|
108
|
+
const repoLines = mr.repos
|
|
109
|
+
.map((r) => `- ${r.label} \`${r.id}/${r.change}\``)
|
|
110
|
+
.join('\n');
|
|
111
|
+
multiRepoBlock = `
|
|
112
|
+
> **多仓范围** (${mode}${mode === 'unified' ? `, 主仓: ${primary}` : mr.bundle?.enabled ? `, bundle 主仓: ${primary}` : ''}${bundleNote}):
|
|
113
|
+
${repoLines}
|
|
114
|
+
`;
|
|
115
|
+
}
|
|
105
116
|
return `# 技术方案审批文档: ${index.change}
|
|
106
|
-
|
|
117
|
+
${multiRepoBlock}
|
|
107
118
|
> 本文档由 \`/specflow:approval\` 基于 refine 收敛后的四件套 + 现有代码与 spec 基线生成,
|
|
108
119
|
> 含架构与详细设计等章节,供人工审批使用。AI 闭环/预审结论在对话中反馈,不写入本文。
|
|
109
120
|
> 生成时间: ${index.generated_at} | phase: refined | 产物语言: ${lang} | 技术栈: ${stack} | 项目模式: ${projectMode} | 生成模式: ${modeNote}
|
|
@@ -166,6 +177,42 @@ export async function assembleApprovalDocument(options) {
|
|
|
166
177
|
],
|
|
167
178
|
};
|
|
168
179
|
}
|
|
180
|
+
if (index.multi_repo?.enabled) {
|
|
181
|
+
const mr = index.multi_repo;
|
|
182
|
+
if (!mr.document_mode) {
|
|
183
|
+
diagnostics.push({
|
|
184
|
+
code: 'multi_repo_mode_missing',
|
|
185
|
+
severity: 'error',
|
|
186
|
+
message: 'multi_repo.enabled but document_mode not set; ask user unified vs per_repo before assemble',
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
if (mr.document_mode === 'unified') {
|
|
190
|
+
if (!mr.primary_repo) {
|
|
191
|
+
diagnostics.push({
|
|
192
|
+
code: 'multi_repo_primary_missing',
|
|
193
|
+
severity: 'error',
|
|
194
|
+
message: 'multi_repo.document_mode=unified requires primary_repo',
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
const primary = mr.repos.find((r) => r.id === mr.primary_repo);
|
|
199
|
+
if (primary && primary.change !== index.change) {
|
|
200
|
+
diagnostics.push({
|
|
201
|
+
code: 'multi_repo_change_mismatch',
|
|
202
|
+
severity: 'warning',
|
|
203
|
+
message: `index.change "${index.change}" differs from primary repo change "${primary.change}"; assemble in primary planning root`,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (mr.document_mode === 'per_repo' && !mr.outputs?.per_repo?.length) {
|
|
209
|
+
diagnostics.push({
|
|
210
|
+
code: 'multi_repo_outputs_missing',
|
|
211
|
+
severity: 'warning',
|
|
212
|
+
message: 'per_repo mode should list outputs.per_repo[] with each repo approval path',
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
}
|
|
169
216
|
const partsDir = getApprovalPartsDir(changeDir);
|
|
170
217
|
try {
|
|
171
218
|
await fs.access(partsDir);
|
|
@@ -225,7 +272,7 @@ export async function assembleApprovalDocument(options) {
|
|
|
225
272
|
}
|
|
226
273
|
body.push(content.trim(), '', '---', '');
|
|
227
274
|
}
|
|
228
|
-
if (!index.parts_order.includes('appendix-a')) {
|
|
275
|
+
if (!index.parts_order.includes('appendix-a') && !partHasAppendixA(partContents)) {
|
|
229
276
|
body.push(buildAppendix().trim());
|
|
230
277
|
}
|
|
231
278
|
for (const table of index.tables) {
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ApprovalBundleResult } from './types.js';
|
|
2
|
+
export interface BundleApprovalOptions {
|
|
3
|
+
readonly workspaceRoot: string;
|
|
4
|
+
readonly changeDir: string;
|
|
5
|
+
readonly playbookPath: string;
|
|
6
|
+
readonly write?: boolean;
|
|
7
|
+
}
|
|
8
|
+
export declare function bundleApprovalDocument(options: BundleApprovalOptions): Promise<ApprovalBundleResult>;
|
|
9
|
+
export declare function resolvePlaybookPath(changeDir: string, explicitPath?: string): Promise<string>;
|