@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.
@@ -0,0 +1,63 @@
1
+ import { existsSync, mkdirSync, writeFileSync } from "fs"
2
+ import { dirname, isAbsolute, join, normalize, resolve } from "path"
3
+ import { readDecksState, writeDecksState } from "../decks-state"
4
+ import { compileExecutiveBrief, DEFAULT_EXECUTIVE_BRIEF_PATH } from "../narrative-state/executive-brief"
5
+
6
+ export interface BriefArgs {
7
+ outputPath?: string
8
+ }
9
+
10
+ export type ParseBriefArgsResult = { ok: true; args: BriefArgs } | { ok: false; error: string }
11
+
12
+ export function parseBriefArgs(input: string): ParseBriefArgsResult {
13
+ const value = input.trim()
14
+ if (!value) return { ok: true, args: {} }
15
+ if (value.startsWith("--")) return { ok: false, error: "Usage: `/revela brief [workspace-relative-output.md]`" }
16
+ if (!value.endsWith(".md")) return { ok: false, error: "Executive brief output must be a Markdown file ending in `.md`." }
17
+ if (isAbsolute(value) || value.split(/[\\/]+/).includes("..")) return { ok: false, error: "Executive brief output must be a safe workspace-relative path." }
18
+ return { ok: true, args: { outputPath: normalize(value).replace(/\\/g, "/") } }
19
+ }
20
+
21
+ export async function handleBrief(
22
+ input: { workspaceRoot: string; outputPath?: string },
23
+ send: (text: string) => Promise<void>,
24
+ ): Promise<void> {
25
+ const statePath = join(input.workspaceRoot, "DECKS.json")
26
+ if (!existsSync(statePath)) {
27
+ await send("No `DECKS.json` found. Run `/revela init` before rendering an executive brief.")
28
+ return
29
+ }
30
+
31
+ const state = readDecksState(input.workspaceRoot)
32
+ const result = compileExecutiveBrief(state, { outputPath: input.outputPath })
33
+ if (!result.ok) {
34
+ await send(
35
+ `**Executive brief not rendered**\n\n${result.reason}\n\n` +
36
+ (result.narrativeHash ? `Narrative hash: \`${result.narrativeHash}\`\n\n` : "") +
37
+ "Run `/revela review` and approve the current narrative, or record an explicit render override before retrying."
38
+ )
39
+ return
40
+ }
41
+
42
+ const filePath = safeWorkspaceFilePath(input.workspaceRoot, result.outputPath)
43
+ mkdirSync(dirname(filePath), { recursive: true })
44
+ writeFileSync(filePath, result.content, "utf-8")
45
+ writeDecksState(input.workspaceRoot, result.state)
46
+
47
+ await send(
48
+ `**Executive brief rendered**\n\n` +
49
+ `- Output: \`${result.outputPath}\`\n` +
50
+ `- Render target: \`${result.target.id}\`\n` +
51
+ `- Narrative hash: \`${result.narrativeHash}\`\n\n` +
52
+ "The brief was compiled from canonical narrative state, not from a deck summary."
53
+ )
54
+ }
55
+
56
+ function safeWorkspaceFilePath(workspaceRoot: string, outputPath: string): string {
57
+ const relative = outputPath || DEFAULT_EXECUTIVE_BRIEF_PATH
58
+ if (isAbsolute(relative) || relative.split(/[\\/]+/).includes("..")) throw new Error("Executive brief output must be a safe workspace-relative path.")
59
+ const root = resolve(workspaceRoot)
60
+ const target = resolve(root, relative)
61
+ if (target !== root && !target.startsWith(`${root}/`)) throw new Error("Executive brief output must stay inside the workspace.")
62
+ return target
63
+ }
@@ -28,6 +28,8 @@ export async function handleHelp(
28
28
  `\`/revela disable\` — disable Revela mode\n` +
29
29
  `\`/revela init\` — initialize or refresh workspace DECKS.json\n` +
30
30
  `\`/revela review\` — review narrative readiness and approval state\n` +
31
+ `\`/revela narrative\` — open read-only narrative workspace map\n` +
32
+ `\`/revela brief [file.md]\` — render executive brief from approved narrative\n` +
31
33
  `\`/revela deck\` — start deck handoff from approved narrative\n` +
32
34
  `\`/revela deck --review\` — review deck/artifact readiness before writing HTML\n` +
33
35
  `\`/revela refine\` — open unified Edit/Inspect refinement workspace\n` +
@@ -0,0 +1,160 @@
1
+ import { mkdirSync, writeFileSync } from "fs"
2
+ import { tmpdir } from "os"
3
+ import { join } from "path"
4
+ import { openUrl as defaultOpenUrl } from "../edit/open"
5
+ import { hasDecksState, readDecksState } from "../decks-state"
6
+ import { buildNarrativeMap, formatNarrativeMap } from "../narrative-state/map"
7
+ import { renderNarrativeMapHtmlWithDisplay } from "../narrative-state/map-html"
8
+ import { emptyDisplayModel, type NarrativeViewLanguage, type ValidatedNarrativeDisplayModel } from "../narrative-state/display"
9
+
10
+ export interface NarrativeArgs {
11
+ language: NarrativeViewLanguage
12
+ raw: boolean
13
+ }
14
+
15
+ export type ParseNarrativeArgsResult = { ok: true; args: NarrativeArgs } | { ok: false; error: string }
16
+
17
+ export function parseNarrativeArgs(param: string): ParseNarrativeArgsResult {
18
+ const tokens = param.trim().split(/\s+/).filter(Boolean)
19
+ let language: NarrativeViewLanguage = "en"
20
+ let raw = false
21
+ const languageParts: string[] = []
22
+ for (const token of tokens) {
23
+ const normalized = token.toLowerCase()
24
+ if (normalized === "--raw") {
25
+ raw = true
26
+ continue
27
+ }
28
+ if (token.startsWith("--") && token.length > 2) {
29
+ language = normalizeLanguageRequest(token.slice(2))
30
+ continue
31
+ }
32
+ languageParts.push(token)
33
+ }
34
+ if (languageParts.length > 0) language = normalizeLanguageRequest(languageParts.join(" "))
35
+ return { ok: true, args: { language, raw } }
36
+ }
37
+
38
+ function normalizeLanguageRequest(value: string): NarrativeViewLanguage {
39
+ const trimmed = value.trim()
40
+ const normalized = trimmed.toLowerCase()
41
+ if (["en", "eng", "english"].includes(normalized)) return "en"
42
+ if (["cn", "zh", "zh-cn", "chinese"].includes(normalized)) return "zh-CN"
43
+ if (["jp", "ja", "ja-jp", "japanese"].includes(normalized)) return "ja-JP"
44
+ return trimmed || "en"
45
+ }
46
+
47
+ export async function handleNarrative(
48
+ options: { workspaceRoot: string; openBrowser?: boolean; openUrl?: (url: string) => void; language?: NarrativeViewLanguage; display?: ValidatedNarrativeDisplayModel },
49
+ send: (text: string) => Promise<void>,
50
+ ): Promise<void> {
51
+ try {
52
+ if (!hasDecksState(options.workspaceRoot)) {
53
+ await send("No `DECKS.json` found. Run `/revela init` first to initialize the narrative workspace.")
54
+ return
55
+ }
56
+
57
+ const state = readDecksState(options.workspaceRoot)
58
+ const map = buildNarrativeMap(state)
59
+ const markdown = formatNarrativeMap(map)
60
+
61
+ if (options.openBrowser) {
62
+ const htmlPath = writeNarrativeMapHtml(map, options.display ?? emptyDisplayModel(options.language ?? "en"))
63
+ const url = `file://${htmlPath}`
64
+ try {
65
+ ;(options.openUrl ?? defaultOpenUrl)(url)
66
+ await send(`Opened read-only narrative workspace: ${url}\n\n${markdown}`)
67
+ } catch (e: any) {
68
+ await send(`Read-only narrative workspace generated but could not open automatically: ${url}\n\n${e.message || String(e)}\n\n${markdown}`)
69
+ }
70
+ return
71
+ }
72
+
73
+ await send(markdown)
74
+ } catch (e: any) {
75
+ await send(`**Narrative map failed:** ${e.message || String(e)}`)
76
+ }
77
+ }
78
+
79
+ export function buildNarrativeViewPrompt(options: { workspaceRoot: string; language: NarrativeViewLanguage }): string {
80
+ if (!hasDecksState(options.workspaceRoot)) {
81
+ return "No `DECKS.json` found. Tell the user to run `/revela init` before opening the narrative view. Do not call any tool."
82
+ }
83
+
84
+ const map = buildNarrativeMap(readDecksState(options.workspaceRoot))
85
+ const projection = {
86
+ narrativeHash: map.snapshot.narrativeHash,
87
+ language: options.language,
88
+ snapshot: map.snapshot,
89
+ claims: map.claimFlow.map((claim) => ({
90
+ id: claim.id,
91
+ kind: claim.kind,
92
+ importance: claim.importance,
93
+ evidenceStatus: claim.evidenceStatus,
94
+ text: claim.text,
95
+ supportedScope: claim.supportedScope,
96
+ unsupportedScope: claim.unsupportedScope,
97
+ evidence: claim.evidence.map((evidence) => ({ source: evidence.source, strength: evidence.strength, findingsFile: evidence.findingsFile, location: evidence.location, quote: evidence.quote, caveat: evidence.caveat, unsupportedScope: evidence.unsupportedScope })),
98
+ })),
99
+ relations: map.claimRelations.map((relation) => ({ id: relation.id, fromClaimId: relation.fromClaimId, toClaimId: relation.toClaimId, relation: relation.relation, rationale: relation.rationale, inferred: relation.inferred })),
100
+ researchGaps: map.researchGaps.map((gap) => ({ id: gap.id, targetType: gap.targetType, targetId: gap.targetId, status: gap.status, priority: gap.priority, question: gap.question })),
101
+ artifactCoverage: map.artifactCoverage.map((artifact) => ({ type: artifact.type, outputPath: artifact.outputPath, stale: artifact.stale, slideRefs: artifact.slideRefs.map((ref) => ({ claimId: ref.claimId, slideIndex: ref.slideIndex, role: ref.role, match: ref.match, location: ref.location })) })),
102
+ }
103
+
104
+ return `Prepare the read-only Revela narrative UI display model.
105
+
106
+ Target language request: ${options.language}
107
+ - The language value is passed from the user's /revela narrative arguments. Interpret it as the desired UI/display language.
108
+ - Examples: --cn maps to zh-CN, --jp maps to ja-JP, while --fr, --de, --es, --ko, --Arabic, --Portuguese-BR, or a written language name should be localized normally into that requested language.
109
+ - Default /revela narrative language is en when the user provides no language request.
110
+
111
+ You must call the \`revela-narrative-view\` tool exactly once.
112
+
113
+ Hard rules:
114
+ - Do not mutate DECKS.json, deck HTML, evidence, claims, relations, approvals, or artifacts.
115
+ - Do not invent new claims, evidence, relations, slide coverage, source paths, findings files, quotes, or caveats.
116
+ - Preserve every claimId exactly.
117
+ - Preserve every relation endpoint exactly: fromClaimId, toClaimId, relation.
118
+ - You may only organize and localize display copy for the UI: pageTitle, summaryLine, section labels, claim card displayTitle, roleLabel, narrativeJob, evidenceSummary, riskOrGapSummary, relation displayLabel, and relation displayRationale.
119
+ - For inferred relations, do not provide relation displayLabel or displayRationale; inferred relations are unconfirmed order notes, not causal/support/dependency judgments.
120
+ - relation displayRationale may only localize or clarify an existing canonical relation rationale. If relation.rationale is missing or the relation is inferred, do not provide displayRationale; the UI will show the missing or inferred status.
121
+ - Keep source paths, findings files, claim IDs, narrative hash, and numbers unchanged.
122
+ - Translate normal UI/display text into the target language request: pageTitle, summaryLine, labels, claim displayTitle, roleLabel, narrativeJob, evidenceSummary, riskOrGapSummary, relation displayLabel, and relation displayRationale.
123
+ - Do not translate claim IDs, relation endpoints, narrative hash, source paths, findings files, URLs, numbers, or quoted/source facts.
124
+ - Use natural business and manufacturing terminology in the target language, not word-by-word machine translation.
125
+ - If a fact is missing, describe it as missing instead of filling it in.
126
+
127
+ Chinese localization rules when the target language request is Chinese, zh, zh-CN, --cn, 中文, or Simplified Chinese:
128
+ - Use natural business/manufacturing Chinese, not word-by-word machine translation.
129
+ - In manufacturing, industrial AI, automation, and autonomous systems context, translate "autonomy" as "自主化", "自主能力", or "自主系统". Do not translate it as "自治".
130
+ - Translate "autonomous" as "自主的" / "自主化的" where appropriate, not "自治的".
131
+ - Translate "architectural" as "架构层面的", "架构性", or "架构问题" according to context.
132
+ - Slug-like or kebab-case claim text such as "autonomy-is-architectural" should become a readable displayTitle such as "自主化是架构问题" or "自主化必须作为架构问题处理", not a literal token-by-token translation.
133
+ - If the canonical claim text is only a slug, preserve the claimId exactly but write displayTitle as a readable claim title.
134
+
135
+ Call \`revela-narrative-view\` with:
136
+ - language: ${options.language}
137
+ - narrativeHash: ${map.snapshot.narrativeHash}
138
+ - displayModel.version: 1
139
+ - displayModel.language: ${options.language}
140
+ - displayModel.claimCards only for claim IDs listed below
141
+ - displayModel.relations only for relations listed below
142
+
143
+ Compact deterministic narrative map:
144
+
145
+ \`\`\`json
146
+ ${JSON.stringify(projection, null, 2)}
147
+ \`\`\``
148
+ }
149
+
150
+ export function writeNarrativeMapHtml(map: ReturnType<typeof buildNarrativeMap>, display: ValidatedNarrativeDisplayModel = emptyDisplayModel("en")): string {
151
+ const dir = join(tmpdir(), "revela-narrative")
152
+ mkdirSync(dir, { recursive: true })
153
+ const file = join(dir, `${safeFilePart(map.snapshot.narrativeId)}-${map.snapshot.narrativeHash}.html`)
154
+ writeFileSync(file, renderNarrativeMapHtmlWithDisplay(map, display), "utf-8")
155
+ return file
156
+ }
157
+
158
+ function safeFilePart(value: string): string {
159
+ return value.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "narrative"
160
+ }
@@ -26,6 +26,7 @@ export type DeckProductionStatus = "planning" | "blocked" | "ready" | "written"
26
26
  export type SlideProductionStatus = "planned" | "ready" | "written" | "qa_passed" | "qa_failed"
27
27
  export type WriteReadinessStatus = "blocked" | "ready" | "written"
28
28
  export type NarrativeRole = "context" | "tension" | "evidence" | "recommendation" | "risk" | "ask" | "appendix" | "close"
29
+ export type SlideClaimRefRole = "primary" | "supporting" | "evidence" | "risk" | "objection"
29
30
 
30
31
  export interface DecksState {
31
32
  version: 1
@@ -135,6 +136,9 @@ export interface SlideSpec {
135
136
  layout: string
136
137
  qa?: boolean
137
138
  components: string[]
139
+ claimIds?: string[]
140
+ claimRefs?: SlideClaimRef[]
141
+ evidenceBindingIds?: string[]
138
142
  content: {
139
143
  headline?: string
140
144
  body?: string[]
@@ -148,6 +152,12 @@ export interface SlideSpec {
148
152
  notes?: string
149
153
  }
150
154
 
155
+ export interface SlideClaimRef {
156
+ claimId: string
157
+ role: SlideClaimRefRole
158
+ note?: string
159
+ }
160
+
151
161
  export interface EvidenceRef {
152
162
  source: string
153
163
  quote?: string
@@ -1478,6 +1488,9 @@ function normalizeSlides(slides: SlideSpec[]): SlideSpec[] {
1478
1488
  title: slide.title ?? "",
1479
1489
  layout: slide.layout ?? "",
1480
1490
  components: slide.components ?? [],
1491
+ claimIds: normalizeTextList(slide.claimIds),
1492
+ claimRefs: normalizeSlideClaimRefs(slide.claimRefs),
1493
+ evidenceBindingIds: normalizeTextList(slide.evidenceBindingIds),
1481
1494
  content: slide.content ?? {},
1482
1495
  evidence: slide.evidence ?? [],
1483
1496
  status: slide.status ?? "planned",
@@ -1485,6 +1498,26 @@ function normalizeSlides(slides: SlideSpec[]): SlideSpec[] {
1485
1498
  .sort((a, b) => a.index - b.index)
1486
1499
  }
1487
1500
 
1501
+ function normalizeSlideClaimRefs(refs: SlideClaimRef[] | undefined): SlideClaimRef[] {
1502
+ const seen = new Set<string>()
1503
+ const out: SlideClaimRef[] = []
1504
+ for (const ref of refs ?? []) {
1505
+ const claimId = cleanOptionalText(ref.claimId)
1506
+ if (!claimId) continue
1507
+ const role = isSlideClaimRefRole(ref.role) ? ref.role : "supporting"
1508
+ const key = `${claimId}:${role}`
1509
+ if (seen.has(key)) continue
1510
+ seen.add(key)
1511
+ const note = cleanOptionalText(ref.note)
1512
+ out.push({ claimId, role, ...(note ? { note } : {}) })
1513
+ }
1514
+ return out
1515
+ }
1516
+
1517
+ function isSlideClaimRefRole(value: string | undefined): value is SlideClaimRefRole {
1518
+ return value === "primary" || value === "supporting" || value === "evidence" || value === "risk" || value === "objection"
1519
+ }
1520
+
1488
1521
  function normalizeNarrativeBrief(brief: NarrativeBrief | undefined): NarrativeBrief | undefined {
1489
1522
  if (!brief) return undefined
1490
1523
  const normalized: NarrativeBrief = {
@@ -71,6 +71,9 @@ Instructions:
71
71
  - Make the smallest targeted change that satisfies the user's comment.
72
72
  - If there are multiple comments, apply them as one coherent edit pass and avoid changes from one comment overwriting another.
73
73
  - Each comment may reference one or more selected elements. Treat the elements in a single comment as a group.
74
+ - Preserve the narrative boundary: if the requested edit changes audience framing, belief shift, decision/action, thesis, recommendation, claim wording, evidence scope, caveat, risk, objection, or decision ask, do not patch the HTML directly. Explain that the canonical narrative must be updated first through ${"`revela-decks`"} action ${"`upsertNarrative`"}, then reviewed/approved or explicitly overridden before updating the deck projection.
75
+ - Pure artifact polish such as layout, spacing, typography, alignment, color, image crop, animation, export fidelity, or deck HTML contract fixes may remain an artifact-level edit.
76
+ - If the request mixes content meaning and visual polish, treat it as narrative-impacting unless the user clarifies otherwise.
74
77
  - Preserve the existing deck structure, active design language, typography, spacing system, animations, and slide count unless the comment explicitly asks otherwise.
75
78
  - Do not rewrite unrelated slides or broad sections of the deck.
76
79
  - Locate each target primarily with slideIndex, slideTitle, selected text, nearbyText, and outerHTMLExcerpt. Use selector/domPath as hints; they may be approximate.
@@ -1,6 +1,7 @@
1
1
  import type { DeckSpec, DecksState, EvidenceRef, NarrativeBrief, NarrativeRole, SlideSpec, SourceMaterial } from "../decks-state"
2
+ import type { NarrativeClaim, NarrativeEvidenceBinding, NarrativeStateV1 } from "../narrative-state/types"
2
3
 
3
- export type InspectionClaimOrigin = "title" | "headline" | "body" | "bullet" | "purpose"
4
+ export type InspectionClaimOrigin = "narrative" | "title" | "headline" | "body" | "bullet" | "purpose"
4
5
  export type InspectionGapType = "missing_evidence" | "weak_evidence"
5
6
  export type InspectionEvidenceSupport = "supported" | "weak" | "unknown"
6
7
 
@@ -13,6 +14,7 @@ export interface InspectionContext {
13
14
  outputPath: string
14
15
  narrativeBrief?: NarrativeBrief
15
16
  sourceMaterials: InspectionSourceMaterial[]
17
+ narrative?: InspectionNarrativeStateContext
16
18
  slides: InspectionSlideContext[]
17
19
  gaps: InspectionGap[]
18
20
  appendixCandidates: InspectionAppendixCandidate[]
@@ -20,6 +22,12 @@ export interface InspectionContext {
20
22
  riskContext: InspectionNarrativeContext[]
21
23
  }
22
24
 
25
+ export interface InspectionNarrativeStateContext {
26
+ id: string
27
+ status: string
28
+ claimCount: number
29
+ }
30
+
23
31
  export interface InspectionSourceMaterial extends SourceMaterial {
24
32
  linkedEvidenceCount: number
25
33
  }
@@ -46,6 +54,7 @@ export interface InspectionSlideText {
46
54
 
47
55
  export interface InspectionClaimCandidate {
48
56
  id: string
57
+ canonicalClaimId?: string
49
58
  slideIndex: number
50
59
  slideTitle: string
51
60
  origin: InspectionClaimOrigin
@@ -54,9 +63,18 @@ export interface InspectionClaimCandidate {
54
63
  evidenceSupport: InspectionEvidenceSupport
55
64
  evidence: InspectionEvidenceTrace[]
56
65
  gaps: InspectionGap[]
66
+ evidenceBindingIds: string[]
67
+ supportedScope?: string
68
+ unsupportedScope?: string
69
+ caveats: string[]
57
70
  }
58
71
 
59
72
  export interface InspectionEvidenceTrace extends EvidenceRef {
73
+ evidenceBindingId?: string
74
+ claimId?: string
75
+ supportScope?: string
76
+ unsupportedScope?: string
77
+ strength?: NarrativeEvidenceBinding["strength"]
60
78
  slideIndex: number
61
79
  slideTitle: string
62
80
  hasDetail: boolean
@@ -87,12 +105,13 @@ export interface InspectionNarrativeContext {
87
105
 
88
106
  export function compileInspectionContext(state: DecksState, slug?: string): InspectionContext {
89
107
  const deck = activeDeck(state, slug)
108
+ const narrative = state.narrative
90
109
  const evidence = collectEvidence(deck)
91
110
  const sourceMaterials = compileSourceMaterials(state.workspace.sourceMaterials ?? [], evidence)
92
111
  const slides = deck.slides
93
112
  .slice()
94
113
  .sort((a, b) => a.index - b.index)
95
- .map((slide) => compileSlide(slide))
114
+ .map((slide) => compileSlide(slide, narrative))
96
115
  const gaps = slides.flatMap((slide) => slide.claims.flatMap((claim) => claim.gaps))
97
116
 
98
117
  return {
@@ -103,6 +122,7 @@ export function compileInspectionContext(state: DecksState, slug?: string): Insp
103
122
  language: deck.language,
104
123
  outputPath: deck.outputPath,
105
124
  narrativeBrief: deck.narrativeBrief,
125
+ narrative: narrative ? { id: narrative.id, status: narrative.status, claimCount: narrative.claims.length } : undefined,
106
126
  sourceMaterials,
107
127
  slides,
108
128
  gaps,
@@ -118,9 +138,15 @@ function activeDeck(state: DecksState, slug?: string): DeckSpec {
118
138
  return state.decks[key]
119
139
  }
120
140
 
121
- function compileSlide(slide: SlideSpec): InspectionSlideContext {
141
+ function compileSlide(slide: SlideSpec, narrative: NarrativeStateV1 | undefined): InspectionSlideContext {
122
142
  const evidence = slide.evidence.map((item) => compileEvidence(slide, item))
123
- const claims = claimCandidates(slide).map((claim, position) => compileClaim(slide, claim, position, evidence))
143
+ const canonicalClaims = narrative ? canonicalClaimCandidates(slide, narrative, evidence) : []
144
+ const canonicalText = new Set(canonicalClaims.map((claim) => normalizeText(claim.text)))
145
+ const heuristicClaims = claimCandidates(slide)
146
+ .filter((claim) => !canonicalText.has(normalizeText(claim.text)))
147
+ .map((claim, position) => compileClaim(slide, claim, position, evidence))
148
+ const claims = [...canonicalClaims, ...heuristicClaims]
149
+ const claimCaveats = canonicalClaims.flatMap((claim) => claim.caveats)
124
150
  return {
125
151
  index: slide.index,
126
152
  title: slide.title,
@@ -136,7 +162,61 @@ function compileSlide(slide: SlideSpec): InspectionSlideContext {
136
162
  },
137
163
  claims,
138
164
  evidence,
139
- caveats: evidence.map((item) => item.caveat).filter((item): item is string => Boolean(item?.trim())),
165
+ caveats: dedupeText([
166
+ ...evidence.map((item) => item.caveat).filter((item): item is string => Boolean(item?.trim())),
167
+ ...claimCaveats,
168
+ ]),
169
+ }
170
+ }
171
+
172
+ function canonicalClaimCandidates(slide: SlideSpec, narrative: NarrativeStateV1, slideEvidence: InspectionEvidenceTrace[]): InspectionClaimCandidate[] {
173
+ const claimRefs = slide.claimRefs ?? []
174
+ const metadataClaimIds = new Set([
175
+ ...claimRefs.map((ref) => ref.claimId),
176
+ ...(slide.claimIds ?? []),
177
+ ].filter(Boolean))
178
+ const evidenceBindingIds = new Set(slide.evidenceBindingIds ?? [])
179
+ for (const binding of narrative.evidenceBindings) {
180
+ if (evidenceBindingIds.has(binding.id)) metadataClaimIds.add(binding.claimId)
181
+ }
182
+
183
+ return narrative.claims
184
+ .filter((claim) => metadataClaimIds.has(claim.id))
185
+ .map((claim) => compileCanonicalClaim(slide, claim, narrative.evidenceBindings, slideEvidence, evidenceBindingIds))
186
+ }
187
+
188
+ function compileCanonicalClaim(
189
+ slide: SlideSpec,
190
+ claim: NarrativeClaim,
191
+ bindings: NarrativeEvidenceBinding[],
192
+ slideEvidence: InspectionEvidenceTrace[],
193
+ slideEvidenceBindingIds: Set<string>,
194
+ ): InspectionClaimCandidate {
195
+ const allClaimBindings = bindings.filter((binding) => binding.claimId === claim.id)
196
+ const selectedBindings = allClaimBindings.filter((binding) => slideEvidenceBindingIds.size === 0 || slideEvidenceBindingIds.has(binding.id))
197
+ const evidenceBindings = selectedBindings.length > 0 ? selectedBindings : allClaimBindings
198
+ const evidence = evidenceBindings.length > 0
199
+ ? evidenceBindings.map((binding) => compileEvidenceBinding(slide, binding))
200
+ : slideEvidence
201
+ const gaps = canonicalClaimGaps(slide, claim, evidence)
202
+ return {
203
+ id: claim.id,
204
+ canonicalClaimId: claim.id,
205
+ slideIndex: slide.index,
206
+ slideTitle: slide.title,
207
+ origin: "narrative",
208
+ text: claim.text,
209
+ evidenceSensitive: claim.evidenceRequired || isEvidenceSensitiveClaim(claim.text),
210
+ evidenceSupport: narrativeEvidenceSupport(claim, evidence),
211
+ evidence,
212
+ gaps,
213
+ evidenceBindingIds: evidenceBindings.map((binding) => binding.id),
214
+ supportedScope: claim.supportedScope,
215
+ unsupportedScope: claim.unsupportedScope,
216
+ caveats: dedupeText([
217
+ ...(claim.caveats ?? []),
218
+ ...evidenceBindings.map((binding) => binding.caveat).filter((item): item is string => Boolean(item?.trim())),
219
+ ]),
140
220
  }
141
221
  }
142
222
 
@@ -159,7 +239,34 @@ function compileClaim(
159
239
  evidenceSupport: evidenceSupport(evidence),
160
240
  evidence,
161
241
  gaps,
242
+ evidenceBindingIds: [],
243
+ caveats: [],
244
+ }
245
+ }
246
+
247
+ function canonicalClaimGaps(slide: SlideSpec, claim: NarrativeClaim, evidence: InspectionEvidenceTrace[]): InspectionGap[] {
248
+ if (!claim.evidenceRequired) return []
249
+ if (claim.evidenceStatus === "missing" || evidence.length === 0) {
250
+ return [{
251
+ type: "missing_evidence",
252
+ slideIndex: slide.index,
253
+ slideTitle: slide.title,
254
+ claimId: claim.id,
255
+ claimText: claim.text,
256
+ message: "Canonical narrative claim requires evidence but has no bound evidence trace.",
257
+ }]
162
258
  }
259
+ if (claim.evidenceStatus === "weak" || evidence.some((item) => !item.hasDetail)) {
260
+ return [{
261
+ type: "weak_evidence",
262
+ slideIndex: slide.index,
263
+ slideTitle: slide.title,
264
+ claimId: claim.id,
265
+ claimText: claim.text,
266
+ message: "Canonical narrative claim has weak or source-only evidence trace.",
267
+ }]
268
+ }
269
+ return []
163
270
  }
164
271
 
165
272
  function claimGaps(slide: SlideSpec, claimId: string, claimText: string, evidence: InspectionEvidenceTrace[]): InspectionGap[] {
@@ -192,6 +299,12 @@ function evidenceSupport(evidence: InspectionEvidenceTrace[]): InspectionEvidenc
192
299
  return "supported"
193
300
  }
194
301
 
302
+ function narrativeEvidenceSupport(claim: NarrativeClaim, evidence: InspectionEvidenceTrace[]): InspectionEvidenceSupport {
303
+ if (claim.evidenceStatus === "supported" || claim.evidenceStatus === "not_required") return "supported"
304
+ if (claim.evidenceStatus === "partial" || claim.evidenceStatus === "weak") return "weak"
305
+ return evidenceSupport(evidence)
306
+ }
307
+
195
308
  function claimCandidates(slide: SlideSpec): Array<{ origin: InspectionClaimOrigin; text: string }> {
196
309
  const claims: Array<{ origin: InspectionClaimOrigin; text: string }> = []
197
310
  pushClaim(claims, "title", slide.title)
@@ -218,6 +331,29 @@ function compileEvidence(slide: SlideSpec, evidence: EvidenceRef): InspectionEvi
218
331
  }
219
332
  }
220
333
 
334
+ function compileEvidenceBinding(slide: SlideSpec, binding: NarrativeEvidenceBinding): InspectionEvidenceTrace {
335
+ const evidence: EvidenceRef = {
336
+ source: binding.source,
337
+ sourcePath: binding.sourcePath,
338
+ findingsFile: binding.findingsFile,
339
+ quote: binding.quote,
340
+ location: binding.location,
341
+ url: binding.url,
342
+ caveat: binding.caveat,
343
+ }
344
+ return {
345
+ ...evidence,
346
+ evidenceBindingId: binding.id,
347
+ claimId: binding.claimId,
348
+ supportScope: binding.supportScope,
349
+ unsupportedScope: binding.unsupportedScope,
350
+ strength: binding.strength,
351
+ slideIndex: slide.index,
352
+ slideTitle: slide.title,
353
+ hasDetail: hasEvidenceDetail(evidence),
354
+ }
355
+ }
356
+
221
357
  function collectEvidence(deck: DeckSpec): InspectionEvidenceTrace[] {
222
358
  return deck.slides.flatMap((slide) => slide.evidence.map((item) => compileEvidence(slide, item)))
223
359
  }
@@ -303,6 +439,24 @@ function cleanOptionalText(value: string | undefined): string | undefined {
303
439
  return text || undefined
304
440
  }
305
441
 
442
+ function dedupeText(values: string[]): string[] {
443
+ const seen = new Set<string>()
444
+ const result: string[] = []
445
+ for (const value of values) {
446
+ const cleaned = cleanOptionalText(value)
447
+ if (!cleaned) continue
448
+ const key = normalizeText(cleaned)
449
+ if (seen.has(key)) continue
450
+ seen.add(key)
451
+ result.push(cleaned)
452
+ }
453
+ return result
454
+ }
455
+
456
+ function normalizeText(value: string | undefined): string {
457
+ return value?.replace(/\s+/g, " ").trim().toLowerCase() ?? ""
458
+ }
459
+
306
460
  const EVIDENCE_SENSITIVE_TERMS = [
307
461
  /\bmarket size\b/,
308
462
  /\bcagr\b/,
@@ -50,10 +50,15 @@ export interface InspectionProjectionMatch {
50
50
  }
51
51
  claim?: {
52
52
  id: string
53
+ canonicalClaimId?: string
53
54
  origin: string
54
55
  text: string
55
56
  evidenceSensitive: boolean
56
57
  evidenceSupport: string
58
+ evidenceBindingIds: string[]
59
+ supportedScope?: string
60
+ unsupportedScope?: string
61
+ caveats: string[]
57
62
  }
58
63
  }
59
64
 
@@ -97,6 +102,8 @@ export interface InspectionAppendixProjection {
97
102
 
98
103
  export interface InspectionEvidenceProjectionTrace {
99
104
  source: string
105
+ evidenceBindingId?: string
106
+ claimId?: string
100
107
  sourcePath?: string
101
108
  findingsFile?: string
102
109
  location?: string
@@ -104,6 +111,9 @@ export interface InspectionEvidenceProjectionTrace {
104
111
  url?: string
105
112
  quote?: string
106
113
  caveat?: string
114
+ supportScope?: string
115
+ unsupportedScope?: string
116
+ strength?: string
107
117
  extractedTextPath?: string
108
118
  extractedManifestPath?: string
109
119
  hasDetail: boolean
@@ -163,10 +173,15 @@ export function projectInspectionMatch(
163
173
  claim: claim
164
174
  ? {
165
175
  id: claim.id,
176
+ canonicalClaimId: claim.canonicalClaimId,
166
177
  origin: claim.origin,
167
178
  text: truncate(claim.text, 500),
168
179
  evidenceSensitive: claim.evidenceSensitive,
169
180
  evidenceSupport: claim.evidenceSupport,
181
+ evidenceBindingIds: claim.evidenceBindingIds,
182
+ supportedScope: truncateOptional(claim.supportedScope, 280),
183
+ unsupportedScope: truncateOptional(claim.unsupportedScope, 280),
184
+ caveats: claim.caveats.map((item) => truncate(item, 280)).slice(0, 8),
170
185
  }
171
186
  : undefined,
172
187
  },
@@ -211,6 +226,8 @@ export function projectInspectionMatch(
211
226
  function projectEvidenceTrace(trace: InspectionEvidenceTrace): InspectionEvidenceProjectionTrace {
212
227
  return {
213
228
  source: truncate(trace.source, 180),
229
+ evidenceBindingId: truncateOptional(trace.evidenceBindingId, 160),
230
+ claimId: truncateOptional(trace.claimId, 160),
214
231
  sourcePath: truncateOptional(trace.sourcePath, 220),
215
232
  findingsFile: truncateOptional(trace.findingsFile, 220),
216
233
  location: truncateOptional(trace.location, 120),
@@ -218,6 +235,9 @@ function projectEvidenceTrace(trace: InspectionEvidenceTrace): InspectionEvidenc
218
235
  url: truncateOptional(trace.url, 240),
219
236
  quote: truncateOptional(trace.quote, 500),
220
237
  caveat: truncateOptional(trace.caveat, 280),
238
+ supportScope: truncateOptional(trace.supportScope, 280),
239
+ unsupportedScope: truncateOptional(trace.unsupportedScope, 280),
240
+ strength: trace.strength,
221
241
  extractedTextPath: truncateOptional(trace.extractedTextPath, 220),
222
242
  extractedManifestPath: truncateOptional(trace.extractedManifestPath, 220),
223
243
  hasDetail: trace.hasDetail,