@cyber-dash-tech/revela 0.10.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.
- package/README.md +54 -28
- package/README.zh-CN.md +54 -28
- package/lib/commands/designs.ts +2 -2
- package/lib/commands/domains.ts +2 -2
- package/lib/commands/enable.ts +19 -19
- package/lib/commands/help.ts +5 -3
- package/lib/commands/init.ts +30 -19
- package/lib/commands/inspect.ts +1 -1
- package/lib/commands/pdf.ts +33 -5
- package/lib/commands/pptx.ts +14 -9
- package/lib/commands/refine.ts +1 -1
- package/lib/commands/review.ts +115 -1
- package/lib/deck-html/contract.ts +252 -0
- package/lib/decks-state.ts +111 -28
- package/lib/document-materials/extract.ts +20 -0
- package/lib/edit/resolve-deck.ts +13 -2
- package/lib/inspect/open.ts +3 -1
- package/lib/narrative-state/hash.ts +52 -0
- package/lib/narrative-state/normalize.ts +307 -0
- package/lib/narrative-state/project-compat.ts +14 -0
- package/lib/narrative-state/readiness.ts +289 -0
- package/lib/narrative-state/render-plan.ts +207 -0
- package/lib/narrative-state/types.ts +139 -0
- package/lib/prompt-builder.ts +59 -26
- package/lib/qa/export-gate.ts +8 -1
- package/lib/refine/open.ts +3 -1
- package/lib/workspace-state/actions.ts +71 -0
- package/lib/workspace-state/compat.ts +10 -0
- package/lib/workspace-state/evidence-status.ts +267 -0
- package/lib/workspace-state/graph.ts +544 -0
- package/lib/workspace-state/render-targets.ts +182 -0
- package/lib/workspace-state/rendered-artifacts.ts +43 -0
- package/lib/workspace-state/repository.ts +43 -0
- package/lib/workspace-state/research-attachments.ts +130 -0
- package/lib/workspace-state/review-snapshots.ts +127 -0
- package/lib/workspace-state/types.ts +122 -0
- package/package.json +1 -1
- package/plugin.ts +53 -3
- package/skill/NARRATIVE_SKILL.md +64 -0
- package/tools/decks.ts +233 -6
- package/tools/pdf.ts +9 -1
- package/tools/pptx.ts +10 -0
- package/tools/research-save.ts +15 -0
- package/tools/workspace-scan.ts +29 -1
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import type { DeckSpec, DecksState, EvidenceRef, SlideSpec } from "../decks-state"
|
|
2
|
+
import {
|
|
3
|
+
stableClaimId,
|
|
4
|
+
stableEvidenceId,
|
|
5
|
+
stableNarrativeId,
|
|
6
|
+
stableObjectionId,
|
|
7
|
+
stableRiskId,
|
|
8
|
+
} from "./hash"
|
|
9
|
+
import type {
|
|
10
|
+
AudienceIntent,
|
|
11
|
+
DecisionIntent,
|
|
12
|
+
NarrativeClaim,
|
|
13
|
+
NarrativeClaimKind,
|
|
14
|
+
NarrativeEvidenceBinding,
|
|
15
|
+
NarrativeEvidenceStatus,
|
|
16
|
+
NarrativeObjection,
|
|
17
|
+
NarrativeRisk,
|
|
18
|
+
NarrativeStateV1,
|
|
19
|
+
NarrativeStatus,
|
|
20
|
+
NarrativeThesis,
|
|
21
|
+
} from "./types"
|
|
22
|
+
|
|
23
|
+
const MIGRATED_UPDATED_AT = "1970-01-01T00:00:00.000Z"
|
|
24
|
+
|
|
25
|
+
export function normalizeCanonicalNarrativeState(input: Partial<NarrativeStateV1> | undefined, seed = "workspace"): NarrativeStateV1 | undefined {
|
|
26
|
+
if (!input) return undefined
|
|
27
|
+
const id = input.id?.trim() || stableNarrativeId(seed)
|
|
28
|
+
const claims = dedupeById((input.claims ?? []).map(normalizeClaim).filter((claim): claim is NarrativeClaim => Boolean(claim)))
|
|
29
|
+
const evidenceBindings = dedupeById((input.evidenceBindings ?? []).map((binding) => normalizeEvidenceBinding(binding, claims)).filter((binding): binding is NarrativeEvidenceBinding => Boolean(binding)))
|
|
30
|
+
return {
|
|
31
|
+
version: 1,
|
|
32
|
+
id,
|
|
33
|
+
status: normalizeStatus(input.status),
|
|
34
|
+
audience: normalizeAudience(input.audience),
|
|
35
|
+
decision: normalizeDecision(input.decision),
|
|
36
|
+
thesis: normalizeThesis(input.thesis),
|
|
37
|
+
claims: claims.map((claim) => ({ ...claim, evidenceStatus: evidenceStatusForClaim(claim, evidenceBindings) })),
|
|
38
|
+
evidenceBindings,
|
|
39
|
+
objections: dedupeById((input.objections ?? []).map(normalizeObjection).filter((objection): objection is NarrativeObjection => Boolean(objection))),
|
|
40
|
+
risks: dedupeById((input.risks ?? []).map(normalizeRisk).filter((risk): risk is NarrativeRisk => Boolean(risk))),
|
|
41
|
+
approvals: input.approvals ?? [],
|
|
42
|
+
updatedAt: input.updatedAt || MIGRATED_UPDATED_AT,
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function normalizeNarrativeState(state: DecksState): NarrativeStateV1 {
|
|
47
|
+
const deck = activeDeck(state)
|
|
48
|
+
const existing = normalizeCanonicalNarrativeState(state.narrative, deck?.slug ?? state.activeDeck ?? "workspace")
|
|
49
|
+
if (existing && hasCanonicalNarrativeContent(existing)) return existing
|
|
50
|
+
return migrateDeckNarrative(deck, state.activeDeck ?? "workspace")
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function migrateDeckNarrative(deck: DeckSpec | undefined, seed: string): NarrativeStateV1 {
|
|
54
|
+
const brief = deck?.narrativeBrief
|
|
55
|
+
const id = stableNarrativeId(deck?.slug || seed)
|
|
56
|
+
const claims = migrateClaims(deck)
|
|
57
|
+
const evidenceBindings = migrateEvidenceBindings(deck, claims)
|
|
58
|
+
const withEvidenceStatus = claims.map((claim) => ({ ...claim, evidenceStatus: evidenceStatusForClaim(claim, evidenceBindings) }))
|
|
59
|
+
return {
|
|
60
|
+
version: 1,
|
|
61
|
+
id,
|
|
62
|
+
status: "draft",
|
|
63
|
+
audience: {
|
|
64
|
+
primary: clean(deck?.audience),
|
|
65
|
+
beliefBefore: clean(brief?.audienceBeliefBefore),
|
|
66
|
+
beliefAfter: clean(brief?.audienceBeliefAfter),
|
|
67
|
+
},
|
|
68
|
+
decision: {
|
|
69
|
+
action: clean(brief?.decisionOrAction),
|
|
70
|
+
decisionType: inferDecisionType(brief?.decisionOrAction),
|
|
71
|
+
},
|
|
72
|
+
thesis: migrateThesis(deck),
|
|
73
|
+
claims: withEvidenceStatus,
|
|
74
|
+
evidenceBindings,
|
|
75
|
+
objections: (brief?.objections ?? []).map((text) => ({ id: stableObjectionId(text), text, priority: "medium" as const })),
|
|
76
|
+
risks: (brief?.risks ?? []).map((text) => ({ id: stableRiskId(text), text, severity: "medium" as const })),
|
|
77
|
+
approvals: [],
|
|
78
|
+
updatedAt: MIGRATED_UPDATED_AT,
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function migrateClaims(deck: DeckSpec | undefined): NarrativeClaim[] {
|
|
83
|
+
const claims: NarrativeClaim[] = []
|
|
84
|
+
for (const text of deck?.narrativeBrief?.keyClaims ?? []) {
|
|
85
|
+
pushClaim(claims, {
|
|
86
|
+
id: stableClaimId(text),
|
|
87
|
+
kind: "recommendation",
|
|
88
|
+
text,
|
|
89
|
+
importance: "central",
|
|
90
|
+
evidenceRequired: true,
|
|
91
|
+
evidenceStatus: "missing",
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
for (const slide of deck?.slides ?? []) {
|
|
96
|
+
for (const item of slideClaimTexts(slide)) {
|
|
97
|
+
pushClaim(claims, {
|
|
98
|
+
id: stableClaimId(item.text),
|
|
99
|
+
kind: claimKindFromSlide(slide),
|
|
100
|
+
text: item.text,
|
|
101
|
+
importance: item.origin === "title" || item.origin === "purpose" ? "background" : "supporting",
|
|
102
|
+
evidenceRequired: isEvidenceRequiredText(item.text, slide),
|
|
103
|
+
evidenceStatus: "missing",
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return claims
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function migrateEvidenceBindings(deck: DeckSpec | undefined, claims: NarrativeClaim[]): NarrativeEvidenceBinding[] {
|
|
111
|
+
const bindings: NarrativeEvidenceBinding[] = []
|
|
112
|
+
for (const slide of deck?.slides ?? []) {
|
|
113
|
+
const slideClaims = slideClaimTexts(slide)
|
|
114
|
+
.map((item) => claims.find((claim) => claim.text === item.text))
|
|
115
|
+
.filter((claim): claim is NarrativeClaim => Boolean(claim))
|
|
116
|
+
const targetClaims = slideClaims.length > 0 ? slideClaims : claims.filter((claim) => claim.importance === "central")
|
|
117
|
+
for (const evidence of slide.evidence ?? []) {
|
|
118
|
+
for (const claim of targetClaims) {
|
|
119
|
+
const binding = evidenceToBinding(evidence, claim.id)
|
|
120
|
+
if (binding) pushBinding(bindings, binding)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return bindings
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function evidenceToBinding(evidence: EvidenceRef, claimId: string): NarrativeEvidenceBinding | undefined {
|
|
128
|
+
const source = clean(evidence.source || evidence.sourcePath || evidence.findingsFile || evidence.url)
|
|
129
|
+
if (!source) return undefined
|
|
130
|
+
const seed = [source, evidence.sourcePath, evidence.findingsFile, evidence.quote, evidence.location, evidence.page, evidence.url, evidence.caveat].filter(Boolean).join("|")
|
|
131
|
+
return {
|
|
132
|
+
id: stableEvidenceId(claimId, seed),
|
|
133
|
+
claimId,
|
|
134
|
+
source,
|
|
135
|
+
sourcePath: clean(evidence.sourcePath),
|
|
136
|
+
findingsFile: clean(evidence.findingsFile),
|
|
137
|
+
quote: clean(evidence.quote),
|
|
138
|
+
location: clean(evidence.location || evidence.page),
|
|
139
|
+
url: clean(evidence.url),
|
|
140
|
+
caveat: clean(evidence.caveat),
|
|
141
|
+
strength: evidence.quote || evidence.location || evidence.page || evidence.url || evidence.findingsFile || evidence.sourcePath ? "partial" : "weak",
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function slideClaimTexts(slide: SlideSpec): Array<{ origin: string; text: string }> {
|
|
146
|
+
return [
|
|
147
|
+
{ origin: "title", text: clean(slide.title) },
|
|
148
|
+
{ origin: "purpose", text: clean(slide.purpose) },
|
|
149
|
+
{ origin: "headline", text: clean(slide.content?.headline) },
|
|
150
|
+
...(slide.content?.body ?? []).map((text) => ({ origin: "body", text: clean(text) })),
|
|
151
|
+
...(slide.content?.bullets ?? []).map((text) => ({ origin: "bullet", text: clean(text) })),
|
|
152
|
+
].filter((item) => item.text.length > 0)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function migrateThesis(deck: DeckSpec | undefined): NarrativeThesis | undefined {
|
|
156
|
+
const statement = clean(deck?.narrativeBrief?.narrativeArc) || clean(deck?.goal)
|
|
157
|
+
if (!statement) return undefined
|
|
158
|
+
return { id: `thesis:${stableClaimId(statement).replace(/^claim:/, "")}`, statement, confidence: "medium" }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function normalizeAudience(input: Partial<AudienceIntent> | undefined): AudienceIntent {
|
|
162
|
+
return {
|
|
163
|
+
primary: clean(input?.primary),
|
|
164
|
+
secondary: (input?.secondary ?? []).map(clean).filter(Boolean),
|
|
165
|
+
beliefBefore: clean(input?.beliefBefore),
|
|
166
|
+
beliefAfter: clean(input?.beliefAfter),
|
|
167
|
+
decisionContext: clean(input?.decisionContext),
|
|
168
|
+
successCriteria: (input?.successCriteria ?? []).map(clean).filter(Boolean),
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function normalizeDecision(input: Partial<DecisionIntent> | undefined): DecisionIntent {
|
|
173
|
+
return {
|
|
174
|
+
action: clean(input?.action),
|
|
175
|
+
owner: clean(input?.owner),
|
|
176
|
+
deadline: clean(input?.deadline),
|
|
177
|
+
decisionType: input?.decisionType,
|
|
178
|
+
consequenceOfNoDecision: clean(input?.consequenceOfNoDecision),
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function normalizeThesis(input: Partial<NarrativeThesis> | undefined): NarrativeThesis | undefined {
|
|
183
|
+
const statement = clean(input?.statement)
|
|
184
|
+
if (!statement) return undefined
|
|
185
|
+
return {
|
|
186
|
+
id: input?.id?.trim() || `thesis:${stableClaimId(statement).replace(/^claim:/, "")}`,
|
|
187
|
+
statement,
|
|
188
|
+
confidence: input?.confidence ?? "medium",
|
|
189
|
+
caveat: clean(input?.caveat),
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function normalizeClaim(input: Partial<NarrativeClaim>): NarrativeClaim | undefined {
|
|
194
|
+
const text = clean(input.text)
|
|
195
|
+
if (!text) return undefined
|
|
196
|
+
return {
|
|
197
|
+
id: input.id?.trim() || stableClaimId(text),
|
|
198
|
+
kind: input.kind ?? "evidence",
|
|
199
|
+
text,
|
|
200
|
+
importance: input.importance ?? "supporting",
|
|
201
|
+
evidenceRequired: input.evidenceRequired ?? true,
|
|
202
|
+
evidenceStatus: input.evidenceStatus ?? "missing",
|
|
203
|
+
supportedScope: clean(input.supportedScope),
|
|
204
|
+
unsupportedScope: clean(input.unsupportedScope),
|
|
205
|
+
caveats: (input.caveats ?? []).map(clean).filter(Boolean),
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function normalizeEvidenceBinding(input: Partial<NarrativeEvidenceBinding>, claims: NarrativeClaim[]): NarrativeEvidenceBinding | undefined {
|
|
210
|
+
const source = clean(input.source || input.sourcePath || input.findingsFile || input.url)
|
|
211
|
+
const claimId = clean(input.claimId)
|
|
212
|
+
if (!source || !claimId || !claims.some((claim) => claim.id === claimId)) return undefined
|
|
213
|
+
const seed = [source, input.sourcePath, input.findingsFile, input.quote, input.location, input.url, input.caveat].filter(Boolean).join("|")
|
|
214
|
+
return {
|
|
215
|
+
id: input.id?.trim() || stableEvidenceId(claimId, seed),
|
|
216
|
+
claimId,
|
|
217
|
+
source,
|
|
218
|
+
sourcePath: clean(input.sourcePath),
|
|
219
|
+
findingsFile: clean(input.findingsFile),
|
|
220
|
+
quote: clean(input.quote),
|
|
221
|
+
location: clean(input.location),
|
|
222
|
+
url: clean(input.url),
|
|
223
|
+
caveat: clean(input.caveat),
|
|
224
|
+
supportScope: clean(input.supportScope),
|
|
225
|
+
unsupportedScope: clean(input.unsupportedScope),
|
|
226
|
+
strength: input.strength ?? "weak",
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function normalizeObjection(input: Partial<NarrativeObjection>): NarrativeObjection | undefined {
|
|
231
|
+
const text = clean(input.text)
|
|
232
|
+
if (!text) return undefined
|
|
233
|
+
return { id: input.id?.trim() || stableObjectionId(text), text, claimId: clean(input.claimId), priority: input.priority ?? "medium", response: clean(input.response) }
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function normalizeRisk(input: Partial<NarrativeRisk>): NarrativeRisk | undefined {
|
|
237
|
+
const text = clean(input.text)
|
|
238
|
+
if (!text) return undefined
|
|
239
|
+
return { id: input.id?.trim() || stableRiskId(text), text, claimId: clean(input.claimId), severity: input.severity ?? "medium", mitigation: clean(input.mitigation) }
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function evidenceStatusForClaim(claim: NarrativeClaim, bindings: NarrativeEvidenceBinding[]): NarrativeEvidenceStatus {
|
|
243
|
+
if (!claim.evidenceRequired) return "not_required"
|
|
244
|
+
const claimBindings = bindings.filter((binding) => binding.claimId === claim.id)
|
|
245
|
+
if (claimBindings.some((binding) => binding.strength === "strong")) return "supported"
|
|
246
|
+
if (claimBindings.some((binding) => binding.strength === "partial")) return "partial"
|
|
247
|
+
if (claimBindings.some((binding) => binding.strength === "weak")) return "weak"
|
|
248
|
+
return "missing"
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function claimKindFromSlide(slide: SlideSpec): NarrativeClaimKind {
|
|
252
|
+
if (slide.narrativeRole === "recommendation") return "recommendation"
|
|
253
|
+
if (slide.narrativeRole === "risk") return "risk"
|
|
254
|
+
if (slide.narrativeRole === "ask") return "ask"
|
|
255
|
+
if (slide.narrativeRole === "tension") return "problem"
|
|
256
|
+
if (slide.narrativeRole === "context") return "context"
|
|
257
|
+
return "evidence"
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function isEvidenceRequiredText(text: string, slide: SlideSpec): boolean {
|
|
261
|
+
if (slide.narrativeRole === "ask" || slide.narrativeRole === "close" || slide.narrativeRole === "appendix") return false
|
|
262
|
+
return /\d|%|\$|market|growth|cagr|tam|risk|recommend|approve|should|must|increase|decrease|增长|市场|风险|建议|投资|批准/i.test(text)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function inferDecisionType(action: string | undefined): DecisionIntent["decisionType"] {
|
|
266
|
+
const text = clean(action).toLowerCase()
|
|
267
|
+
if (!text) return undefined
|
|
268
|
+
if (/approve|批准/.test(text)) return "approve"
|
|
269
|
+
if (/invest|投资/.test(text)) return "invest"
|
|
270
|
+
if (/prioriti[sz]e|优先/.test(text)) return "prioritize"
|
|
271
|
+
if (/align|共识/.test(text)) return "align"
|
|
272
|
+
if (/choose|select|选择/.test(text)) return "choose"
|
|
273
|
+
if (/understand|理解/.test(text)) return "understand"
|
|
274
|
+
return "other"
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function normalizeStatus(status: NarrativeStatus | undefined): NarrativeStatus {
|
|
278
|
+
return status ?? "draft"
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function activeDeck(state: DecksState): DeckSpec | undefined {
|
|
282
|
+
if (state.activeDeck && state.decks[state.activeDeck]) return state.decks[state.activeDeck]
|
|
283
|
+
const keys = Object.keys(state.decks ?? {})
|
|
284
|
+
return keys.length === 1 ? state.decks[keys[0]] : undefined
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function hasCanonicalNarrativeContent(narrative: NarrativeStateV1): boolean {
|
|
288
|
+
return Boolean(narrative.audience.primary || narrative.audience.beliefBefore || narrative.audience.beliefAfter || narrative.decision.action || narrative.thesis || narrative.claims.length > 0)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function pushClaim(claims: NarrativeClaim[], claim: NarrativeClaim): void {
|
|
292
|
+
if (claims.some((item) => item.text === claim.text)) return
|
|
293
|
+
claims.push(claim)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function pushBinding(bindings: NarrativeEvidenceBinding[], binding: NarrativeEvidenceBinding): void {
|
|
297
|
+
if (bindings.some((item) => item.id === binding.id)) return
|
|
298
|
+
bindings.push(binding)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function dedupeById<T extends { id: string }>(items: T[]): T[] {
|
|
302
|
+
return [...new Map(items.map((item) => [item.id, item])).values()]
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function clean(value: string | undefined): string {
|
|
306
|
+
return value?.trim() ?? ""
|
|
307
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { NarrativeBrief } from "../decks-state"
|
|
2
|
+
import type { NarrativeStateV1 } from "./types"
|
|
3
|
+
|
|
4
|
+
export function narrativeToBrief(narrative: NarrativeStateV1): NarrativeBrief {
|
|
5
|
+
return {
|
|
6
|
+
audienceBeliefBefore: narrative.audience.beliefBefore || undefined,
|
|
7
|
+
audienceBeliefAfter: narrative.audience.beliefAfter || undefined,
|
|
8
|
+
decisionOrAction: narrative.decision.action || undefined,
|
|
9
|
+
narrativeArc: narrative.thesis?.statement,
|
|
10
|
+
keyClaims: narrative.claims.filter((claim) => claim.importance === "central").map((claim) => claim.text),
|
|
11
|
+
objections: narrative.objections.map((objection) => objection.text),
|
|
12
|
+
risks: narrative.risks.map((risk) => risk.text),
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -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
|
+
}
|