@gordon.gan/specflow 1.8.0-alpha → 1.8.1-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 (107) hide show
  1. package/README.md +5 -3
  2. package/dist/cli/commands/document-run.d.ts +14 -1
  3. package/dist/cli/commands/document-run.js +518 -212
  4. package/dist/core/document/chapters.js +18 -3
  5. package/dist/core/document/digests.d.ts +0 -1
  6. package/dist/core/document/digests.js +18 -10
  7. package/dist/core/document/engine.d.ts +9 -0
  8. package/dist/core/document/engine.js +273 -38
  9. package/dist/core/document/extract.d.ts +61 -0
  10. package/dist/core/document/extract.js +437 -0
  11. package/dist/core/document/forbidden-patterns.js +7 -4
  12. package/dist/core/document/gates.d.ts +13 -1
  13. package/dist/core/document/gates.js +48 -6
  14. package/dist/core/document/input-digest.d.ts +8 -0
  15. package/dist/core/document/input-digest.js +93 -14
  16. package/dist/core/document/input-features.d.ts +23 -4
  17. package/dist/core/document/input-features.js +51 -2
  18. package/dist/core/document/lint.d.ts +17 -0
  19. package/dist/core/document/lint.js +118 -0
  20. package/dist/core/document/llm.d.ts +3 -10
  21. package/dist/core/document/llm.js +3 -8
  22. package/dist/core/document/map.d.ts +6 -0
  23. package/dist/core/document/map.js +114 -30
  24. package/dist/core/document/outline.d.ts +3 -0
  25. package/dist/core/document/outline.js +52 -9
  26. package/dist/core/document/paths.d.ts +5 -5
  27. package/dist/core/document/paths.js +11 -6
  28. package/dist/core/document/profile-validator.js +33 -5
  29. package/dist/core/document/profiles.js +5 -0
  30. package/dist/core/document/render.d.ts +26 -0
  31. package/dist/core/document/render.js +110 -14
  32. package/dist/core/document/review.d.ts +19 -4
  33. package/dist/core/document/review.js +65 -20
  34. package/dist/core/document/scene-detect.d.ts +10 -3
  35. package/dist/core/document/scene-detect.js +138 -22
  36. package/dist/core/document/schemas.d.ts +188 -34
  37. package/dist/core/document/schemas.js +39 -26
  38. package/dist/integrations/shared/capability-evidence.js +4 -4
  39. package/dist/integrations/shared/command-catalog.js +2 -1
  40. package/dist/integrations/shared/parity-manifest.js +4 -4
  41. package/package.json +2 -1
  42. package/prompts/document/map/acceptance.md +1 -0
  43. package/prompts/document/map/anti-ai.md +29 -0
  44. package/prompts/document/map/api-design.md +13 -4
  45. package/prompts/document/map/architecture.md +21 -1
  46. package/prompts/document/map/benchmark.md +26 -0
  47. package/prompts/document/map/closed-loop.md +1 -0
  48. package/prompts/document/map/compat-migration.md +24 -1
  49. package/prompts/document/map/component-design.md +30 -0
  50. package/prompts/document/map/config-runtime.md +1 -0
  51. package/prompts/document/map/core-flow.md +62 -0
  52. package/prompts/document/map/core-logic.md +1 -0
  53. package/prompts/document/map/data-model.md +1 -0
  54. package/prompts/document/map/deploy.md +20 -2
  55. package/prompts/document/map/fix.md +1 -0
  56. package/prompts/document/map/frontend-architecture.md +35 -0
  57. package/prompts/document/map/goal.md +1 -0
  58. package/prompts/document/map/impact.md +1 -0
  59. package/prompts/document/map/implementability.md +1 -0
  60. package/prompts/document/map/migration-guide.md +36 -0
  61. package/prompts/document/map/mvp-boundary.md +1 -0
  62. package/prompts/document/map/non-goals.md +1 -0
  63. package/prompts/document/map/ops.md +33 -0
  64. package/prompts/document/map/performance.md +32 -0
  65. package/prompts/document/map/poc-demo.md +25 -0
  66. package/prompts/document/map/regression.md +1 -0
  67. package/prompts/document/map/reproduce.md +1 -0
  68. package/prompts/document/map/requirement.md +1 -0
  69. package/prompts/document/map/research.md +25 -0
  70. package/prompts/document/map/root-cause.md +1 -0
  71. package/prompts/document/map/signoff.md +1 -0
  72. package/prompts/document/map/state-management.md +23 -0
  73. package/prompts/document/map/tech-selection.md +13 -1
  74. package/prompts/document/map/test-strategy.md +18 -1
  75. package/prompts/document/map/ui-design.md +10 -7
  76. package/prompts/document/outline/general.md +9 -0
  77. package/prompts/document/review/ai-review.md +2 -1
  78. package/prompts/document/shared/grounding.md +84 -0
  79. package/skills/specflow-techdoc/SKILL.md +143 -0
  80. package/skills/specflow-techdoc-synth/SKILL.md +99 -0
  81. package/templates/document/chapters/api-design.yaml +6 -1
  82. package/templates/document/chapters/architecture.yaml +7 -4
  83. package/templates/document/chapters/benchmark.yaml +20 -0
  84. package/templates/document/chapters/compat-migration.yaml +7 -3
  85. package/templates/document/chapters/component-design.yaml +22 -0
  86. package/templates/document/chapters/core-flow.yaml +27 -0
  87. package/templates/document/chapters/core-logic.yaml +1 -1
  88. package/templates/document/chapters/deploy.yaml +11 -7
  89. package/templates/document/chapters/frontend-architecture.yaml +22 -0
  90. package/templates/document/chapters/migration-guide.yaml +21 -0
  91. package/templates/document/chapters/ops.yaml +25 -0
  92. package/templates/document/chapters/performance.yaml +21 -0
  93. package/templates/document/chapters/poc-demo.yaml +22 -0
  94. package/templates/document/chapters/research.yaml +22 -0
  95. package/templates/document/chapters/state-management.yaml +22 -0
  96. package/templates/document/chapters/tech-selection.yaml +6 -3
  97. package/templates/document/chapters/test-strategy.yaml +5 -2
  98. package/templates/document/chapters/ui-design.yaml +7 -1
  99. package/templates/document/profiles/0to1.yaml +41 -9
  100. package/templates/document/profiles/bugfix.yaml +8 -3
  101. package/templates/document/profiles/feature.yaml +19 -7
  102. package/templates/document/profiles/frontend-0to1.yaml +47 -0
  103. package/templates/document/profiles/migration.yaml +42 -0
  104. package/templates/document/profiles/poc.yaml +46 -0
  105. package/dist/core/document/index.d.ts +0 -7
  106. package/dist/core/document/index.js +0 -7
  107. package/skills/specflow-document/SKILL.md +0 -124
@@ -8,13 +8,28 @@ import yaml from 'js-yaml';
8
8
  import { parseChapterComponent } from './schemas.js';
9
9
  const DEFAULT_CHAPTERS_DIR = join(process.cwd(), 'templates', 'document', 'chapters');
10
10
  export async function loadChapterLibrary(dir = DEFAULT_CHAPTERS_DIR) {
11
- const entries = await fs.readdir(dir);
11
+ const entries = await fs.readdir(dir, { withFileTypes: true });
12
12
  const lib = new Map();
13
- for (const file of entries) {
13
+ const seen = new Set();
14
+ for (const entry of entries) {
15
+ if (!entry.isFile())
16
+ continue; // 跳过目录(P2:避免 EISDIR)
17
+ const file = entry.name;
14
18
  if (!file.endsWith('.yaml') && !file.endsWith('.yml'))
15
19
  continue;
16
- const raw = yaml.load(await fs.readFile(join(dir, file), 'utf-8'));
20
+ let raw;
21
+ try {
22
+ raw = yaml.load(await fs.readFile(join(dir, file), 'utf-8'));
23
+ }
24
+ catch (e) {
25
+ throw new Error(`Failed to parse chapter file ${file}: ${e instanceof Error ? e.message : String(e)}`);
26
+ }
17
27
  const ch = parseChapterComponent(raw);
28
+ // 重复 id 检测(P2):Map.set 静默覆盖且覆盖顺序依赖目录枚举,id 碰撞会静默改变整条流水线。
29
+ if (seen.has(ch.id)) {
30
+ throw new Error(`Duplicate chapter id "${ch.id}" (in ${file}); chapter ids must be unique across templates/document/chapters/`);
31
+ }
32
+ seen.add(ch.id);
18
33
  lib.set(ch.id, ch);
19
34
  }
20
35
  return lib;
@@ -10,7 +10,6 @@
10
10
  */
11
11
  import { OutlineChapter, ConstraintItem, Entities } from './schemas.js';
12
12
  import type { DocumentLLM } from './llm.js';
13
- export declare const DIGEST_BUDGET_TOKENS = 1000;
14
13
  export declare function extractConstraintList(chapter: OutlineChapter, entities: Entities): ConstraintItem[];
15
14
  export interface DigestInput {
16
15
  chapterId: string;
@@ -8,7 +8,6 @@
8
8
  * items present in digest).
9
9
  * - recordDigestSources / affectedDownstream: invalidation propagation (decision 9).
10
10
  */
11
- export const DIGEST_BUDGET_TOKENS = 1000;
12
11
  // ============= Constraint extraction (T7.1) =============
13
12
  export function extractConstraintList(chapter, entities) {
14
13
  const items = [];
@@ -19,11 +18,13 @@ export function extractConstraintList(chapter, entities) {
19
18
  }
20
19
  }
21
20
  for (const d of entities.decisions) {
22
- if (chapter.points.some((p) => (p.references ?? []).includes(d.id)) || true) {
23
- // decisions referenced anywhere are preserved if they relate to this chapter's points
24
- if (chapter.points.some((p) => p.text.includes(d.text.slice(0, 20)) || (p.references ?? []).includes(d.id))) {
25
- items.push({ kind: 'decision', id: d.id, label: d.text.slice(0, 40) });
26
- }
21
+ // A decision is preserved in this chapter's digest when the chapter references it
22
+ // (deterministic signal) or its text is mentioned in a point (heuristic fallback).
23
+ // (Previously wrapped in an always-true `|| true` outer condition — dead code removed.)
24
+ const referenced = chapter.points.some((p) => (p.references ?? []).includes(d.id));
25
+ const mentioned = chapter.points.some((p) => p.text.includes(d.text.slice(0, 20)));
26
+ if (referenced || mentioned) {
27
+ items.push({ kind: 'decision', id: d.id, label: d.text.slice(0, 40) });
27
28
  }
28
29
  }
29
30
  // dedupe by kind+id
@@ -75,8 +76,11 @@ export function deterministicDigest(entities, ids) {
75
76
  export function lintDigest(digest, constraints, knownEntityIds) {
76
77
  const diags = [];
77
78
  // Reverse: every constraint item must appear in digest (decision 8).
79
+ // 用整词匹配(P2):短 id 子串匹配会把 T1 误判为出现(T10/ST1/GET /api/T1),
80
+ // 或把 t1/D-1 误判为缺失 → 触发确定性回退丢弃约束。
78
81
  for (const c of constraints) {
79
- if (!digest.includes(c.id)) {
82
+ const re = new RegExp(`(^|[^A-Za-z0-9_])${escapeRegExp(c.id)}($|[^A-Za-z0-9_])`);
83
+ if (!re.test(digest)) {
80
84
  diags.push({
81
85
  code: 'constraint_missing',
82
86
  severity: 'error',
@@ -85,14 +89,14 @@ export function lintDigest(digest, constraints, knownEntityIds) {
85
89
  }
86
90
  }
87
91
  // Forward: any entity id mentioned in digest that we can detect must exist in known set.
88
- // (Heuristic: backtick-quoted tokens + bare I\d+/T\d+ ids.)
92
+ // (P2 修复:此前 I\d+/T\d+ 被豁免导致前向检查恒不触发;现加入 D\d+ 且不再豁免。)
89
93
  const mentioned = new Set();
90
94
  for (const m of digest.matchAll(/`([A-Za-z0-9_.]+)`/g))
91
95
  mentioned.add(m[1]);
92
- for (const m of digest.matchAll(/\b(I\d+|T\d+)\b/g))
96
+ for (const m of digest.matchAll(/\b(I\d+|T\d+|D\d+)\b/g))
93
97
  mentioned.add(m[1]);
94
98
  for (const id of mentioned) {
95
- if (knownEntityIds.size > 0 && !knownEntityIds.has(id) && !/^I\d+$/.test(id) && !/^T\d+$/.test(id)) {
99
+ if (knownEntityIds.size > 0 && !knownEntityIds.has(id)) {
96
100
  diags.push({
97
101
  code: 'digest_hallucinated_entity',
98
102
  severity: 'warning',
@@ -102,6 +106,10 @@ export function lintDigest(digest, constraints, knownEntityIds) {
102
106
  }
103
107
  return diags;
104
108
  }
109
+ /** 转义正则特殊字符(供整词匹配使用)。 */
110
+ function escapeRegExp(s) {
111
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
112
+ }
105
113
  export function recordDigestSources(chapter, sourceEntities) {
106
114
  const entityIds = new Set();
107
115
  for (const p of chapter.points) {
@@ -9,6 +9,7 @@ import type { DocumentLLM } from './llm.js';
9
9
  import { SegmentableInput } from './input-digest.js';
10
10
  import { expandProfile } from './profiles.js';
11
11
  import { GateDiagnostic } from './gates.js';
12
+ import { ExtractedContracts } from './extract.js';
12
13
  export interface RunDocumentOptions {
13
14
  llm: DocumentLLM;
14
15
  workRoot: string;
@@ -34,6 +35,12 @@ export interface RunDocumentOptions {
34
35
  renderHtmlOutput?: boolean;
35
36
  outlinePrompt?: string;
36
37
  reviewPrompt?: string;
38
+ /** Project root for deterministic contract extraction (OpenAPI/proto/DDL). Optional —
39
+ * when set, engine extracts real contract entities and injects them into outline
40
+ * generation as frozen candidates (grounding P0). */
41
+ projectRoot?: string;
42
+ /** Pre-extracted contracts (skip file scan). Useful for tests/hosts that already know them. */
43
+ extractedContracts?: ExtractedContracts;
37
44
  /** Persist per-chapter files/entities to workRoot (default true). */
38
45
  persist?: boolean;
39
46
  }
@@ -47,6 +54,8 @@ export interface RunDocumentResult {
47
54
  tokenEstimate?: number;
48
55
  }
49
56
  export declare function runDocument(options: RunDocumentOptions): Promise<RunDocumentResult>;
57
+ /** 附录覆盖校验(README §7.3):profile.appendices 含 anti-ai 但自检条目为空/不足 → warning。 */
58
+ export declare function appendixDiagnostics(profile: ScenarioProfile, antiAiContent: string): GateDiagnostic[];
50
59
  export interface AgentStep {
51
60
  /** stable id: 'input-summary' | 'outline' | 'chapter:<id>' | 'review' */
52
61
  id: string;
@@ -12,14 +12,15 @@ import { generateOutline, lintOutline, outlineHasErrors, freezeOutline, readOutl
12
12
  import { expandProfile } from './profiles.js';
13
13
  import { generateDigest, extractConstraintList, lintDigest, deterministicDigest, recordDigestSources, affectedDownstream } from './digests.js';
14
14
  import { fillChapter, fillOnce, shouldFillOnce } from './map.js';
15
- import { buildReviewPacket, reviewWithAgent, checkReviewResult, writeReviewResult, computePartHashes, applyFindings } from './review.js';
15
+ import { buildReviewPacket, reviewWithAgent, checkReviewResult, writeReviewResult, computePartHashes, applyFindings, sha256 } from './review.js';
16
16
  import { renderDocument, renderHtml } from './render.js';
17
17
  import { runGates } from './gates.js';
18
- import { resolvePaths } from './paths.js';
18
+ import { resolvePaths, reposPath, chapterPath } from './paths.js';
19
19
  import { parseEntities } from './schemas.js';
20
- import { lintNarrative } from './lint.js';
20
+ import { lintNarrative, checkRepoSectionCoverage } from './lint.js';
21
21
  import { checkStructuredCoverage } from './coverage.js';
22
22
  import { resolveProjectConventionPaths } from '../project-conventions.js';
23
+ import { extractContracts, EMPTY_CONTRACTS } from './extract.js';
23
24
  const DEFAULT_MAX_ROUNDS = 3;
24
25
  const OUTLINE_MAX_CONFIRM_ROUNDS = 2;
25
26
  export async function runDocument(options) {
@@ -29,6 +30,12 @@ export async function runDocument(options) {
29
30
  const paths = resolvePaths(workRoot);
30
31
  // --- Pass 0.5: input compression (T4) ---
31
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);
32
39
  // --- Pass 1: outline (T5) with user confirmation gate (T5.4) ---
33
40
  const expanded = expandProfile(profile, library, inputFeatures);
34
41
  if (expanded.chapters.length === 0) {
@@ -38,7 +45,7 @@ export async function runDocument(options) {
38
45
  let confirmed = false;
39
46
  const confirmer = options.outlineConfirmer;
40
47
  for (let round = 0; round < OUTLINE_MAX_CONFIRM_ROUNDS; round++) {
41
- outline = await generateOutline(llm, { profile, inputDigest, prompt: options.outlinePrompt });
48
+ outline = await generateOutline(llm, { profile, inputDigest, prompt: options.outlinePrompt, extractedContracts });
42
49
  const outlineDiags = lintOutline(outline, profile);
43
50
  if (outlineHasErrors(outlineDiags)) {
44
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 })) };
@@ -66,7 +73,7 @@ export async function runDocument(options) {
66
73
  }
67
74
  // --- Chapter-by-chapter fill (Pass 2, T6) with real dependency digests (T7) ---
68
75
  const chapterById = library;
69
- const ctx = { outline, digests: new Map(), upstreamEntities: { interfaces: [], tables: [], decisions: [] }, workRoot, chapterById };
76
+ const ctx = { outline, digests: new Map(), upstreamEntities: { interfaces: [], tables: [], decisions: [] }, workRoot, chapterById, contextCache: new Map() };
70
77
  // Process in DAG order: outline.chapters is already topological from expandProfile.
71
78
  const narrativeMap = new Map();
72
79
  const entityMap = new Map();
@@ -94,7 +101,7 @@ export async function runDocument(options) {
94
101
  allEntities.decisions.push(...f.entities.decisions);
95
102
  if (persist) {
96
103
  await fs.mkdir(paths.chaptersDir, { recursive: true });
97
- await fs.writeFile(join(paths.chaptersDir, `${f.chapterId}.md`), f.narrative, 'utf-8');
104
+ await fs.writeFile(chapterPath(paths.workRoot, f.chapterId), f.narrative, 'utf-8');
98
105
  }
99
106
  }
100
107
  }
@@ -111,9 +118,26 @@ export async function runDocument(options) {
111
118
  let review;
112
119
  let reviewDiags = [];
113
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();
114
127
  for (let round = 0; round < maxRounds; round++) {
115
- const packet = await buildReviewPacket(workRoot, change, outline);
128
+ const packet = await buildReviewPacket(workRoot, change, outline, prevFindings, narrativeMap);
116
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
+ }
117
141
  // Apply findings by target:
118
142
  // narrative → append fix note to chapters/<id>.md
119
143
  // entity → regenerate the affected chapter (real entity repair via re-fill)
@@ -127,59 +151,84 @@ export async function runDocument(options) {
127
151
  if (target === 'narrative' || target === 'mixed') {
128
152
  const chId = finding.chapter;
129
153
  if (chId) {
154
+ touchedChapters.add(chId);
130
155
  const existing = narrativeMap.get(chId) ?? '';
131
- narrativeMap.set(chId, `${existing}\n\n<!-- review-fix ${finding.id} -->\n${finding.message}`);
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} -->`);
132
162
  if (persist) {
133
163
  await fs.mkdir(paths.chaptersDir, { recursive: true });
134
- await fs.writeFile(join(paths.chaptersDir, `${chId}.md`), narrativeMap.get(chId), 'utf-8');
164
+ await fs.writeFile(chapterPath(paths.workRoot, chId), narrativeMap.get(chId), 'utf-8');
135
165
  }
136
166
  }
137
167
  }
138
- });
168
+ }, appliedFindingIds);
139
169
  // Entity targets: re-fill the affected chapters so contract entities are regenerated (real fix).
140
170
  for (const chId of new Set(entityRefills)) {
141
171
  const ch = outline.chapters.find((c) => c.id === chId);
142
172
  if (!ch)
143
173
  continue;
174
+ touchedChapters.add(ch.id);
144
175
  ctx.upstreamEntities = allEntities;
145
176
  ctx.digests = await buildDigestsFor(ch, ctx.chapterById, outline, narrativeMap, allEntities, llm);
146
177
  const result = await fillChapter(llm, ch, ctx, ctx.chapterById.get(ch.id)?.output_budget_tokens);
147
178
  if (result.ok && result.fill) {
148
179
  narrativeMap.set(ch.id, result.fill.narrative);
149
- entityMap.set(ch.id, result.fill.entities);
150
180
  tokenMap.set(ch.id, result.fill.tokens);
151
- // Replace this chapter's entities in allEntities (remove old, add new).
152
- allEntities.interfaces = allEntities.interfaces.filter((i) => !result.fill.entities.interfaces.some((n) => n.id === i.id));
153
- allEntities.tables = allEntities.tables.filter((t) => !result.fill.entities.tables.some((n) => n.id === t.id));
154
- allEntities.decisions = allEntities.decisions.filter((d) => !result.fill.entities.decisions.some((n) => n.id === d.id));
155
- allEntities.interfaces.push(...result.fill.entities.interfaces);
156
- allEntities.tables.push(...result.fill.entities.tables);
157
- allEntities.decisions.push(...result.fill.entities.decisions);
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);
158
188
  if (persist) {
159
189
  await fs.mkdir(paths.chaptersDir, { recursive: true });
160
- await fs.writeFile(join(paths.chaptersDir, `${ch.id}.md`), result.fill.narrative, 'utf-8');
190
+ await fs.writeFile(chapterPath(paths.workRoot, ch.id), result.fill.narrative, 'utf-8');
161
191
  await fs.writeFile(paths.entities, JSON.stringify(allEntities, null, 2), 'utf-8');
162
192
  }
163
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
+ }
164
204
  }
165
- // After applying fixes, refresh part_hashes so tamper checks verify against the fixed state
166
- // (repairs are engine-driven, not external tampering).
167
- if (persist) {
168
- candidate.part_hashes = await computePartHashes(workRoot, outline.chapters.map((c) => c.id));
169
- }
170
- const check = await checkReviewResult(workRoot, candidate, outline);
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);
171
216
  review = candidate;
172
217
  reviewDiags = check.diagnostics.map((d) => ({ gate: 'review', code: d.code, severity: d.severity, message: d.message }));
173
218
  if (check.ok) {
174
219
  break;
175
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;
176
224
  }
177
225
  }
178
226
  if (review && persist) {
179
227
  await writeReviewResult(workRoot, review);
180
228
  }
181
229
  // --- Pass 4: render (T9) ---
182
- const documentMd = renderDocument({ outline, entities: allEntities, narratives: narrativeMap });
230
+ const appendixAntiAI = await readAppendix(workRoot, 'anti-ai');
231
+ const documentMd = renderDocument({ outline, entities: allEntities, narratives: narrativeMap, profile, appendixAntiAI });
183
232
  if (persist)
184
233
  await fs.writeFile(paths.documentMd, documentMd, 'utf-8');
185
234
  let documentHtml;
@@ -189,6 +238,16 @@ export async function runDocument(options) {
189
238
  await fs.writeFile(paths.documentHtml, documentHtml, 'utf-8');
190
239
  }
191
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
+ }
192
251
  const gates = await runGates({
193
252
  outline,
194
253
  chapterNarratives: narrativeMap,
@@ -198,7 +257,17 @@ export async function runDocument(options) {
198
257
  entities: allEntities,
199
258
  review,
200
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,
201
268
  });
269
+ // 附录覆盖校验(§7.3):anti-ai 自检条目为空/不足 → warning。
270
+ const appendixDiags = appendixDiagnostics(profile, appendixAntiAI);
202
271
  let totalTokens = 0;
203
272
  for (const t of tokenMap.values())
204
273
  totalTokens += t;
@@ -208,10 +277,36 @@ export async function runDocument(options) {
208
277
  outline,
209
278
  documentMd,
210
279
  documentHtml,
211
- diagnostics: [...reviewDiags, ...gates.diagnostics],
280
+ diagnostics: [...reviewDiags, ...appendixDiags, ...gates.diagnostics],
212
281
  tokenEstimate: totalTokens,
213
282
  };
214
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
+ }
215
310
  /**
216
311
  * Build dependency digests for a chapter's direct deps (decisions 2 + 8).
217
312
  * For each dep chapter, derive its constraint list (points/entities/decisions it must preserve),
@@ -261,6 +356,68 @@ async function buildDigestsFor(chapter, library, outline, narrativeMap, allEntit
261
356
  }
262
357
  return digests;
263
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 = parseEntities(JSON.parse(m[1]));
413
+ mergeEntities(out, parsed);
414
+ }
415
+ catch {
416
+ // 非实体块(叙述示例)→ 忽略
417
+ }
418
+ }
419
+ return out;
420
+ }
264
421
  /**
265
422
  * Fill all chapters in DAG order (real dependency digests + per-chapter budget).
266
423
  * Returns [] on success, or a non-empty error diagnostic array on failure.
@@ -272,7 +429,19 @@ async function fillAllChapters(outline, ctx, narrativeMap, entityMap, tokenMap,
272
429
  const changedChapterId = incremental.changedChapterId;
273
430
  const changedEntityIds = incremental.changedEntityIds ?? [];
274
431
  if (changedChapterId || changedEntityIds.length > 0) {
275
- const sources = outline.chapters.map((ch) => recordDigestSources(ch, allEntities));
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));
276
445
  const affected = affectedDownstream(changedChapterId ?? '', changedEntityIds, sources);
277
446
  refillSet = new Set(affected);
278
447
  }
@@ -285,11 +454,16 @@ async function fillAllChapters(outline, ctx, narrativeMap, entityMap, tokenMap,
285
454
  if (refillSet && !refillSet.has(ch.id) && !(incremental?.changedChapterId === ch.id) && !producesEntities) {
286
455
  if (persist) {
287
456
  try {
288
- const cached = await fs.readFile(join(paths.chaptersDir, `${ch.id}.md`), 'utf-8');
457
+ const cached = await fs.readFile(chapterPath(paths.workRoot, ch.id), 'utf-8');
289
458
  if (cached.trim().length > 0) {
290
459
  narrativeMap.set(ch.id, cached);
291
460
  tokenMap.set(ch.id, estimateTokens(cached));
292
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);
293
467
  continue;
294
468
  }
295
469
  }
@@ -313,13 +487,11 @@ async function fillAllChapters(outline, ctx, narrativeMap, entityMap, tokenMap,
313
487
  entityMap.set(ch.id, fill.entities);
314
488
  tokenMap.set(ch.id, fill.tokens);
315
489
  budgetMap.set(ch.id, budget);
316
- // Merge entities for downstream digest/render.
317
- allEntities.interfaces.push(...fill.entities.interfaces);
318
- allEntities.tables.push(...fill.entities.tables);
319
- allEntities.decisions.push(...fill.entities.decisions);
490
+ // Merge entities for downstream digest/render(按 id 去重,P2:同一 id 保留新版本)。
491
+ mergeEntities(allEntities, fill.entities);
320
492
  if (persist) {
321
493
  await fs.mkdir(paths.chaptersDir, { recursive: true });
322
- await fs.writeFile(join(paths.chaptersDir, `${ch.id}.md`), fill.narrative, 'utf-8');
494
+ await fs.writeFile(chapterPath(paths.workRoot, ch.id), fill.narrative, 'utf-8');
323
495
  await fs.writeFile(paths.entities, JSON.stringify(allEntities, null, 2), 'utf-8');
324
496
  }
325
497
  }
@@ -330,10 +502,16 @@ export function sceneTopics(profileId) {
330
502
  switch (profileId) {
331
503
  case '0to1':
332
504
  return ['architecture', 'database', 'api', 'frontend'];
505
+ case 'frontend-0to1':
506
+ return ['frontend', 'api'];
333
507
  case 'bugfix':
334
- return ['database', 'api', 'architecture'];
508
+ return ['database', 'api', 'frontend', 'architecture'];
335
509
  case 'feature':
336
510
  return ['api', 'database', 'frontend', 'architecture'];
511
+ case 'migration':
512
+ return ['frontend', 'api', 'database', 'architecture'];
513
+ case 'poc':
514
+ return ['frontend', 'api', 'database'];
337
515
  default:
338
516
  return [];
339
517
  }
@@ -416,6 +594,19 @@ export function planSteps(options) {
416
594
  outputFile: 'review-result.json',
417
595
  required: false,
418
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
+ }
419
610
  return { steps, chapters: expanded.chapters };
420
611
  }
421
612
  /** Read Agent-written artifacts from workRoot, validate (lint/coverage/zod) and render. No LLM. */
@@ -441,9 +632,26 @@ export async function validateWork(options) {
441
632
  const tokenMap = new Map();
442
633
  for (const ch of outline.chapters) {
443
634
  try {
444
- const content = await fs.readFile(join(paths.chaptersDir, `${ch.id}.md`), 'utf-8');
635
+ const content = await fs.readFile(chapterPath(paths.workRoot, ch.id), 'utf-8');
445
636
  narrativeMap.set(ch.id, content);
446
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 = parseEntities(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);
447
655
  }
448
656
  catch (e) {
449
657
  diags.push({ gate: 'lint', code: 'chapter_missing', severity: 'error', message: `Missing chapter file ${ch.id}.md`, chapter: ch.id });
@@ -466,16 +674,43 @@ export async function validateWork(options) {
466
674
  diags.push({ gate: 'lint', code: d.code, severity: d.severity, message: d.message, chapter: chId });
467
675
  }
468
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
+ }
469
701
  // Structured coverage (entity points must be filled).
470
702
  for (const d of checkStructuredCoverage(outline, entityMap)) {
471
703
  diags.push({ gate: 'coverage', code: d.code, severity: d.severity, message: d.message, chapter: d.chapter });
472
704
  }
705
+ // 附录覆盖校验(§7.3):anti-ai 自检条目为空/不足 → warning。
706
+ const appendixAntiAI = await readAppendix(workRoot, 'anti-ai');
707
+ diags.push(...appendixDiagnostics(profile, appendixAntiAI));
473
708
  const hasError = diags.some((d) => d.severity === 'error');
474
709
  if (hasError) {
475
710
  return { ok: false, diagnostics: diags };
476
711
  }
477
- // Render.
478
- const documentMd = renderDocument({ outline, entities: allEntities, narratives: narrativeMap });
712
+ // Render (appendices driven by profile.appendices).
713
+ const documentMd = renderDocument({ outline, entities: allEntities, narratives: narrativeMap, profile, appendixAntiAI });
479
714
  await fs.writeFile(paths.documentMd, documentMd, 'utf-8');
480
715
  let documentHtml;
481
716
  if (options.renderHtmlOutput !== false) {