@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.
Files changed (41) hide show
  1. package/README.md +2 -2
  2. package/dist/cli/commands/approval-assemble.d.ts +48 -5
  3. package/dist/cli/commands/approval-assemble.js +347 -34
  4. package/dist/core/approval/assemble.js +64 -17
  5. package/dist/core/approval/bundle.d.ts +9 -0
  6. package/dist/core/approval/bundle.js +172 -0
  7. package/dist/core/approval/forbidden-patterns.d.ts +6 -0
  8. package/dist/core/approval/forbidden-patterns.js +37 -0
  9. package/dist/core/approval/index-schema.d.ts +432 -0
  10. package/dist/core/approval/index-schema.js +103 -0
  11. package/dist/core/approval/index.d.ts +10 -2
  12. package/dist/core/approval/index.js +7 -1
  13. package/dist/core/approval/lint.d.ts +10 -0
  14. package/dist/core/approval/lint.js +302 -0
  15. package/dist/core/approval/paths.d.ts +5 -0
  16. package/dist/core/approval/paths.js +15 -0
  17. package/dist/core/approval/pipeline.d.ts +28 -0
  18. package/dist/core/approval/pipeline.js +146 -0
  19. package/dist/core/approval/playbook-schema.d.ts +182 -0
  20. package/dist/core/approval/playbook-schema.js +51 -0
  21. package/dist/core/approval/render.d.ts +20 -0
  22. package/dist/core/approval/render.js +210 -0
  23. package/dist/core/approval/review-pack.d.ts +26 -0
  24. package/dist/core/approval/review-pack.js +205 -0
  25. package/dist/core/approval/types.d.ts +131 -0
  26. package/dist/integrations/shared/capability-evidence.js +2 -0
  27. package/dist/integrations/shared/parity-manifest.js +2 -0
  28. package/package.json +2 -1
  29. package/prompts/approval/acp-pipeline.md +104 -0
  30. package/prompts/approval/ai-review.md +145 -0
  31. package/prompts/approval/api-guidance.md +179 -0
  32. package/prompts/approval/generate.md +164 -13
  33. package/prompts/approval/multi-repo-guidance.md +238 -0
  34. package/prompts/approval/project-conventions-guidance.md +1 -1
  35. package/prompts/approval/runtime-guidance.md +64 -0
  36. package/prompts/approval/segmented-generation.md +23 -11
  37. package/skills/GUIDANCE_PACKS.md +2 -2
  38. package/skills/specflow-approval/SKILL.md +80 -18
  39. package/templates/approval-index.yaml +41 -0
  40. package/templates/approval-part.md +1 -1
  41. 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.4.6-beta)
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 对四件套做 7 维闭环性检查(含代码落地性、基线对照)+ 设计质量评估 + 可实施性评估,生成 `approval.md` 供人工审批;非轻量变更分片写入 `approval/parts/` `specflow approval assemble` 拼接;不改 phase,不阻塞 apply |
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
- * Deterministic Reduce step for segmented approval generation.
5
- * No LLM merges approval/parts/*.md per approval/index.yaml.
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 ApprovalAssembleCommandOptions {
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: ApprovalAssembleCommandOptions & {
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
- * Deterministic Reduce step for segmented approval generation.
5
- * No LLM merges approval/parts/*.md per approval/index.yaml.
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, getChangeDirectory, getApprovalOutputPath, } from '../../core/approval/index.js';
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
- for (const d of result.diagnostics) {
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(async (change, opts) => {
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(async (change, opts) => {
84
- const { exitCode, payload } = await runApprovalAssemble(change, {
85
- ...opts,
86
- checkOnly: true,
87
- });
88
- if (opts.json) {
89
- console.info(JSON.stringify(payload, null, 2));
90
- }
91
- if (exitCode !== 0) {
92
- process.exitCode = exitCode;
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
- for (const pattern of LAZY_PATTERNS) {
84
- if (pattern.test(trimmed)) {
85
- return `lazy/stub pattern detected: ${pattern.source}`;
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>;