@gordon.gan/specflow 1.8.0-beta → 1.8.2-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 (181) hide show
  1. package/README.md +7 -5
  2. package/dist/cli/commands/document-run.d.ts +98 -0
  3. package/dist/cli/commands/document-run.js +1064 -0
  4. package/dist/cli/index.js +2 -2
  5. package/dist/core/artifact-language.js +11 -1
  6. package/dist/core/document/asset-paths.d.ts +15 -0
  7. package/dist/core/document/asset-paths.js +38 -0
  8. package/dist/core/document/chapters.d.ts +6 -0
  9. package/dist/core/document/chapters.js +38 -0
  10. package/dist/core/document/coverage.d.ts +25 -0
  11. package/dist/core/document/coverage.js +49 -0
  12. package/dist/core/document/digests.d.ts +40 -0
  13. package/dist/core/document/digests.js +144 -0
  14. package/dist/core/document/engine.d.ts +101 -0
  15. package/dist/core/document/engine.js +722 -0
  16. package/dist/core/document/extract.d.ts +61 -0
  17. package/dist/core/document/extract.js +437 -0
  18. package/dist/core/{approval → document}/forbidden-patterns.d.ts +4 -2
  19. package/dist/core/document/forbidden-patterns.js +36 -0
  20. package/dist/core/document/gates.d.ts +39 -0
  21. package/dist/core/document/gates.js +75 -0
  22. package/dist/core/document/input-digest.d.ts +63 -0
  23. package/dist/core/document/input-digest.js +200 -0
  24. package/dist/core/document/input-features.d.ts +43 -0
  25. package/dist/core/document/input-features.js +161 -0
  26. package/dist/core/document/lint.d.ts +39 -0
  27. package/dist/core/document/lint.js +147 -0
  28. package/dist/core/document/llm.d.ts +28 -0
  29. package/dist/core/document/llm.js +22 -0
  30. package/dist/core/document/map.d.ts +52 -0
  31. package/dist/core/document/map.js +296 -0
  32. package/dist/core/document/outline.d.ts +29 -0
  33. package/dist/core/document/outline.js +171 -0
  34. package/dist/core/document/paths.d.ts +23 -0
  35. package/dist/core/document/paths.js +36 -0
  36. package/dist/core/document/profile-validator.d.ts +23 -0
  37. package/dist/core/document/profile-validator.js +155 -0
  38. package/dist/core/document/profiles.d.ts +23 -0
  39. package/dist/core/document/profiles.js +76 -0
  40. package/dist/core/document/render.d.ts +61 -0
  41. package/dist/core/document/render.js +243 -0
  42. package/dist/core/document/review.d.ts +57 -0
  43. package/dist/core/document/review.js +202 -0
  44. package/dist/core/document/scene-detect.d.ts +32 -0
  45. package/dist/core/document/scene-detect.js +237 -0
  46. package/dist/core/document/schemas.d.ts +1111 -0
  47. package/dist/core/document/schemas.js +191 -0
  48. package/dist/integrations/shared/capability-evidence.js +4 -9
  49. package/dist/integrations/shared/command-catalog.js +2 -1
  50. package/dist/integrations/shared/parity-manifest.js +4 -9
  51. package/package.json +2 -1
  52. package/prompts/apply/phase-a-plan.md +1 -1
  53. package/prompts/document/map/acceptance.md +9 -0
  54. package/prompts/document/map/anti-ai.md +29 -0
  55. package/prompts/document/map/api-design.md +26 -0
  56. package/prompts/document/map/architecture.md +27 -0
  57. package/prompts/document/map/benchmark.md +26 -0
  58. package/prompts/document/map/closed-loop.md +9 -0
  59. package/prompts/document/map/compat-migration.md +31 -0
  60. package/prompts/document/map/component-design.md +30 -0
  61. package/prompts/document/map/config-runtime.md +9 -0
  62. package/prompts/document/map/core-flow.md +62 -0
  63. package/prompts/document/map/core-logic.md +9 -0
  64. package/prompts/document/map/data-model.md +18 -0
  65. package/prompts/document/map/deploy.md +26 -0
  66. package/prompts/document/map/fix.md +8 -0
  67. package/prompts/document/map/frontend-architecture.md +35 -0
  68. package/prompts/document/map/goal.md +8 -0
  69. package/prompts/document/map/impact.md +8 -0
  70. package/prompts/document/map/implementability.md +9 -0
  71. package/prompts/document/map/migration-guide.md +36 -0
  72. package/prompts/document/map/mvp-boundary.md +8 -0
  73. package/prompts/document/map/non-goals.md +8 -0
  74. package/prompts/document/map/ops.md +33 -0
  75. package/prompts/document/map/performance.md +32 -0
  76. package/prompts/document/map/poc-demo.md +25 -0
  77. package/prompts/document/map/regression.md +8 -0
  78. package/prompts/document/map/reproduce.md +8 -0
  79. package/prompts/document/map/requirement.md +8 -0
  80. package/prompts/document/map/research.md +25 -0
  81. package/prompts/document/map/root-cause.md +8 -0
  82. package/prompts/document/map/signoff.md +9 -0
  83. package/prompts/document/map/state-management.md +23 -0
  84. package/prompts/document/map/tech-selection.md +29 -0
  85. package/prompts/document/map/test-strategy.md +24 -0
  86. package/prompts/document/map/ui-design.md +21 -0
  87. package/prompts/document/outline/general.md +46 -0
  88. package/prompts/document/review/ai-review.md +57 -0
  89. package/prompts/document/shared/grounding.md +84 -0
  90. package/prompts/explore/explore-session.md +1 -1
  91. package/prompts/propose/design-draft.md +1 -1
  92. package/prompts/propose/proposal.md +1 -1
  93. package/prompts/propose/specs.md +1 -1
  94. package/prompts/propose/tasks-draft.md +1 -1
  95. package/prompts/refine/design-output.md +1 -1
  96. package/prompts/refine/update-artifacts.md +2 -0
  97. package/prompts/shared/artifact-language.md +13 -0
  98. package/skills/specflow-techdoc/SKILL.md +143 -0
  99. package/skills/specflow-techdoc-synth/SKILL.md +115 -0
  100. package/templates/document/chapters/acceptance.yaml +16 -0
  101. package/templates/document/chapters/api-design.yaml +29 -0
  102. package/templates/document/chapters/architecture.yaml +21 -0
  103. package/templates/document/chapters/benchmark.yaml +20 -0
  104. package/templates/document/chapters/closed-loop.yaml +22 -0
  105. package/templates/document/chapters/compat-migration.yaml +21 -0
  106. package/templates/document/chapters/component-design.yaml +22 -0
  107. package/templates/document/chapters/config-runtime.yaml +16 -0
  108. package/templates/document/chapters/core-flow.yaml +27 -0
  109. package/templates/document/chapters/core-logic.yaml +16 -0
  110. package/templates/document/chapters/data-model.yaml +25 -0
  111. package/templates/document/chapters/deploy.yaml +22 -0
  112. package/templates/document/chapters/fix.yaml +18 -0
  113. package/templates/document/chapters/frontend-architecture.yaml +22 -0
  114. package/templates/document/chapters/goal.yaml +17 -0
  115. package/templates/document/chapters/impact.yaml +16 -0
  116. package/templates/document/chapters/implementability.yaml +22 -0
  117. package/templates/document/chapters/migration-guide.yaml +21 -0
  118. package/templates/document/chapters/mvp-boundary.yaml +20 -0
  119. package/templates/document/chapters/non-goals.yaml +17 -0
  120. package/templates/document/chapters/ops.yaml +25 -0
  121. package/templates/document/chapters/performance.yaml +21 -0
  122. package/templates/document/chapters/poc-demo.yaml +22 -0
  123. package/templates/document/chapters/regression.yaml +16 -0
  124. package/templates/document/chapters/reproduce.yaml +16 -0
  125. package/templates/document/chapters/requirement.yaml +18 -0
  126. package/templates/document/chapters/research.yaml +22 -0
  127. package/templates/document/chapters/root-cause.yaml +18 -0
  128. package/templates/document/chapters/signoff.yaml +15 -0
  129. package/templates/document/chapters/state-management.yaml +22 -0
  130. package/templates/document/chapters/tech-selection.yaml +23 -0
  131. package/templates/document/chapters/test-strategy.yaml +23 -0
  132. package/templates/document/chapters/ui-design.yaml +28 -0
  133. package/templates/document/profiles/0to1.yaml +44 -0
  134. package/templates/document/profiles/approve.yaml +22 -0
  135. package/templates/document/profiles/bugfix.yaml +15 -0
  136. package/templates/document/profiles/feature.yaml +23 -0
  137. package/templates/document/profiles/frontend-0to1.yaml +47 -0
  138. package/templates/document/profiles/migration.yaml +42 -0
  139. package/templates/document/profiles/poc.yaml +46 -0
  140. package/dist/cli/commands/approval-assemble.d.ts +0 -64
  141. package/dist/cli/commands/approval-assemble.js +0 -408
  142. package/dist/core/approval/assemble.d.ts +0 -10
  143. package/dist/core/approval/assemble.js +0 -384
  144. package/dist/core/approval/bundle.d.ts +0 -9
  145. package/dist/core/approval/bundle.js +0 -172
  146. package/dist/core/approval/forbidden-patterns.js +0 -40
  147. package/dist/core/approval/index-schema.d.ts +0 -676
  148. package/dist/core/approval/index-schema.js +0 -193
  149. package/dist/core/approval/index.d.ts +0 -12
  150. package/dist/core/approval/index.js +0 -9
  151. package/dist/core/approval/lint.d.ts +0 -10
  152. package/dist/core/approval/lint.js +0 -302
  153. package/dist/core/approval/paths.d.ts +0 -13
  154. package/dist/core/approval/paths.js +0 -43
  155. package/dist/core/approval/pipeline.d.ts +0 -28
  156. package/dist/core/approval/pipeline.js +0 -146
  157. package/dist/core/approval/playbook-schema.d.ts +0 -182
  158. package/dist/core/approval/playbook-schema.js +0 -51
  159. package/dist/core/approval/render.d.ts +0 -20
  160. package/dist/core/approval/render.js +0 -210
  161. package/dist/core/approval/review-pack.d.ts +0 -26
  162. package/dist/core/approval/review-pack.js +0 -205
  163. package/dist/core/approval/types.d.ts +0 -223
  164. package/dist/core/approval/types.js +0 -1
  165. package/prompts/approval/acp-pipeline.md +0 -106
  166. package/prompts/approval/ai-review.md +0 -145
  167. package/prompts/approval/api-guidance.md +0 -179
  168. package/prompts/approval/database-guidance.md +0 -105
  169. package/prompts/approval/frontend-guidance.md +0 -249
  170. package/prompts/approval/generate.md +0 -1596
  171. package/prompts/approval/multi-repo-guidance.md +0 -263
  172. package/prompts/approval/multi-repo-spoke-subagent.md +0 -94
  173. package/prompts/approval/project-conventions-guidance.md +0 -171
  174. package/prompts/approval/runtime-guidance.md +0 -64
  175. package/prompts/approval/segmented-generation.md +0 -157
  176. package/skills/specflow-approval/SKILL.md +0 -778
  177. package/templates/approval-index.yaml +0 -93
  178. package/templates/approval-part.md +0 -15
  179. package/templates/approval-playbook-talos-scenario-job-compile.yaml +0 -29
  180. package/templates/approval-playbook.yaml +0 -28
  181. package/templates/approval.md +0 -261
@@ -0,0 +1,722 @@
1
+ /**
2
+ * Document Engine — unified orchestration entry (T11.2).
3
+ *
4
+ * runDocument wires Pass 0.5 → 1 → 2 → 3 → 4 + gates. Consumers inject the LLM,
5
+ * the work root, and the input. An `outlineOnly` mode stops after Pass 1 (for user gate).
6
+ */
7
+ import { promises as fs } from 'node:fs';
8
+ import { join } from 'node:path';
9
+ import { estimateTokens } from './llm.js';
10
+ import { compressInputs } from './input-digest.js';
11
+ import { generateOutline, lintOutline, outlineHasErrors, freezeOutline, readOutline } from './outline.js';
12
+ import { expandProfile } from './profiles.js';
13
+ import { generateDigest, extractConstraintList, lintDigest, deterministicDigest, recordDigestSources, affectedDownstream } from './digests.js';
14
+ import { fillChapter, fillOnce, shouldFillOnce } from './map.js';
15
+ import { buildReviewPacket, reviewWithAgent, checkReviewResult, writeReviewResult, computePartHashes, applyFindings, sha256 } from './review.js';
16
+ import { renderDocument, renderHtml } from './render.js';
17
+ import { runGates } from './gates.js';
18
+ import { resolvePaths, reposPath, chapterPath } from './paths.js';
19
+ import { parseEntitiesLenient } from './schemas.js';
20
+ import { lintNarrative, checkRepoSectionCoverage } from './lint.js';
21
+ import { checkStructuredCoverage } from './coverage.js';
22
+ import { resolveProjectConventionPaths } from '../project-conventions.js';
23
+ import { extractContracts, EMPTY_CONTRACTS } from './extract.js';
24
+ const DEFAULT_MAX_ROUNDS = 3;
25
+ const OUTLINE_MAX_CONFIRM_ROUNDS = 2;
26
+ export async function runDocument(options) {
27
+ const { llm, workRoot, profile, library, inputs, inputFeatures, change } = options;
28
+ const maxRounds = options.maxRounds ?? DEFAULT_MAX_ROUNDS;
29
+ const persist = options.persist ?? true;
30
+ const paths = resolvePaths(workRoot);
31
+ // --- Pass 0.5: input compression (T4) ---
32
+ const inputDigest = await compressInputs(workRoot, inputs, llm);
33
+ // --- Pass 0.5b: deterministic contract extraction (grounding P0, engine-level) ---
34
+ // 显式注入的提取结果优先(测试/宿主);否则从 projectRoot + 输入文件扫描 OpenAPI/proto/DDL。
35
+ const extractedContracts = options.extractedContracts
36
+ ?? (options.projectRoot
37
+ ? await extractContracts(inputs, options.projectRoot)
38
+ : EMPTY_CONTRACTS);
39
+ // --- Pass 1: outline (T5) with user confirmation gate (T5.4) ---
40
+ const expanded = expandProfile(profile, library, inputFeatures);
41
+ if (expanded.chapters.length === 0) {
42
+ return { ok: false, stage: 'profile-expand', diagnostics: expanded.diagnostics.map((d) => ({ gate: 'profile', code: d.code, severity: d.severity, message: d.message })) };
43
+ }
44
+ let outline;
45
+ let confirmed = false;
46
+ const confirmer = options.outlineConfirmer;
47
+ for (let round = 0; round < OUTLINE_MAX_CONFIRM_ROUNDS; round++) {
48
+ outline = await generateOutline(llm, { profile, inputDigest, prompt: options.outlinePrompt, extractedContracts });
49
+ const outlineDiags = lintOutline(outline, profile);
50
+ if (outlineHasErrors(outlineDiags)) {
51
+ return { ok: false, stage: 'outline-lint', diagnostics: outlineDiags.map((d) => ({ gate: 'outline', code: d.code, severity: d.severity, message: d.message, chapter: d.chapter })) };
52
+ }
53
+ // User confirmation gate: if a confirmer is provided, ask; reject → regenerate.
54
+ if (confirmer) {
55
+ if (await confirmer(outline)) {
56
+ confirmed = true;
57
+ break;
58
+ }
59
+ // rejected → loop regenerates
60
+ }
61
+ else {
62
+ confirmed = true; // non-interactive / test path: auto-accept
63
+ break;
64
+ }
65
+ }
66
+ if (!confirmed || !outline) {
67
+ return { ok: false, stage: 'outline-unconfirmed', outline: outline ?? undefined, diagnostics: [{ gate: 'outline', code: 'outline_rejected', severity: 'error', message: 'Outline rejected by user after max confirm rounds' }] };
68
+ }
69
+ if (persist)
70
+ await freezeOutline(workRoot, outline);
71
+ if (options.outlineOnly) {
72
+ return { ok: true, stage: 'outline-only', outline, diagnostics: [] };
73
+ }
74
+ // --- Chapter-by-chapter fill (Pass 2, T6) with real dependency digests (T7) ---
75
+ const chapterById = library;
76
+ const ctx = { outline, digests: new Map(), upstreamEntities: { interfaces: [], tables: [], decisions: [] }, workRoot, chapterById, contextCache: new Map() };
77
+ // Process in DAG order: outline.chapters is already topological from expandProfile.
78
+ const narrativeMap = new Map();
79
+ const entityMap = new Map();
80
+ const tokenMap = new Map();
81
+ const budgetMap = new Map();
82
+ const allEntities = { interfaces: [], tables: [], decisions: [] };
83
+ const useFillOnce = await shouldFillOnce(outline, ctx);
84
+ if (useFillOnce) {
85
+ const result = await fillOnce(llm, outline, ctx);
86
+ if (!result.ok) {
87
+ // Honest degradation: fill_once failed → fall back to per-chapter fill (decision 9).
88
+ const fillErr = await fillAllChapters(outline, ctx, narrativeMap, entityMap, tokenMap, budgetMap, allEntities, llm, persist, paths, options.incremental);
89
+ if (fillErr.length > 0) {
90
+ return { ok: false, stage: 'fill', outline, diagnostics: fillErr };
91
+ }
92
+ }
93
+ else {
94
+ for (const f of result.chapters) {
95
+ narrativeMap.set(f.chapterId, f.narrative);
96
+ entityMap.set(f.chapterId, f.entities);
97
+ tokenMap.set(f.chapterId, f.tokens);
98
+ budgetMap.set(f.chapterId, chapterById.get(f.chapterId)?.output_budget_tokens);
99
+ allEntities.interfaces.push(...f.entities.interfaces);
100
+ allEntities.tables.push(...f.entities.tables);
101
+ allEntities.decisions.push(...f.entities.decisions);
102
+ if (persist) {
103
+ await fs.mkdir(paths.chaptersDir, { recursive: true });
104
+ await fs.writeFile(chapterPath(paths.workRoot, f.chapterId), f.narrative, 'utf-8');
105
+ }
106
+ }
107
+ }
108
+ }
109
+ else {
110
+ const fillErr = await fillAllChapters(outline, ctx, narrativeMap, entityMap, tokenMap, budgetMap, allEntities, llm, persist, paths, options.incremental);
111
+ if (fillErr.length > 0) {
112
+ return { ok: false, stage: 'fill', outline, diagnostics: fillErr };
113
+ }
114
+ }
115
+ // --- Pass 3: AI review with real fix→re-review loop (T8, decision 9) ---
116
+ if (persist)
117
+ await fs.writeFile(paths.entities, JSON.stringify(allEntities, null, 2), 'utf-8');
118
+ let review;
119
+ let reviewDiags = [];
120
+ if (!options.skipReview) {
121
+ let prevFindings = [];
122
+ // 跨轮去重:同一 finding 只应用一次(open → 应用后标记;fixed/waived 不再应用)。
123
+ const appliedFindingIds = new Set();
124
+ // 跨轮累积修复章节:post_review_repairs 必须记录所有轮次的 engine 改动,
125
+ // 否则后一轮(无新修复)会用空集合覆盖前一轮的 tamper 豁免记录。
126
+ const touchedChapters = new Set();
127
+ for (let round = 0; round < maxRounds; round++) {
128
+ const packet = await buildReviewPacket(workRoot, change, outline, prevFindings, narrativeMap);
129
+ const candidate = await reviewWithAgent(llm, packet, options.reviewPrompt);
130
+ // Authoritative snapshot of what the reviewer actually saw: the LLM cannot compute sha256
131
+ // reliably, so the engine replaces the reviewer-reported hashes with its own computation
132
+ // taken at review time. This snapshot becomes the tamper baseline — it is NOT refreshed
133
+ // after repairs (that made the check vacuous).
134
+ // persist=false 时磁盘无章节文件 → 基于内存 narrativeMap 计算(P2:否则篡改校验形同虚设)。
135
+ if (persist) {
136
+ candidate.part_hashes = await computePartHashes(workRoot, outline.chapters.map((c) => c.id));
137
+ }
138
+ else {
139
+ candidate.part_hashes = [...narrativeMap.entries()].map(([id, content]) => ({ id, sha256: sha256(content) }));
140
+ }
141
+ // Apply findings by target:
142
+ // narrative → append fix note to chapters/<id>.md
143
+ // entity → regenerate the affected chapter (real entity repair via re-fill)
144
+ const entityRefills = [];
145
+ await applyFindings(workRoot, candidate.findings, async (finding) => {
146
+ const target = finding.target ?? 'narrative';
147
+ if (target === 'entity' || target === 'mixed') {
148
+ if (finding.chapter)
149
+ entityRefills.push(finding.chapter);
150
+ }
151
+ if (target === 'narrative' || target === 'mixed') {
152
+ const chId = finding.chapter;
153
+ if (chId) {
154
+ touchedChapters.add(chId);
155
+ const existing = narrativeMap.get(chId) ?? '';
156
+ // Note format: message INSIDE the HTML comment so `stripReviewNotes` can remove the
157
+ // whole note from the final render; the audit trail stays in chapters/<id>.md and
158
+ // review-result.json. message/id 可能含 `-->`,先净化避免注释提前闭合注入 HTML。
159
+ const safeId = String(finding.id).replace(/--/g, '—');
160
+ const safeMsg = String(finding.message).replace(/--/g, '—').replace(/<!--/g, '〈!--');
161
+ narrativeMap.set(chId, `${existing}\n\n<!-- review-fix ${safeId}: ${safeMsg} -->`);
162
+ if (persist) {
163
+ await fs.mkdir(paths.chaptersDir, { recursive: true });
164
+ await fs.writeFile(chapterPath(paths.workRoot, chId), narrativeMap.get(chId), 'utf-8');
165
+ }
166
+ }
167
+ }
168
+ }, appliedFindingIds);
169
+ // Entity targets: re-fill the affected chapters so contract entities are regenerated (real fix).
170
+ for (const chId of new Set(entityRefills)) {
171
+ const ch = outline.chapters.find((c) => c.id === chId);
172
+ if (!ch)
173
+ continue;
174
+ touchedChapters.add(ch.id);
175
+ ctx.upstreamEntities = allEntities;
176
+ ctx.digests = await buildDigestsFor(ch, ctx.chapterById, outline, narrativeMap, allEntities, llm);
177
+ const result = await fillChapter(llm, ch, ctx, ctx.chapterById.get(ch.id)?.output_budget_tokens);
178
+ if (result.ok && result.fill) {
179
+ narrativeMap.set(ch.id, result.fill.narrative);
180
+ tokenMap.set(ch.id, result.fill.tokens);
181
+ // 按「章节所有权」整体替换该章节的实体(P2):先移除该章旧实体(含 id 漂移的陈旧实体),
182
+ // 再合并新实体,避免全量按 id 过滤时误删他章副本/残留已改名实体。
183
+ // 注意顺序:先读旧实体(entityMap 当前值)用于移除,再写新实体。
184
+ const oldEntities = entityMap.get(ch.id);
185
+ removeChapterEntities(allEntities, oldEntities);
186
+ mergeEntities(allEntities, result.fill.entities);
187
+ entityMap.set(ch.id, result.fill.entities);
188
+ if (persist) {
189
+ await fs.mkdir(paths.chaptersDir, { recursive: true });
190
+ await fs.writeFile(chapterPath(paths.workRoot, ch.id), result.fill.narrative, 'utf-8');
191
+ await fs.writeFile(paths.entities, JSON.stringify(allEntities, null, 2), 'utf-8');
192
+ }
193
+ }
194
+ else {
195
+ // refill 失败(P2):记录诊断而非静默吞掉,避免问题只在模糊的 review_finding_open 中暴露。
196
+ reviewDiags.push({
197
+ gate: 'review',
198
+ code: 'entity_refill_failed',
199
+ severity: 'warning',
200
+ message: `Review entity refill for chapter "${ch.id}" failed: ${result.error ?? 'unknown error'}`,
201
+ chapter: ch.id,
202
+ });
203
+ }
204
+ }
205
+ // Record which chapters the ENGINE changed after the review snapshot (repairs are
206
+ // engine-sanctioned, not external tampering) so the tamper check can distinguish the two.
207
+ candidate.post_review_repairs = [...touchedChapters].map((chapter) => ({
208
+ chapter,
209
+ at: new Date().toISOString(),
210
+ }));
211
+ // 当前哈希:persist 时读磁盘,否则用内存 narrativeMap(P2:与 review 时快照同源)。
212
+ const currentHashes = persist
213
+ ? await computePartHashes(workRoot, outline.chapters.map((c) => c.id))
214
+ : [...narrativeMap.entries()].map(([id, content]) => ({ id, sha256: sha256(content) }));
215
+ const check = await checkReviewResult(workRoot, candidate, outline, currentHashes);
216
+ review = candidate;
217
+ reviewDiags = check.diagnostics.map((d) => ({ gate: 'review', code: d.code, severity: d.severity, message: d.message }));
218
+ if (check.ok) {
219
+ break;
220
+ }
221
+ // Feed this round's findings to the next round so the reviewer verifies the applied
222
+ // fix notes instead of re-reporting the same issues (closed loop, decision 9).
223
+ prevFindings = candidate.findings;
224
+ }
225
+ }
226
+ if (review && persist) {
227
+ await writeReviewResult(workRoot, review);
228
+ }
229
+ // --- Pass 4: render (T9) ---
230
+ const appendixAntiAI = await readAppendix(workRoot, 'anti-ai');
231
+ const documentMd = renderDocument({ outline, entities: allEntities, narratives: narrativeMap, profile, appendixAntiAI });
232
+ if (persist)
233
+ await fs.writeFile(paths.documentMd, documentMd, 'utf-8');
234
+ let documentHtml;
235
+ if (options.renderHtmlOutput !== false) {
236
+ documentHtml = await renderHtml(documentMd, change);
237
+ if (persist)
238
+ await fs.writeFile(paths.documentHtml, documentHtml, 'utf-8');
239
+ }
240
+ // --- Gates (T10) ---
241
+ // Multi-repo synthesize marker: repos.json (written by document synthesize) → runGates enforces
242
+ // the per-repo section gate in the library pipeline too (not only Agent-mode validateWork).
243
+ let synthRepos = [];
244
+ try {
245
+ const raw = JSON.parse(await fs.readFile(reposPath(workRoot), 'utf-8'));
246
+ synthRepos = Array.isArray(raw) ? raw.filter((r) => typeof r === 'string') : [];
247
+ }
248
+ catch {
249
+ // no repos.json → not a synthesize workRoot → gate skipped (bundle/run unaffected)
250
+ }
251
+ const gates = await runGates({
252
+ outline,
253
+ chapterNarratives: narrativeMap,
254
+ chapterEntities: entityMap,
255
+ chapterTokens: tokenMap,
256
+ chapterBudget: budgetMap,
257
+ entities: allEntities,
258
+ review,
259
+ workRoot,
260
+ library,
261
+ repos: synthRepos,
262
+ // 审查篡改校验的当前哈希(P2):persist=false 时磁盘无章节,传最终内存快照。
263
+ currentHashes: review && persist
264
+ ? await computePartHashes(workRoot, outline.chapters.map((c) => c.id))
265
+ : review
266
+ ? [...narrativeMap.entries()].map(([id, content]) => ({ id, sha256: sha256(content) }))
267
+ : undefined,
268
+ });
269
+ // 附录覆盖校验(§7.3):anti-ai 自检条目为空/不足 → warning。
270
+ const appendixDiags = appendixDiagnostics(profile, appendixAntiAI);
271
+ let totalTokens = 0;
272
+ for (const t of tokenMap.values())
273
+ totalTokens += t;
274
+ return {
275
+ ok: gates.ok,
276
+ stage: gates.ok ? 'done' : 'gates-fail',
277
+ outline,
278
+ documentMd,
279
+ documentHtml,
280
+ diagnostics: [...reviewDiags, ...appendixDiags, ...gates.diagnostics],
281
+ tokenEstimate: totalTokens,
282
+ };
283
+ }
284
+ /** Read an LLM-written appendix file (appendices/<name>.md); missing → empty string. */
285
+ async function readAppendix(workRoot, name) {
286
+ try {
287
+ return await fs.readFile(join(workRoot, 'appendices', `${name}.md`), 'utf-8');
288
+ }
289
+ catch {
290
+ return '';
291
+ }
292
+ }
293
+ /** 附录覆盖校验(README §7.3):profile.appendices 含 anti-ai 但自检条目为空/不足 → warning。 */
294
+ export function appendixDiagnostics(profile, antiAiContent) {
295
+ if (!profile.appendices.includes('anti-ai'))
296
+ return [];
297
+ const items = antiAiContent
298
+ .trim()
299
+ .split('\n')
300
+ .filter((l) => l.trim().length > 0 && !l.trim().startsWith('#'))
301
+ .length;
302
+ if (items === 0) {
303
+ return [{ gate: 'appendix', code: 'anti_ai_empty', severity: 'warning', message: '附录 B 去AI味自检条目为空(profile.appendices 含 anti-ai,需 ≥3 条)' }];
304
+ }
305
+ if (items < 3) {
306
+ return [{ gate: 'appendix', code: 'anti_ai_too_few', severity: 'warning', message: `附录 B 去AI味自检仅 ${items} 条(需 ≥3 条)` }];
307
+ }
308
+ return [];
309
+ }
310
+ /**
311
+ * Build dependency digests for a chapter's direct deps (decisions 2 + 8).
312
+ * For each dep chapter, derive its constraint list (points/entities/decisions it must preserve),
313
+ * generate a bounded LLM summary carrying those constraints, then bidirectional lint:
314
+ * - forward: digest ids must exist in source entities
315
+ * - reverse: every constraint item must appear in the digest
316
+ * On lint error, fall back to the deterministic entity-registry digest (honest degradation).
317
+ */
318
+ async function buildDigestsFor(chapter, library, outline, narrativeMap, allEntities, llm) {
319
+ const digests = new Map();
320
+ const chapterById = new Map(outline.chapters.map((ch) => [ch.id, ch]));
321
+ const comp = library.get(chapter.id);
322
+ for (const dep of comp?.depends_on ?? []) {
323
+ const depChapter = chapterById.get(dep);
324
+ if (!depChapter)
325
+ continue;
326
+ const source = narrativeMap.get(dep) ?? '';
327
+ const constraints = extractConstraintList(depChapter, allEntities);
328
+ const knownIds = new Set([
329
+ ...allEntities.interfaces.map((i) => i.id),
330
+ ...allEntities.tables.map((t) => t.id),
331
+ ...allEntities.decisions.map((d) => d.id),
332
+ ]);
333
+ let digest = '';
334
+ if (source.trim().length > 0) {
335
+ try {
336
+ digest = await generateDigest(llm, {
337
+ chapterId: dep,
338
+ chapterTitle: depChapter.title,
339
+ source,
340
+ constraints,
341
+ });
342
+ }
343
+ catch {
344
+ digest = ''; // LLM failure → fall through to deterministic registry
345
+ }
346
+ }
347
+ // Bidirectional lint; on error fall back to deterministic registry.
348
+ const lint = lintDigest(digest || '', constraints, knownIds);
349
+ if (digest && lint.every((d) => d.severity !== 'error')) {
350
+ digests.set(dep, digest);
351
+ }
352
+ else {
353
+ const ids = constraints.filter((c) => c.kind === 'entity').map((c) => c.id);
354
+ digests.set(dep, deterministicDigest(allEntities, ids));
355
+ }
356
+ }
357
+ return digests;
358
+ }
359
+ /** 按 id 去重合并契约实体(P2):同一 id 保留后写者(最新版本),避免共享实体重复渲染。 */
360
+ function mergeEntities(target, incoming) {
361
+ const interfaceIds = new Set(target.interfaces.map((i) => i.id));
362
+ for (const it of incoming.interfaces) {
363
+ if (interfaceIds.has(it.id)) {
364
+ target.interfaces = target.interfaces.map((x) => (x.id === it.id ? it : x));
365
+ }
366
+ else {
367
+ interfaceIds.add(it.id);
368
+ target.interfaces.push(it);
369
+ }
370
+ }
371
+ const tableIds = new Set(target.tables.map((t) => t.id));
372
+ for (const tb of incoming.tables) {
373
+ if (tableIds.has(tb.id)) {
374
+ target.tables = target.tables.map((x) => (x.id === tb.id ? tb : x));
375
+ }
376
+ else {
377
+ tableIds.add(tb.id);
378
+ target.tables.push(tb);
379
+ }
380
+ }
381
+ const decisionIds = new Set(target.decisions.map((d) => d.id));
382
+ for (const d of incoming.decisions) {
383
+ if (decisionIds.has(d.id)) {
384
+ target.decisions = target.decisions.map((x) => (x.id === d.id ? d : x));
385
+ }
386
+ else {
387
+ decisionIds.add(d.id);
388
+ target.decisions.push(d);
389
+ }
390
+ }
391
+ }
392
+ /** 从目标集合中移除某章节贡献的全部实体(按章节所有权,P2)。 */
393
+ function removeChapterEntities(target, chapterEntities) {
394
+ if (!chapterEntities)
395
+ return;
396
+ const ids = new Set([
397
+ ...chapterEntities.interfaces.map((i) => i.id),
398
+ ...chapterEntities.tables.map((t) => t.id),
399
+ ...chapterEntities.decisions.map((d) => d.id),
400
+ ]);
401
+ target.interfaces = target.interfaces.filter((i) => !ids.has(i.id));
402
+ target.tables = target.tables.filter((t) => !ids.has(t.id));
403
+ target.decisions = target.decisions.filter((d) => !ids.has(d.id));
404
+ }
405
+ /** 从章节叙述提取其内嵌的契约实体(```json 块,与 map.ts 语义一致)。 */
406
+ function extractChapterEntities(content) {
407
+ const out = { interfaces: [], tables: [], decisions: [] };
408
+ const jsonBlockRe = /```json\s*([\s\S]*?)```/g;
409
+ let m;
410
+ while ((m = jsonBlockRe.exec(content)) !== null) {
411
+ try {
412
+ const parsed = parseEntitiesLenient(JSON.parse(m[1]));
413
+ mergeEntities(out, parsed);
414
+ }
415
+ catch {
416
+ // 非实体块(叙述示例)→ 忽略
417
+ }
418
+ }
419
+ return out;
420
+ }
421
+ /**
422
+ * Fill all chapters in DAG order (real dependency digests + per-chapter budget).
423
+ * Returns [] on success, or a non-empty error diagnostic array on failure.
424
+ */
425
+ async function fillAllChapters(outline, ctx, narrativeMap, entityMap, tokenMap, budgetMap, allEntities, llm, persist, paths, incremental) {
426
+ // Compute invalidation set (decision 9): which chapters' digests reference the changed entities.
427
+ let refillSet;
428
+ if (incremental) {
429
+ const changedChapterId = incremental.changedChapterId;
430
+ const changedEntityIds = incremental.changedEntityIds ?? [];
431
+ if (changedChapterId || changedEntityIds.length > 0) {
432
+ // 失效集必须基于「已知实体集合」计算,而不是入口处的空 allEntities(P2):
433
+ // 用 outline 冻结的实体注册表作为 known ids,否则 affectedDownstream 恒返回空、
434
+ // 增量失效静默 no-op。实体注册表在 Pass 1 已冻结,是增量场景下唯一可靠的已知集。
435
+ const registry = {
436
+ interfaces: outline.entities
437
+ .filter((e) => /^I\d+$/.test(e.id))
438
+ .map((e) => ({ id: e.id, name: e.frozen, method: 'GET', path: '/', errors: [], failure_examples: [] })),
439
+ tables: outline.entities
440
+ .filter((e) => /^T\d+$/.test(e.id))
441
+ .map((e) => ({ id: e.id, name: e.frozen, ddl: '' })),
442
+ decisions: [],
443
+ };
444
+ const sources = outline.chapters.map((ch) => recordDigestSources(ch, registry));
445
+ const affected = affectedDownstream(changedChapterId ?? '', changedEntityIds, sources);
446
+ refillSet = new Set(affected);
447
+ }
448
+ }
449
+ for (const ch of outline.chapters) {
450
+ // Incremental skip (decision 9): reuse cache only for chapters that (a) are not in the
451
+ // invalidation set, (b) are not the change source, and (c) produce NO contract entities —
452
+ // entity-producing chapters always refill to avoid entity-state divergence across maps.
453
+ const producesEntities = ch.points.some((p) => (p.kind === 'entity' || p.kind === 'mixed') && (p.references ?? []).length > 0);
454
+ if (refillSet && !refillSet.has(ch.id) && !(incremental?.changedChapterId === ch.id) && !producesEntities) {
455
+ if (persist) {
456
+ try {
457
+ const cached = await fs.readFile(chapterPath(paths.workRoot, ch.id), 'utf-8');
458
+ if (cached.trim().length > 0) {
459
+ narrativeMap.set(ch.id, cached);
460
+ tokenMap.set(ch.id, estimateTokens(cached));
461
+ budgetMap.set(ch.id, ctx.chapterById.get(ch.id)?.output_budget_tokens);
462
+ // 缓存复用的章节:把其上一轮产出的契约实体合并回 allEntities(P2),
463
+ // 否则第 175 行用内存中的精简集合覆写 entities.json 会丢掉缓存章节的实体。
464
+ const cachedEntities = extractChapterEntities(cached);
465
+ mergeEntities(allEntities, cachedEntities);
466
+ entityMap.set(ch.id, cachedEntities);
467
+ continue;
468
+ }
469
+ }
470
+ catch {
471
+ // no cache → fall through to refill
472
+ }
473
+ }
474
+ }
475
+ // Real dependency digests (decision 2/8): for each direct dep, build a digest that carries
476
+ // the dep chapter's constraint list (entities/points it must preserve) via a bounded LLM
477
+ // summary, then bidirectional lint; fall back to deterministic registry on lint error.
478
+ ctx.upstreamEntities = allEntities;
479
+ ctx.digests = await buildDigestsFor(ch, ctx.chapterById, outline, narrativeMap, allEntities, llm);
480
+ const budget = ctx.chapterById.get(ch.id)?.output_budget_tokens;
481
+ const result = await fillChapter(llm, ch, ctx, budget);
482
+ if (!result.ok || !result.fill) {
483
+ return [{ gate: 'map', code: 'fill_failed', severity: 'error', message: result.error ?? `fill ${ch.id} failed`, chapter: ch.id }];
484
+ }
485
+ const fill = result.fill;
486
+ narrativeMap.set(ch.id, fill.narrative);
487
+ entityMap.set(ch.id, fill.entities);
488
+ tokenMap.set(ch.id, fill.tokens);
489
+ budgetMap.set(ch.id, budget);
490
+ // Merge entities for downstream digest/render(按 id 去重,P2:同一 id 保留新版本)。
491
+ mergeEntities(allEntities, fill.entities);
492
+ if (persist) {
493
+ await fs.mkdir(paths.chaptersDir, { recursive: true });
494
+ await fs.writeFile(chapterPath(paths.workRoot, ch.id), fill.narrative, 'utf-8');
495
+ await fs.writeFile(paths.entities, JSON.stringify(allEntities, null, 2), 'utf-8');
496
+ }
497
+ }
498
+ return [];
499
+ }
500
+ /** Map a scene profile to the project-convention topics it should read (approval-era: 项目约定 > guidance > LLM). */
501
+ export function sceneTopics(profileId) {
502
+ switch (profileId) {
503
+ case '0to1':
504
+ return ['architecture', 'database', 'api', 'frontend'];
505
+ case 'frontend-0to1':
506
+ return ['frontend', 'api'];
507
+ case 'bugfix':
508
+ return ['database', 'api', 'frontend', 'architecture'];
509
+ case 'feature':
510
+ return ['api', 'database', 'frontend', 'architecture'];
511
+ case 'migration':
512
+ return ['frontend', 'api', 'database', 'architecture'];
513
+ case 'poc':
514
+ return ['frontend', 'api', 'database'];
515
+ default:
516
+ return [];
517
+ }
518
+ }
519
+ /** Emit the Agent-executable step list (no LLM call — the Agent's IDE model does the work). */
520
+ export function planSteps(options) {
521
+ const { profile, library, inputFeatures, inputs, workRoot, projectRoot } = options;
522
+ const expanded = expandProfile(profile, library, inputFeatures);
523
+ const steps = [];
524
+ // Prep: read project conventions / IDE rules & skills (approval-era "项目约定 > guidance > LLM").
525
+ // The Agent MUST ground the document in the project's own conventions before generating —
526
+ // even when no convention files are found, it scans IDE skills/rules + guidance packs.
527
+ if (projectRoot) {
528
+ const topics = sceneTopics(profile.id);
529
+ const conventionFiles = [];
530
+ for (const topic of topics) {
531
+ conventionFiles.push(...resolveProjectConventionPaths({ projectRoot, topic: topic }));
532
+ }
533
+ const found = conventionFiles.length > 0
534
+ ? `解析到的规约文件:${conventionFiles.join('、')}`
535
+ : '未发现项目规约文件(config conventions 或中立文档),请扫描 IDE skills/rules(.cursor / .claude / .agents)与 guidance packs(skills/guidance-packs.yaml 等)';
536
+ steps.push({
537
+ id: 'conventions',
538
+ phase: 'prep',
539
+ title: '读取项目规约与 IDE 规则',
540
+ description: `生成前先读取项目规约(优先级:项目约定 > SpecFlow guidance > LLM)。${found}。` +
541
+ '将相关约束(数据库 DDL 约定、API 规范、前端规约、架构禁令)落实到后续章节;未发现时显式标注「未发现项目规约」。',
542
+ prompt: '.claude/specflow/prompts/document/outline/general.md',
543
+ inputFiles: conventionFiles,
544
+ outputFile: 'digests/conventions.md',
545
+ required: false,
546
+ });
547
+ }
548
+ // Prep: compress inputs into a digest (skip for single short description).
549
+ const multiInput = inputs.length > 1 || inputs.some((i) => i.content.length > 800);
550
+ if (multiInput) {
551
+ steps.push({
552
+ id: 'input-summary',
553
+ phase: 'prep',
554
+ title: '压缩输入为全局摘要',
555
+ description: '阅读全部输入(proposal/design/specs/code),产出紧凑摘要(保留实体候选与约束),写入 digests/input-summary.md。',
556
+ prompt: '.claude/specflow/prompts/document/outline/general.md',
557
+ inputFiles: inputs.map((i) => i.source),
558
+ outputFile: 'digests/input-summary.md',
559
+ required: true,
560
+ });
561
+ }
562
+ // Outline.
563
+ steps.push({
564
+ id: 'outline',
565
+ phase: 'outline',
566
+ title: '生成文档大纲',
567
+ description: `按 ${profile.id} 场景(必选章 ${profile.required.join('、')})生成 outline.yaml:章节顺序 + 每章要点(kind/references)+ 实体注册表 + 决策。`,
568
+ prompt: '.claude/specflow/prompts/document/outline/general.md',
569
+ inputFiles: multiInput ? ['digests/input-summary.md'] : [],
570
+ outputFile: 'outline.yaml',
571
+ required: true,
572
+ });
573
+ // Fill each chapter.
574
+ for (const chId of expanded.chapters) {
575
+ steps.push({
576
+ id: `chapter:${chId}`,
577
+ phase: 'fill',
578
+ title: `填充章节 ${chId}`,
579
+ description: `按 outline.yaml 中 ${chId} 的要点填空,契约实体写入 entities.json(接口须含 failure_examples,表须含 ddl),叙述写入 chapters/${chId}.md。`,
580
+ prompt: `.claude/specflow/prompts/document/map/${chId}.md`,
581
+ inputFiles: ['outline.yaml'],
582
+ outputFile: `chapters/${chId}.md`,
583
+ required: true,
584
+ });
585
+ }
586
+ // Review (optional).
587
+ steps.push({
588
+ id: 'review',
589
+ phase: 'review',
590
+ title: 'AI 审查',
591
+ description: '独立审查全部章节,产出 review-result.json(findings + part_hashes,R14 覆盖核对)。',
592
+ prompt: '.claude/specflow/prompts/document/review/ai-review.md',
593
+ inputFiles: expanded.chapters.map((c) => `chapters/${c}.md`),
594
+ outputFile: 'review-result.json',
595
+ required: false,
596
+ });
597
+ // 附录(profile.appendices,§7.3):anti-ai 需要 LLM 条目;handoff 为确定性模板,无需步骤。
598
+ if (profile.appendices.includes('anti-ai')) {
599
+ steps.push({
600
+ id: 'appendix:anti-ai',
601
+ phase: 'fill',
602
+ title: '填写去AI味自检条目(附录 B)',
603
+ description: '按五维自检表(不做决策/数字具体/竞品立场/此刻合理性/优先级倾斜)写至少 3 条自检条目,写入 appendices/anti-ai.md。缺失将触发 validate warning。',
604
+ prompt: '.claude/specflow/prompts/document/map/anti-ai.md',
605
+ inputFiles: expanded.chapters.map((c) => `chapters/${c}.md`),
606
+ outputFile: 'appendices/anti-ai.md',
607
+ required: true,
608
+ });
609
+ }
610
+ return { steps, chapters: expanded.chapters };
611
+ }
612
+ /** Read Agent-written artifacts from workRoot, validate (lint/coverage/zod) and render. No LLM. */
613
+ export async function validateWork(options) {
614
+ const { profile, workRoot } = options;
615
+ const paths = resolvePaths(workRoot);
616
+ const diags = [];
617
+ // Read outline (readOutline handles both YAML and JSON).
618
+ let outline;
619
+ try {
620
+ outline = await readOutline(workRoot);
621
+ }
622
+ catch (e) {
623
+ return { ok: false, diagnostics: [{ gate: 'outline', code: 'outline_unreadable', severity: 'error', message: `Cannot read outline.yaml: ${e instanceof Error ? e.message : String(e)}` }] };
624
+ }
625
+ // Outline lint.
626
+ const outlineDiags = lintOutline(outline, profile);
627
+ diags.push(...outlineDiags.map((d) => ({ gate: 'outline', code: d.code, severity: d.severity, message: d.message, chapter: d.chapter })));
628
+ // Read chapters + entities.
629
+ const narrativeMap = new Map();
630
+ const entityMap = new Map();
631
+ const allEntities = { interfaces: [], tables: [], decisions: [] };
632
+ const tokenMap = new Map();
633
+ for (const ch of outline.chapters) {
634
+ try {
635
+ const content = await fs.readFile(chapterPath(paths.workRoot, ch.id), 'utf-8');
636
+ narrativeMap.set(ch.id, content);
637
+ tokenMap.set(ch.id, estimateTokens(content));
638
+ // 章节级实体归属:从章节内嵌 ```json 块提取该章贡献的契约实体,供结构化覆盖校验
639
+ // (与 map.ts 的提取语义一致;无法解析的块不影响叙述 lint)。
640
+ const chapterEntities = { interfaces: [], tables: [], decisions: [] };
641
+ const jsonBlockRe = /```json\s*([\s\S]*?)```/g;
642
+ let jm;
643
+ while ((jm = jsonBlockRe.exec(content)) !== null) {
644
+ try {
645
+ const parsed = parseEntitiesLenient(JSON.parse(jm[1]));
646
+ chapterEntities.interfaces.push(...parsed.interfaces);
647
+ chapterEntities.tables.push(...parsed.tables);
648
+ chapterEntities.decisions.push(...parsed.decisions);
649
+ }
650
+ catch {
651
+ // 非实体块(叙述示例 JSON)→ 忽略
652
+ }
653
+ }
654
+ entityMap.set(ch.id, chapterEntities);
655
+ }
656
+ catch (e) {
657
+ diags.push({ gate: 'lint', code: 'chapter_missing', severity: 'error', message: `Missing chapter file ${ch.id}.md`, chapter: ch.id });
658
+ }
659
+ }
660
+ // entities.json.
661
+ try {
662
+ const raw = await fs.readFile(paths.entities, 'utf-8');
663
+ const parsed = parseEntitiesLenient(JSON.parse(raw));
664
+ allEntities.interfaces = parsed.interfaces;
665
+ allEntities.tables = parsed.tables;
666
+ allEntities.decisions = parsed.decisions;
667
+ }
668
+ catch (e) {
669
+ diags.push({ gate: 'entities', code: 'entities_unreadable', severity: 'warning', message: `entities.json unreadable or invalid: ${e instanceof Error ? e.message : String(e)}` });
670
+ }
671
+ // Narrative lint.
672
+ for (const [chId, narrative] of narrativeMap) {
673
+ for (const d of lintNarrative(chId, narrative)) {
674
+ diags.push({ gate: 'lint', code: d.code, severity: d.severity, message: d.message, chapter: chId });
675
+ }
676
+ }
677
+ // Multi-repo synthesize quality gate: if repos.json exists (document synthesize wrote it),
678
+ // every required chapter must have a per-repo section (`### <repo>`) for ALL repos.
679
+ // This prevents "伪统一" — cross-repo content diluting each other into vague prose.
680
+ let synthRepos = [];
681
+ try {
682
+ const raw = JSON.parse(await fs.readFile(reposPath(workRoot), 'utf-8'));
683
+ synthRepos = Array.isArray(raw) ? raw.filter((r) => typeof r === 'string') : [];
684
+ }
685
+ catch {
686
+ // no repos.json → not a synthesize workRoot → gate skipped (bundle/run unaffected)
687
+ }
688
+ if (synthRepos.length >= 2) {
689
+ const requiredIds = new Set(profile.required);
690
+ for (const ch of outline.chapters) {
691
+ if (!requiredIds.has(ch.id))
692
+ continue;
693
+ const narrative = narrativeMap.get(ch.id);
694
+ if (narrative === undefined)
695
+ continue; // chapter_missing already reported
696
+ for (const d of checkRepoSectionCoverage(ch.id, narrative, synthRepos)) {
697
+ diags.push({ gate: 'synthesize', code: d.code, severity: d.severity, message: d.message, chapter: ch.id });
698
+ }
699
+ }
700
+ }
701
+ // Structured coverage (entity points must be filled).
702
+ for (const d of checkStructuredCoverage(outline, entityMap)) {
703
+ diags.push({ gate: 'coverage', code: d.code, severity: d.severity, message: d.message, chapter: d.chapter });
704
+ }
705
+ // 附录覆盖校验(§7.3):anti-ai 自检条目为空/不足 → warning。
706
+ const appendixAntiAI = await readAppendix(workRoot, 'anti-ai');
707
+ diags.push(...appendixDiagnostics(profile, appendixAntiAI));
708
+ const hasError = diags.some((d) => d.severity === 'error');
709
+ if (hasError) {
710
+ return { ok: false, diagnostics: diags };
711
+ }
712
+ // Render (appendices driven by profile.appendices).
713
+ const documentMd = renderDocument({ outline, entities: allEntities, narratives: narrativeMap, profile, appendixAntiAI });
714
+ await fs.writeFile(paths.documentMd, documentMd, 'utf-8');
715
+ let documentHtml;
716
+ if (options.renderHtmlOutput !== false) {
717
+ documentHtml = await renderHtml(documentMd, outline.profile);
718
+ await fs.writeFile(paths.documentHtml, documentHtml, 'utf-8');
719
+ }
720
+ return { ok: true, diagnostics: diags, documentMd, documentHtml };
721
+ }
722
+ export { expandProfile };