@cyber-dash-tech/revela 0.17.1 → 0.17.3

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, type VisualBrief } 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,7 +64,54 @@ 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
+
38
113
  type VisualIntentKind = "hero" | "toc" | "metric-stat" | "evidence-table" | "comparison-grid" | "risk-matrix" | "steps" | "text-only"
114
+ type ClaimChapterSlideKind = "framing" | "evidence" | "implication"
39
115
 
40
116
  interface VisualIntent {
41
117
  kind: VisualIntentKind
@@ -66,10 +142,8 @@ export function compileDeckPlanFromNarrative(state: DecksState, options: Compile
66
142
  const deckKey = state.activeDeck ?? Object.keys(state.decks)[0]
67
143
  const deck = deckKey ? state.decks[deckKey] : undefined
68
144
  const slug = deck?.slug ?? state.activeDeck ?? "deck"
69
- const plan = buildDeckPlan(narrative)
70
- const { slides, chapters } = plan
71
- const qualityChecks = checkPlanQuality(narrative, slides, chapters)
72
- const planCoverage = deckPlanCoverage(narrative, slides)
145
+ const planningPacket = buildDeckPlanningPacket(narrative, narrativeHash, deck?.outputPath ?? `decks/${slug}.html`)
146
+ const deckPlanRequirements = buildDeckPlanRequirements(narrativeHash)
73
147
  const requiredInputs: Partial<RequiredInputs> = {
74
148
  topicClarified: true,
75
149
  audienceClarified: Boolean(narrative.audience.primary),
@@ -93,13 +167,12 @@ export function compileDeckPlanFromNarrative(state: DecksState, options: Compile
93
167
  } as RequiredInputs,
94
168
  writeReadiness: deck?.writeReadiness ?? { status: "blocked" as const, blockers: [] },
95
169
  })
96
- next = upsertSlides(next, slug, slides)
97
170
  const plannedDeck = next.decks[slug]
98
171
  plannedDeck.planReview = {
99
172
  status: "pending",
100
173
  narrativeHash,
101
- planHash: deckPlanHash(plannedDeck.slides),
102
- qualityChecks,
174
+ planHash: "pending-deck-plan-md",
175
+ qualityChecks: [],
103
176
  }
104
177
  plannedDeck.requiredInputs = { ...plannedDeck.requiredInputs, slidePlanConfirmed: false }
105
178
  plannedDeck.writeReadiness = { status: "blocked", blockers: [] }
@@ -109,13 +182,13 @@ export function compileDeckPlanFromNarrative(state: DecksState, options: Compile
109
182
  if (htmlTarget) {
110
183
  htmlTarget.data = {
111
184
  ...(htmlTarget.data ?? {}),
112
- narrativeId: narrative.id,
113
- narrativeHash,
114
- planQualityChecks: qualityChecks,
115
- planChapters: chapters,
116
- requiredClaimIds: planCoverage.requiredClaimIds,
117
- coveredClaimIds: planCoverage.coveredClaimIds,
118
- 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),
119
192
  claimSlideRefs: getClaimSlideRefs(next).map((ref) => ({
120
193
  claimId: ref.claimId,
121
194
  claimText: ref.claimText,
@@ -134,14 +207,169 @@ export function compileDeckPlanFromNarrative(state: DecksState, options: Compile
134
207
  compiled: true,
135
208
  skipped: false,
136
209
  narrativeHash,
137
- slideCount: slides.length,
138
- slides,
139
- chapters,
140
- qualityChecks,
210
+ planArtifactPath: "deck-plan/index.md",
211
+ slideCount: 0,
212
+ slides: [],
213
+ chapters: [],
214
+ qualityChecks: [],
215
+ planningPacket,
216
+ deckPlanRequirements,
141
217
  },
142
218
  }
143
219
  }
144
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
+
145
373
  function buildDeckPlan(narrative: NarrativeStateV1): { slides: SlideSpec[]; chapters: DeckPlanChapter[] } {
146
374
  const slides: SlideSpec[] = []
147
375
  const evidenceByClaim = evidenceBindingsByClaim(narrative.evidenceBindings)
@@ -154,9 +382,18 @@ function buildDeckPlan(narrative: NarrativeStateV1): { slides: SlideSpec[]; chap
154
382
  slides.push(tocSlide(slides.length + 1, chapters))
155
383
 
156
384
  for (const claim of centralClaims) {
157
- const slide = claimSlide(slides.length + 1, claim, evidenceByClaim.get(claim.id) ?? [])
158
- slides.push(slide)
159
- 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
+ }
160
397
  }
161
398
  if (supportingClaims.length > 0) {
162
399
  const slide = supportingLogicSlide(slides.length + 1, supportingClaims, evidenceByClaim)
@@ -223,6 +460,45 @@ function tocSlide(index: number, chapters: DeckPlanChapter[]): SlideSpec {
223
460
  }
224
461
  }
225
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
+
226
502
  function claimSlide(index: number, claim: NarrativeClaim, bindings: NarrativeEvidenceBinding[]): SlideSpec {
227
503
  const visualIntent = visualIntentForClaim(claim, bindings)
228
504
  return {
@@ -247,9 +523,105 @@ function claimSlide(index: number, claim: NarrativeClaim, bindings: NarrativeEvi
247
523
  }
248
524
  }
249
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" },
555
+ },
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),
617
+ status: "planned",
618
+ }
619
+ }
620
+
250
621
  function supportingLogicSlide(index: number, claims: NarrativeClaim[], evidenceByClaim: Map<string, NarrativeEvidenceBinding[]>): SlideSpec {
251
622
  const supportingBindings = claims.flatMap((claim) => evidenceByClaim.get(claim.id) ?? [])
252
623
  const visualIntent = visualIntentForSupportingLogic(claims, supportingBindings)
624
+ const notes = claims.map((claim) => internalEvidenceDiagnostics(claim, evidenceByClaim.get(claim.id) ?? [])).filter(Boolean).join("\n\n") || undefined
253
625
  return {
254
626
  index,
255
627
  title: "Supporting Logic",
@@ -263,7 +635,8 @@ function supportingLogicSlide(index: number, claims: NarrativeClaim[], evidenceB
263
635
  evidenceBindingIds: supportingBindings.map((binding) => binding.id),
264
636
  content: {
265
637
  headline: "Supporting claims and boundaries",
266
- 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,
267
640
  data: { visualIntent },
268
641
  },
269
642
  evidence: supportingBindings.map(evidenceRefFromBinding),
@@ -359,20 +732,27 @@ function deriveChapters(narrative: NarrativeStateV1, centralClaims: NarrativeCla
359
732
  const claims = [...centralClaims, ...supportingClaims]
360
733
  const chapters: DeckPlanChapter[] = []
361
734
  addChapter(chapters, narrative.audience.decisionContext ? "Decision context" : "Context and belief shift", "context")
362
- if (hasClaimKind(claims, ["problem", "opportunity"])) addChapter(chapters, "Tension and opportunity", "tension")
363
- if (claims.some((claim) => claim.kind === "evidence") || narrative.evidenceBindings.length > 0) addChapter(chapters, "Evidence and proof", "evidence")
364
- 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")
365
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")
366
739
  addChapter(chapters, "Decision ask", "ask")
367
740
  if (chapters.length < 3) addChapter(chapters, "Evidence and proof", "evidence")
368
741
  while (chapters.length > 5) {
369
- const tensionIndex = chapters.findIndex((chapter) => chapter.role === "tension")
370
- if (tensionIndex >= 0) chapters.splice(tensionIndex, 1)
371
- 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
372
745
  }
373
746
  return chapters
374
747
  }
375
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
+
376
756
  function addChapter(chapters: DeckPlanChapter[], title: string, role: DeckPlanChapter["role"]): void {
377
757
  if (chapters.some((chapter) => chapter.role === role || chapter.title === title)) return
378
758
  chapters.push({ title, role, slideIndexes: [], claimIds: [], evidenceBindingIds: [] })
@@ -381,6 +761,10 @@ function addChapter(chapters: DeckPlanChapter[], title: string, role: DeckPlanCh
381
761
  function assignSlideToChapter(chapters: DeckPlanChapter[], role: DeckPlanChapter["role"], slide: SlideSpec): void {
382
762
  const chapter = chapters.find((item) => item.role === role) ?? chapters.find((item) => item.role === "evidence") ?? chapters[chapters.length - 1]
383
763
  if (!chapter) return
764
+ assignSlideToSpecificChapter(chapter, slide)
765
+ }
766
+
767
+ function assignSlideToSpecificChapter(chapter: DeckPlanChapter, slide: SlideSpec): void {
384
768
  chapter.slideIndexes.push(slide.index)
385
769
  for (const claimId of slide.claimIds ?? []) addUnique(chapter.claimIds, claimId)
386
770
  for (const ref of slide.claimRefs ?? []) addUnique(chapter.claimIds, ref.claimId)
@@ -470,8 +854,36 @@ function visualIntentForRiskObjection(narrative: NarrativeStateV1, bindings: Nar
470
854
  }
471
855
  }
472
856
 
473
- function visualIntentForStructuralSlide(kind: Extract<VisualIntentKind, "hero" | "toc" | "steps">, rationale: string): VisualIntent {
474
- return { kind, component: kind === "toc" ? "toc" : kind === "steps" ? "steps" : "hero", rationale, dataSignals: [], evidenceBindingIds: [] }
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
+ }
475
887
  }
476
888
 
477
889
  function componentsForVisualIntent(base: string[], visualIntent: VisualIntent): string[] {
@@ -502,21 +914,27 @@ function numericSignals(text: string): string[] {
502
914
  function claimBullets(claim: NarrativeClaim, bindings: NarrativeEvidenceBinding[]): string[] {
503
915
  return [
504
916
  ...claimBoundaryBullets(claim),
505
- ...bindings.slice(0, 2).map((binding) => binding.supportScope ? `Evidence supports: ${binding.supportScope}` : undefined),
506
- evidenceGapBullet(claim, bindings),
917
+ ...bindings.slice(0, 2).map((binding) => binding.supportScope ? `Evidence points to: ${binding.supportScope}` : undefined),
918
+ audienceEvidenceGapBullet(claim, bindings),
507
919
  ].filter((item): item is string => Boolean(item))
508
920
  }
509
921
 
510
- function evidenceGapBullet(claim: NarrativeClaim, bindings: NarrativeEvidenceBinding[]): string | undefined {
922
+ function audienceEvidenceGapBullet(claim: NarrativeClaim, bindings: NarrativeEvidenceBinding[]): string | undefined {
511
923
  if (!claim.evidenceRequired || bindings.length > 0) return undefined
512
- 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"))
513
931
  }
514
932
 
515
933
  function claimBoundaryBullets(claim: NarrativeClaim): string[] {
516
934
  return [
517
- claim.supportedScope ? `Supported scope: ${claim.supportedScope}` : undefined,
518
- claim.unsupportedScope ? `Unsupported scope: ${claim.unsupportedScope}` : undefined,
519
- ...(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}`),
520
938
  ].filter((item): item is string => Boolean(item))
521
939
  }
522
940
 
@@ -525,6 +943,18 @@ function claimBoundaryNote(claim: NarrativeClaim): string | undefined {
525
943
  return notes.length > 0 ? notes.join(" ") : undefined
526
944
  }
527
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
+
528
958
  function dedupeClaimRefs<T extends { claimId: string; role: "risk" | "objection" }>(refs: T[]): T[] {
529
959
  const seen = new Set<string>()
530
960
  return refs.filter((ref) => {
@@ -551,6 +981,24 @@ function checkPlanQuality(narrative: NarrativeStateV1, slides: SlideSpec[], chap
551
981
  const coverage = deckPlanCoverage(narrative, slides)
552
982
  const centralClaimIds = narrative.claims.filter((claim) => claim.importance === "central").map((claim) => claim.id)
553
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
+ })
554
1002
  const incompatibleComponents = [...new Set(slides.flatMap((slide) => slide.components).filter((component) => component === "card"))]
555
1003
  const toc = slides.find((slide) => slide.components.includes("toc"))
556
1004
  const tocBullets = toc?.content.bullets ?? []
@@ -560,6 +1008,31 @@ function checkPlanQuality(narrative: NarrativeStateV1, slides: SlideSpec[], chap
560
1008
  const risksOrObjectionsVisible = narrative.risks.length === 0 && narrative.objections.length === 0 || slides.some((slide) => slide.narrativeRole === "risk")
561
1009
 
562
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
+ },
563
1036
  {
564
1037
  id: "chapter_structure_present",
565
1038
  status: chapters.length >= 3 && chapters.length <= 5 && chapters.every((chapter) => chapter.slideIndexes.length > 0) ? "pass" : "blocker",
@@ -608,6 +1081,17 @@ function checkPlanQuality(narrative: NarrativeStateV1, slides: SlideSpec[], chap
608
1081
  ]
609
1082
  }
610
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
+
611
1095
  function deckPlanCoverage(narrative: NarrativeStateV1, slides: SlideSpec[]): { requiredClaimIds: string[]; coveredClaimIds: string[]; missingClaimIds: string[] } {
612
1096
  const requiredClaimIds = narrative.claims
613
1097
  .filter((claim) => claim.importance === "central" || claim.evidenceRequired)
@@ -627,6 +1111,11 @@ function titleFromClaim(claim: NarrativeClaim): string {
627
1111
  return words || claim.kind
628
1112
  }
629
1113
 
1114
+ function claimChapterTitle(claim: NarrativeClaim): string {
1115
+ const title = titleFromClaim(claim)
1116
+ return title.endsWith(".") ? title.slice(0, -1) : title
1117
+ }
1118
+
630
1119
  function hasCurrentApprovalOrOverride(narrative: NarrativeStateV1, narrativeHash: string): boolean {
631
1120
  return narrative.approvals.some((approval) => approval.narrativeHash === narrativeHash && (approval.scope === "narrative" && approval.approvedBy === "user" || approval.scope === "render_override" || approval.approvedBy === "override"))
632
1121
  }