@cyber-dash-tech/revela 0.17.0 → 0.17.2

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.
@@ -1,4 +1,4 @@
1
- import { deckPlanHash, upsertDeck, upsertSlides, type DecksState, type EvidenceRef, type RequiredInputs, type SlideSpec } from "../decks-state"
1
+ import { deckPlanHash, upsertDeck, upsertSlides, type DeckSpec, type DecksState, type EvidenceRef, type RequiredInputs, type SlideSpec, type VisualBrief } from "../decks-state"
2
2
  import { ensureActiveHtmlDeckRenderTarget } from "../workspace-state/render-targets"
3
3
  import { getClaimSlideRefs } from "./queries"
4
4
  import { computeNarrativeHash } from "./hash"
@@ -15,10 +15,38 @@ export interface CompileDeckPlanResult {
15
15
  skipped: boolean
16
16
  reason?: string
17
17
  narrativeHash: string
18
+ planHash?: string
19
+ planArtifactPath?: string
18
20
  slideCount: number
19
21
  slides: SlideSpec[]
20
22
  chapters?: DeckPlanChapter[]
21
23
  qualityChecks?: DeckPlanQualityCheck[]
24
+ renderPlan?: RenderPlanContract
25
+ planningPacket?: DeckPlanningPacket
26
+ deckPlanRequirements?: DeckPlanRequirements
27
+ }
28
+
29
+ export interface DeckPlanningPacket {
30
+ narrativeHash: string
31
+ outputPath: string
32
+ audience: NarrativeStateV1["audience"]
33
+ decision: NarrativeStateV1["decision"]
34
+ thesis: NarrativeStateV1["thesis"]
35
+ centralClaims: NarrativeClaim[]
36
+ supportingClaims: NarrativeClaim[]
37
+ evidenceBindings: NarrativeEvidenceBinding[]
38
+ objections: NarrativeStateV1["objections"]
39
+ risks: NarrativeStateV1["risks"]
40
+ researchGaps: NarrativeStateV1["researchGaps"]
41
+ }
42
+
43
+ export interface DeckPlanRequirements {
44
+ planArtifactPath: string
45
+ slidePlanDir: string
46
+ defaultProfile: string
47
+ userConfirmations: string[]
48
+ authoringRules: string[]
49
+ requiredSections: string[]
22
50
  }
23
51
 
24
52
  export interface DeckPlanChapter {
@@ -27,6 +55,7 @@ export interface DeckPlanChapter {
27
55
  slideIndexes: number[]
28
56
  claimIds: string[]
29
57
  evidenceBindingIds: string[]
58
+ sourceClaimId?: string
30
59
  }
31
60
 
32
61
  export interface DeckPlanQualityCheck {
@@ -35,6 +64,63 @@ export interface DeckPlanQualityCheck {
35
64
  message: string
36
65
  }
37
66
 
67
+ export type RenderPlanSlideKind = "cover" | "toc" | "chapter-divider" | "claim-framing" | "claim-evidence" | "claim-implication" | "supporting-evidence" | "risk" | "ask" | "content"
68
+
69
+ export interface RenderPlanSlideMetadata {
70
+ index: number
71
+ title: string
72
+ chapterTitle?: string
73
+ chapterRole?: DeckPlanChapter["role"]
74
+ slideKind: RenderPlanSlideKind
75
+ structural: boolean
76
+ countsTowardClaimSubstance: boolean
77
+ requiredComponents: string[]
78
+ evidenceTraceRequired: boolean
79
+ claimChapterRequirement?: "divider" | "framing" | "proof" | "implication" | "supporting-evidence" | "risk" | "ask"
80
+ }
81
+
82
+ export interface RenderPlanChapterRequirement {
83
+ title: string
84
+ role: DeckPlanChapter["role"]
85
+ sourceClaimId?: string
86
+ slideIndexes: number[]
87
+ requiredSubstanceSlides: number
88
+ actualSubstanceSlides: number
89
+ allowedStructuralSlides: string[]
90
+ }
91
+
92
+ export interface RenderPlanWritingBatch {
93
+ label: string
94
+ chapterTitle: string
95
+ slideIndexes: number[]
96
+ instructions: string
97
+ }
98
+
99
+ export interface RenderPlanContract {
100
+ sourceAuthority: {
101
+ meaning: string
102
+ renderPlan: string
103
+ state: string
104
+ htmlIdentity: string
105
+ }
106
+ renderRules: string[]
107
+ htmlIdentityContract: string[]
108
+ chapterRequirements: RenderPlanChapterRequirement[]
109
+ chapterWritingBatches: RenderPlanWritingBatch[]
110
+ slideRenderMetadata: RenderPlanSlideMetadata[]
111
+ }
112
+
113
+ type VisualIntentKind = "hero" | "toc" | "metric-stat" | "evidence-table" | "comparison-grid" | "risk-matrix" | "steps" | "text-only"
114
+ type ClaimChapterSlideKind = "framing" | "evidence" | "implication"
115
+
116
+ interface VisualIntent {
117
+ kind: VisualIntentKind
118
+ component: string
119
+ rationale: string
120
+ dataSignals: string[]
121
+ evidenceBindingIds: string[]
122
+ }
123
+
38
124
  export function compileDeckPlanFromNarrative(state: DecksState, options: CompileDeckPlanOptions = {}): { state: DecksState; result: CompileDeckPlanResult } {
39
125
  const narrative = normalizeNarrativeState(state)
40
126
  const narrativeHash = computeNarrativeHash(narrative)
@@ -56,10 +142,8 @@ export function compileDeckPlanFromNarrative(state: DecksState, options: Compile
56
142
  const deckKey = state.activeDeck ?? Object.keys(state.decks)[0]
57
143
  const deck = deckKey ? state.decks[deckKey] : undefined
58
144
  const slug = deck?.slug ?? state.activeDeck ?? "deck"
59
- const plan = buildDeckPlan(narrative)
60
- const { slides, chapters } = plan
61
- const qualityChecks = checkPlanQuality(narrative, slides, chapters)
62
- const planCoverage = deckPlanCoverage(narrative, slides)
145
+ const planningPacket = buildDeckPlanningPacket(narrative, narrativeHash, deck?.outputPath ?? `decks/${slug}.html`)
146
+ const deckPlanRequirements = buildDeckPlanRequirements(narrativeHash)
63
147
  const requiredInputs: Partial<RequiredInputs> = {
64
148
  topicClarified: true,
65
149
  audienceClarified: Boolean(narrative.audience.primary),
@@ -83,13 +167,12 @@ export function compileDeckPlanFromNarrative(state: DecksState, options: Compile
83
167
  } as RequiredInputs,
84
168
  writeReadiness: deck?.writeReadiness ?? { status: "blocked" as const, blockers: [] },
85
169
  })
86
- next = upsertSlides(next, slug, slides)
87
170
  const plannedDeck = next.decks[slug]
88
171
  plannedDeck.planReview = {
89
172
  status: "pending",
90
173
  narrativeHash,
91
- planHash: deckPlanHash(plannedDeck.slides),
92
- qualityChecks,
174
+ planHash: "pending-deck-plan-md",
175
+ qualityChecks: [],
93
176
  }
94
177
  plannedDeck.requiredInputs = { ...plannedDeck.requiredInputs, slidePlanConfirmed: false }
95
178
  plannedDeck.writeReadiness = { status: "blocked", blockers: [] }
@@ -99,13 +182,13 @@ export function compileDeckPlanFromNarrative(state: DecksState, options: Compile
99
182
  if (htmlTarget) {
100
183
  htmlTarget.data = {
101
184
  ...(htmlTarget.data ?? {}),
102
- narrativeId: narrative.id,
103
- narrativeHash,
104
- planQualityChecks: qualityChecks,
105
- planChapters: chapters,
106
- requiredClaimIds: planCoverage.requiredClaimIds,
107
- coveredClaimIds: planCoverage.coveredClaimIds,
108
- missingClaimIds: planCoverage.missingClaimIds,
185
+ narrativeId: narrative.id,
186
+ narrativeHash,
187
+ planningPacket,
188
+ deckPlanRequirements,
189
+ requiredClaimIds: narrative.claims.filter((claim) => claim.importance === "central").map((claim) => claim.id),
190
+ coveredClaimIds: [],
191
+ missingClaimIds: narrative.claims.filter((claim) => claim.importance === "central").map((claim) => claim.id),
109
192
  claimSlideRefs: getClaimSlideRefs(next).map((ref) => ({
110
193
  claimId: ref.claimId,
111
194
  claimText: ref.claimText,
@@ -124,14 +207,169 @@ export function compileDeckPlanFromNarrative(state: DecksState, options: Compile
124
207
  compiled: true,
125
208
  skipped: false,
126
209
  narrativeHash,
127
- slideCount: slides.length,
128
- slides,
129
- chapters,
130
- qualityChecks,
210
+ planArtifactPath: "deck-plan/index.md",
211
+ slideCount: 0,
212
+ slides: [],
213
+ chapters: [],
214
+ qualityChecks: [],
215
+ planningPacket,
216
+ deckPlanRequirements,
131
217
  },
132
218
  }
133
219
  }
134
220
 
221
+ function buildDeckPlanningPacket(narrative: NarrativeStateV1, narrativeHash: string, outputPath: string): DeckPlanningPacket {
222
+ return {
223
+ narrativeHash,
224
+ outputPath,
225
+ audience: narrative.audience,
226
+ decision: narrative.decision,
227
+ thesis: narrative.thesis,
228
+ centralClaims: orderedClaims(narrative, (claim) => claim.importance === "central"),
229
+ supportingClaims: orderedClaims(narrative, (claim) => claim.importance !== "central"),
230
+ evidenceBindings: narrative.evidenceBindings,
231
+ objections: narrative.objections,
232
+ risks: narrative.risks,
233
+ researchGaps: narrative.researchGaps,
234
+ }
235
+ }
236
+
237
+ function buildDeckPlanRequirements(narrativeHash: string): DeckPlanRequirements {
238
+ return {
239
+ planArtifactPath: "deck-plan/index.md",
240
+ slidePlanDir: "deck-plan/slides",
241
+ defaultProfile: "executive decision deck, usually 12-18 slides unless the user confirms otherwise",
242
+ userConfirmations: [
243
+ "Confirm target slide count or acceptable range when it is unclear.",
244
+ "Confirm audience and decision context when the narrative does not make them explicit.",
245
+ "Confirm language, emphasis, or visual style only when needed before writing the plan.",
246
+ ],
247
+ authoringRules: [
248
+ "LLM writes deck-plan/index.md and deck-plan/slides/*.md from the planning packet; compileDeckPlan does not generate the final slide list.",
249
+ "Use 3-5 chapters for normal executive decks.",
250
+ "Cover every central claim, but group related central claims into chapters instead of giving each claim its own chapter.",
251
+ "Each substantive chapter should have framing, proof, and implication/boundary coverage.",
252
+ "Chapter divider or chapter TOC slides may use the toc component as structural wayfinding.",
253
+ "Do not create filler slides, repeated thesis pages, or generic bridge slides.",
254
+ "Preserve evidence ids, source trace, supported scope, unsupported scope, caveats, and strength where available.",
255
+ "Do not render internal labels such as Evidence gap:, Unsupported scope:, Caveat:, Missing Data, or Evidence Boundary in executive body copy.",
256
+ "Do not infer plan structure from DECKS.json slides[]; it is compatibility cache only.",
257
+ "Use ## Narrative Links in slide files for [[claim-id]], [[evidence-id]], [[risk-id]], [[objection-id]], or [[gap-id]] references; do not use canonical ## Relations in deck-plan files.",
258
+ ],
259
+ requiredSections: [
260
+ "Source Authority",
261
+ "Audience / Goal / Decision",
262
+ "Deck Parameters",
263
+ "Chapter Map",
264
+ "Slide Plan",
265
+ "Evidence Trace",
266
+ "Boundary / Risk Treatment",
267
+ "Chapter Writing Batches",
268
+ "HTML Identity Contract",
269
+ ],
270
+ }
271
+ }
272
+
273
+ export function buildRenderPlanContract(deck: DeckSpec, chapters: DeckPlanChapter[]): RenderPlanContract {
274
+ return {
275
+ sourceAuthority: {
276
+ meaning: "revela-narrative/ canonical narrative state",
277
+ renderPlan: "deck-plan/ projection workspace plus compileDeckPlan planning packet",
278
+ state: "DECKS.json compatibility/render state only; slides[] is cached projection data",
279
+ htmlIdentity: "positive 1-based data-slide-index values, unique and strictly increasing in DOM order",
280
+ },
281
+ renderRules: [
282
+ "Do not infer deck structure, slide count, or chapter substance from DECKS.json slides[].",
283
+ "Use the compileDeckPlan planning packet plus deck-plan/ projection Markdown as the render-plan contract.",
284
+ "Render chapter divider slides with the toc component when slideKind is chapter-divider.",
285
+ "Chapter divider and global TOC slides are structural wayfinding and do not count toward central-claim substance.",
286
+ "Each central claim chapter needs non-structural framing, proof, and implication/boundary slides unless the current deck-plan projection explicitly says otherwise.",
287
+ "Generate HTML chapter by chapter, preserving valid HTML and already-written slides after every batch.",
288
+ ],
289
+ htmlIdentityContract: [
290
+ "Every written slide section uses class slide and a positive 1-based data-slide-index.",
291
+ "data-slide-index values are unique and strictly increase in DOM order.",
292
+ "Partial chapter-by-chapter artifacts are allowed when written slide identities are self-consistent.",
293
+ "Do not pad missing planned chapters just to match cached DECKS.json slides[].",
294
+ ],
295
+ chapterRequirements: chapters.map((chapter) => {
296
+ const substanceSlides = chapter.slideIndexes
297
+ .map((index) => deck.slides.find((slide) => slide.index === index))
298
+ .filter((slide): slide is SlideSpec => Boolean(slide))
299
+ .filter((slide) => slideCountsTowardClaimSubstance(slide))
300
+ return {
301
+ title: chapter.title,
302
+ role: chapter.role,
303
+ sourceClaimId: chapter.sourceClaimId,
304
+ slideIndexes: chapter.slideIndexes,
305
+ requiredSubstanceSlides: chapter.sourceClaimId ? 3 : 0,
306
+ actualSubstanceSlides: substanceSlides.length,
307
+ allowedStructuralSlides: chapter.sourceClaimId ? ["chapter-divider", "toc"] : ["cover", "toc", "ask"],
308
+ }
309
+ }),
310
+ chapterWritingBatches: chapters.map((chapter, index) => ({
311
+ label: index === 0 ? "Initial shell and first chapter" : `Chapter batch ${index + 1}`,
312
+ chapterTitle: chapter.title,
313
+ slideIndexes: chapter.slideIndexes,
314
+ instructions: index === 0
315
+ ? "Create the stable HTML shell, required structural slides, and this first chapter range only."
316
+ : "Patch exactly this chapter range, preserve previously written slides, and keep the file valid after the patch.",
317
+ })),
318
+ slideRenderMetadata: deck.slides.map((slide) => slideRenderMetadata(slide, chapters)),
319
+ }
320
+ }
321
+
322
+ function slideRenderMetadata(slide: SlideSpec, chapters: DeckPlanChapter[]): RenderPlanSlideMetadata {
323
+ const chapter = chapters.find((item) => item.slideIndexes.includes(slide.index))
324
+ const slideKind = renderPlanSlideKind(slide, chapter)
325
+ return {
326
+ index: slide.index,
327
+ title: slide.title,
328
+ chapterTitle: chapter?.title,
329
+ chapterRole: chapter?.role,
330
+ slideKind,
331
+ structural: isStructuralRenderPlanSlide(slideKind),
332
+ countsTowardClaimSubstance: slideCountsTowardClaimSubstance(slide),
333
+ requiredComponents: slide.components ?? [],
334
+ evidenceTraceRequired: (slide.evidenceBindingIds?.length ?? 0) > 0 || (slide.evidence?.length ?? 0) > 0,
335
+ claimChapterRequirement: claimChapterRequirementForSlideKind(slideKind),
336
+ }
337
+ }
338
+
339
+ function renderPlanSlideKind(slide: SlideSpec, chapter: DeckPlanChapter | undefined): RenderPlanSlideKind {
340
+ const chapterSlide = (slide.content?.data as { chapterSlide?: string } | undefined)?.chapterSlide
341
+ if (slide.index === 1 && slide.components.includes("hero")) return "cover"
342
+ if (slide.layout === "toc" && chapter?.sourceClaimId) return "chapter-divider"
343
+ if (slide.layout === "toc") return "toc"
344
+ if (chapterSlide === "framing") return "claim-framing"
345
+ if (chapterSlide === "evidence") return "claim-evidence"
346
+ if (chapterSlide === "implication") return "claim-implication"
347
+ if (slide.narrativeRole === "risk") return "risk"
348
+ if (slide.narrativeRole === "ask" || slide.narrativeRole === "close") return "ask"
349
+ if (slide.narrativeRole === "evidence") return "supporting-evidence"
350
+ return "content"
351
+ }
352
+
353
+ function isStructuralRenderPlanSlide(kind: RenderPlanSlideKind): boolean {
354
+ return kind === "cover" || kind === "toc" || kind === "chapter-divider" || kind === "ask"
355
+ }
356
+
357
+ function slideCountsTowardClaimSubstance(slide: SlideSpec): boolean {
358
+ const kind = (slide.content?.data as { chapterSlide?: string } | undefined)?.chapterSlide
359
+ return kind === "framing" || kind === "evidence" || kind === "implication"
360
+ }
361
+
362
+ function claimChapterRequirementForSlideKind(kind: RenderPlanSlideKind): RenderPlanSlideMetadata["claimChapterRequirement"] {
363
+ if (kind === "chapter-divider") return "divider"
364
+ if (kind === "claim-framing") return "framing"
365
+ if (kind === "claim-evidence") return "proof"
366
+ if (kind === "claim-implication") return "implication"
367
+ if (kind === "supporting-evidence") return "supporting-evidence"
368
+ if (kind === "risk") return "risk"
369
+ if (kind === "ask") return "ask"
370
+ return undefined
371
+ }
372
+
135
373
  function buildDeckPlan(narrative: NarrativeStateV1): { slides: SlideSpec[]; chapters: DeckPlanChapter[] } {
136
374
  const slides: SlideSpec[] = []
137
375
  const evidenceByClaim = evidenceBindingsByClaim(narrative.evidenceBindings)
@@ -144,9 +382,18 @@ function buildDeckPlan(narrative: NarrativeStateV1): { slides: SlideSpec[]; chap
144
382
  slides.push(tocSlide(slides.length + 1, chapters))
145
383
 
146
384
  for (const claim of centralClaims) {
147
- const slide = claimSlide(slides.length + 1, claim, evidenceByClaim.get(claim.id) ?? [])
148
- slides.push(slide)
149
- assignSlideToChapter(chapters, chapterRoleForClaim(claim), slide)
385
+ const bindings = evidenceByClaim.get(claim.id) ?? []
386
+ const chapter = chapters.find((item) => item.sourceClaimId === claim.id)
387
+ const divider = chapterDividerSlide(slides.length + 1, chapters, chapter ?? chapterForClaimFallback(claim))
388
+ slides.push(divider)
389
+ if (chapter) assignSlideToSpecificChapter(chapter, divider)
390
+ else assignSlideToChapter(chapters, chapterRoleForClaim(claim), divider)
391
+ for (const kind of ["framing", "evidence", "implication"] as const) {
392
+ const slide = claimChapterSlide(slides.length + 1, claim, bindings, kind, narrative)
393
+ slides.push(slide)
394
+ if (chapter) assignSlideToSpecificChapter(chapter, slide)
395
+ else assignSlideToChapter(chapters, chapterRoleForClaim(claim), slide)
396
+ }
150
397
  }
151
398
  if (supportingClaims.length > 0) {
152
399
  const slide = supportingLogicSlide(slides.length + 1, supportingClaims, evidenceByClaim)
@@ -168,6 +415,7 @@ function buildDeckPlan(narrative: NarrativeStateV1): { slides: SlideSpec[]; chap
168
415
  }
169
416
 
170
417
  function coverSlide(index: number, narrative: NarrativeStateV1): SlideSpec {
418
+ const visualIntent = visualIntentForStructuralSlide("hero", "Use a hero frame to anchor the decision context and belief shift before evidence detail.")
171
419
  return {
172
420
  index,
173
421
  title: "Decision Context",
@@ -183,13 +431,16 @@ function coverSlide(index: number, narrative: NarrativeStateV1): SlideSpec {
183
431
  narrative.audience.beliefAfter ? `After: ${narrative.audience.beliefAfter}` : "After belief needs confirmation.",
184
432
  ],
185
433
  bullets: narrative.decision.action ? [`Decision: ${narrative.decision.action}`] : [],
434
+ data: { visualIntent },
186
435
  },
436
+ visuals: visualBriefs(index, visualIntent),
187
437
  evidence: [],
188
438
  status: "planned",
189
439
  }
190
440
  }
191
441
 
192
442
  function tocSlide(index: number, chapters: DeckPlanChapter[]): SlideSpec {
443
+ const visualIntent = visualIntentForStructuralSlide("toc", "Render the chapter sequence as a visual table of contents instead of a bullet-only agenda.")
193
444
  return {
194
445
  index,
195
446
  title: "Storyline",
@@ -201,14 +452,55 @@ function tocSlide(index: number, chapters: DeckPlanChapter[]): SlideSpec {
201
452
  content: {
202
453
  headline: "How the decision story is organized",
203
454
  bullets: chapters.map((chapter) => chapter.title),
204
- data: { chapters: chapters.map((chapter) => ({ title: chapter.title, role: chapter.role })) },
455
+ data: { chapters: chapters.map((chapter) => ({ title: chapter.title, role: chapter.role })), visualIntent },
205
456
  },
457
+ visuals: visualBriefs(index, visualIntent),
206
458
  evidence: [],
207
459
  status: "planned",
208
460
  }
209
461
  }
210
462
 
463
+ function chapterDividerSlide(index: number, chapters: DeckPlanChapter[], chapter: DeckPlanChapter): SlideSpec {
464
+ const visualIntent = visualIntentForStructuralSlide("toc", "Use a chapter divider as wayfinding before the claim-led chapter; it is structural and does not replace framing, proof, or implication slides.")
465
+ return {
466
+ index,
467
+ title: chapter.title,
468
+ purpose: `Open the ${chapter.title} chapter with a TOC-style divider before claim substance slides.`,
469
+ narrativeRole: "context",
470
+ layout: "toc",
471
+ qa: false,
472
+ components: ["toc", "text-panel"],
473
+ claimIds: chapter.sourceClaimId ? [chapter.sourceClaimId] : [],
474
+ claimRefs: chapter.sourceClaimId ? [{ claimId: chapter.sourceClaimId, role: "supporting", note: "Structural chapter divider; not counted as claim proof." }] : [],
475
+ evidenceBindingIds: [],
476
+ content: {
477
+ headline: chapter.title,
478
+ bullets: chapters.map((item) => item.title),
479
+ data: {
480
+ activeChapter: chapter.title,
481
+ chapters: chapters.map((item) => ({ title: item.title, role: item.role, active: item.title === chapter.title })),
482
+ visualIntent,
483
+ },
484
+ },
485
+ visuals: visualBriefs(index, visualIntent),
486
+ evidence: [],
487
+ status: "planned",
488
+ }
489
+ }
490
+
491
+ function chapterForClaimFallback(claim: NarrativeClaim): DeckPlanChapter {
492
+ return {
493
+ title: claimChapterTitle(claim),
494
+ role: chapterRoleForClaim(claim),
495
+ slideIndexes: [],
496
+ claimIds: [claim.id],
497
+ evidenceBindingIds: [],
498
+ sourceClaimId: claim.id,
499
+ }
500
+ }
501
+
211
502
  function claimSlide(index: number, claim: NarrativeClaim, bindings: NarrativeEvidenceBinding[]): SlideSpec {
503
+ const visualIntent = visualIntentForClaim(claim, bindings)
212
504
  return {
213
505
  index,
214
506
  title: titleFromClaim(claim),
@@ -216,21 +508,120 @@ function claimSlide(index: number, claim: NarrativeClaim, bindings: NarrativeEvi
216
508
  narrativeRole: claim.kind === "risk" || claim.kind === "assumption" ? "risk" : claim.kind === "ask" ? "ask" : claim.kind === "recommendation" ? "recommendation" : "evidence",
217
509
  layout: "two-col",
218
510
  qa: true,
219
- components: claimComponents(claim, bindings),
511
+ components: claimComponents(claim, bindings, visualIntent),
220
512
  claimIds: [claim.id],
221
513
  claimRefs: [{ claimId: claim.id, role: "primary", note: claimBoundaryNote(claim) }],
222
514
  evidenceBindingIds: bindings.map((binding) => binding.id),
223
515
  content: {
224
516
  headline: claim.text,
225
517
  bullets: claimBullets(claim, bindings),
518
+ data: { visualIntent },
519
+ },
520
+ evidence: bindings.map(evidenceRefFromBinding),
521
+ visuals: visualBriefs(index, visualIntent),
522
+ status: "planned",
523
+ }
524
+ }
525
+
526
+ function claimChapterSlide(index: number, claim: NarrativeClaim, bindings: NarrativeEvidenceBinding[], kind: ClaimChapterSlideKind, narrative: NarrativeStateV1): SlideSpec {
527
+ if (kind === "framing") return claimFramingSlide(index, claim, bindings)
528
+ if (kind === "evidence") return claimEvidenceSlide(index, claim, bindings)
529
+ return claimImplicationSlide(index, claim, bindings, narrative)
530
+ }
531
+
532
+ function claimFramingSlide(index: number, claim: NarrativeClaim, bindings: NarrativeEvidenceBinding[]): SlideSpec {
533
+ const visualIntent = visualIntentForStructuralSlide("text-only", "Frame why this central claim deserves a chapter before proof detail; keep evidence boundaries visible.")
534
+ const notes = internalEvidenceDiagnostics(claim, bindings)
535
+ return {
536
+ index,
537
+ title: `${titleFromClaim(claim)} - framing`,
538
+ purpose: `Frame the audience context, decision relevance, and evidence boundary for central claim ${claim.id}.`,
539
+ narrativeRole: claim.kind === "problem" || claim.kind === "opportunity" ? "tension" : claim.kind === "context" ? "context" : "evidence",
540
+ layout: "two-col",
541
+ qa: true,
542
+ components: componentsForVisualIntent(["box", "text-panel"], visualIntent),
543
+ claimIds: [claim.id],
544
+ claimRefs: [{ claimId: claim.id, role: "primary", note: claimBoundaryNote(claim) }],
545
+ evidenceBindingIds: bindings.map((binding) => binding.id),
546
+ content: {
547
+ headline: claim.text,
548
+ bullets: [
549
+ `Chapter claim: ${claim.text}`,
550
+ claim.supportedScope ? `What the available evidence supports: ${claim.supportedScope}` : undefined,
551
+ claim.evidenceRequired ? audienceEvidenceGapBullet(claim, bindings) ?? `Proof base: ${bindings.length} bound evidence item${bindings.length === 1 ? "" : "s"} supports this claim.` : "This framing claim does not require separate proof in the deck plan.",
552
+ ].filter((item): item is string => Boolean(item)),
553
+ speakerNotes: notes,
554
+ data: { visualIntent, chapterSlide: "framing" },
226
555
  },
227
556
  evidence: bindings.map(evidenceRefFromBinding),
557
+ visuals: visualBriefs(index, visualIntent),
558
+ status: "planned",
559
+ }
560
+ }
561
+
562
+ function claimEvidenceSlide(index: number, claim: NarrativeClaim, bindings: NarrativeEvidenceBinding[]): SlideSpec {
563
+ const visualIntent = visualIntentForClaim(claim, bindings)
564
+ const notes = internalEvidenceDiagnostics(claim, bindings)
565
+ return {
566
+ index,
567
+ title: `${titleFromClaim(claim)} - proof`,
568
+ purpose: `Show the specific evidence, source trace, support scope, unsupported scope, caveat, and strength for central claim ${claim.id}.`,
569
+ narrativeRole: "evidence",
570
+ layout: "two-col",
571
+ qa: true,
572
+ components: claimComponents(claim, bindings, visualIntent),
573
+ claimIds: [claim.id],
574
+ claimRefs: [{ claimId: claim.id, role: "evidence", note: claimBoundaryNote(claim) }],
575
+ evidenceBindingIds: bindings.map((binding) => binding.id),
576
+ content: {
577
+ headline: claim.text,
578
+ bullets: claimBullets(claim, bindings),
579
+ speakerNotes: notes,
580
+ data: { visualIntent, chapterSlide: "evidence" },
581
+ },
582
+ evidence: bindings.map(evidenceRefFromBinding),
583
+ visuals: visualBriefs(index, visualIntent),
584
+ status: "planned",
585
+ }
586
+ }
587
+
588
+ function claimImplicationSlide(index: number, claim: NarrativeClaim, bindings: NarrativeEvidenceBinding[], narrative: NarrativeStateV1): SlideSpec {
589
+ const relatedRisks = narrative.risks.filter((risk) => risk.claimId === claim.id)
590
+ const relatedObjections = narrative.objections.filter((objection) => objection.claimId === claim.id)
591
+ const visualIntent = visualIntentForClaimImplication(claim, relatedRisks.length + relatedObjections.length)
592
+ const notes = internalEvidenceDiagnostics(claim, bindings)
593
+ return {
594
+ index,
595
+ title: `${titleFromClaim(claim)} - implication`,
596
+ purpose: `Translate central claim ${claim.id} into decision implication while keeping boundaries and risks explicit.`,
597
+ narrativeRole: claim.kind === "recommendation" || claim.kind === "ask" ? "recommendation" : relatedRisks.length > 0 || relatedObjections.length > 0 ? "risk" : "evidence",
598
+ layout: "two-col",
599
+ qa: true,
600
+ components: componentsForVisualIntent(["box", "text-panel"], visualIntent),
601
+ claimIds: [claim.id],
602
+ claimRefs: [{ claimId: claim.id, role: relatedRisks.length > 0 || relatedObjections.length > 0 ? "risk" : "supporting", note: claimBoundaryNote(claim) }],
603
+ evidenceBindingIds: bindings.map((binding) => binding.id),
604
+ content: {
605
+ headline: `Decision implication: ${claim.text}`,
606
+ bullets: [
607
+ ...claimBoundaryBullets(claim),
608
+ ...relatedRisks.slice(0, 2).map((risk) => risk.mitigation ? `Risk: ${risk.text} Mitigation: ${risk.mitigation}` : `Risk: ${risk.text}`),
609
+ ...relatedObjections.slice(0, 2).map((objection) => objection.response ? `Objection: ${objection.text} Response: ${objection.response}` : `Objection: ${objection.text}`),
610
+ relatedRisks.length === 0 && relatedObjections.length === 0 && !claim.unsupportedScope && (claim.caveats ?? []).length === 0 ? "Decision use: no specific limiting condition is recorded for this central claim." : undefined,
611
+ ].filter((item): item is string => Boolean(item)),
612
+ speakerNotes: notes,
613
+ data: { visualIntent, chapterSlide: "implication" },
614
+ },
615
+ evidence: bindings.map(evidenceRefFromBinding),
616
+ visuals: visualBriefs(index, visualIntent),
228
617
  status: "planned",
229
618
  }
230
619
  }
231
620
 
232
621
  function supportingLogicSlide(index: number, claims: NarrativeClaim[], evidenceByClaim: Map<string, NarrativeEvidenceBinding[]>): SlideSpec {
233
622
  const supportingBindings = claims.flatMap((claim) => evidenceByClaim.get(claim.id) ?? [])
623
+ const visualIntent = visualIntentForSupportingLogic(claims, supportingBindings)
624
+ const notes = claims.map((claim) => internalEvidenceDiagnostics(claim, evidenceByClaim.get(claim.id) ?? [])).filter(Boolean).join("\n\n") || undefined
234
625
  return {
235
626
  index,
236
627
  title: "Supporting Logic",
@@ -238,15 +629,18 @@ function supportingLogicSlide(index: number, claims: NarrativeClaim[], evidenceB
238
629
  narrativeRole: "evidence",
239
630
  layout: "card-grid",
240
631
  qa: true,
241
- components: ["box", "text-panel"],
632
+ components: componentsForVisualIntent(["box", "text-panel"], visualIntent),
242
633
  claimIds: claims.map((claim) => claim.id),
243
634
  claimRefs: claims.map((claim) => ({ claimId: claim.id, role: "supporting" as const, note: claimBoundaryNote(claim) })),
244
635
  evidenceBindingIds: supportingBindings.map((binding) => binding.id),
245
636
  content: {
246
637
  headline: "Supporting claims and boundaries",
247
- bullets: claims.slice(0, 5).flatMap((claim) => [claim.text, ...claimBoundaryBullets(claim), evidenceGapBullet(claim, evidenceByClaim.get(claim.id) ?? [])]).filter((item): item is string => Boolean(item)).slice(0, 8),
638
+ bullets: claims.slice(0, 5).flatMap((claim) => [claim.text, ...claimBoundaryBullets(claim), audienceEvidenceGapBullet(claim, evidenceByClaim.get(claim.id) ?? [])]).filter((item): item is string => Boolean(item)).slice(0, 8),
639
+ speakerNotes: notes,
640
+ data: { visualIntent },
248
641
  },
249
642
  evidence: supportingBindings.map(evidenceRefFromBinding),
643
+ visuals: visualBriefs(index, visualIntent),
250
644
  status: "planned",
251
645
  }
252
646
  }
@@ -258,6 +652,7 @@ function riskObjectionSlide(index: number, narrative: NarrativeStateV1, evidence
258
652
  ]
259
653
  const challengedClaimIds = [...new Set(challengedClaimRefs.map((ref) => ref.claimId))]
260
654
  const challengedBindings = challengedClaimIds.flatMap((claimId) => evidenceByClaim.get(claimId) ?? [])
655
+ const visualIntent = visualIntentForRiskObjection(narrative, challengedBindings)
261
656
  return {
262
657
  index,
263
658
  title: "Risks And Objections",
@@ -265,7 +660,7 @@ function riskObjectionSlide(index: number, narrative: NarrativeStateV1, evidence
265
660
  narrativeRole: "risk",
266
661
  layout: "two-col",
267
662
  qa: true,
268
- components: ["box", "text-panel"],
663
+ components: componentsForVisualIntent(["box", "text-panel"], visualIntent),
269
664
  claimIds: challengedClaimIds,
270
665
  claimRefs: dedupeClaimRefs(challengedClaimRefs),
271
666
  evidenceBindingIds: challengedBindings.map((binding) => binding.id),
@@ -275,14 +670,17 @@ function riskObjectionSlide(index: number, narrative: NarrativeStateV1, evidence
275
670
  ...narrative.risks.slice(0, 3).map((risk) => risk.mitigation ? `${risk.text} Mitigation: ${risk.mitigation}` : risk.text),
276
671
  ...narrative.objections.slice(0, 3).map((objection) => objection.response ? `${objection.text} Response: ${objection.response}` : objection.text),
277
672
  ],
673
+ data: { visualIntent },
278
674
  },
279
675
  evidence: challengedBindings.map(evidenceRefFromBinding),
676
+ visuals: visualBriefs(index, visualIntent),
280
677
  status: "planned",
281
678
  }
282
679
  }
283
680
 
284
681
  function decisionAskSlide(index: number, narrative: NarrativeStateV1): SlideSpec {
285
682
  const askClaims = orderedClaims(narrative, (claim) => claim.kind === "ask" || claim.kind === "recommendation")
683
+ const visualIntent = visualIntentForStructuralSlide("steps", "Show the requested decision, owner, deadline, and consequence as an action sequence rather than a dense closing paragraph.")
286
684
  return {
287
685
  index,
288
686
  title: "Decision Ask",
@@ -300,8 +698,10 @@ function decisionAskSlide(index: number, narrative: NarrativeStateV1): SlideSpec
300
698
  narrative.decision.deadline ? `Deadline: ${narrative.decision.deadline}` : undefined,
301
699
  narrative.decision.consequenceOfNoDecision ? `If no decision: ${narrative.decision.consequenceOfNoDecision}` : undefined,
302
700
  ].filter((item): item is string => Boolean(item)),
701
+ data: { visualIntent },
303
702
  },
304
703
  evidence: [],
704
+ visuals: visualBriefs(index, visualIntent),
305
705
  status: "planned",
306
706
  }
307
707
  }
@@ -332,20 +732,27 @@ function deriveChapters(narrative: NarrativeStateV1, centralClaims: NarrativeCla
332
732
  const claims = [...centralClaims, ...supportingClaims]
333
733
  const chapters: DeckPlanChapter[] = []
334
734
  addChapter(chapters, narrative.audience.decisionContext ? "Decision context" : "Context and belief shift", "context")
335
- if (hasClaimKind(claims, ["problem", "opportunity"])) addChapter(chapters, "Tension and opportunity", "tension")
336
- if (claims.some((claim) => claim.kind === "evidence") || narrative.evidenceBindings.length > 0) addChapter(chapters, "Evidence and proof", "evidence")
337
- if (claims.some((claim) => claim.kind === "recommendation" || claim.kind === "ask") || narrative.decision.action) addChapter(chapters, "Recommendation and decision", "recommendation")
735
+ for (const claim of centralClaims) addClaimChapter(chapters, claim)
736
+ if (supportingClaims.length > 0 || centralClaims.length === 0 && (claims.some((claim) => claim.kind === "evidence") || narrative.evidenceBindings.length > 0)) addChapter(chapters, "Evidence and proof", "evidence")
737
+ if (centralClaims.length === 0 && claims.some((claim) => claim.kind === "recommendation" || claim.kind === "ask")) addChapter(chapters, "Recommendation and decision", "recommendation")
338
738
  if (narrative.risks.length > 0 || narrative.objections.length > 0 || centralClaims.some((claim) => claim.unsupportedScope || (claim.caveats ?? []).length > 0)) addChapter(chapters, "Risks and boundaries", "risk")
339
739
  addChapter(chapters, "Decision ask", "ask")
340
740
  if (chapters.length < 3) addChapter(chapters, "Evidence and proof", "evidence")
341
741
  while (chapters.length > 5) {
342
- const tensionIndex = chapters.findIndex((chapter) => chapter.role === "tension")
343
- if (tensionIndex >= 0) chapters.splice(tensionIndex, 1)
344
- else chapters.splice(Math.max(1, chapters.length - 2), 1)
742
+ const removableIndex = chapters.findIndex((chapter) => !chapter.sourceClaimId && chapter.role !== "context" && chapter.role !== "ask")
743
+ if (removableIndex >= 0) chapters.splice(removableIndex, 1)
744
+ else break
345
745
  }
346
746
  return chapters
347
747
  }
348
748
 
749
+ function addClaimChapter(chapters: DeckPlanChapter[], claim: NarrativeClaim): void {
750
+ const role = chapterRoleForClaim(claim)
751
+ const title = claimChapterTitle(claim)
752
+ if (chapters.some((chapter) => chapter.sourceClaimId === claim.id)) return
753
+ chapters.push({ title, role, slideIndexes: [], claimIds: [], evidenceBindingIds: [], sourceClaimId: claim.id })
754
+ }
755
+
349
756
  function addChapter(chapters: DeckPlanChapter[], title: string, role: DeckPlanChapter["role"]): void {
350
757
  if (chapters.some((chapter) => chapter.role === role || chapter.title === title)) return
351
758
  chapters.push({ title, role, slideIndexes: [], claimIds: [], evidenceBindingIds: [] })
@@ -354,6 +761,10 @@ function addChapter(chapters: DeckPlanChapter[], title: string, role: DeckPlanCh
354
761
  function assignSlideToChapter(chapters: DeckPlanChapter[], role: DeckPlanChapter["role"], slide: SlideSpec): void {
355
762
  const chapter = chapters.find((item) => item.role === role) ?? chapters.find((item) => item.role === "evidence") ?? chapters[chapters.length - 1]
356
763
  if (!chapter) return
764
+ assignSlideToSpecificChapter(chapter, slide)
765
+ }
766
+
767
+ function assignSlideToSpecificChapter(chapter: DeckPlanChapter, slide: SlideSpec): void {
357
768
  chapter.slideIndexes.push(slide.index)
358
769
  for (const claimId of slide.claimIds ?? []) addUnique(chapter.claimIds, claimId)
359
770
  for (const ref of slide.claimRefs ?? []) addUnique(chapter.claimIds, ref.claimId)
@@ -377,30 +788,153 @@ function hasClaimKind(claims: NarrativeClaim[], kinds: NarrativeClaim["kind"][])
377
788
  return claims.some((claim) => kinds.includes(claim.kind))
378
789
  }
379
790
 
380
- function claimComponents(claim: NarrativeClaim, bindings: NarrativeEvidenceBinding[]): string[] {
381
- if (bindings.some((binding) => binding.quote?.trim())) return ["box", "text-panel", "quote"]
382
- if (claim.kind === "recommendation" || claim.kind === "ask") return ["box", "text-panel", "steps"]
383
- return ["box", "text-panel"]
791
+ function claimComponents(claim: NarrativeClaim, bindings: NarrativeEvidenceBinding[], visualIntent: VisualIntent): string[] {
792
+ const base = bindings.some((binding) => binding.quote?.trim()) ? ["box", "text-panel", "quote"] : ["box", "text-panel"]
793
+ if ((claim.kind === "recommendation" || claim.kind === "ask") && visualIntent.kind === "text-only") return ["box", "text-panel", "steps"]
794
+ return componentsForVisualIntent(base, visualIntent)
795
+ }
796
+
797
+ function visualIntentForClaim(claim: NarrativeClaim, bindings: NarrativeEvidenceBinding[]): VisualIntent {
798
+ const evidenceBindingIds = bindings.map((binding) => binding.id)
799
+ const dataSignals = dataSignalsFromBindings(bindings)
800
+ if (bindings.length >= 2) {
801
+ return {
802
+ kind: "evidence-table",
803
+ component: "data-table",
804
+ rationale: "Compare multiple evidence bindings with source, support scope, and caveat columns so the slide is not a bullet stack.",
805
+ dataSignals,
806
+ evidenceBindingIds,
807
+ }
808
+ }
809
+ if (dataSignals.length > 0) {
810
+ return {
811
+ kind: "metric-stat",
812
+ component: "stat-card",
813
+ rationale: "Promote the strongest quantitative evidence signal into a metric card, with the source quote retained for traceability.",
814
+ dataSignals,
815
+ evidenceBindingIds,
816
+ }
817
+ }
818
+ if (claim.kind === "recommendation" || claim.kind === "ask") {
819
+ return {
820
+ kind: "steps",
821
+ component: "steps",
822
+ rationale: "Show the recommendation as phased actions or decision gates rather than a paragraph.",
823
+ dataSignals,
824
+ evidenceBindingIds,
825
+ }
826
+ }
827
+ return {
828
+ kind: "text-only",
829
+ component: "box",
830
+ rationale: "No quantified or multi-source visual signal is available; use semantic evidence boxes and keep boundaries explicit.",
831
+ dataSignals,
832
+ evidenceBindingIds,
833
+ }
834
+ }
835
+
836
+ function visualIntentForSupportingLogic(claims: NarrativeClaim[], bindings: NarrativeEvidenceBinding[]): VisualIntent {
837
+ const dataSignals = dataSignalsFromBindings(bindings)
838
+ return {
839
+ kind: claims.length >= 3 || bindings.length >= 2 ? "comparison-grid" : "evidence-table",
840
+ component: "data-table",
841
+ rationale: "Organize supporting claims as a comparison grid with evidence status and boundaries, avoiding a long undifferentiated bullet list.",
842
+ dataSignals,
843
+ evidenceBindingIds: bindings.map((binding) => binding.id),
844
+ }
845
+ }
846
+
847
+ function visualIntentForRiskObjection(narrative: NarrativeStateV1, bindings: NarrativeEvidenceBinding[]): VisualIntent {
848
+ return {
849
+ kind: "risk-matrix",
850
+ component: "data-table",
851
+ rationale: "Pair each risk or objection with mitigation or response in a compact matrix so caveats stay visible without becoming prose-heavy.",
852
+ dataSignals: [...narrative.risks.map((risk) => risk.severity), ...narrative.objections.map((objection) => objection.priority)].filter(Boolean),
853
+ evidenceBindingIds: bindings.map((binding) => binding.id),
854
+ }
855
+ }
856
+
857
+ function visualIntentForStructuralSlide(kind: Extract<VisualIntentKind, "hero" | "toc" | "steps" | "text-only">, rationale: string): VisualIntent {
858
+ return { kind, component: kind === "toc" ? "toc" : kind === "steps" ? "steps" : kind === "text-only" ? "box" : "hero", rationale, dataSignals: [], evidenceBindingIds: [] }
859
+ }
860
+
861
+ function visualIntentForClaimImplication(claim: NarrativeClaim, relatedBoundaryCount: number): VisualIntent {
862
+ if (claim.kind === "recommendation" || claim.kind === "ask") {
863
+ return {
864
+ kind: "steps",
865
+ component: "steps",
866
+ rationale: "Translate the chapter proof into concrete decision gates or actions while preserving evidence boundaries.",
867
+ dataSignals: [],
868
+ evidenceBindingIds: [],
869
+ }
870
+ }
871
+ if (relatedBoundaryCount > 0 || claim.unsupportedScope || (claim.caveats ?? []).length > 0) {
872
+ return {
873
+ kind: "risk-matrix",
874
+ component: "data-table",
875
+ rationale: "Show the decision implication alongside risks, objections, unsupported scope, and caveats so boundaries shape the recommendation.",
876
+ dataSignals: [],
877
+ evidenceBindingIds: [],
878
+ }
879
+ }
880
+ return {
881
+ kind: "text-only",
882
+ component: "box",
883
+ rationale: "State the decision implication without inventing risk or ROI detail that is not present in the canonical narrative.",
884
+ dataSignals: [],
885
+ evidenceBindingIds: [],
886
+ }
887
+ }
888
+
889
+ function componentsForVisualIntent(base: string[], visualIntent: VisualIntent): string[] {
890
+ const next = [...base]
891
+ if (visualIntent.component && !next.includes(visualIntent.component)) next.push(visualIntent.component)
892
+ return next
893
+ }
894
+
895
+ function visualBriefs(slideIndex: number, visualIntent: VisualIntent): VisualBrief[] {
896
+ return [{
897
+ id: `visual:${slideIndex}:${visualIntent.kind}`,
898
+ purpose: visualIntent.kind,
899
+ brief: `${visualIntent.rationale} Use ${visualIntent.component} and preserve cited evidence boundaries${visualIntent.dataSignals.length > 0 ? `; visible signals: ${visualIntent.dataSignals.slice(0, 4).join(", ")}.` : "."}`,
900
+ }]
901
+ }
902
+
903
+ function dataSignalsFromBindings(bindings: NarrativeEvidenceBinding[]): string[] {
904
+ const signals = bindings.flatMap((binding) => numericSignals([binding.quote, binding.supportScope, binding.source, binding.location].filter(Boolean).join(" ")))
905
+ return [...new Set(signals)].slice(0, 6)
906
+ }
907
+
908
+ function numericSignals(text: string): string[] {
909
+ return [...text.matchAll(/(?:[$€£¥]\s*)?\d+(?:\.\d+)?\s*(?:%|bps|x|k|m|bn|billion|million|year|years|yr|yrs)?/gi)]
910
+ .map((match) => match[0].trim())
911
+ .filter(Boolean)
384
912
  }
385
913
 
386
914
  function claimBullets(claim: NarrativeClaim, bindings: NarrativeEvidenceBinding[]): string[] {
387
915
  return [
388
916
  ...claimBoundaryBullets(claim),
389
- ...bindings.slice(0, 2).map((binding) => binding.supportScope ? `Evidence supports: ${binding.supportScope}` : undefined),
390
- evidenceGapBullet(claim, bindings),
917
+ ...bindings.slice(0, 2).map((binding) => binding.supportScope ? `Evidence points to: ${binding.supportScope}` : undefined),
918
+ audienceEvidenceGapBullet(claim, bindings),
391
919
  ].filter((item): item is string => Boolean(item))
392
920
  }
393
921
 
394
- function evidenceGapBullet(claim: NarrativeClaim, bindings: NarrativeEvidenceBinding[]): string | undefined {
922
+ function audienceEvidenceGapBullet(claim: NarrativeClaim, bindings: NarrativeEvidenceBinding[]): string | undefined {
395
923
  if (!claim.evidenceRequired || bindings.length > 0) return undefined
396
- return `Evidence gap: ${claim.evidenceStatus === "missing" ? "no binding yet" : "support remains incomplete"}.`
924
+ return claim.evidenceStatus === "missing"
925
+ ? "Decision boundary: this point needs supporting proof before it can carry the recommendation."
926
+ : "Decision boundary: current support is incomplete, so use this point as a qualified signal rather than a proven conclusion."
927
+ }
928
+
929
+ function isAudienceEvidenceGapBullet(text: string): boolean {
930
+ return text.startsWith("Decision boundary:") && (text.includes("supporting proof") || text.includes("qualified signal"))
397
931
  }
398
932
 
399
933
  function claimBoundaryBullets(claim: NarrativeClaim): string[] {
400
934
  return [
401
- claim.supportedScope ? `Supported scope: ${claim.supportedScope}` : undefined,
402
- claim.unsupportedScope ? `Unsupported scope: ${claim.unsupportedScope}` : undefined,
403
- ...(claim.caveats ?? []).map((caveat) => `Caveat: ${caveat}`),
935
+ claim.supportedScope ? `What the available evidence supports: ${claim.supportedScope}` : undefined,
936
+ claim.unsupportedScope ? `What this does not yet prove: ${claim.unsupportedScope}` : undefined,
937
+ ...(claim.caveats ?? []).map((caveat) => `Use with caution: ${caveat}`),
404
938
  ].filter((item): item is string => Boolean(item))
405
939
  }
406
940
 
@@ -409,6 +943,18 @@ function claimBoundaryNote(claim: NarrativeClaim): string | undefined {
409
943
  return notes.length > 0 ? notes.join(" ") : undefined
410
944
  }
411
945
 
946
+ function internalEvidenceDiagnostics(claim: NarrativeClaim, bindings: NarrativeEvidenceBinding[]): string | undefined {
947
+ const lines = [
948
+ claim.supportedScope ? `Supported scope: ${claim.supportedScope}` : undefined,
949
+ claim.unsupportedScope ? `Unsupported scope: ${claim.unsupportedScope}` : undefined,
950
+ ...(claim.caveats ?? []).map((caveat) => `Caveat: ${caveat}`),
951
+ !claim.evidenceRequired ? undefined : bindings.length === 0 ? `Evidence gap: ${claim.evidenceStatus === "missing" ? "no binding yet" : "support remains incomplete"}.` : undefined,
952
+ ...bindings.map((binding) => binding.caveat ? `Evidence ${binding.id} caveat: ${binding.caveat}` : undefined),
953
+ ...bindings.map((binding) => binding.unsupportedScope ? `Evidence ${binding.id} unsupported scope: ${binding.unsupportedScope}` : undefined),
954
+ ].filter((item): item is string => Boolean(item))
955
+ return lines.length > 0 ? `Internal evidence diagnostics for author/reviewer use only. Do not render these labels as executive-facing body copy.\n${lines.map((line) => `- ${line}`).join("\n")}` : undefined
956
+ }
957
+
412
958
  function dedupeClaimRefs<T extends { claimId: string; role: "risk" | "objection" }>(refs: T[]): T[] {
413
959
  const seen = new Set<string>()
414
960
  return refs.filter((ref) => {
@@ -435,6 +981,24 @@ function checkPlanQuality(narrative: NarrativeStateV1, slides: SlideSpec[], chap
435
981
  const coverage = deckPlanCoverage(narrative, slides)
436
982
  const centralClaimIds = narrative.claims.filter((claim) => claim.importance === "central").map((claim) => claim.id)
437
983
  const missingCentralClaims = centralClaimIds.filter((claimId) => coverage.missingClaimIds.includes(claimId))
984
+ const claimChapters = chapters.filter((chapter) => chapter.sourceClaimId)
985
+ const centralClaimsWithoutChapter = centralClaimIds.filter((claimId) => !claimChapters.some((chapter) => chapter.sourceClaimId === claimId))
986
+ const thinClaimChapters = claimChapters.filter((chapter) => nonStructuralClaimSlides(chapter, slides).length < 3)
987
+ const evidenceInvisibleChapters = claimChapters.filter((chapter) => {
988
+ const claim = narrative.claims.find((item) => item.id === chapter.sourceClaimId)
989
+ if (!claim?.evidenceRequired) return false
990
+ return chapter.evidenceBindingIds.length === 0 && !nonStructuralClaimSlides(chapter, slides).some((slide) => (slide.content.bullets ?? []).some((bullet) => isAudienceEvidenceGapBullet(bullet)))
991
+ })
992
+ const paddedClaimChapters = claimChapters.filter((chapter) => nonStructuralClaimSlides(chapter, slides).some((slide) => isFillerSlide(slide)))
993
+ const boundaryMissingChapters = claimChapters.filter((chapter) => {
994
+ const claim = narrative.claims.find((item) => item.id === chapter.sourceClaimId)
995
+ if (!claim) return false
996
+ const hasBoundary = Boolean(claim.unsupportedScope || (claim.caveats ?? []).length > 0 || narrative.risks.some((risk) => risk.claimId === claim.id) || narrative.objections.some((objection) => objection.claimId === claim.id))
997
+ if (!hasBoundary) return false
998
+ const text = nonStructuralClaimSlides(chapter, slides).flatMap((slide) => [slide.content.headline, ...(slide.content.bullets ?? [])]).join("\n")
999
+ const claimBoundaryVisible = [claim.unsupportedScope, ...(claim.caveats ?? [])].filter(Boolean).some((boundary) => text.includes(boundary!))
1000
+ return !(claimBoundaryVisible || text.includes("Risk:") || text.includes("Objection:"))
1001
+ })
438
1002
  const incompatibleComponents = [...new Set(slides.flatMap((slide) => slide.components).filter((component) => component === "card"))]
439
1003
  const toc = slides.find((slide) => slide.components.includes("toc"))
440
1004
  const tocBullets = toc?.content.bullets ?? []
@@ -444,6 +1008,31 @@ function checkPlanQuality(narrative: NarrativeStateV1, slides: SlideSpec[], chap
444
1008
  const risksOrObjectionsVisible = narrative.risks.length === 0 && narrative.objections.length === 0 || slides.some((slide) => slide.narrativeRole === "risk")
445
1009
 
446
1010
  return [
1011
+ {
1012
+ id: "claim_chapters_present",
1013
+ status: centralClaimsWithoutChapter.length === 0 ? "pass" : "blocker",
1014
+ message: centralClaimsWithoutChapter.length === 0 ? "Every central claim has a deterministic claim-led chapter." : `Central claims missing claim-led chapters: ${centralClaimsWithoutChapter.join(", ")}`,
1015
+ },
1016
+ {
1017
+ id: "claim_chapters_min_three_slides",
1018
+ status: thinClaimChapters.length === 0 ? "pass" : "blocker",
1019
+ message: thinClaimChapters.length === 0 ? "Every central claim chapter has at least three non-structural slides: framing, proof, and implication/boundary." : `Central claim chapters need at least three non-structural slides: ${thinClaimChapters.map((chapter) => chapter.sourceClaimId).join(", ")}`,
1020
+ },
1021
+ {
1022
+ id: "claim_chapter_evidence_visible",
1023
+ status: evidenceInvisibleChapters.length === 0 ? "pass" : "blocker",
1024
+ message: evidenceInvisibleChapters.length === 0 ? "Evidence-required claim chapters show bound evidence or an explicit evidence gap." : `Evidence-required claim chapters hide evidence gaps: ${evidenceInvisibleChapters.map((chapter) => chapter.sourceClaimId).join(", ")}`,
1025
+ },
1026
+ {
1027
+ id: "claim_chapter_not_padded_by_filler",
1028
+ status: paddedClaimChapters.length === 0 ? "pass" : "blocker",
1029
+ message: paddedClaimChapters.length === 0 ? "Claim chapters may use structural TOC dividers, but are not padded by placeholder, repeated thesis, or generic bridge slides." : `Claim chapters contain filler slides: ${paddedClaimChapters.map((chapter) => chapter.sourceClaimId).join(", ")}`,
1030
+ },
1031
+ {
1032
+ id: "claim_chapter_boundary_visible",
1033
+ status: boundaryMissingChapters.length === 0 ? "pass" : "blocker",
1034
+ message: boundaryMissingChapters.length === 0 ? "Central claim chapter boundaries, risks, objections, and caveats remain visible when recorded." : `Central claim chapters hide recorded boundaries: ${boundaryMissingChapters.map((chapter) => chapter.sourceClaimId).join(", ")}`,
1035
+ },
447
1036
  {
448
1037
  id: "chapter_structure_present",
449
1038
  status: chapters.length >= 3 && chapters.length <= 5 && chapters.every((chapter) => chapter.slideIndexes.length > 0) ? "pass" : "blocker",
@@ -492,6 +1081,17 @@ function checkPlanQuality(narrative: NarrativeStateV1, slides: SlideSpec[], chap
492
1081
  ]
493
1082
  }
494
1083
 
1084
+ function nonStructuralClaimSlides(chapter: DeckPlanChapter, slides: SlideSpec[]): SlideSpec[] {
1085
+ const indexes = new Set(chapter.slideIndexes)
1086
+ return slides.filter((slide) => indexes.has(slide.index) && slide.qa && slide.title !== "Decision Ask" && !slide.components.includes("toc"))
1087
+ }
1088
+
1089
+ function isFillerSlide(slide: SlideSpec): boolean {
1090
+ const text = `${slide.title}\n${slide.purpose}\n${slide.content.headline}\n${(slide.content.bullets ?? []).join("\n")}`.toLowerCase()
1091
+ if (slide.components.includes("hero") && slide.qa) return true
1092
+ return ["section divider", "bridge slide", "repeat thesis", "repeated thesis", "generic overview"].some((marker) => text.includes(marker))
1093
+ }
1094
+
495
1095
  function deckPlanCoverage(narrative: NarrativeStateV1, slides: SlideSpec[]): { requiredClaimIds: string[]; coveredClaimIds: string[]; missingClaimIds: string[] } {
496
1096
  const requiredClaimIds = narrative.claims
497
1097
  .filter((claim) => claim.importance === "central" || claim.evidenceRequired)
@@ -511,6 +1111,11 @@ function titleFromClaim(claim: NarrativeClaim): string {
511
1111
  return words || claim.kind
512
1112
  }
513
1113
 
1114
+ function claimChapterTitle(claim: NarrativeClaim): string {
1115
+ const title = titleFromClaim(claim)
1116
+ return title.endsWith(".") ? title.slice(0, -1) : title
1117
+ }
1118
+
514
1119
  function hasCurrentApprovalOrOverride(narrative: NarrativeStateV1, narrativeHash: string): boolean {
515
1120
  return narrative.approvals.some((approval) => approval.narrativeHash === narrativeHash && (approval.scope === "narrative" && approval.approvedBy === "user" || approval.scope === "render_override" || approval.approvedBy === "override"))
516
1121
  }