@cyber-dash-tech/revela 0.8.9 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,5 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "fs"
2
+ import { createHash } from "crypto"
2
3
  import { basename, dirname, join, resolve } from "path"
3
4
 
4
5
  export const DECKS_STATE_FILE = "DECKS.json"
@@ -58,6 +59,7 @@ export interface DeckSpec {
58
59
  audience?: string
59
60
  language?: string
60
61
  outputPath: string
62
+ narrativeBrief?: NarrativeBrief
61
63
  theme: {
62
64
  design?: string
63
65
  domain?: string
@@ -73,6 +75,16 @@ export interface DeckSpec {
73
75
  }
74
76
  }
75
77
 
78
+ export interface NarrativeBrief {
79
+ audienceBeliefBefore?: string
80
+ audienceBeliefAfter?: string
81
+ decisionOrAction?: string
82
+ narrativeArc?: string
83
+ keyClaims: string[]
84
+ objections: string[]
85
+ risks: string[]
86
+ }
87
+
76
88
  export interface RequiredInputs {
77
89
  topicClarified: boolean
78
90
  audienceClarified: boolean
@@ -150,6 +162,7 @@ export interface DeckStateReadinessResult {
150
162
  blockers: string[]
151
163
  warnings: string[]
152
164
  issues: ReadinessIssue[]
165
+ evidenceCandidates?: EvidenceBindingCandidate[]
153
166
  }
154
167
 
155
168
  export type ReadinessSeverity = "blocker" | "warning"
@@ -171,10 +184,70 @@ export interface ReadinessIssue {
171
184
  slideIndex?: number
172
185
  slideTitle?: string
173
186
  claimText?: string
187
+ evidenceCandidates?: EvidenceBindingCandidate[]
188
+ evidenceCandidateSearch?: EvidenceCandidateSearchDiagnostic
189
+ }
190
+
191
+ export interface EvidenceBindingCandidate {
192
+ candidateId: string
193
+ slideIndex: number
194
+ slideTitle?: string
195
+ claimText?: string
196
+ source: string
197
+ findingsFile?: string
198
+ sourcePath?: string
199
+ location?: string
200
+ quote?: string
201
+ caveat?: string
202
+ supportScope: string[]
203
+ supportStrength: "partial" | "strong"
204
+ sourceKind?: "researchPlan" | "researchesFallback"
205
+ evidenceDraft?: EvidenceRef
206
+ unsupportedScope?: string[]
207
+ recommendedRewrite?: string
208
+ }
209
+
210
+ export interface EvidenceCandidateSearchDiagnostic {
211
+ queryTokens: string[]
212
+ researchPlanFindingsSearched: string[]
213
+ fallbackResearchFilesSearched: string[]
214
+ fallbackResearchFilesSkipped: string[]
215
+ nearMisses: EvidenceCandidateNearMiss[]
216
+ }
217
+
218
+ export interface EvidenceCandidateNearMiss {
219
+ findingsFile: string
220
+ sourceKind: "researchPlan" | "researchesFallback"
221
+ bestScore: number
222
+ threshold: number
223
+ supportScope: string[]
224
+ quote?: string
225
+ reason: string
226
+ }
227
+
228
+ export interface ApplyEvidenceCandidatesResult {
229
+ applied: AppliedEvidenceCandidate[]
230
+ skipped: SkippedEvidenceCandidate[]
231
+ nextReviewNeeded: boolean
232
+ }
233
+
234
+ export interface AppliedEvidenceCandidate {
235
+ candidateId: string
236
+ slideIndex: number
237
+ evidence: EvidenceRef
238
+ }
239
+
240
+ export interface SkippedEvidenceCandidate {
241
+ candidateId: string
242
+ reason: string
174
243
  }
175
244
 
176
245
  const SOURCE_TRACE_ACTION = "Add slide evidence with source plus source trace such as findingsFile or sourcePath, and quote, location, url, or caveat where available; otherwise reframe the claim as an explicit assumption/opinion."
177
246
 
247
+ export interface ReviewDeckStateOptions {
248
+ workspaceRoot?: string
249
+ }
250
+
178
251
  export function decksStatePath(workspaceRoot: string): string {
179
252
  return join(workspaceRoot, DECKS_STATE_FILE)
180
253
  }
@@ -242,6 +315,7 @@ export function createDeckSpec(input: Partial<DeckSpec> & { slug: string }): Dec
242
315
  audience: input.audience,
243
316
  language: input.language,
244
317
  outputPath: normalizeDeckPath(input.outputPath || `decks/${slug}.html`),
318
+ narrativeBrief: normalizeNarrativeBrief(input.narrativeBrief),
245
319
  theme: input.theme ?? {},
246
320
  requiredInputs: defaultRequiredInputs(input.requiredInputs),
247
321
  researchPlan: input.researchPlan ?? [],
@@ -299,7 +373,65 @@ export function upsertSlides(state: DecksState, slug: string, slides: SlideSpec[
299
373
  return normalized
300
374
  }
301
375
 
302
- export function reviewDeckState(state: DecksState, slug?: string): { state: DecksState; result: DeckStateReadinessResult } {
376
+ export function applyEvidenceCandidates(state: DecksState, candidateIds: string[], options: ReviewDeckStateOptions = {}): { state: DecksState; result: ApplyEvidenceCandidatesResult } {
377
+ const normalized = normalizeDecksState(state)
378
+ const ids = [...new Set(candidateIds.map((id) => id.trim()).filter(Boolean))]
379
+ const applied: AppliedEvidenceCandidate[] = []
380
+ const skipped: SkippedEvidenceCandidate[] = []
381
+ const key = currentDeckKey(normalized)
382
+ const deck = key ? normalized.decks[key] : undefined
383
+
384
+ if (!deck) {
385
+ return {
386
+ state: normalized,
387
+ result: {
388
+ applied,
389
+ skipped: ids.map((candidateId) => ({ candidateId, reason: `No active deck exists in ${DECKS_STATE_FILE}.` })),
390
+ nextReviewNeeded: false,
391
+ },
392
+ }
393
+ }
394
+
395
+ const review = reviewDeckState(normalized, deck.slug, options)
396
+ const byId = new Map((review.result.evidenceCandidates ?? []).map((candidate) => [candidate.candidateId, candidate]))
397
+ const next = normalizeDecksState(review.state)
398
+ const nextDeck = next.decks[deck.slug]
399
+
400
+ for (const candidateId of ids) {
401
+ const candidate = byId.get(candidateId)
402
+ if (!candidate) {
403
+ skipped.push({ candidateId, reason: "Candidate was not found in the current review result." })
404
+ continue
405
+ }
406
+ if (!candidate.evidenceDraft) {
407
+ skipped.push({ candidateId, reason: "Candidate has no evidenceDraft to apply." })
408
+ continue
409
+ }
410
+ const slide = nextDeck.slides.find((item) => item.index === candidate.slideIndex)
411
+ if (!slide) {
412
+ skipped.push({ candidateId, reason: `Slide ${candidate.slideIndex} no longer exists.` })
413
+ continue
414
+ }
415
+ const evidence = cleanEvidenceRef(candidate.evidenceDraft)
416
+ if (slide.evidence.some((item) => sameEvidenceRef(item, evidence))) {
417
+ skipped.push({ candidateId, reason: `Slide ${candidate.slideIndex} already has this evidence record.` })
418
+ continue
419
+ }
420
+ slide.evidence.push(evidence)
421
+ applied.push({ candidateId, slideIndex: candidate.slideIndex, evidence })
422
+ }
423
+
424
+ return {
425
+ state: next,
426
+ result: {
427
+ applied,
428
+ skipped,
429
+ nextReviewNeeded: applied.length > 0,
430
+ },
431
+ }
432
+ }
433
+
434
+ export function reviewDeckState(state: DecksState, slug?: string, options: ReviewDeckStateOptions = {}): { state: DecksState; result: DeckStateReadinessResult } {
303
435
  const normalized = normalizeDecksState(state)
304
436
  const key = normalizeSlug(slug || currentDeckKey(normalized) || "")
305
437
  const deck = key ? normalized.decks[key] : undefined
@@ -323,9 +455,10 @@ export function reviewDeckState(state: DecksState, slug?: string): { state: Deck
323
455
  }
324
456
  }
325
457
 
326
- const issues = computeDeckReadinessIssues(deck, normalized.workspace)
458
+ const issues = computeDeckReadinessIssues(deck, normalized.workspace, options)
327
459
  const blockers = issues.filter((issue) => issue.severity === "blocker").map((issue) => issue.message)
328
460
  const warnings = issues.filter((issue) => issue.severity === "warning").map((issue) => issue.message)
461
+ const evidenceCandidates = issues.flatMap((issue) => issue.evidenceCandidates ?? [])
329
462
  deck.writeReadiness = {
330
463
  status: blockers.length === 0 ? "ready" : "blocked",
331
464
  blockers,
@@ -344,6 +477,7 @@ export function reviewDeckState(state: DecksState, slug?: string): { state: Deck
344
477
  blockers,
345
478
  warnings,
346
479
  issues,
480
+ evidenceCandidates,
347
481
  },
348
482
  }
349
483
  }
@@ -449,6 +583,7 @@ export function buildDecksStatePromptLayer(workspaceRoot: string, maxChars = 140
449
583
  let text = JSON.stringify(compact, null, 2)
450
584
  if (text.length > maxChars) text = text.slice(0, maxChars).trimEnd() + "\n[DECKS.json state truncated for prompt size.]"
451
585
  return `---\n\n# Revela Workspace State From ${DECKS_STATE_FILE}\n\n\`\`\`json\n${text}\n\`\`\`\n\nRules for this state layer:\n- Treat ${DECKS_STATE_FILE} as the source of truth for the single current deck's specs, slide plan, and write readiness.\n- The decks map is compatibility storage; operate only on the current workspace deck.\n- Do not edit ${DECKS_STATE_FILE} directly; use the revela-decks tool.\n- Before writing decks/*.html, the current deck must have writeReadiness.status=ready and a complete slide spec, and its outputPath must match the target file.`
586
+ return `---\n\n# Revela Workspace State From ${DECKS_STATE_FILE}\n\n\`\`\`json\n${text}\n\`\`\`\n\nRules for this state layer:\n- Treat ${DECKS_STATE_FILE} as the source of truth for the single current deck's specs, slide plan, evidence, and write readiness.\n- The decks map is compatibility storage; operate only on the current workspace deck.\n- ${DECKS_STATE_FILE} deck slides use 1-based \`slides[].index\` values. Render every HTML \`<section class="slide">\` with a matching 1-based \`data-slide-index\` attribute, and do not use 0-based \`data-index\` as slide identity.\n- Do not edit ${DECKS_STATE_FILE} directly; use the revela-decks tool.\n- Before writing decks/*.html, the current deck must have writeReadiness.status=ready and a complete slide spec, and its outputPath must match the target file.`
452
587
  }
453
588
 
454
589
  function compactWorkspaceForPrompt(workspace: DecksState["workspace"]): DecksState["workspace"] {
@@ -468,6 +603,7 @@ function compactWorkspaceForPrompt(workspace: DecksState["workspace"]): DecksSta
468
603
  function compactDeckForPrompt(deck: DeckSpec): DeckSpec {
469
604
  return {
470
605
  ...deck,
606
+ narrativeBrief: compactNarrativeBriefForPrompt(deck.narrativeBrief),
471
607
  slides: deck.slides.map((slide) => ({
472
608
  ...slide,
473
609
  content: {
@@ -480,6 +616,19 @@ function compactDeckForPrompt(deck: DeckSpec): DeckSpec {
480
616
  }
481
617
  }
482
618
 
619
+ function compactNarrativeBriefForPrompt(brief: NarrativeBrief | undefined): NarrativeBrief | undefined {
620
+ if (!brief) return undefined
621
+ return {
622
+ audienceBeliefBefore: truncatePromptText(brief.audienceBeliefBefore),
623
+ audienceBeliefAfter: truncatePromptText(brief.audienceBeliefAfter),
624
+ decisionOrAction: truncatePromptText(brief.decisionOrAction),
625
+ narrativeArc: truncatePromptText(brief.narrativeArc),
626
+ keyClaims: brief.keyClaims.map((claim) => truncatePromptText(claim)).filter(Boolean) as string[],
627
+ objections: brief.objections.map((objection) => truncatePromptText(objection)).filter(Boolean) as string[],
628
+ risks: brief.risks.map((risk) => truncatePromptText(risk)).filter(Boolean) as string[],
629
+ }
630
+ }
631
+
483
632
  function compactEvidenceForPrompt(evidence: EvidenceRef): EvidenceRef {
484
633
  return {
485
634
  ...evidence,
@@ -536,7 +685,7 @@ function currentDeckBlocker(state: DecksState): string {
536
685
  return `${DECKS_STATE_FILE} contains multiple deck records and no activeDeck. Select one current deck explicitly or move extra decks to separate workspaces.`
537
686
  }
538
687
 
539
- function computeDeckReadinessIssues(deck: DeckSpec, workspace: DecksState["workspace"]): ReadinessIssue[] {
688
+ function computeDeckReadinessIssues(deck: DeckSpec, workspace: DecksState["workspace"], options: ReviewDeckStateOptions = {}): ReadinessIssue[] {
540
689
  const issues: ReadinessIssue[] = []
541
690
  if (!deck.goal.trim()) issues.push(blockerIssue("missing_slide_spec", "Deck goal is missing", "Set the deck goal through revela-decks upsertDeck."))
542
691
  if (!isDeckHtmlPath(deck.outputPath)) {
@@ -566,12 +715,18 @@ function computeDeckReadinessIssues(deck: DeckSpec, workspace: DecksState["works
566
715
  if (!hasSlideContent(slide)) issues.push(blockerIssue("missing_slide_spec", `Slide ${slide.index} content is missing`, "Add structured headline/body/bullets/data content to the slide spec.", slideRef))
567
716
 
568
717
  const claim = findEvidenceSensitiveClaim(slide)
569
- if (claim && slide.evidence.length === 0) {
718
+ if (claim && slide.evidence.length === 0 && !isNavigationSlide(slide)) {
719
+ const { candidates: evidenceCandidates, search: evidenceCandidateSearch } = findEvidenceBindingCandidates(deck, slide, claim, options)
570
720
  issues.push(blockerIssue(
571
721
  "missing_evidence",
572
722
  `Slide ${slide.index} has an evidence-sensitive claim without evidence: ${claim}`,
573
723
  SOURCE_TRACE_ACTION,
574
- { ...slideRef, claimText: claim },
724
+ {
725
+ ...slideRef,
726
+ claimText: claim,
727
+ evidenceCandidates: evidenceCandidates.length > 0 ? evidenceCandidates : undefined,
728
+ evidenceCandidateSearch,
729
+ },
575
730
  ))
576
731
  } else if (claim && slide.evidence.some((item) => !hasEvidenceDetail(item))) {
577
732
  issues.push(warningIssue(
@@ -598,6 +753,7 @@ function computeDeckReadinessIssues(deck: DeckSpec, workspace: DecksState["works
598
753
  const hasNeededResearch = deck.researchPlan.some((axis) => axis.needed && axis.status !== "skipped")
599
754
  for (const material of workspace.sourceMaterials ?? []) {
600
755
  if (material.status !== "discovered") continue
756
+ if (isIgnorableSourceMaterial(material.path)) continue
601
757
  const message = `Source material ${material.path} has been identified but not extracted, summarized, or researched`
602
758
  if (hasNeededResearch) {
603
759
  issues.push(blockerIssue(
@@ -617,10 +773,348 @@ function computeDeckReadinessIssues(deck: DeckSpec, workspace: DecksState["works
617
773
  return issues
618
774
  }
619
775
 
776
+ function findEvidenceBindingCandidates(deck: DeckSpec, slide: SlideSpec, claimText: string, options: ReviewDeckStateOptions): { candidates: EvidenceBindingCandidate[]; search?: EvidenceCandidateSearchDiagnostic } {
777
+ if (!options.workspaceRoot) return { candidates: [] }
778
+ const queryText = slideSearchText(slide)
779
+ const queryTokens = meaningfulTokens(queryText)
780
+ if (queryTokens.length === 0) return { candidates: [] }
781
+
782
+ const candidates: EvidenceBindingCandidate[] = []
783
+ const search: EvidenceCandidateSearchDiagnostic = {
784
+ queryTokens,
785
+ researchPlanFindingsSearched: [],
786
+ fallbackResearchFilesSearched: [],
787
+ fallbackResearchFilesSkipped: [],
788
+ nearMisses: [],
789
+ }
790
+ const planFindings = new Set<string>()
791
+ for (const axis of deck.researchPlan) {
792
+ if (!axis.needed || (axis.status !== "done" && axis.status !== "read") || !axis.findingsFile?.trim()) continue
793
+ const normalizedFindingsFile = normalizePath(axis.findingsFile)
794
+ planFindings.add(normalizedFindingsFile)
795
+ search.researchPlanFindingsSearched.push(normalizedFindingsFile)
796
+ const findingsPath = safeWorkspacePath(options.workspaceRoot, axis.findingsFile)
797
+ if (!findingsPath || !existsSync(findingsPath)) continue
798
+ const text = readTextPrefix(findingsPath, 100_000)
799
+ if (!text.trim()) continue
800
+ const result = candidateFromFindingsFile({
801
+ slide,
802
+ claimText,
803
+ queryTokens,
804
+ findingsFile: normalizedFindingsFile,
805
+ text,
806
+ sourceKind: "researchPlan",
807
+ })
808
+ if (result.candidate) candidates.push(result.candidate)
809
+ else if (result.nearMiss) search.nearMisses.push(result.nearMiss)
810
+ }
811
+
812
+ if (candidates.length === 0) {
813
+ for (const findingsFile of listWorkspaceResearchFindings(options.workspaceRoot, planFindings)) {
814
+ search.fallbackResearchFilesSearched.push(findingsFile)
815
+ const findingsPath = safeWorkspacePath(options.workspaceRoot, findingsFile)
816
+ if (!findingsPath || !existsSync(findingsPath)) {
817
+ search.fallbackResearchFilesSkipped.push(findingsFile)
818
+ continue
819
+ }
820
+ const text = readTextPrefix(findingsPath, 100_000)
821
+ if (!text.trim()) {
822
+ search.fallbackResearchFilesSkipped.push(findingsFile)
823
+ continue
824
+ }
825
+ const result = candidateFromFindingsFile({
826
+ slide,
827
+ claimText,
828
+ queryTokens,
829
+ findingsFile,
830
+ text,
831
+ sourceKind: "researchesFallback",
832
+ })
833
+ if (result.candidate) candidates.push(result.candidate)
834
+ else if (result.nearMiss) search.nearMisses.push(result.nearMiss)
835
+ }
836
+ }
837
+
838
+ search.nearMisses = search.nearMisses
839
+ .sort((a, b) => b.bestScore - a.bestScore)
840
+ .slice(0, 5)
841
+ return {
842
+ candidates: candidates
843
+ .sort((a, b) => b.supportScope.length - a.supportScope.length)
844
+ .slice(0, 3),
845
+ search,
846
+ }
847
+ }
848
+
849
+ function candidateFromFindingsFile({
850
+ slide,
851
+ claimText,
852
+ queryTokens,
853
+ findingsFile,
854
+ text,
855
+ sourceKind,
856
+ }: {
857
+ slide: SlideSpec
858
+ claimText: string
859
+ queryTokens: string[]
860
+ findingsFile: string
861
+ text: string
862
+ sourceKind: "researchPlan" | "researchesFallback"
863
+ }): { candidate?: EvidenceBindingCandidate; nearMiss?: EvidenceCandidateNearMiss } {
864
+ const lines = extractFindingsLines(text)
865
+ let best: { line: string; scope: string[]; score: number } | undefined
866
+ for (const line of lines) {
867
+ const normalizedLine = line.toLowerCase()
868
+ const scope = queryTokens.filter((token) => normalizedLine.includes(token))
869
+ const phraseScore = importantPhrases(slide).filter((phrase) => normalizedLine.includes(phrase)).length * 2
870
+ const score = scope.length + phraseScore
871
+ if (!best || score > best.score) best = { line, scope, score }
872
+ }
873
+ if (!best || best.score <= 0) return {}
874
+
875
+ const threshold = 2
876
+ const supportScope = [...new Set(best.scope)].slice(0, 8)
877
+ if (best.score < threshold) {
878
+ return {
879
+ nearMiss: {
880
+ findingsFile,
881
+ sourceKind,
882
+ bestScore: best.score,
883
+ threshold,
884
+ supportScope,
885
+ quote: best.line,
886
+ reason: `Best matching line scored ${best.score}, below binding threshold ${threshold}.`,
887
+ },
888
+ }
889
+ }
890
+
891
+ const sourcePath = extractSourcePath(text)
892
+ const coverage = supportScope.length / Math.max(1, queryTokens.length)
893
+ const supportStrength = best.score >= Math.min(5, Math.max(3, queryTokens.length)) && coverage >= 0.5 ? "strong" : "partial"
894
+ const unsupportedScope = unsupportedClaimScope(slide, best.line).slice(0, 5)
895
+ const caveats = []
896
+ if (supportStrength === "partial") {
897
+ caveats.push("Candidate support is partial. Bind only the matched claim scope; do not use it to support unrelated future-state or recommendation claims on the same slide.")
898
+ }
899
+ if (sourceKind === "researchesFallback") {
900
+ caveats.push("Candidate was discovered from researches/ fallback and is not referenced by researchPlan; confirm relevance before binding it into slide evidence.")
901
+ }
902
+ if (unsupportedScope.length > 0) {
903
+ caveats.push(`Unsupported claim scope: ${unsupportedScope.join("; ")}.`)
904
+ }
905
+ const caveat = caveats.length > 0 ? caveats.join(" ") : undefined
906
+ const evidenceDraft: EvidenceRef = {
907
+ source: sourcePath || findingsFile,
908
+ findingsFile,
909
+ sourcePath,
910
+ location: "research findings excerpt",
911
+ quote: best.line,
912
+ caveat,
913
+ }
914
+ return {
915
+ candidate: {
916
+ candidateId: evidenceCandidateId(slide.index, findingsFile, best.line, supportScope),
917
+ slideIndex: slide.index,
918
+ slideTitle: slide.title,
919
+ claimText,
920
+ source: sourcePath || findingsFile,
921
+ findingsFile,
922
+ sourcePath,
923
+ location: "research findings excerpt",
924
+ quote: best.line,
925
+ caveat,
926
+ supportScope,
927
+ supportStrength,
928
+ sourceKind,
929
+ evidenceDraft,
930
+ unsupportedScope,
931
+ recommendedRewrite: recommendedEvidenceRewrite(supportScope, unsupportedScope),
932
+ },
933
+ }
934
+ }
935
+
936
+ function unsupportedClaimScope(slide: SlideSpec, supportedLine: string): string[] {
937
+ const normalizedLine = supportedLine.toLowerCase()
938
+ const phrases = [slide.purpose, slide.content?.headline, ...(slide.content?.bullets ?? [])]
939
+ .map((item) => cleanMarkdownText(item ?? ""))
940
+ .filter((item) => item.length >= 8)
941
+
942
+ return [...new Set(phrases.filter((phrase) => {
943
+ const normalizedPhrase = phrase.toLowerCase()
944
+ return FUTURE_STATE_SCOPE_PATTERN.test(normalizedPhrase) && !normalizedLine.includes(normalizedPhrase)
945
+ }))]
946
+ }
947
+
948
+ function recommendedEvidenceRewrite(supportScope: string[], unsupportedScope: string[]): string | undefined {
949
+ if (unsupportedScope.length === 0) return undefined
950
+ const supported = supportScope.length > 0 ? supportScope.join(", ") : "the quoted current-state support"
951
+ return `Bind this evidence only to the supported scope (${supported}). Reframe unsupported scope as internal synthesis, target-state hypothesis, or a separately sourced claim: ${unsupportedScope.join("; ")}.`
952
+ }
953
+
954
+ function evidenceCandidateId(slideIndex: number, findingsFile: string, quote: string, supportScope: string[]): string {
955
+ const hash = createHash("sha1")
956
+ .update(JSON.stringify({ slideIndex, findingsFile, quote, supportScope }))
957
+ .digest("hex")
958
+ .slice(0, 8)
959
+ return `s${slideIndex}-${hash}`
960
+ }
961
+
962
+ function cleanEvidenceRef(evidence: EvidenceRef): EvidenceRef {
963
+ const cleaned: EvidenceRef = { source: cleanMarkdownText(evidence.source) }
964
+ for (const key of ["quote", "page", "url", "sourcePath", "location", "findingsFile", "caveat", "extractedTextPath", "extractedManifestPath"] as const) {
965
+ const value = cleanOptionalText(evidence[key])
966
+ if (value) cleaned[key] = value
967
+ }
968
+ return cleaned
969
+ }
970
+
971
+ function sameEvidenceRef(a: EvidenceRef, b: EvidenceRef): boolean {
972
+ return normalizeEvidenceComparable(a) === normalizeEvidenceComparable(b)
973
+ }
974
+
975
+ function normalizeEvidenceComparable(evidence: EvidenceRef): string {
976
+ const cleaned = cleanEvidenceRef(evidence)
977
+ return JSON.stringify({
978
+ source: cleaned.source,
979
+ findingsFile: cleaned.findingsFile,
980
+ sourcePath: cleaned.sourcePath,
981
+ quote: cleaned.quote,
982
+ location: cleaned.location,
983
+ caveat: cleaned.caveat,
984
+ })
985
+ }
986
+
987
+ function listWorkspaceResearchFindings(workspaceRoot: string, exclude: Set<string>): string[] {
988
+ const researchRoot = safeWorkspacePath(workspaceRoot, "researches")
989
+ if (!researchRoot || !existsSync(researchRoot)) return []
990
+ const files: string[] = []
991
+ collectMarkdownFiles(researchRoot, files, 0)
992
+ return files
993
+ .map((file) => normalizePath(file.slice(resolve(workspaceRoot).length + 1)))
994
+ .filter((file) => file.startsWith("researches/") && !exclude.has(file))
995
+ .slice(0, 50)
996
+ }
997
+
998
+ function collectMarkdownFiles(dir: string, output: string[], depth: number): void {
999
+ if (depth > 4) return
1000
+ let entries: string[]
1001
+ try {
1002
+ entries = readdirSync(dir)
1003
+ } catch {
1004
+ return
1005
+ }
1006
+ for (const entry of entries) {
1007
+ const fullPath = join(dir, entry)
1008
+ let stat
1009
+ try {
1010
+ stat = statSync(fullPath)
1011
+ } catch {
1012
+ continue
1013
+ }
1014
+ if (stat.isDirectory()) {
1015
+ collectMarkdownFiles(fullPath, output, depth + 1)
1016
+ } else if (stat.isFile() && entry.endsWith(".md")) {
1017
+ output.push(fullPath)
1018
+ }
1019
+ }
1020
+ }
1021
+
1022
+ function safeWorkspacePath(workspaceRoot: string, relativePath: string): string | undefined {
1023
+ const root = resolve(workspaceRoot)
1024
+ const target = resolve(root, relativePath)
1025
+ if (target !== root && !target.startsWith(root + "/")) return undefined
1026
+ return target
1027
+ }
1028
+
1029
+ function readTextPrefix(filePath: string, maxChars: number): string {
1030
+ try {
1031
+ return readFileSync(filePath, "utf-8").slice(0, maxChars)
1032
+ } catch {
1033
+ return ""
1034
+ }
1035
+ }
1036
+
1037
+ function extractFindingsLines(text: string): string[] {
1038
+ return text
1039
+ .split(/\r?\n/)
1040
+ .map((line) => line.replace(/^\s*(?:[-*+]\s+|\d+\.\s+|>\s*)/, "").trim())
1041
+ .filter((line) => line.length >= 24 && !/^---$/.test(line) && !/^#/.test(line))
1042
+ .slice(0, 300)
1043
+ }
1044
+
1045
+ function extractSourcePath(text: string): string | undefined {
1046
+ const sourceLine = text.split(/\r?\n/).find((line) => /^\s*(?:[-*+]\s*)?(?:source|来源)\s*:/i.test(line))
1047
+ if (!sourceLine) return undefined
1048
+ return cleanMarkdownText(sourceLine.replace(/^\s*(?:[-*+]\s*)?(?:source|来源)\s*:\s*/i, "")) || undefined
1049
+ }
1050
+
1051
+ function meaningfulTokens(text: string): string[] {
1052
+ const normalized = text.toLowerCase().replace(/[^a-z0-9\u4e00-\u9fa5]+/g, " ")
1053
+ const latin = normalized
1054
+ .split(/\s+/)
1055
+ .map((token) => token.trim())
1056
+ .filter((token) => token.length >= 4 && !EVIDENCE_BINDING_STOPWORDS.has(token))
1057
+ const chinese = Array.from(normalized.matchAll(/[\u4e00-\u9fa5]{2,}/g), (match) => match[0])
1058
+ return [...new Set([...latin, ...chinese])].slice(0, 40)
1059
+ }
1060
+
1061
+ function importantPhrases(slide: SlideSpec): string[] {
1062
+ return [slide.title, slide.content?.headline, ...(slide.content?.bullets ?? [])]
1063
+ .map((item) => item?.trim().toLowerCase())
1064
+ .filter((item): item is string => Boolean(item && item.length >= 8 && item.length <= 80))
1065
+ }
1066
+
620
1067
  function computeNarrativeReadinessIssues(deck: DeckSpec): ReadinessIssue[] {
621
1068
  const issues: ReadinessIssue[] = []
622
1069
  const slides = deck.slides.filter((slide) => slide.index > 0).sort((a, b) => a.index - b.index)
623
1070
  if (slides.length === 0) return issues
1071
+ const decisionOriented = isDecisionOrientedDeck(deck, slides)
1072
+
1073
+ if (decisionOriented && !hasNarrativeBriefContent(deck.narrativeBrief)) {
1074
+ issues.push(warningIssue(
1075
+ "narrative_gap",
1076
+ "Narrative brief is missing for a decision-oriented deck",
1077
+ "Add a 0.9 narrativeBrief with audience belief before/after, decisionOrAction, narrativeArc, keyClaims, objections, and risks so review can compile the deck against explicit story intent.",
1078
+ ))
1079
+ }
1080
+
1081
+ if (decisionOriented && deck.narrativeBrief) {
1082
+ if (!deck.narrativeBrief.audienceBeliefAfter?.trim()) {
1083
+ issues.push(warningIssue(
1084
+ "narrative_gap",
1085
+ "Narrative brief is missing the intended audience belief after the deck",
1086
+ "Set narrativeBrief.audienceBeliefAfter so the deck can be reviewed against the belief change it is meant to create.",
1087
+ ))
1088
+ }
1089
+ if (!deck.narrativeBrief.decisionOrAction?.trim() && slides.some((slide) => isAskSlide(slide) || isRecommendationSlide(slide))) {
1090
+ issues.push(warningIssue(
1091
+ "narrative_gap",
1092
+ "Narrative brief is missing the decision or action the deck should drive",
1093
+ "Set narrativeBrief.decisionOrAction so recommendation and ask slides have an explicit communication target.",
1094
+ ))
1095
+ }
1096
+ if (deck.narrativeBrief.keyClaims.length === 0 && slides.some(isRecommendationSlide)) {
1097
+ issues.push(warningIssue(
1098
+ "narrative_gap",
1099
+ "Narrative brief has no key claims for the recommendation to prove",
1100
+ "Add narrativeBrief.keyClaims that capture the main claims the deck must support with slide evidence.",
1101
+ ))
1102
+ }
1103
+ if (deck.narrativeBrief.objections.length === 0 && slides.some((slide) => isAskSlide(slide) || isRecommendationSlide(slide))) {
1104
+ issues.push(warningIssue(
1105
+ "narrative_gap",
1106
+ "Narrative brief has no stakeholder objections to handle",
1107
+ "Add likely objections or questions to narrativeBrief.objections so the story can anticipate resistance before the ask.",
1108
+ ))
1109
+ }
1110
+ if (deck.narrativeBrief.risks.length === 0 && slides.some(isRecommendationSlide)) {
1111
+ issues.push(warningIssue(
1112
+ "narrative_gap",
1113
+ "Narrative brief has no risks, assumptions, or tradeoffs for the recommendation",
1114
+ "Add risks, assumptions, caveats, or tradeoffs to narrativeBrief.risks so the recommendation does not overclaim certainty.",
1115
+ ))
1116
+ }
1117
+ }
624
1118
 
625
1119
  if (slides.length >= 4 && slides.every((slide) => !slide.narrativeRole)) {
626
1120
  issues.push(warningIssue(
@@ -700,6 +1194,26 @@ function computeNarrativeReadinessIssues(deck: DeckSpec): ReadinessIssue[] {
700
1194
  return issues
701
1195
  }
702
1196
 
1197
+ function isDecisionOrientedDeck(deck: DeckSpec, slides: SlideSpec[]): boolean {
1198
+ return Boolean(
1199
+ deck.narrativeBrief?.decisionOrAction?.trim() ||
1200
+ slides.some((slide) => isAskSlide(slide) || isRecommendationSlide(slide)) ||
1201
+ /\b(decision|approve|approval|recommend(?:ation)?|go\/?no-go|action)\b|决策|批准|建议|行动/.test(deck.goal.toLowerCase()),
1202
+ )
1203
+ }
1204
+
1205
+ function hasNarrativeBriefContent(brief: NarrativeBrief | undefined): boolean {
1206
+ return Boolean(
1207
+ brief?.audienceBeliefBefore?.trim() ||
1208
+ brief?.audienceBeliefAfter?.trim() ||
1209
+ brief?.decisionOrAction?.trim() ||
1210
+ brief?.narrativeArc?.trim() ||
1211
+ brief?.keyClaims.length ||
1212
+ brief?.objections.length ||
1213
+ brief?.risks.length,
1214
+ )
1215
+ }
1216
+
703
1217
  function blockerIssue(type: ReadinessIssueType, message: string, suggestedAction: string, extra: Partial<ReadinessIssue> = {}): ReadinessIssue {
704
1218
  return { type, severity: "blocker", message, suggestedAction, ...extra }
705
1219
  }
@@ -749,6 +1263,25 @@ function hasClearEnding(slides: SlideSpec[]): boolean {
749
1263
  return finalSlides.some((slide) => slide.narrativeRole === "recommendation" || slide.narrativeRole === "ask" || slide.narrativeRole === "close" || /\b(so what|takeaway|recommend(?:ation)?|decision|ask|next step|conclusion|close)\b|结论|建议|决策|请求|下一步|收尾|总结/.test(slideSearchText(slide)))
750
1264
  }
751
1265
 
1266
+ function isNavigationSlide(slide: SlideSpec): boolean {
1267
+ const text = slideSearchText(slide)
1268
+ return slide.layout === "toc" || /\b(table of contents|agenda|contents|outline|section guide)\b|目录|议程|大纲/.test(text)
1269
+ }
1270
+
1271
+ function isIgnorableSourceMaterial(path: string): boolean {
1272
+ const normalized = normalizePath(path).replace(/^\.\//, "")
1273
+ const name = basename(normalized)
1274
+ return Boolean(
1275
+ name.startsWith("~$") ||
1276
+ /^(AGENTS|README(?:\.zh-CN)?|DECKS)\.md$/.test(name) ||
1277
+ name === DECKS_STATE_FILE ||
1278
+ normalized.startsWith("decks/") ||
1279
+ normalized.startsWith("researches/") ||
1280
+ normalized.startsWith("assets/") ||
1281
+ normalized.startsWith(".opencode/"),
1282
+ )
1283
+ }
1284
+
752
1285
  function slideSearchText(slide: SlideSpec): string {
753
1286
  return [
754
1287
  slide.title,
@@ -825,6 +1358,36 @@ const EVIDENCE_SENSITIVE_TERMS = [
825
1358
  /可扩展/,
826
1359
  ]
827
1360
 
1361
+ const FUTURE_STATE_SCOPE_PATTERN = /\b(20\d{2}|future|target-state|end state|roadmap|pathway|architecture|capabilit(?:y|ies)|autonomy|autonomous|self-organizing|ecosystem|ai manufacturing os|ai brain|digital workers|closed-loop|orchestration)\b|未来|目标态|路线图|架构|能力|自治|自组织|生态|智能体|闭环/
1362
+
1363
+ const EVIDENCE_BINDING_STOPWORDS = new Set([
1364
+ "about",
1365
+ "after",
1366
+ "again",
1367
+ "also",
1368
+ "before",
1369
+ "between",
1370
+ "could",
1371
+ "deck",
1372
+ "from",
1373
+ "have",
1374
+ "into",
1375
+ "must",
1376
+ "only",
1377
+ "over",
1378
+ "page",
1379
+ "roadmap",
1380
+ "show",
1381
+ "slide",
1382
+ "that",
1383
+ "their",
1384
+ "there",
1385
+ "this",
1386
+ "through",
1387
+ "with",
1388
+ "would",
1389
+ ])
1390
+
828
1391
  function normalizeSlides(slides: SlideSpec[]): SlideSpec[] {
829
1392
  return slides
830
1393
  .map((slide) => ({
@@ -839,6 +1402,38 @@ function normalizeSlides(slides: SlideSpec[]): SlideSpec[] {
839
1402
  .sort((a, b) => a.index - b.index)
840
1403
  }
841
1404
 
1405
+ function normalizeNarrativeBrief(brief: NarrativeBrief | undefined): NarrativeBrief | undefined {
1406
+ if (!brief) return undefined
1407
+ const normalized: NarrativeBrief = {
1408
+ audienceBeliefBefore: cleanOptionalText(brief.audienceBeliefBefore),
1409
+ audienceBeliefAfter: cleanOptionalText(brief.audienceBeliefAfter),
1410
+ decisionOrAction: cleanOptionalText(brief.decisionOrAction),
1411
+ narrativeArc: cleanOptionalText(brief.narrativeArc),
1412
+ keyClaims: normalizeTextList(brief.keyClaims),
1413
+ objections: normalizeTextList(brief.objections),
1414
+ risks: normalizeTextList(brief.risks),
1415
+ }
1416
+ if (
1417
+ !normalized.audienceBeliefBefore &&
1418
+ !normalized.audienceBeliefAfter &&
1419
+ !normalized.decisionOrAction &&
1420
+ !normalized.narrativeArc &&
1421
+ normalized.keyClaims.length === 0 &&
1422
+ normalized.objections.length === 0 &&
1423
+ normalized.risks.length === 0
1424
+ ) return undefined
1425
+ return normalized
1426
+ }
1427
+
1428
+ function normalizeTextList(values: string[] | undefined): string[] {
1429
+ return [...new Set((values ?? []).map(cleanOptionalText).filter(Boolean) as string[])]
1430
+ }
1431
+
1432
+ function cleanOptionalText(value: string | undefined): string | undefined {
1433
+ const text = String(value ?? "").trim()
1434
+ return text || undefined
1435
+ }
1436
+
842
1437
  function hasSlideContent(slide: SlideSpec): boolean {
843
1438
  const content = slide.content ?? {}
844
1439
  return Boolean(