@cyber-dash-tech/revela 0.12.0 → 0.13.0

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,9 +1,11 @@
1
1
  import type { DeckSpec, DecksState, EvidenceRef, SlideSpec } from "../decks-state"
2
2
  import {
3
3
  stableClaimId,
4
+ stableClaimRelationId,
4
5
  stableEvidenceId,
5
6
  stableNarrativeId,
6
7
  stableObjectionId,
8
+ stableResearchGapId,
7
9
  stableRiskId,
8
10
  } from "./hash"
9
11
  import type {
@@ -11,9 +13,12 @@ import type {
11
13
  DecisionIntent,
12
14
  NarrativeClaim,
13
15
  NarrativeClaimKind,
16
+ NarrativeClaimRelation,
17
+ NarrativeClaimRelationType,
14
18
  NarrativeEvidenceBinding,
15
19
  NarrativeEvidenceStatus,
16
20
  NarrativeObjection,
21
+ NarrativeResearchGap,
17
22
  NarrativeRisk,
18
23
  NarrativeStateV1,
19
24
  NarrativeStatus,
@@ -26,6 +31,7 @@ export function normalizeCanonicalNarrativeState(input: Partial<NarrativeStateV1
26
31
  if (!input) return undefined
27
32
  const id = input.id?.trim() || stableNarrativeId(seed)
28
33
  const claims = dedupeById((input.claims ?? []).map(normalizeClaim).filter((claim): claim is NarrativeClaim => Boolean(claim)))
34
+ const claimRelations = dedupeById((input.claimRelations ?? []).map((relation) => normalizeClaimRelation(relation, claims)).filter((relation): relation is NarrativeClaimRelation => Boolean(relation)))
29
35
  const evidenceBindings = dedupeById((input.evidenceBindings ?? []).map((binding) => normalizeEvidenceBinding(binding, claims)).filter((binding): binding is NarrativeEvidenceBinding => Boolean(binding)))
30
36
  return {
31
37
  version: 1,
@@ -35,9 +41,11 @@ export function normalizeCanonicalNarrativeState(input: Partial<NarrativeStateV1
35
41
  decision: normalizeDecision(input.decision),
36
42
  thesis: normalizeThesis(input.thesis),
37
43
  claims: claims.map((claim) => ({ ...claim, evidenceStatus: evidenceStatusForClaim(claim, evidenceBindings) })),
44
+ claimRelations,
38
45
  evidenceBindings,
39
46
  objections: dedupeById((input.objections ?? []).map(normalizeObjection).filter((objection): objection is NarrativeObjection => Boolean(objection))),
40
47
  risks: dedupeById((input.risks ?? []).map(normalizeRisk).filter((risk): risk is NarrativeRisk => Boolean(risk))),
48
+ researchGaps: dedupeById((input.researchGaps ?? []).map(normalizeResearchGap).filter((gap): gap is NarrativeResearchGap => Boolean(gap))),
41
49
  approvals: input.approvals ?? [],
42
50
  updatedAt: input.updatedAt || MIGRATED_UPDATED_AT,
43
51
  }
@@ -71,9 +79,11 @@ function migrateDeckNarrative(deck: DeckSpec | undefined, seed: string): Narrati
71
79
  },
72
80
  thesis: migrateThesis(deck),
73
81
  claims: withEvidenceStatus,
82
+ claimRelations: [],
74
83
  evidenceBindings,
75
84
  objections: (brief?.objections ?? []).map((text) => ({ id: stableObjectionId(text), text, priority: "medium" as const })),
76
85
  risks: (brief?.risks ?? []).map((text) => ({ id: stableRiskId(text), text, severity: "medium" as const })),
86
+ researchGaps: [],
77
87
  approvals: [],
78
88
  updatedAt: MIGRATED_UPDATED_AT,
79
89
  }
@@ -206,6 +216,26 @@ function normalizeClaim(input: Partial<NarrativeClaim>): NarrativeClaim | undefi
206
216
  }
207
217
  }
208
218
 
219
+ function normalizeClaimRelation(input: Partial<NarrativeClaimRelation>, claims: NarrativeClaim[]): NarrativeClaimRelation | undefined {
220
+ const fromClaimId = clean(input.fromClaimId)
221
+ const toClaimId = clean(input.toClaimId)
222
+ if (!fromClaimId || !toClaimId || fromClaimId === toClaimId) return undefined
223
+ if (!claims.some((claim) => claim.id === fromClaimId) || !claims.some((claim) => claim.id === toClaimId)) return undefined
224
+ const relation = normalizeClaimRelationType(input.relation)
225
+ return {
226
+ id: input.id?.trim() || stableClaimRelationId(fromClaimId, toClaimId, relation),
227
+ fromClaimId,
228
+ toClaimId,
229
+ relation,
230
+ rationale: clean(input.rationale),
231
+ }
232
+ }
233
+
234
+ function normalizeClaimRelationType(input: NarrativeClaimRelationType | undefined): NarrativeClaimRelationType {
235
+ if (input === "supports" || input === "depends_on" || input === "contrasts_with" || input === "constrains" || input === "answers") return input
236
+ return "leads_to"
237
+ }
238
+
209
239
  function normalizeEvidenceBinding(input: Partial<NarrativeEvidenceBinding>, claims: NarrativeClaim[]): NarrativeEvidenceBinding | undefined {
210
240
  const source = clean(input.source || input.sourcePath || input.findingsFile || input.url)
211
241
  const claimId = clean(input.claimId)
@@ -239,6 +269,30 @@ function normalizeRisk(input: Partial<NarrativeRisk>): NarrativeRisk | undefined
239
269
  return { id: input.id?.trim() || stableRiskId(text), text, claimId: clean(input.claimId), severity: input.severity ?? "medium", mitigation: clean(input.mitigation) }
240
270
  }
241
271
 
272
+ function normalizeResearchGap(input: Partial<NarrativeResearchGap>): NarrativeResearchGap | undefined {
273
+ const question = clean(input.question)
274
+ if (!question) return undefined
275
+ const targetType = input.targetType ?? "narrative"
276
+ const targetId = clean(input.targetId)
277
+ const now = clean(input.updatedAt) || clean(input.createdAt) || MIGRATED_UPDATED_AT
278
+ const status = input.status ?? "open"
279
+ return {
280
+ id: input.id?.trim() || stableResearchGapId([targetType, targetId, question].filter(Boolean).join("|")),
281
+ targetType,
282
+ targetId,
283
+ question,
284
+ status,
285
+ priority: input.priority ?? "medium",
286
+ findingsFile: clean(input.findingsFile),
287
+ evidenceBindingIds: (input.evidenceBindingIds ?? []).map(clean).filter(Boolean),
288
+ createdFromIssueType: input.createdFromIssueType,
289
+ notes: clean(input.notes),
290
+ createdAt: clean(input.createdAt) || now,
291
+ updatedAt: now,
292
+ closedAt: status === "closed" ? clean(input.closedAt) || now : clean(input.closedAt),
293
+ }
294
+ }
295
+
242
296
  function evidenceStatusForClaim(claim: NarrativeClaim, bindings: NarrativeEvidenceBinding[]): NarrativeEvidenceStatus {
243
297
  if (!claim.evidenceRequired) return "not_required"
244
298
  const claimBindings = bindings.filter((binding) => binding.claimId === claim.id)
@@ -0,0 +1,433 @@
1
+ import type { DeckSpec, DecksState, EvidenceRef, SlideClaimRefRole, SlideSpec } from "../decks-state"
2
+ import { projectWorkspaceGraph } from "../workspace-state/graph"
3
+ import { artifactNodeIdForRenderTarget } from "../workspace-state/render-targets"
4
+ import type { GraphEdge, RenderTarget } from "../workspace-state/types"
5
+ import { computeNarrativeHash } from "./hash"
6
+ import { normalizeNarrativeState } from "./normalize"
7
+ import type { NarrativeClaim, NarrativeEvidenceBinding, NarrativeStateV1 } from "./types"
8
+
9
+ export interface ClaimEvidenceBoard {
10
+ version: 1
11
+ claims: Record<NarrativeClaim["evidenceStatus"], ClaimEvidenceRecord[]>
12
+ }
13
+
14
+ export interface ClaimEvidenceRecord {
15
+ id: string
16
+ text: string
17
+ kind: NarrativeClaim["kind"]
18
+ importance: NarrativeClaim["importance"]
19
+ evidenceRequired: boolean
20
+ evidenceStatus: NarrativeClaim["evidenceStatus"]
21
+ supportedScope?: string
22
+ unsupportedScope?: string
23
+ caveats: string[]
24
+ evidence: ClaimEvidenceBindingRecord[]
25
+ }
26
+
27
+ export interface ClaimEvidenceBindingRecord {
28
+ id: string
29
+ claimId: string
30
+ source: string
31
+ findingsFile?: string
32
+ sourcePath?: string
33
+ quote?: string
34
+ location?: string
35
+ url?: string
36
+ caveat?: string
37
+ supportScope?: string
38
+ unsupportedScope?: string
39
+ strength: NarrativeEvidenceBinding["strength"]
40
+ }
41
+
42
+ export interface SourceClaimIndexRecord {
43
+ sourceKey: string
44
+ source: string
45
+ findingsFile?: string
46
+ sourcePath?: string
47
+ url?: string
48
+ claims: Array<{
49
+ claimId: string
50
+ claimText: string
51
+ evidenceId: string
52
+ strength: NarrativeEvidenceBinding["strength"]
53
+ supportScope?: string
54
+ unsupportedScope?: string
55
+ caveat?: string
56
+ }>
57
+ }
58
+
59
+ export interface ObjectionRiskClaimIndex {
60
+ objections: Array<{
61
+ id: string
62
+ text: string
63
+ claimId?: string
64
+ claimText?: string
65
+ priority: "high" | "medium" | "low"
66
+ response?: string
67
+ }>
68
+ risks: Array<{
69
+ id: string
70
+ text: string
71
+ claimId?: string
72
+ claimText?: string
73
+ severity: "high" | "medium" | "low"
74
+ mitigation?: string
75
+ }>
76
+ }
77
+
78
+ export interface ArtifactClaimRef {
79
+ artifactId: string
80
+ type: RenderTarget["type"]
81
+ outputPath?: string
82
+ contractStatus?: RenderTarget["contractStatus"]
83
+ sourceNodeIds: string[]
84
+ claimIds: string[]
85
+ narrativeIds: string[]
86
+ slideRefs: ClaimSlideRef[]
87
+ coverageStatus: "current" | "stale" | "partial" | "missing"
88
+ affectedClaimIds: string[]
89
+ missingClaimIds: string[]
90
+ staleReasons: string[]
91
+ stale: boolean
92
+ staleReason?: string
93
+ note?: string
94
+ }
95
+
96
+ export interface ClaimSlideRef {
97
+ claimId: string
98
+ claimText: string
99
+ slideIndex: number
100
+ slideTitle: string
101
+ match: "content" | "evidence" | "metadata"
102
+ role: SlideClaimRefRole
103
+ location: string
104
+ }
105
+
106
+ export function getClaimEvidenceBoard(state: DecksState): ClaimEvidenceBoard {
107
+ const narrative = canonicalNarrative(state)
108
+ const evidenceByClaim = groupEvidenceByClaim(narrative.evidenceBindings)
109
+ const claims: ClaimEvidenceBoard["claims"] = {
110
+ supported: [],
111
+ partial: [],
112
+ weak: [],
113
+ missing: [],
114
+ not_required: [],
115
+ }
116
+
117
+ for (const claim of narrative.claims) {
118
+ claims[claim.evidenceStatus].push({
119
+ id: claim.id,
120
+ text: claim.text,
121
+ kind: claim.kind,
122
+ importance: claim.importance,
123
+ evidenceRequired: claim.evidenceRequired,
124
+ evidenceStatus: claim.evidenceStatus,
125
+ supportedScope: claim.supportedScope,
126
+ unsupportedScope: claim.unsupportedScope,
127
+ caveats: claim.caveats ?? [],
128
+ evidence: evidenceByClaim.get(claim.id) ?? [],
129
+ })
130
+ }
131
+
132
+ for (const group of Object.values(claims)) group.sort((a, b) => claimSortValue(a) - claimSortValue(b) || a.text.localeCompare(b.text))
133
+ return { version: 1, claims }
134
+ }
135
+
136
+ export function getSourceClaimIndex(state: DecksState): SourceClaimIndexRecord[] {
137
+ const narrative = canonicalNarrative(state)
138
+ const claimTextById = new Map(narrative.claims.map((claim) => [claim.id, claim.text]))
139
+ const grouped = new Map<string, SourceClaimIndexRecord>()
140
+
141
+ for (const binding of narrative.evidenceBindings) {
142
+ const sourceKey = binding.findingsFile || binding.sourcePath || binding.url || binding.source
143
+ if (!sourceKey) continue
144
+ const existing = grouped.get(sourceKey) ?? {
145
+ sourceKey,
146
+ source: binding.source,
147
+ findingsFile: binding.findingsFile,
148
+ sourcePath: binding.sourcePath,
149
+ url: binding.url,
150
+ claims: [],
151
+ }
152
+ existing.claims.push({
153
+ claimId: binding.claimId,
154
+ claimText: claimTextById.get(binding.claimId) ?? binding.claimId,
155
+ evidenceId: binding.id,
156
+ strength: binding.strength,
157
+ supportScope: binding.supportScope,
158
+ unsupportedScope: binding.unsupportedScope,
159
+ caveat: binding.caveat,
160
+ })
161
+ grouped.set(sourceKey, existing)
162
+ }
163
+
164
+ return [...grouped.values()]
165
+ .map((item) => ({ ...item, claims: item.claims.sort((a, b) => a.claimText.localeCompare(b.claimText)) }))
166
+ .sort((a, b) => a.sourceKey.localeCompare(b.sourceKey))
167
+ }
168
+
169
+ export function getObjectionRiskClaimIndex(state: DecksState): ObjectionRiskClaimIndex {
170
+ const narrative = canonicalNarrative(state)
171
+ const claimTextById = new Map(narrative.claims.map((claim) => [claim.id, claim.text]))
172
+ return {
173
+ objections: narrative.objections.map((objection) => ({
174
+ id: objection.id,
175
+ text: objection.text,
176
+ claimId: objection.claimId,
177
+ claimText: objection.claimId ? claimTextById.get(objection.claimId) : undefined,
178
+ priority: objection.priority,
179
+ response: objection.response,
180
+ })),
181
+ risks: narrative.risks.map((risk) => ({
182
+ id: risk.id,
183
+ text: risk.text,
184
+ claimId: risk.claimId,
185
+ claimText: risk.claimId ? claimTextById.get(risk.claimId) : undefined,
186
+ severity: risk.severity,
187
+ mitigation: risk.mitigation,
188
+ })),
189
+ }
190
+ }
191
+
192
+ export function getArtifactClaimRefs(state: DecksState): ArtifactClaimRef[] {
193
+ const narrative = canonicalNarrative(state)
194
+ const narrativeHash = computeNarrativeHash(narrative)
195
+ const deck = maybeActiveDeck(state)
196
+ const slideRefs = deck ? getClaimSlideRefs({ ...state, narrative }, deck) : []
197
+ const slideClaimIds = [...new Set(slideRefs.map((ref) => ref.claimId))].sort()
198
+ const rendersFromByArtifact = deck ? rendersFromIndex(projectWorkspaceGraph({ ...state, narrative }).edges) : new Map<string, string[]>()
199
+ const claimIds = new Set(narrative.claims.map((claim) => claim.id))
200
+ const requiredClaimIds = narrative.claims
201
+ .filter((claim) => claim.importance === "central" || claim.evidenceRequired)
202
+ .map((claim) => claim.id)
203
+ const narrativeIds = new Set([narrative.id])
204
+ const htmlCoverageByPath = new Map<string, ClaimSlideRef[]>()
205
+
206
+ return (state.renderTargets ?? [])
207
+ .map((target) => {
208
+ const artifactId = artifactNodeIdForRenderTarget(target)
209
+ const rendersFrom = rendersFromByArtifact.get(artifactId) ?? []
210
+ const targetSlideRefs = claimSlideRefsForTarget(target, slideRefs, htmlCoverageByPath)
211
+ const artifactClaimIds = [...new Set([...target.sourceNodeIds, ...rendersFrom, ...targetSlideRefs.map((ref) => ref.claimId), ...slideClaimIds].filter((id) => claimIds.has(id)))].sort()
212
+ const artifactNarrativeIds = [...new Set([...target.sourceNodeIds, ...rendersFrom].filter((id) => narrativeIds.has(id)))].sort()
213
+ const missingClaimIds = requiredClaimIds.filter((id) => !artifactClaimIds.includes(id)).sort()
214
+ const coverage = coverageState(target, narrativeHash, artifactClaimIds, missingClaimIds, artifactNarrativeIds)
215
+ if (target.type === "html_deck" && target.outputPath) htmlCoverageByPath.set(target.outputPath, targetSlideRefs)
216
+ return {
217
+ artifactId,
218
+ type: target.type,
219
+ outputPath: target.outputPath,
220
+ contractStatus: target.contractStatus,
221
+ sourceNodeIds: target.sourceNodeIds ?? [],
222
+ claimIds: artifactClaimIds,
223
+ narrativeIds: artifactNarrativeIds,
224
+ slideRefs: targetSlideRefs,
225
+ coverageStatus: coverage.status,
226
+ affectedClaimIds: coverage.affectedClaimIds,
227
+ missingClaimIds,
228
+ staleReasons: coverage.reasons,
229
+ stale: coverage.status === "stale",
230
+ staleReason: coverage.reasons[0],
231
+ note: coverage.note,
232
+ }
233
+ })
234
+ .sort((a, b) => artifactSortValue(a.type) - artifactSortValue(b.type) || (a.outputPath ?? a.artifactId).localeCompare(b.outputPath ?? b.artifactId))
235
+ }
236
+
237
+ export function getClaimSlideRefs(state: DecksState, deck: DeckSpec = activeDeck(state)): ClaimSlideRef[] {
238
+ const narrative = canonicalNarrative(state)
239
+ const bindingsByClaim = groupRawEvidenceByClaim(narrative.evidenceBindings)
240
+ const refs: ClaimSlideRef[] = []
241
+ const seen = new Set<string>()
242
+ for (const claim of narrative.claims) {
243
+ for (const slide of deck.slides ?? []) {
244
+ const explicitRef = slide.claimRefs?.find((ref) => ref.claimId === claim.id)
245
+ if (explicitRef) {
246
+ pushSlideRef(refs, seen, claim, slide, "metadata", `claimRefs:${explicitRef.role}`, explicitRef.role)
247
+ continue
248
+ }
249
+ if (slide.claimIds?.includes(claim.id)) {
250
+ pushSlideRef(refs, seen, claim, slide, "metadata", "claimIds", "primary")
251
+ continue
252
+ }
253
+ if (slideEvidenceBindingIdsMatch(slide.evidenceBindingIds ?? [], bindingsByClaim.get(claim.id) ?? [])) {
254
+ pushSlideRef(refs, seen, claim, slide, "metadata", "evidenceBindingIds", "evidence")
255
+ continue
256
+ }
257
+ const contentMatch = slideContentMatch(slide, claim.text)
258
+ if (contentMatch) pushSlideRef(refs, seen, claim, slide, "content", contentMatch, "primary")
259
+ else if (slideEvidenceMatches(slide.evidence ?? [], bindingsByClaim.get(claim.id) ?? [])) pushSlideRef(refs, seen, claim, slide, "evidence", "evidence", "evidence")
260
+ }
261
+ }
262
+ return refs.sort((a, b) => a.slideIndex - b.slideIndex || a.claimText.localeCompare(b.claimText) || a.location.localeCompare(b.location))
263
+ }
264
+
265
+ function canonicalNarrative(state: DecksState): NarrativeStateV1 {
266
+ return state.narrative ?? normalizeNarrativeState(state)
267
+ }
268
+
269
+ function activeDeck(state: DecksState): DeckSpec {
270
+ const key = state.activeDeck || (Object.keys(state.decks).length === 1 ? Object.keys(state.decks)[0] : undefined)
271
+ if (!key || !state.decks[key]) throw new Error("No active deck is available for narrative artifact coverage.")
272
+ return state.decks[key]
273
+ }
274
+
275
+ function maybeActiveDeck(state: DecksState): DeckSpec | undefined {
276
+ const key = state.activeDeck || (Object.keys(state.decks).length === 1 ? Object.keys(state.decks)[0] : undefined)
277
+ return key ? state.decks[key] : undefined
278
+ }
279
+
280
+ function groupEvidenceByClaim(bindings: NarrativeEvidenceBinding[]): Map<string, ClaimEvidenceBindingRecord[]> {
281
+ const grouped = new Map<string, ClaimEvidenceBindingRecord[]>()
282
+ for (const binding of bindings) {
283
+ grouped.set(binding.claimId, [...(grouped.get(binding.claimId) ?? []), {
284
+ id: binding.id,
285
+ claimId: binding.claimId,
286
+ source: binding.source,
287
+ findingsFile: binding.findingsFile,
288
+ sourcePath: binding.sourcePath,
289
+ quote: binding.quote,
290
+ location: binding.location,
291
+ url: binding.url,
292
+ caveat: binding.caveat,
293
+ supportScope: binding.supportScope,
294
+ unsupportedScope: binding.unsupportedScope,
295
+ strength: binding.strength,
296
+ }])
297
+ }
298
+ return grouped
299
+ }
300
+
301
+ function groupRawEvidenceByClaim(bindings: NarrativeEvidenceBinding[]): Map<string, NarrativeEvidenceBinding[]> {
302
+ const grouped = new Map<string, NarrativeEvidenceBinding[]>()
303
+ for (const binding of bindings) grouped.set(binding.claimId, [...(grouped.get(binding.claimId) ?? []), binding])
304
+ return grouped
305
+ }
306
+
307
+ function slideContentMatch(slide: SlideSpec, claimText: string): string | undefined {
308
+ const claim = normalizeText(claimText)
309
+ if (!claim) return undefined
310
+ const candidates: Array<[string, string | undefined]> = [
311
+ ["title", slide.title],
312
+ ["purpose", slide.purpose],
313
+ ["headline", slide.content?.headline],
314
+ ["speakerNotes", slide.content?.speakerNotes],
315
+ ...((slide.content?.body ?? []).map((item, index) => [`body:${index + 1}`, item] as [string, string])),
316
+ ...((slide.content?.bullets ?? []).map((item, index) => [`bullet:${index + 1}`, item] as [string, string])),
317
+ ]
318
+ for (const [location, value] of candidates) {
319
+ const normalized = normalizeText(value)
320
+ if (!normalized) continue
321
+ if (normalized === claim || normalized.includes(claim) || claim.includes(normalized)) return location
322
+ }
323
+ return undefined
324
+ }
325
+
326
+ function slideEvidenceMatches(evidence: EvidenceRef[], bindings: NarrativeEvidenceBinding[]): boolean {
327
+ return evidence.some((item) => bindings.some((binding) => evidenceMatchesBinding(item, binding)))
328
+ }
329
+
330
+ function slideEvidenceBindingIdsMatch(ids: string[], bindings: NarrativeEvidenceBinding[]): boolean {
331
+ const slideIds = new Set(ids)
332
+ return bindings.some((binding) => slideIds.has(binding.id))
333
+ }
334
+
335
+ function evidenceMatchesBinding(evidence: EvidenceRef, binding: NarrativeEvidenceBinding): boolean {
336
+ return Boolean(
337
+ evidence.findingsFile && evidence.findingsFile === binding.findingsFile ||
338
+ evidence.sourcePath && evidence.sourcePath === binding.sourcePath ||
339
+ evidence.url && evidence.url === binding.url ||
340
+ evidence.quote && binding.quote && normalizeText(evidence.quote) === normalizeText(binding.quote) ||
341
+ evidence.source && normalizeText(evidence.source) === normalizeText(binding.source),
342
+ )
343
+ }
344
+
345
+ function pushSlideRef(refs: ClaimSlideRef[], seen: Set<string>, claim: NarrativeClaim, slide: SlideSpec, match: ClaimSlideRef["match"], location: string, role: SlideClaimRefRole): void {
346
+ const key = `${claim.id}:${slide.index}:${match}:${location}:${role}`
347
+ if (seen.has(key)) return
348
+ seen.add(key)
349
+ refs.push({ claimId: claim.id, claimText: claim.text, slideIndex: slide.index, slideTitle: slide.title, match, role, location })
350
+ }
351
+
352
+ function claimSlideRefsForTarget(target: RenderTarget, currentHtmlRefs: ClaimSlideRef[], htmlCoverageByPath: Map<string, ClaimSlideRef[]>): ClaimSlideRef[] {
353
+ const stored = parseStoredClaimSlideRefs(target)
354
+ if (stored.length > 0) return stored
355
+ if (target.type === "html_deck") return currentHtmlRefs
356
+ const sourceOutputPath = typeof target.data?.sourceOutputPath === "string" ? target.data.sourceOutputPath : undefined
357
+ if (sourceOutputPath) return htmlCoverageByPath.get(sourceOutputPath) ?? currentHtmlRefs
358
+ return currentHtmlRefs
359
+ }
360
+
361
+ function parseStoredClaimSlideRefs(target: RenderTarget): ClaimSlideRef[] {
362
+ const value = target.data?.claimSlideRefs
363
+ if (!Array.isArray(value)) return []
364
+ return value.flatMap((item): ClaimSlideRef[] => {
365
+ if (!item || typeof item !== "object") return []
366
+ const record = item as Partial<ClaimSlideRef>
367
+ if (!record.claimId || !record.claimText || typeof record.slideIndex !== "number" || !record.slideTitle) return []
368
+ return [{
369
+ claimId: record.claimId,
370
+ claimText: record.claimText,
371
+ slideIndex: record.slideIndex,
372
+ slideTitle: record.slideTitle,
373
+ match: record.match ?? "metadata",
374
+ role: record.role ?? "supporting",
375
+ location: record.location ?? "metadata",
376
+ }]
377
+ }).sort((a, b) => a.slideIndex - b.slideIndex || a.claimText.localeCompare(b.claimText))
378
+ }
379
+
380
+ function coverageState(
381
+ target: RenderTarget,
382
+ narrativeHash: string,
383
+ artifactClaimIds: string[],
384
+ missingClaimIds: string[],
385
+ artifactNarrativeIds: string[],
386
+ ): { status: ArtifactClaimRef["coverageStatus"]; reasons: string[]; affectedClaimIds: string[]; note?: string } {
387
+ const targetHash = typeof target.data?.narrativeHash === "string" ? target.data.narrativeHash : undefined
388
+ const reasons: string[] = []
389
+ if (targetHash && targetHash !== narrativeHash) reasons.push("Narrative hash changed after this artifact coverage was recorded.")
390
+ if (!targetHash) reasons.push("Artifact does not record the narrative hash used for coverage.")
391
+ if (artifactClaimIds.length === 0) reasons.push("No claim-to-slide coverage is recorded or inferred for this artifact.")
392
+ if (missingClaimIds.length > 0) reasons.push(`Artifact does not cover ${missingClaimIds.length} central or evidence-required claim${missingClaimIds.length === 1 ? "" : "s"}.`)
393
+ if (artifactNarrativeIds.length === 0 && target.type !== "pdf" && target.type !== "pptx") reasons.push("Artifact is not linked to the current canonical narrative.")
394
+
395
+ if (targetHash && targetHash !== narrativeHash) {
396
+ return {
397
+ status: "stale",
398
+ reasons,
399
+ affectedClaimIds: artifactClaimIds.length > 0 ? artifactClaimIds : missingClaimIds,
400
+ note: undefined,
401
+ }
402
+ }
403
+ if (artifactClaimIds.length === 0) return { status: "missing", reasons, affectedClaimIds: [], note: reasons[0] }
404
+ if (!targetHash || missingClaimIds.length > 0) return { status: "partial", reasons, affectedClaimIds: missingClaimIds, note: reasons[0] }
405
+ return { status: "current", reasons: [], affectedClaimIds: [], note: undefined }
406
+ }
407
+
408
+ function rendersFromIndex(edges: GraphEdge[]): Map<string, string[]> {
409
+ const grouped = new Map<string, string[]>()
410
+ for (const edge of edges) {
411
+ if (edge.type !== "renders_from" || !edge.from.startsWith("artifact:")) continue
412
+ grouped.set(edge.from, [...(grouped.get(edge.from) ?? []), edge.to])
413
+ }
414
+ return grouped
415
+ }
416
+
417
+ function claimSortValue(claim: Pick<ClaimEvidenceRecord, "importance">): number {
418
+ if (claim.importance === "central") return 0
419
+ if (claim.importance === "supporting") return 1
420
+ return 2
421
+ }
422
+
423
+ function artifactSortValue(type: RenderTarget["type"]): number {
424
+ if (type === "html_deck") return 0
425
+ if (type === "pdf") return 1
426
+ if (type === "pptx") return 2
427
+ if (type === "executive_brief" || type === "brief") return 3
428
+ return 3
429
+ }
430
+
431
+ function normalizeText(value: string | undefined): string {
432
+ return value?.replace(/\s+/g, " ").trim().toLowerCase() ?? ""
433
+ }
@@ -150,6 +150,8 @@ function computeNarrativeReadiness(narrative: NarrativeStateV1, state: DecksStat
150
150
  const centralClaims = narrative.claims.filter((claim) => claim.importance === "central")
151
151
  if (isDecisionOriented(narrative) && centralClaims.length === 0) add(blocker("claim_chain_gap", "Decision-oriented narrative has no central claims.", "Add one to three central claims that the narrative must prove."))
152
152
  if (centralClaims.length > 4) add(warning("claim_chain_gap", "Narrative has many central claims.", "Tighten the claim chain to the few claims the audience must believe."))
153
+ if (needsClaimRelations(narrative)) add(warning("claim_chain_gap", "Narrative claim progression is not explicit.", "Add claimRelations so the narrative flow shows how claims lead to, support, constrain, or answer each other."))
154
+ for (const relationIssue of claimRelationIssues(narrative)) add(relationIssue)
153
155
 
154
156
  for (const claim of narrative.claims) {
155
157
  if (!claim.evidenceRequired) continue
@@ -191,7 +193,8 @@ function computeNarrativeReadiness(narrative: NarrativeStateV1, state: DecksStat
191
193
  const path = typeof action.outputs?.path === "string" ? action.outputs.path : undefined
192
194
  if (!path) continue
193
195
  const attached = Object.values(state.decks ?? {}).some((deck) => deck.researchPlan.some((axis) => axis.findingsFile === path))
194
- if (!attached) add({
196
+ const boundToNarrative = narrative.evidenceBindings.some((binding) => binding.findingsFile === path)
197
+ if (!attached && !boundToNarrative && !isVisualOrMediaFindings(action.inputs?.axis, path)) add({
195
198
  type: "research_findings_unattached",
196
199
  severity: "warning",
197
200
  message: `Research findings are saved but not attached: ${path}`,
@@ -200,6 +203,20 @@ function computeNarrativeReadiness(narrative: NarrativeStateV1, state: DecksStat
200
203
  })
201
204
  }
202
205
 
206
+ for (const gap of narrative.researchGaps ?? []) {
207
+ if (gap.status === "closed" || gap.status === "evidence_bound") continue
208
+ add({
209
+ type: "research_gap_open",
210
+ severity: "warning",
211
+ message: `Research gap is ${gap.status}: ${gap.question}`,
212
+ suggestedAction: gap.status === "open" || gap.status === "in_progress"
213
+ ? "Save findings, attach them to the research plan, and bind evidence before closing the gap."
214
+ : "Bind specific evidence from the findings before treating the gap as resolved.",
215
+ claimId: gap.targetType === "claim" ? gap.targetId : undefined,
216
+ source: gap.findingsFile,
217
+ })
218
+ }
219
+
203
220
  const approval = approvalState(narrative, narrativeHash)
204
221
  if (!approval.current) add({
205
222
  type: approval.stale ? "approval_stale" : "approval_missing",
@@ -243,6 +260,11 @@ function approvalState(narrative: NarrativeStateV1, narrativeHash: string): Narr
243
260
  return { current: Boolean(latest && latest.narrativeHash === narrativeHash), stale: Boolean(latest && latest.narrativeHash !== narrativeHash), latest }
244
261
  }
245
262
 
263
+ function isVisualOrMediaFindings(axis: unknown, path: string): boolean {
264
+ const value = `${typeof axis === "string" ? axis : ""} ${path}`.toLowerCase()
265
+ return /(^|[-_/\s])(image|images|media|asset|assets|visual|visuals|logo|logos|screenshot|screenshots)([-_/\s.]|$)/.test(value)
266
+ }
267
+
246
268
  function nextActions(issues: NarrativeReadinessIssue[], approvalCurrent: boolean): string[] {
247
269
  const blockers = issues.filter((issue) => issue.severity === "blocker")
248
270
  if (blockers.length > 0) return unique(blockers.map((issue) => issue.suggestedAction))
@@ -259,6 +281,54 @@ function hasRecommendation(narrative: NarrativeStateV1): boolean {
259
281
  return narrative.claims.some((claim) => claim.kind === "recommendation" || claim.kind === "ask") || /recommend|approve|invest|prioriti[sz]e|建议|批准|投资|优先/i.test(narrative.decision.action)
260
282
  }
261
283
 
284
+ function needsClaimRelations(narrative: NarrativeStateV1): boolean {
285
+ if (!isDecisionOriented(narrative)) return false
286
+ if ((narrative.claimRelations ?? []).length > 0) return false
287
+ const flowClaims = narrative.claims.filter((claim) => claim.importance === "central" || claim.kind === "recommendation" || claim.kind === "ask")
288
+ return flowClaims.length > 1
289
+ }
290
+
291
+ function claimRelationIssues(narrative: NarrativeStateV1): NarrativeReadinessIssue[] {
292
+ const issues: NarrativeReadinessIssue[] = []
293
+ const claimsById = new Map(narrative.claims.map((claim) => [claim.id, claim]))
294
+ for (const relation of narrative.claimRelations ?? []) {
295
+ const fromClaim = claimsById.get(relation.fromClaimId)
296
+ const toClaim = claimsById.get(relation.toClaimId)
297
+ if (!relation.rationale?.trim()) {
298
+ issues.push({
299
+ type: "claim_chain_gap",
300
+ severity: "warning",
301
+ message: "Claim relation lacks objective causal rationale.",
302
+ suggestedAction: "Add a factual causal bridge explaining what the source claim establishes and why the target claim follows within the evidence boundary.",
303
+ claimId: relation.toClaimId,
304
+ claimText: toClaim?.text,
305
+ })
306
+ }
307
+ if (relationMayOverextendEvidence(relation.relation) && fromClaim && toClaim && weakSourceForCausalBridge(fromClaim, toClaim)) {
308
+ issues.push({
309
+ type: "claim_chain_gap",
310
+ severity: "warning",
311
+ message: "Claim relation may overextend the source claim's evidence boundary.",
312
+ suggestedAction: "Narrow the target claim, add stronger evidence, or rewrite the relation rationale so it objectively reflects the supported scope.",
313
+ claimId: relation.toClaimId,
314
+ claimText: toClaim?.text,
315
+ })
316
+ }
317
+ }
318
+ return issues
319
+ }
320
+
321
+ function relationMayOverextendEvidence(relation: string): boolean {
322
+ return relation === "supports" || relation === "leads_to" || relation === "depends_on"
323
+ }
324
+
325
+ function weakSourceForCausalBridge(fromClaim: NarrativeClaim, toClaim: NarrativeClaim): boolean {
326
+ if (!fromClaim.evidenceRequired) return false
327
+ const targetNeedsObjectiveBridge = toClaim.importance === "central" || toClaim.kind === "recommendation" || toClaim.kind === "ask"
328
+ if (!targetNeedsObjectiveBridge) return false
329
+ return fromClaim.evidenceStatus === "missing" || fromClaim.evidenceStatus === "weak" || fromClaim.evidenceStatus === "partial" || Boolean(fromClaim.unsupportedScope)
330
+ }
331
+
262
332
  function hasRiskHandling(narrative: NarrativeStateV1): boolean {
263
333
  return narrative.risks.length > 0 || narrative.claims.some((claim) => claim.kind === "risk" || claim.kind === "assumption" || claim.caveats?.length || claim.unsupportedScope) || narrative.evidenceBindings.some((binding) => binding.caveat || binding.unsupportedScope)
264
334
  }