@cyber-dash-tech/revela 0.11.0 → 0.12.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,289 @@
1
+ import type { DecksState } from "../decks-state"
2
+ import { recordWorkspaceAction } from "../workspace-state/actions"
3
+ import { computeNarrativeHash, stableHash } from "./hash"
4
+ import { normalizeNarrativeState } from "./normalize"
5
+ import type {
6
+ NarrativeApproval,
7
+ NarrativeClaim,
8
+ NarrativeReadinessIssue,
9
+ NarrativeReadinessResult,
10
+ NarrativeReadinessStatus,
11
+ NarrativeStateV1,
12
+ } from "./types"
13
+
14
+ interface NarrativeApprovalState {
15
+ current: boolean
16
+ stale: boolean
17
+ latest?: NarrativeApproval
18
+ }
19
+
20
+ export interface ReviewNarrativeOptions {
21
+ now?: string
22
+ }
23
+
24
+ export interface ApproveNarrativeOptions {
25
+ now?: string
26
+ approvedBy?: "user" | "override"
27
+ scope?: "narrative" | "render_override"
28
+ note?: string
29
+ }
30
+
31
+ export interface ApproveNarrativeResult {
32
+ approved: boolean
33
+ skipped: boolean
34
+ reason?: string
35
+ narrativeHash: string
36
+ approval?: NarrativeApproval
37
+ readiness: NarrativeReadinessResult
38
+ }
39
+
40
+ export function reviewNarrativeState(state: DecksState, options: ReviewNarrativeOptions = {}): { state: DecksState; result: NarrativeReadinessResult } {
41
+ const next: DecksState = { ...state, narrative: normalizeNarrativeState(state) }
42
+ const result = computeNarrativeReadiness(next.narrative!, next, options)
43
+ next.narrative = { ...next.narrative!, status: narrativeStatusFromReadiness(result.status), updatedAt: options.now ?? next.narrative!.updatedAt }
44
+ return { state: next, result }
45
+ }
46
+
47
+ export function approveNarrativeState(state: DecksState, options: ApproveNarrativeOptions = {}): { state: DecksState; result: ApproveNarrativeResult } {
48
+ const now = options.now ?? new Date().toISOString()
49
+ const reviewed = reviewNarrativeState(state, { now })
50
+ const narrative = reviewed.state.narrative!
51
+ const scope = options.scope ?? "narrative"
52
+ const approvedBy = options.approvedBy ?? "user"
53
+ const override = approvedBy === "override" || scope === "render_override"
54
+ const blocking = reviewed.result.issues.filter((issue) => issue.severity === "blocker")
55
+ const incomplete = blocking.some((issue) => issue.type !== "approval_missing" && issue.type !== "approval_stale")
56
+
57
+ if (incomplete && !override) {
58
+ return {
59
+ state: reviewed.state,
60
+ result: {
61
+ approved: false,
62
+ skipped: true,
63
+ reason: "narrative has unresolved readiness blockers; use an explicit override to record a render override",
64
+ narrativeHash: reviewed.result.narrativeHash,
65
+ readiness: reviewed.result,
66
+ },
67
+ }
68
+ }
69
+
70
+ const approval: NarrativeApproval = {
71
+ id: `approval:${stableHash(`${reviewed.result.narrativeHash}:${now}:${scope}:${approvedBy}`)}`,
72
+ narrativeHash: reviewed.result.narrativeHash,
73
+ approvedAt: now,
74
+ approvedBy,
75
+ scope,
76
+ note: clean(options.note),
77
+ }
78
+ const approvals = dedupeApprovals([...(narrative.approvals ?? []), approval])
79
+ const updatedNarrative: NarrativeStateV1 = {
80
+ ...narrative,
81
+ approvals,
82
+ status: scope === "narrative" && approvedBy === "user" ? "approved" : narrative.status,
83
+ updatedAt: now,
84
+ }
85
+ const next: DecksState = { ...reviewed.state, narrative: updatedNarrative }
86
+ const readiness = computeNarrativeReadiness(updatedNarrative, next, { now })
87
+ next.narrative = { ...updatedNarrative, status: narrativeStatusFromReadiness(readiness.status) }
88
+ return {
89
+ state: next,
90
+ result: {
91
+ approved: true,
92
+ skipped: false,
93
+ narrativeHash: reviewed.result.narrativeHash,
94
+ approval,
95
+ readiness,
96
+ },
97
+ }
98
+ }
99
+
100
+ export function recordNarrativeReviewAction(state: DecksState, result: NarrativeReadinessResult): void {
101
+ recordWorkspaceAction(state, {
102
+ type: "review.performed",
103
+ actor: "revela-decks",
104
+ inputs: { kind: "narrative", narrativeId: state.narrative?.id },
105
+ outputs: {
106
+ kind: "narrative",
107
+ status: result.status,
108
+ narrativeHash: result.narrativeHash,
109
+ blockerCount: result.blockers.length,
110
+ warningCount: result.warnings.length,
111
+ issueCount: result.issues.length,
112
+ approvalCurrent: result.approval?.current ?? false,
113
+ approvalStale: result.approval?.stale ?? false,
114
+ },
115
+ status: "success",
116
+ summary: `Reviewed narrative readiness: ${result.status}.`,
117
+ nodeIds: state.narrative ? [state.narrative.id] : [],
118
+ })
119
+ }
120
+
121
+ export function recordNarrativeApprovalAction(state: DecksState, result: ApproveNarrativeResult): void {
122
+ recordWorkspaceAction(state, {
123
+ type: "narrative.approved",
124
+ actor: "revela-decks",
125
+ inputs: { narrativeId: state.narrative?.id, approvedBy: result.approval?.approvedBy, scope: result.approval?.scope },
126
+ outputs: {
127
+ approved: result.approved,
128
+ skipped: result.skipped,
129
+ reason: result.reason,
130
+ narrativeHash: result.narrativeHash,
131
+ approvalId: result.approval?.id,
132
+ },
133
+ status: result.skipped ? "skipped" : "success",
134
+ summary: result.skipped ? `Skipped narrative approval: ${result.reason ?? "not approved"}.` : `Recorded narrative ${result.approval?.scope ?? "narrative"} approval.`,
135
+ nodeIds: [state.narrative?.id, result.approval?.id].filter((item): item is string => Boolean(item)),
136
+ })
137
+ }
138
+
139
+ function computeNarrativeReadiness(narrative: NarrativeStateV1, state: DecksState, options: ReviewNarrativeOptions): NarrativeReadinessResult {
140
+ const now = options.now ?? new Date().toISOString()
141
+ const narrativeHash = computeNarrativeHash(narrative)
142
+ const issues: NarrativeReadinessIssue[] = []
143
+ const add = (issue: NarrativeReadinessIssue) => issues.push(issue)
144
+
145
+ if (!narrative.audience.primary) add(blocker("missing_audience", "Primary audience is missing.", "Define the primary audience before reviewing the narrative."))
146
+ if (!narrative.audience.beliefBefore || !narrative.audience.beliefAfter) add(blocker("missing_belief_shift", "Audience belief shift is incomplete.", "Add both beliefBefore and beliefAfter so the narrative has a persuasion target."))
147
+ if (!narrative.decision.action) add(blocker("missing_decision", "Decision or action is missing.", "Define the decision, approval, alignment, or action this narrative should drive."))
148
+ if (isDecisionOriented(narrative) && !narrative.thesis?.statement) add(blocker("missing_thesis", "Decision-oriented narrative has no thesis.", "Add a compact thesis that carries the recommendation and evidence boundary."))
149
+
150
+ const centralClaims = narrative.claims.filter((claim) => claim.importance === "central")
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
+ 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
+
154
+ for (const claim of narrative.claims) {
155
+ if (!claim.evidenceRequired) continue
156
+ if (claim.evidenceStatus === "missing" && claim.importance === "central") add(claimIssue("missing_evidence", "blocker", claim, "Central claim lacks evidence.", "Bind source-backed evidence or revise the claim scope before approval."))
157
+ else if (claim.evidenceStatus === "missing") add(claimIssue("missing_evidence", "warning", claim, "Supporting claim lacks evidence.", "Bind evidence or mark the claim as not evidence-required if it is purely framing."))
158
+ else if (claim.evidenceStatus === "weak" || claim.evidenceStatus === "partial") add(claimIssue("weak_evidence", "warning", claim, `Claim evidence is ${claim.evidenceStatus}.`, "Add stronger source trace, caveats, or narrow the claim to the supported scope."))
159
+ if (claim.unsupportedScope) add(claimIssue("unsupported_scope", "warning", claim, "Claim has unsupported scope.", "Keep unsupported scope visible or revise the claim before rendering."))
160
+ }
161
+
162
+ for (const binding of narrative.evidenceBindings) {
163
+ if (binding.unsupportedScope) {
164
+ const claim = narrative.claims.find((item) => item.id === binding.claimId)
165
+ add({
166
+ type: "unsupported_scope",
167
+ severity: "warning",
168
+ message: "Evidence binding records unsupported scope.",
169
+ suggestedAction: "Preserve the unsupported scope caveat or add separate evidence before expanding the claim.",
170
+ claimId: binding.claimId,
171
+ claimText: claim?.text,
172
+ source: binding.source,
173
+ })
174
+ }
175
+ }
176
+
177
+ if (hasRecommendation(narrative) && !hasRiskHandling(narrative)) add(blocker("missing_risk", "Recommendation narrative lacks risk, assumption, or caveat handling.", "Add a risk, assumption, caveat, or tradeoff before approval."))
178
+ for (const objection of narrative.objections) {
179
+ if (objection.priority === "high" && !objection.response) add({
180
+ type: "unhandled_objection",
181
+ severity: "blocker",
182
+ message: "High-priority objection has no response.",
183
+ suggestedAction: "Add a response, evidence boundary, or fallback framing for this objection.",
184
+ claimId: objection.claimId,
185
+ claimText: objection.text,
186
+ })
187
+ }
188
+
189
+ for (const action of state.actions ?? []) {
190
+ if (action.type !== "research.findings_saved") continue
191
+ const path = typeof action.outputs?.path === "string" ? action.outputs.path : undefined
192
+ if (!path) continue
193
+ const attached = Object.values(state.decks ?? {}).some((deck) => deck.researchPlan.some((axis) => axis.findingsFile === path))
194
+ if (!attached) add({
195
+ type: "research_findings_unattached",
196
+ severity: "warning",
197
+ message: `Research findings are saved but not attached: ${path}`,
198
+ suggestedAction: "Attach the findings to a research axis or bind specific evidence before treating them as canonical support.",
199
+ source: path,
200
+ })
201
+ }
202
+
203
+ const approval = approvalState(narrative, narrativeHash)
204
+ if (!approval.current) add({
205
+ type: approval.stale ? "approval_stale" : "approval_missing",
206
+ severity: "warning",
207
+ message: approval.stale ? "Latest narrative approval is stale." : "Narrative is not approved yet.",
208
+ suggestedAction: approval.stale ? "Review changes and approve the current narrative hash." : "Ask the user to approve the narrative before deck handoff.",
209
+ })
210
+
211
+ const blockers = issues.filter((issue) => issue.severity === "blocker").map((issue) => issue.message)
212
+ const warnings = issues.filter((issue) => issue.severity === "warning").map((issue) => issue.message)
213
+ const status = readinessStatus(issues, approval.current)
214
+ return {
215
+ status,
216
+ narrativeHash,
217
+ reviewedAt: now,
218
+ blockers,
219
+ warnings,
220
+ issues,
221
+ approval,
222
+ nextActions: nextActions(issues, approval.current),
223
+ }
224
+ }
225
+
226
+ function readinessStatus(issues: NarrativeReadinessIssue[], approvalCurrent: boolean): NarrativeReadinessStatus {
227
+ const blockers = issues.filter((issue) => issue.severity === "blocker")
228
+ if (blockers.some((issue) => issue.type === "missing_evidence" || issue.type === "unsupported_scope")) return "needs_research"
229
+ if (blockers.length > 0) return "blocked"
230
+ if (issues.some((issue) => issue.type === "missing_audience" || issue.type === "missing_belief_shift" || issue.type === "missing_decision" || issue.type === "missing_thesis" || issue.type === "claim_chain_gap")) return "needs_user_confirmation"
231
+ return approvalCurrent ? "approved" : "ready_for_approval"
232
+ }
233
+
234
+ function narrativeStatusFromReadiness(status: NarrativeReadinessStatus): NarrativeStateV1["status"] {
235
+ if (status === "blocked") return "needs_user_confirmation"
236
+ if (status === "needs_research") return "needs_research"
237
+ return status
238
+ }
239
+
240
+ function approvalState(narrative: NarrativeStateV1, narrativeHash: string): NarrativeApprovalState {
241
+ const narrativeApprovals = [...(narrative.approvals ?? [])].filter((approval) => approval.scope === "narrative" && approval.approvedBy === "user")
242
+ const latest = narrativeApprovals[narrativeApprovals.length - 1]
243
+ return { current: Boolean(latest && latest.narrativeHash === narrativeHash), stale: Boolean(latest && latest.narrativeHash !== narrativeHash), latest }
244
+ }
245
+
246
+ function nextActions(issues: NarrativeReadinessIssue[], approvalCurrent: boolean): string[] {
247
+ const blockers = issues.filter((issue) => issue.severity === "blocker")
248
+ if (blockers.length > 0) return unique(blockers.map((issue) => issue.suggestedAction))
249
+ const approvalIssue = issues.find((issue) => issue.type === "approval_missing" || issue.type === "approval_stale")
250
+ if (!approvalCurrent && approvalIssue) return [approvalIssue.suggestedAction]
251
+ return unique(issues.slice(0, 3).map((issue) => issue.suggestedAction))
252
+ }
253
+
254
+ function isDecisionOriented(narrative: NarrativeStateV1): boolean {
255
+ return Boolean(narrative.decision.action || narrative.decision.decisionType && narrative.decision.decisionType !== "understand" || narrative.claims.some((claim) => claim.kind === "recommendation" || claim.kind === "ask"))
256
+ }
257
+
258
+ function hasRecommendation(narrative: NarrativeStateV1): boolean {
259
+ return narrative.claims.some((claim) => claim.kind === "recommendation" || claim.kind === "ask") || /recommend|approve|invest|prioriti[sz]e|建议|批准|投资|优先/i.test(narrative.decision.action)
260
+ }
261
+
262
+ function hasRiskHandling(narrative: NarrativeStateV1): boolean {
263
+ 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
+ }
265
+
266
+ function blocker(type: NarrativeReadinessIssue["type"], message: string, suggestedAction: string): NarrativeReadinessIssue {
267
+ return { type, severity: "blocker", message, suggestedAction }
268
+ }
269
+
270
+ function warning(type: NarrativeReadinessIssue["type"], message: string, suggestedAction: string): NarrativeReadinessIssue {
271
+ return { type, severity: "warning", message, suggestedAction }
272
+ }
273
+
274
+ function claimIssue(type: NarrativeReadinessIssue["type"], severity: "blocker" | "warning", claim: NarrativeClaim, message: string, suggestedAction: string): NarrativeReadinessIssue {
275
+ return { type, severity, message, suggestedAction, claimId: claim.id, claimText: claim.text }
276
+ }
277
+
278
+ function dedupeApprovals(approvals: NarrativeApproval[]): NarrativeApproval[] {
279
+ return [...new Map(approvals.map((approval) => [approval.id, approval])).values()]
280
+ }
281
+
282
+ function unique(items: string[]): string[] {
283
+ return [...new Set(items.filter(Boolean))]
284
+ }
285
+
286
+ function clean(value: string | undefined): string | undefined {
287
+ const trimmed = value?.trim()
288
+ return trimmed || undefined
289
+ }
@@ -0,0 +1,207 @@
1
+ import { upsertDeck, upsertSlides, type DecksState, type EvidenceRef, type RequiredInputs, type SlideSpec } from "../decks-state"
2
+ import { computeNarrativeHash } from "./hash"
3
+ import { normalizeNarrativeState } from "./normalize"
4
+ import { narrativeToBrief } from "./project-compat"
5
+ import type { NarrativeClaim, NarrativeEvidenceBinding, NarrativeStateV1 } from "./types"
6
+
7
+ export interface CompileDeckPlanOptions {
8
+ now?: string
9
+ }
10
+
11
+ export interface CompileDeckPlanResult {
12
+ compiled: boolean
13
+ skipped: boolean
14
+ reason?: string
15
+ narrativeHash: string
16
+ slideCount: number
17
+ slides: SlideSpec[]
18
+ }
19
+
20
+ export function compileDeckPlanFromNarrative(state: DecksState, options: CompileDeckPlanOptions = {}): { state: DecksState; result: CompileDeckPlanResult } {
21
+ const narrative = normalizeNarrativeState(state)
22
+ const narrativeHash = computeNarrativeHash(narrative)
23
+ const approval = hasCurrentApprovalOrOverride(narrative, narrativeHash)
24
+ if (!approval) {
25
+ return {
26
+ state: { ...state, narrative },
27
+ result: {
28
+ compiled: false,
29
+ skipped: true,
30
+ reason: "narrative must be approved or explicitly overridden before compiling a deck plan",
31
+ narrativeHash,
32
+ slideCount: 0,
33
+ slides: [],
34
+ },
35
+ }
36
+ }
37
+
38
+ const deckKey = state.activeDeck ?? Object.keys(state.decks)[0]
39
+ const deck = deckKey ? state.decks[deckKey] : undefined
40
+ const slug = deck?.slug ?? state.activeDeck ?? "deck"
41
+ const slides = buildSlides(narrative)
42
+ const requiredInputs: Partial<RequiredInputs> = {
43
+ topicClarified: true,
44
+ audienceClarified: Boolean(narrative.audience.primary),
45
+ languageDecided: Boolean(deck?.language),
46
+ sourceMaterialsIdentified: (state.workspace.sourceMaterials ?? []).length > 0 || narrative.evidenceBindings.length > 0,
47
+ researchNeedAssessed: true,
48
+ researchFindingsRead: narrative.evidenceBindings.some((binding) => Boolean(binding.findingsFile)),
49
+ slidePlanConfirmed: false,
50
+ designLayoutsFetched: false,
51
+ }
52
+ let next = upsertDeck({ ...state, narrative }, {
53
+ ...deck,
54
+ slug,
55
+ goal: deck?.goal || narrative.thesis?.statement || narrative.decision.action,
56
+ audience: narrative.audience.primary || deck?.audience,
57
+ outputPath: deck?.outputPath,
58
+ narrativeBrief: narrativeToBrief(narrative),
59
+ requiredInputs: {
60
+ ...(deck?.requiredInputs ?? {}),
61
+ ...requiredInputs,
62
+ } as RequiredInputs,
63
+ writeReadiness: deck?.writeReadiness ?? { status: "blocked" as const, blockers: [] },
64
+ })
65
+ next = upsertSlides(next, slug, slides)
66
+ next.narrative = { ...narrative, updatedAt: options.now ?? narrative.updatedAt }
67
+
68
+ return {
69
+ state: next,
70
+ result: {
71
+ compiled: true,
72
+ skipped: false,
73
+ narrativeHash,
74
+ slideCount: slides.length,
75
+ slides,
76
+ },
77
+ }
78
+ }
79
+
80
+ function buildSlides(narrative: NarrativeStateV1): SlideSpec[] {
81
+ const slides: SlideSpec[] = []
82
+ const centralClaims = narrative.claims.filter((claim) => claim.importance === "central")
83
+ const supportingClaims = narrative.claims.filter((claim) => claim.importance !== "central")
84
+ const evidenceByClaim = new Map<string, NarrativeEvidenceBinding[]>()
85
+ for (const binding of narrative.evidenceBindings) {
86
+ const list = evidenceByClaim.get(binding.claimId) ?? []
87
+ list.push(binding)
88
+ evidenceByClaim.set(binding.claimId, list)
89
+ }
90
+
91
+ slides.push({
92
+ index: slides.length + 1,
93
+ title: "Decision Context",
94
+ purpose: "Frame the audience belief shift and decision required before presenting the recommendation.",
95
+ narrativeRole: "context",
96
+ layout: "cover",
97
+ qa: false,
98
+ components: [],
99
+ content: {
100
+ headline: narrative.thesis?.statement || narrative.decision.action || "Narrative context",
101
+ body: [
102
+ narrative.audience.beliefBefore ? `Before: ${narrative.audience.beliefBefore}` : "Before belief needs confirmation.",
103
+ narrative.audience.beliefAfter ? `After: ${narrative.audience.beliefAfter}` : "After belief needs confirmation.",
104
+ ],
105
+ bullets: narrative.decision.action ? [`Decision: ${narrative.decision.action}`] : [],
106
+ },
107
+ evidence: [],
108
+ status: "planned",
109
+ })
110
+
111
+ for (const claim of centralClaims) slides.push(claimSlide(slides.length + 1, claim, evidenceByClaim.get(claim.id) ?? []))
112
+ if (supportingClaims.length > 0) {
113
+ slides.push({
114
+ index: slides.length + 1,
115
+ title: "Supporting Logic",
116
+ purpose: "Connect supporting claims to the central recommendation without overloading the main proof slides.",
117
+ narrativeRole: "evidence",
118
+ layout: "card-grid",
119
+ qa: true,
120
+ components: ["card"],
121
+ content: {
122
+ headline: "Supporting claims and boundaries",
123
+ bullets: supportingClaims.slice(0, 5).map((claim) => claim.text),
124
+ },
125
+ evidence: supportingClaims.flatMap((claim) => (evidenceByClaim.get(claim.id) ?? []).map(evidenceRefFromBinding)),
126
+ status: "planned",
127
+ })
128
+ }
129
+
130
+ if (narrative.risks.length > 0 || narrative.objections.length > 0) {
131
+ slides.push({
132
+ index: slides.length + 1,
133
+ title: "Risks And Objections",
134
+ purpose: "Make caveats and stakeholder objections visible before asking for a decision.",
135
+ narrativeRole: "risk",
136
+ layout: "two-col",
137
+ qa: true,
138
+ components: ["card"],
139
+ content: {
140
+ headline: "What could break the recommendation",
141
+ bullets: [
142
+ ...narrative.risks.slice(0, 3).map((risk) => risk.mitigation ? `${risk.text} Mitigation: ${risk.mitigation}` : risk.text),
143
+ ...narrative.objections.slice(0, 3).map((objection) => objection.response ? `${objection.text} Response: ${objection.response}` : objection.text),
144
+ ],
145
+ },
146
+ evidence: [],
147
+ status: "planned",
148
+ })
149
+ }
150
+
151
+ slides.push({
152
+ index: slides.length + 1,
153
+ title: "Decision Ask",
154
+ purpose: "Close with the explicit decision or action requested from the audience.",
155
+ narrativeRole: "ask",
156
+ layout: "closing",
157
+ qa: false,
158
+ components: [],
159
+ content: {
160
+ headline: narrative.decision.action || "Confirm the decision",
161
+ bullets: narrative.decision.consequenceOfNoDecision ? [`If no decision: ${narrative.decision.consequenceOfNoDecision}`] : [],
162
+ },
163
+ evidence: [],
164
+ status: "planned",
165
+ })
166
+
167
+ return slides
168
+ }
169
+
170
+ function claimSlide(index: number, claim: NarrativeClaim, bindings: NarrativeEvidenceBinding[]): SlideSpec {
171
+ return {
172
+ index,
173
+ title: titleFromClaim(claim),
174
+ purpose: `Prove or bound this ${claim.importance} ${claim.kind} claim for the audience.`,
175
+ narrativeRole: claim.kind === "risk" || claim.kind === "assumption" ? "risk" : claim.kind === "ask" ? "ask" : claim.kind === "recommendation" ? "recommendation" : "evidence",
176
+ layout: "two-col",
177
+ qa: true,
178
+ components: ["card"],
179
+ content: {
180
+ headline: claim.text,
181
+ bullets: [claim.supportedScope, claim.unsupportedScope ? `Unsupported scope: ${claim.unsupportedScope}` : undefined, ...(claim.caveats ?? [])].filter((item): item is string => Boolean(item)),
182
+ },
183
+ evidence: bindings.map(evidenceRefFromBinding),
184
+ status: "planned",
185
+ }
186
+ }
187
+
188
+ function evidenceRefFromBinding(binding: NarrativeEvidenceBinding): EvidenceRef {
189
+ return {
190
+ source: binding.source,
191
+ quote: binding.quote,
192
+ url: binding.url,
193
+ sourcePath: binding.sourcePath,
194
+ location: binding.location,
195
+ findingsFile: binding.findingsFile,
196
+ caveat: binding.caveat || binding.unsupportedScope,
197
+ }
198
+ }
199
+
200
+ function titleFromClaim(claim: NarrativeClaim): string {
201
+ const words = claim.text.split(/\s+/).filter(Boolean).slice(0, 6).join(" ")
202
+ return words || claim.kind
203
+ }
204
+
205
+ function hasCurrentApprovalOrOverride(narrative: NarrativeStateV1, narrativeHash: string): boolean {
206
+ return narrative.approvals.some((approval) => approval.narrativeHash === narrativeHash && (approval.scope === "narrative" && approval.approvedBy === "user" || approval.scope === "render_override" || approval.approvedBy === "override"))
207
+ }
@@ -0,0 +1,139 @@
1
+ export type NarrativeStatus = "draft" | "needs_research" | "needs_user_confirmation" | "ready_for_approval" | "approved"
2
+
3
+ export type NarrativeClaimKind = "context" | "problem" | "opportunity" | "evidence" | "recommendation" | "risk" | "assumption" | "ask"
4
+
5
+ export type NarrativeEvidenceStatus = "supported" | "partial" | "weak" | "missing" | "not_required"
6
+
7
+ export interface NarrativeStateV1 {
8
+ version: 1
9
+ id: string
10
+ status: NarrativeStatus
11
+ audience: AudienceIntent
12
+ decision: DecisionIntent
13
+ thesis?: NarrativeThesis
14
+ claims: NarrativeClaim[]
15
+ evidenceBindings: NarrativeEvidenceBinding[]
16
+ objections: NarrativeObjection[]
17
+ risks: NarrativeRisk[]
18
+ approvals: NarrativeApproval[]
19
+ updatedAt: string
20
+ }
21
+
22
+ export interface AudienceIntent {
23
+ primary: string
24
+ secondary?: string[]
25
+ beliefBefore: string
26
+ beliefAfter: string
27
+ decisionContext?: string
28
+ successCriteria?: string[]
29
+ }
30
+
31
+ export interface DecisionIntent {
32
+ action: string
33
+ owner?: string
34
+ deadline?: string
35
+ decisionType?: "approve" | "invest" | "prioritize" | "align" | "choose" | "understand" | "other"
36
+ consequenceOfNoDecision?: string
37
+ }
38
+
39
+ export interface NarrativeThesis {
40
+ id: string
41
+ statement: string
42
+ confidence: "high" | "medium" | "low"
43
+ caveat?: string
44
+ }
45
+
46
+ export interface NarrativeClaim {
47
+ id: string
48
+ kind: NarrativeClaimKind
49
+ text: string
50
+ importance: "central" | "supporting" | "background"
51
+ evidenceRequired: boolean
52
+ evidenceStatus: NarrativeEvidenceStatus
53
+ supportedScope?: string
54
+ unsupportedScope?: string
55
+ caveats?: string[]
56
+ }
57
+
58
+ export interface NarrativeEvidenceBinding {
59
+ id: string
60
+ claimId: string
61
+ source: string
62
+ sourcePath?: string
63
+ findingsFile?: string
64
+ quote?: string
65
+ location?: string
66
+ url?: string
67
+ caveat?: string
68
+ supportScope?: string
69
+ unsupportedScope?: string
70
+ strength: "strong" | "partial" | "weak"
71
+ }
72
+
73
+ export interface NarrativeObjection {
74
+ id: string
75
+ text: string
76
+ claimId?: string
77
+ priority: "high" | "medium" | "low"
78
+ response?: string
79
+ }
80
+
81
+ export interface NarrativeRisk {
82
+ id: string
83
+ text: string
84
+ claimId?: string
85
+ severity: "high" | "medium" | "low"
86
+ mitigation?: string
87
+ }
88
+
89
+ export interface NarrativeApproval {
90
+ id: string
91
+ narrativeHash: string
92
+ approvedAt: string
93
+ approvedBy: "user" | "override"
94
+ scope: "narrative" | "render_override"
95
+ note?: string
96
+ }
97
+
98
+ export type NarrativeReadinessStatus = "blocked" | "needs_research" | "needs_user_confirmation" | "ready_for_approval" | "approved"
99
+
100
+ export type NarrativeReadinessIssueType =
101
+ | "missing_audience"
102
+ | "missing_belief_shift"
103
+ | "missing_decision"
104
+ | "missing_thesis"
105
+ | "claim_chain_gap"
106
+ | "missing_evidence"
107
+ | "weak_evidence"
108
+ | "unsupported_scope"
109
+ | "unhandled_objection"
110
+ | "missing_risk"
111
+ | "approval_missing"
112
+ | "approval_stale"
113
+ | "artifact_stale"
114
+ | "research_findings_unattached"
115
+
116
+ export interface NarrativeReadinessIssue {
117
+ type: NarrativeReadinessIssueType
118
+ severity: "blocker" | "warning"
119
+ message: string
120
+ suggestedAction: string
121
+ claimId?: string
122
+ claimText?: string
123
+ source?: string
124
+ }
125
+
126
+ export interface NarrativeReadinessResult {
127
+ status: NarrativeReadinessStatus
128
+ narrativeHash: string
129
+ reviewedAt: string
130
+ blockers: string[]
131
+ warnings: string[]
132
+ issues: NarrativeReadinessIssue[]
133
+ approval?: {
134
+ current: boolean
135
+ stale: boolean
136
+ latest?: NarrativeApproval
137
+ }
138
+ nextActions: string[]
139
+ }