@cyber-dash-tech/revela 0.17.0 → 0.17.2

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.
@@ -328,6 +328,8 @@ export interface ConfirmDeckPlanOptions {
328
328
  approvedBy?: "user"
329
329
  note?: string
330
330
  now?: string
331
+ approvedAt?: string
332
+ planHash?: string
331
333
  }
332
334
 
333
335
  export interface ConfirmDeckPlanResult {
@@ -441,12 +443,12 @@ export function deckPlanHash(slides: SlideSpec[]): string {
441
443
  }
442
444
 
443
445
  export function currentDeckPlanReviewStatus(deck: DeckSpec, narrativeHash?: string): { current: boolean; stale: boolean; reason?: string; planHash: string } {
444
- const planHash = deckPlanHash(deck.slides)
445
446
  const review = deck.planReview
447
+ const planHash = deck.slides.length > 0 ? deckPlanHash(deck.slides) : review?.planHash ?? deckPlanHash(deck.slides)
446
448
  if (!review) return { current: false, stale: false, reason: "deck plan has not been shown and confirmed", planHash }
447
449
  if (review.status !== "confirmed") return { current: false, stale: false, reason: "deck plan is pending user confirmation", planHash }
448
450
  if (narrativeHash && review.narrativeHash !== narrativeHash) return { current: false, stale: true, reason: "deck plan confirmation is stale because the narrative hash changed", planHash }
449
- if (review.planHash !== planHash) return { current: false, stale: true, reason: "deck plan confirmation is stale because the slide plan changed", planHash }
451
+ if (deck.slides.length > 0 && review.planHash !== planHash) return { current: false, stale: true, reason: "deck plan confirmation is stale because the cached slide projection changed", planHash }
450
452
  return { current: true, stale: false, planHash }
451
453
  }
452
454
 
@@ -457,14 +459,14 @@ export function confirmDeckPlan(state: DecksState, options: ConfirmDeckPlanOptio
457
459
  if (!deck) {
458
460
  return { state: normalized, result: { confirmed: false, skipped: true, reason: `No active deck exists in ${DECKS_STATE_FILE}.` } }
459
461
  }
460
- if (deck.slides.length === 0) {
461
- return { state: normalized, result: { confirmed: false, skipped: true, slug: deck.slug, reason: "Cannot confirm a deck plan with no slides." } }
462
- }
463
462
  const narrative = normalizeNarrativeState(normalized)
464
463
  const narrativeHash = computeNarrativeHash(narrative)
465
- const planHash = deckPlanHash(deck.slides)
464
+ const planHash = options.planHash ?? deckPlanHash(deck.slides)
466
465
  const pending = deck.planReview
467
- if (pending && pending.status === "pending" && (pending.narrativeHash !== narrativeHash || pending.planHash !== planHash)) {
466
+ if (pending && pending.status === "pending" && pending.narrativeHash !== narrativeHash) {
467
+ return { state: normalized, result: { confirmed: false, skipped: true, slug: deck.slug, narrativeHash, planHash, reason: "Cannot confirm because the pending deck plan is stale. Re-run compileDeckPlan first." } }
468
+ }
469
+ if (!options.planHash && pending && pending.status === "pending" && pending.planHash !== planHash) {
468
470
  return { state: normalized, result: { confirmed: false, skipped: true, slug: deck.slug, narrativeHash, planHash, reason: "Cannot confirm because the pending deck plan is stale. Re-run compileDeckPlan first." } }
469
471
  }
470
472
 
@@ -472,7 +474,7 @@ export function confirmDeckPlan(state: DecksState, options: ConfirmDeckPlanOptio
472
474
  status: "confirmed",
473
475
  narrativeHash,
474
476
  planHash,
475
- confirmedAt: options.now ?? new Date().toISOString(),
477
+ confirmedAt: options.approvedAt ?? options.now ?? new Date().toISOString(),
476
478
  confirmedBy: options.approvedBy ?? "user",
477
479
  summary: cleanOptionalText(options.note),
478
480
  qualityChecks: pending?.qualityChecks,
@@ -676,20 +678,32 @@ export function evaluateDeckStateWriteReadiness(state: DecksState, filePath: str
676
678
  const targetPath = normalizeDeckPath(filePath)
677
679
  const targetSlug = deckSlugFromPath(targetPath)
678
680
  const normalized = normalizeDecksState(state)
681
+ if (!isDeckHtmlPath(targetPath)) {
682
+ const message = `Deck HTML writes must target decks/*.html, got ${filePath || "missing"}`
683
+ return {
684
+ ready: false,
685
+ slug: targetSlug,
686
+ blocker: message,
687
+ blockers: [message],
688
+ warnings: [],
689
+ issues: [blockerIssue("missing_slide_spec", message, "Write deck artifacts to a workspace-local decks/*.html path.")],
690
+ }
691
+ }
679
692
  const key = currentDeckKey(normalized)
680
693
  const deck = key ? normalized.decks[key] : undefined
681
694
  if (!deck) {
695
+ const warning = currentDeckBlocker(normalized)
682
696
  return {
683
- ready: false,
697
+ ready: true,
684
698
  slug: targetSlug,
685
- blocker: currentDeckBlocker(normalized),
686
- blockers: [currentDeckBlocker(normalized)],
687
- warnings: [],
699
+ blocker: "",
700
+ blockers: [],
701
+ warnings: [warning],
688
702
  issues: [{
689
703
  type: "missing_slide_spec",
690
- severity: "blocker",
691
- message: currentDeckBlocker(normalized),
692
- suggestedAction: "Create or select the current workspace deck through revela-decks before writing deck HTML.",
704
+ severity: "warning",
705
+ message: warning,
706
+ suggestedAction: "Proceed from the file-native deck-plan/ projection or explicit user request; DECKS.json deck records are not required for artifact writing.",
693
707
  }],
694
708
  }
695
709
  }
@@ -701,32 +715,12 @@ export function evaluateDeckStateWriteReadiness(state: DecksState, filePath: str
701
715
  const warnings = issues.filter((issue) => issue.severity === "warning").map((issue) => issue.message)
702
716
  if (normalizeDeckPath(deck.outputPath) !== targetPath) {
703
717
  const message = `Deck outputPath is ${deck.outputPath || "missing"}, not ${targetPath}`
704
- blockers.unshift(message)
718
+ warnings.unshift(message)
705
719
  issues.unshift({
706
720
  type: "missing_slide_spec",
707
- severity: "blocker",
721
+ severity: "warning",
708
722
  message,
709
- suggestedAction: "Update deck.outputPath through revela-decks or write to the reviewed outputPath.",
710
- })
711
- }
712
- if (deck.writeReadiness.status !== "ready") {
713
- const message = `Deck writeReadiness is ${deck.writeReadiness.status || "missing"}, not ready`
714
- blockers.unshift(message)
715
- issues.unshift({
716
- type: "missing_slide_spec",
717
- severity: "blocker",
718
- message,
719
- suggestedAction: "Run /revela make --deck and resolve all readiness blockers before writing deck HTML.",
720
- })
721
- }
722
- if (deck.writeReadiness.blockers.length > 0) {
723
- const message = `Deck still has readiness blockers: ${deck.writeReadiness.blockers.join("; ")}`
724
- blockers.unshift(message)
725
- issues.unshift({
726
- type: "missing_slide_spec",
727
- severity: "blocker",
728
- message,
729
- suggestedAction: "Resolve the stored writeReadiness blockers and rerun /revela make --deck.",
723
+ suggestedAction: "Treat cached deck outputPath as diagnostic only; use the explicit artifact path requested by the user.",
730
724
  })
731
725
  }
732
726
  if (normalized.reviews.length > 0) {
@@ -734,30 +728,30 @@ export function evaluateDeckStateWriteReadiness(state: DecksState, filePath: str
734
728
  const snapshot = latestReviewSnapshotForTarget(normalized, targetId)
735
729
  if (!snapshot) {
736
730
  const message = "No review snapshot exists for the active HTML render target"
737
- blockers.unshift(message)
731
+ warnings.unshift(message)
738
732
  issues.unshift({
739
733
  type: "missing_slide_spec",
740
- severity: "blocker",
734
+ severity: "warning",
741
735
  message,
742
- suggestedAction: "Run /revela make --deck so readiness is recorded against the current active render target.",
736
+ suggestedAction: "Review snapshots are diagnostic only; proceed if the user explicitly requested this artifact path and HTML contract checks pass.",
743
737
  })
744
738
  } else if (!isReviewSnapshotCurrent(normalized, snapshot, deck.slug)) {
745
739
  const message = "Latest review snapshot is stale for the current deck, sources, evidence, narrative state, or render target"
746
- blockers.unshift(message)
740
+ warnings.unshift(message)
747
741
  issues.unshift({
748
742
  type: "missing_slide_spec",
749
- severity: "blocker",
743
+ severity: "warning",
750
744
  message,
751
- suggestedAction: "Run /revela make --deck again after the latest state changes before writing deck HTML.",
745
+ suggestedAction: "Treat stale review snapshots as diagnostics; user decides whether to continue or refresh.",
752
746
  })
753
747
  } else if (snapshot.status !== "ready") {
754
748
  const message = `Latest review snapshot is ${snapshot.status}, not ready`
755
- blockers.unshift(message)
749
+ warnings.unshift(message)
756
750
  issues.unshift({
757
751
  type: "missing_slide_spec",
758
- severity: "blocker",
752
+ severity: "warning",
759
753
  message,
760
- suggestedAction: "Resolve review blockers and rerun /revela make --deck before writing deck HTML.",
754
+ suggestedAction: "Treat prior review status as diagnostic only; current artifact validity checks decide whether writing can proceed.",
761
755
  })
762
756
  }
763
757
  }
@@ -804,7 +798,7 @@ export function buildDecksStatePromptLayer(workspaceRoot: string, maxChars = 140
804
798
  }
805
799
  let text = JSON.stringify(compact, null, 2)
806
800
  if (text.length > maxChars) text = text.slice(0, maxChars).trimEnd() + "\n[DECKS.json state truncated for prompt size.]"
807
- 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, render targets, 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- The active HTML deck is represented as a \`renderTarget\` of type \`html_deck\`; PDF/PPTX exports should be recorded as derived render targets, not as separate deck specs.\n- \`writeReadiness\` is a compatibility projection for the /revela make --deck generation workflow, not a hard blocker for targeted artifact-level HTML fixes.\n- Do not edit ${DECKS_STATE_FILE} directly; use the revela-decks tool.\n- For /revela make --deck generated HTML, use the current deck's outputPath and satisfy the deck HTML contract. For targeted artifact-level edits, patch the requested deck HTML directly without treating \`writeReadiness\` or \`planReview\` as a precondition.`
801
+ 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 compatibility/render state: workspace context, active output path, render targets, reviews, readiness, provenance, artifact coverage, and cached projections.\n- Do not treat ${DECKS_STATE_FILE} \`slides[]\` as the authoritative HTML slide-count, slide-order, or slide-content contract. When \`deck-plan/\` exists, use \`deck-plan/index.md\` and \`deck-plan/slides/*.md\` as the deck execution blueprint for HTML generation/remake.\n- The decks map is compatibility storage; operate only on the current workspace deck.\n- HTML slide identity is artifact self-consistency: each \`<section class="slide">\` needs a positive 1-based \`data-slide-index\`, indexes must be unique and strictly increase in DOM order, and 0-based \`data-index\` is never canonical identity. Cached ${DECKS_STATE_FILE} \`slides[].index\` values are diagnostic context only.\n- The active HTML deck is represented as a \`renderTarget\` of type \`html_deck\`; PDF/PPTX exports should be recorded as derived render targets, not as separate deck specs.\n- \`writeReadiness\` and \`planReview\` are compatibility projections for the /revela make --deck generation workflow, not hard blockers for targeted artifact-level HTML fixes.\n- Do not edit ${DECKS_STATE_FILE} directly; use the revela-decks tool.\n- For /revela make --deck generated HTML, use the current deck's outputPath, read \`deck-plan/\` when present, and satisfy the deck HTML contract without padding missing chapters just to match cached ${DECKS_STATE_FILE} \`slides[]\`. Deck-plan diagnostics are advisory; for targeted artifact-level edits, patch the requested deck HTML directly without treating \`writeReadiness\` or \`planReview\` as a precondition.`
808
802
  }
809
803
 
810
804
  function compactWorkspaceForPrompt(workspace: DecksState["workspace"]): DecksState["workspace"] {
@@ -969,52 +963,47 @@ function currentDeckBlocker(state: DecksState): string {
969
963
  function computeDeckReadinessIssues(state: DecksState, deck: DeckSpec, options: ReviewDeckStateOptions = {}): ReadinessIssue[] {
970
964
  const issues: ReadinessIssue[] = []
971
965
  const workspace = state.workspace
972
- if (!deck.goal.trim()) issues.push(blockerIssue("missing_slide_spec", "Deck goal is missing", "Set the deck goal through revela-decks upsertDeck."))
966
+ if (!deck.goal.trim()) issues.push(warningIssue("missing_slide_spec", "Deck goal is missing", "Clarify the deck goal if it matters for this artifact pass; do not block execution solely on cached deck metadata."))
973
967
  if (!isDeckHtmlPath(deck.outputPath)) {
974
- issues.push(blockerIssue(
968
+ issues.push(warningIssue(
975
969
  "missing_slide_spec",
976
970
  `outputPath must be decks/*.html, got ${deck.outputPath || "missing"}`,
977
- "Set outputPath to the target decks/*.html file through revela-decks upsertDeck.",
971
+ "Resolve output path from the user request, deck-plan/index.md, or a deterministic decks/*.html default instead of treating cached state as permission.",
978
972
  ))
979
973
  }
980
974
 
981
975
  for (const [key, value] of Object.entries(deck.requiredInputs) as Array<[keyof RequiredInputs, boolean]>) {
982
- if (value !== true) {
983
- issues.push(blockerIssue(
984
- "missing_required_input",
985
- `requiredInputs.${key} is not true`,
986
- `Complete and explicitly record requiredInputs.${key} before writing the deck.`,
987
- ))
988
- }
976
+ if (value !== true) issues.push(warningIssue(
977
+ "missing_required_input",
978
+ `Legacy requiredInputs.${key} is not true`,
979
+ "requiredInputs is legacy diagnostic/cache state only; ask the user for missing intent if needed, but do not block execution.",
980
+ ))
989
981
  }
990
982
 
991
- if (deck.slides.length === 0) issues.push(blockerIssue("missing_slide_spec", "slides are missing", "Add the confirmed slide plan through revela-decks upsertSlides."))
992
- if (deck.slides.length > 0) {
993
- const planReview = currentDeckPlanReviewStatus(deck, options.narrativeHash)
994
- if (!planReview.current) {
995
- issues.push(blockerIssue(
996
- "slide_plan_unconfirmed",
997
- planReview.stale ? `Deck slide plan confirmation is stale: ${planReview.reason}` : `Deck slide plan is not confirmed: ${planReview.reason}`,
998
- "Show the compiled deck plan with low-fidelity layout sketches to the user, then call revela-decks confirmDeckPlan only after explicit user confirmation.",
999
- ))
1000
- }
983
+ const planReview = currentDeckPlanReviewStatus(deck, options.narrativeHash)
984
+ if (!planReview.current) {
985
+ issues.push(warningIssue(
986
+ "slide_plan_unconfirmed",
987
+ planReview.stale ? `Deck plan confirmation is stale: ${planReview.reason}` : `Deck plan is not confirmed: ${planReview.reason}`,
988
+ "Write or read deck-plan/ projection Markdown if useful, then decide whether to continue. This is advisory and does not block artifact work.",
989
+ ))
1001
990
  }
1002
991
  issues.push(...deckPlanQualityIssues(deck))
1003
992
  issues.push(...artifactCoverageIssues(state, deck))
1004
993
  for (const slide of deck.slides) {
1005
994
  const slideRef = { slideIndex: slide.index, slideTitle: slide.title }
1006
- if (!slide.title.trim()) issues.push(blockerIssue("missing_slide_spec", `Slide ${slide.index} title is missing`, "Add a slide title to the slide spec.", slideRef))
1007
- if (!slide.layout.trim()) issues.push(blockerIssue("missing_slide_spec", `Slide ${slide.index} layout is missing`, "Fetch and record the intended design layout for this slide.", slideRef))
1008
- if (slide.components.length === 0) issues.push(blockerIssue("missing_slide_spec", `Slide ${slide.index} components are missing`, "Record the design components needed for this slide.", slideRef))
1009
- 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))
995
+ if (!slide.title.trim()) issues.push(warningIssue("missing_slide_spec", `Cached slide ${slide.index} title is missing`, "Use deck-plan/ and artifact content as source for rendering; cached slide specs are diagnostics only.", slideRef))
996
+ if (!slide.layout.trim()) issues.push(warningIssue("missing_slide_spec", `Cached slide ${slide.index} layout is missing`, "Fetch needed design layouts before writing HTML, but do not block solely on cached slide specs.", slideRef))
997
+ if (slide.components.length === 0) issues.push(warningIssue("missing_slide_spec", `Cached slide ${slide.index} components are missing`, "Fetch needed design components before writing HTML, but do not block solely on cached slide specs.", slideRef))
998
+ if (!hasSlideContent(slide)) issues.push(warningIssue("missing_slide_spec", `Cached slide ${slide.index} content is missing`, "Use deck-plan/ and the artifact request as render guidance; cached slide specs are diagnostics only.", slideRef))
1010
999
 
1011
1000
  const claim = findEvidenceSensitiveClaim(slide)
1012
1001
  if (claim && slide.evidence.length === 0 && !isNavigationSlide(slide)) {
1013
1002
  const { candidates: evidenceCandidates, search: evidenceCandidateSearch } = findEvidenceBindingCandidates(deck, slide, claim, options)
1014
- issues.push(blockerIssue(
1003
+ issues.push(warningIssue(
1015
1004
  "missing_evidence",
1016
1005
  `Slide ${slide.index} has an evidence-sensitive claim without evidence: ${claim}`,
1017
- SOURCE_TRACE_ACTION,
1006
+ `${SOURCE_TRACE_ACTION} Missing evidence is diagnostic; keep the boundary visible rather than blocking the user's requested artifact step.`,
1018
1007
  {
1019
1008
  ...slideRef,
1020
1009
  claimText: claim,
@@ -1036,10 +1025,10 @@ function computeDeckReadinessIssues(state: DecksState, deck: DeckSpec, options:
1036
1025
 
1037
1026
  for (const axis of deck.researchPlan) {
1038
1027
  if (axis.needed && axis.status !== "done" && axis.status !== "read" && axis.status !== "skipped") {
1039
- issues.push(blockerIssue(
1028
+ issues.push(warningIssue(
1040
1029
  "research_not_ready",
1041
1030
  `Research axis ${axis.axis || "unnamed"} is needed but ${axis.status}`,
1042
- "Complete, read, or explicitly skip this research axis before writing the deck.",
1031
+ "Research status is diagnostic only; user decides whether to continue, run research, or keep the gap visible.",
1043
1032
  ))
1044
1033
  }
1045
1034
  }
@@ -1050,10 +1039,10 @@ function computeDeckReadinessIssues(state: DecksState, deck: DeckSpec, options:
1050
1039
  if (isIgnorableSourceMaterial(material.path)) continue
1051
1040
  const message = `Source material ${material.path} has been identified but not extracted, summarized, or researched`
1052
1041
  if (hasNeededResearch) {
1053
- issues.push(blockerIssue(
1042
+ issues.push(warningIssue(
1054
1043
  "source_not_processed",
1055
1044
  message,
1056
- "Extract, summarize, research, or explicitly exclude this source before writing evidence-backed slides.",
1045
+ "Extract, summarize, research, or exclude this source if it matters; do not block solely on source processing state.",
1057
1046
  ))
1058
1047
  } else {
1059
1048
  issues.push(warningIssue(
@@ -1072,11 +1061,11 @@ function deckPlanQualityIssues(deck: DeckSpec): ReadinessIssue[] {
1072
1061
  return checks.flatMap((check): ReadinessIssue[] => {
1073
1062
  if (check.status === "pass") return []
1074
1063
  const suggestedAction = check.status === "blocker"
1075
- ? "Re-run compileDeckPlan or revise the deck projection so the deterministic plan includes Cover, TOC, central claim coverage, compatible components, and Closing/Decision Ask before confirming or writing the deck."
1064
+ ? "Revise deck-plan/ if this quality issue matters for the user's goal. It is diagnostic, not a workflow permission blocker."
1076
1065
  : "Keep the stated claim boundaries visible in the plan and rendered artifact; do not stretch partial evidence beyond the supported scope."
1077
1066
  return [{
1078
1067
  type: "plan_quality",
1079
- severity: check.status,
1068
+ severity: "warning",
1080
1069
  message: check.message,
1081
1070
  suggestedAction,
1082
1071
  }]
@@ -1088,17 +1077,17 @@ function artifactCoverageIssues(state: DecksState, deck: DeckSpec): ReadinessIss
1088
1077
  if (!coverage) return []
1089
1078
  const issues: ReadinessIssue[] = []
1090
1079
  if (coverage.missingClaimIds.length > 0) {
1091
- issues.push(blockerIssue(
1080
+ issues.push(warningIssue(
1092
1081
  "artifact_coverage",
1093
1082
  `Active deck plan is missing required narrative claims: ${coverage.missingClaimIds.join(", ")}`,
1094
- "Re-run compileDeckPlan or revise the deck projection so every central or evidence-required claim appears in the planned slides before writing the deck.",
1083
+ "Coverage gaps are diagnostic; revise deck-plan/ or proceed with the visible limitation if the user chooses.",
1095
1084
  ))
1096
1085
  }
1097
1086
  if (coverage.coverageStatus === "stale") {
1098
- issues.push(blockerIssue(
1087
+ issues.push(warningIssue(
1099
1088
  "artifact_coverage",
1100
1089
  `Active deck artifact coverage is stale: ${coverage.staleReasons.join("; ") || "narrative or render target changed"}`,
1101
- "Re-run /revela make --deck so the deck plan and artifact coverage are regenerated from the current approved narrative state.",
1090
+ "Artifact coverage staleness is diagnostic; user decides whether to remake, review, or export the current artifact.",
1102
1091
  ))
1103
1092
  } else if (coverage.coverageStatus === "partial") {
1104
1093
  issues.push(warningIssue(
@@ -1147,8 +1136,8 @@ function readinessNextActions(issues: ReadinessIssue[], coverage?: ArtifactCover
1147
1136
  const actions = issues
1148
1137
  .filter((issue) => issue.severity === "blocker" || issue.type === "plan_quality" || issue.type === "artifact_coverage")
1149
1138
  .map((issue) => issue.suggestedAction)
1150
- if (coverage?.missingClaimIds.length) actions.unshift("Review missingClaimIds in artifactCoverage and recompile the deterministic deck plan before writing HTML.")
1151
- if (coverage?.coverageStatus === "stale") actions.unshift("Regenerate the deck plan from the current narrative before writing or exporting artifacts.")
1139
+ if (coverage?.missingClaimIds.length) actions.unshift("Review missingClaimIds in artifactCoverage and decide whether to revise deck-plan/ or continue with the current artifact.")
1140
+ if (coverage?.coverageStatus === "stale") actions.unshift("Artifact coverage is stale; decide whether to remake, review, or export the current artifact.")
1152
1141
  return [...new Set(actions)].slice(0, 5)
1153
1142
  }
1154
1143
 
@@ -1,16 +1,3 @@
1
- import { readFileSync } from "fs"
2
- import { activeDesign } from "../design/designs"
3
- import { activeDomain } from "../domain/domains"
4
- import {
5
- defaultRequiredInputs,
6
- DECKS_STATE_FILE,
7
- normalizeWorkspaceDeckState,
8
- readOrCreateDecksState,
9
- upsertDeck,
10
- upsertSlides,
11
- writeDecksState,
12
- type SlideSpec,
13
- } from "../decks-state"
14
1
  import type { EditableDeck } from "./resolve-deck"
15
2
 
16
3
  export interface EditDeckStatePreflightResult {
@@ -18,102 +5,7 @@ export interface EditDeckStatePreflightResult {
18
5
  }
19
6
 
20
7
  export function ensureEditableDeckState(workspaceRoot: string, deck: EditableDeck): EditDeckStatePreflightResult {
21
- let state = normalizeWorkspaceDeckState(readOrCreateDecksState(workspaceRoot), workspaceRoot)
22
- const active = state.activeDeck ? state.decks[state.activeDeck] : undefined
23
- if (active && active.slug !== deck.slug && active.outputPath !== deck.file) {
24
- throw new Error(`${DECKS_STATE_FILE} already points to ${active.outputPath}. Revela 0.8 expects one deck per workspace; move extra decks to a separate workspace.`)
25
- }
26
- const existing = state.decks[deck.slug]
27
- let changed = !existing || existing.outputPath !== deck.file
28
-
29
- state = upsertDeck(state, {
30
- ...existing,
31
- slug: deck.slug,
32
- goal: existing?.goal || `Edit existing Revela deck ${deck.slug}.`,
33
- audience: existing?.audience || "Existing deck viewers",
34
- language: existing?.language || "en",
35
- outputPath: deck.file,
36
- theme: {
37
- design: existing?.theme?.design || safeActiveDesign(),
38
- domain: existing?.theme?.domain || safeActiveDomain(),
39
- },
40
- requiredInputs: defaultRequiredInputs({
41
- ...existing?.requiredInputs,
42
- topicClarified: true,
43
- audienceClarified: true,
44
- languageDecided: true,
45
- visualStyleSelected: true,
46
- sourceMaterialsIdentified: true,
47
- researchNeedAssessed: true,
48
- researchFindingsRead: true,
49
- slidePlanConfirmed: true,
50
- designLayoutsFetched: true,
51
- }),
52
- researchPlan: existing?.researchPlan || [],
53
- })
54
-
55
- const current = state.decks[deck.slug]
56
- if (current.slides.length === 0) {
57
- state = upsertSlides(state, deck.slug, inferSlides(deck.absoluteFile))
58
- changed = true
59
- }
60
-
61
- writeDecksState(workspaceRoot, state)
62
-
63
- return {
64
- changed,
65
- }
66
- }
67
-
68
- function inferSlides(filePath: string): SlideSpec[] {
69
- const html = readFileSync(filePath, "utf-8")
70
- const chunks = html.match(/<section\b[\s\S]*?<\/section>/gi) || [html]
71
- return chunks.map((chunk, index) => {
72
- const title = extractTitle(chunk) || `Slide ${index + 1}`
73
- return {
74
- index: index + 1,
75
- title,
76
- purpose: "Existing HTML slide prepared for targeted visual edits.",
77
- layout: "existing-html",
78
- qa: /slide-qa=["']true["']/i.test(chunk),
79
- components: ["existing-html"],
80
- content: {
81
- headline: title,
82
- body: [extractText(chunk) || "Existing HTML slide content."],
83
- },
84
- evidence: [],
85
- visuals: [],
86
- status: "ready",
87
- notes: "Inferred automatically by /revela review --deck preflight.",
88
- }
89
- })
90
- }
91
-
92
- function extractTitle(html: string): string {
93
- const match = /<(?:h1|h2|h3|title)\b[^>]*>([\s\S]*?)<\/(?:h1|h2|h3|title)>/i.exec(html)
94
- return normalizeText(match?.[1] || "").slice(0, 160)
95
- }
96
-
97
- function extractText(html: string): string {
98
- return normalizeText(html.replace(/<script\b[\s\S]*?<\/script>/gi, " ").replace(/<style\b[\s\S]*?<\/style>/gi, " ").replace(/<[^>]+>/g, " ")).slice(0, 600)
99
- }
100
-
101
- function normalizeText(value: string): string {
102
- return value.replace(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/\s+/g, " ").trim()
103
- }
104
-
105
- function safeActiveDesign(): string {
106
- try {
107
- return activeDesign()
108
- } catch {
109
- return "aurora"
110
- }
111
- }
112
-
113
- function safeActiveDomain(): string {
114
- try {
115
- return activeDomain()
116
- } catch {
117
- return "general"
118
- }
8
+ void workspaceRoot
9
+ void deck
10
+ return { changed: false }
119
11
  }
package/lib/edit/open.ts CHANGED
@@ -85,8 +85,8 @@ function openEditableDeckInternal(
85
85
  const shouldOpen = options.openBrowser !== false && !(behavior.skipLiveSession && session.live)
86
86
  if (shouldOpen) (options.openUrl ?? openUrl)(url)
87
87
 
88
- const source = deck.source === "decks-state" ? "DECKS.json" : deck.source === "file-path" ? "file path" : "fallback path"
89
- const stateNote = preflight.changed ? "Deck state was prepared in DECKS.json for visual editing." : "Deck state already points to this visual edit target."
88
+ const source = deck.source === "file-path" ? "file path" : "discovered deck file"
89
+ const stateNote = preflight.changed ? "Deck file preflight updated runtime state." : "Deck visual edit uses the selected HTML artifact directly."
90
90
 
91
91
  return {
92
92
  deck,
@@ -1,39 +1,27 @@
1
- import { existsSync, readdirSync } from "fs"
1
+ import { existsSync, readdirSync, statSync } from "fs"
2
2
  import { relative, resolve, sep } from "path"
3
- import { DECKS_STATE_FILE, hasDecksState, isDeckHtmlPath, readDecksState, workspaceDeckSlug } from "../decks-state"
4
- import { resolveActiveHtmlDeckPath } from "../workspace-state/render-targets"
3
+ import { isDeckHtmlPath, workspaceDeckSlug } from "../decks-state"
5
4
 
6
5
  export interface EditableDeck {
7
6
  slug: string
8
7
  file: string
9
8
  absoluteFile: string
10
- source: "render-target" | "decks-state" | "fallback" | "file-path"
9
+ source: "discovered" | "file-path"
11
10
  }
12
11
 
13
12
  export function resolveEditableDeck(workspaceRoot: string, input = ""): EditableDeck {
14
- if (input.trim()) {
15
- throw new Error("/revela review --deck does not accept a target. It opens the active HTML deck or the only HTML deck in decks/.")
16
- }
17
-
18
- if (hasDecksState(workspaceRoot)) {
19
- const state = readDecksState(workspaceRoot)
20
- const deckPath = resolveActiveHtmlDeckPath(state)
21
- const source = state.renderTargets.some((target) => target.type === "html_deck" && target.outputPath === deckPath) ? "render-target" : "decks-state"
22
- if (deckPath && isDeckHtmlPath(deckPath)) {
23
- const absoluteFile = resolve(workspaceRoot, deckPath)
24
- if (existsSync(absoluteFile)) return resolveDeckFile(workspaceRoot, workspaceDeckSlug(workspaceRoot), deckPath, source)
25
- }
26
- }
13
+ const explicit = input.trim()
14
+ if (explicit) return resolveDeckFile(workspaceRoot, workspaceDeckSlug(workspaceRoot), explicit, "file-path")
27
15
 
28
16
  const htmlFiles = listDeckHtmlFiles(workspaceRoot)
29
17
  if (htmlFiles.length === 0) {
30
- throw new Error("No deck HTML found in decks/. Generate a deck first.")
18
+ throw new Error("No deck HTML found in decks/. Pass a deck path or generate a deck first.")
31
19
  }
32
20
  if (htmlFiles.length > 1) {
33
- throw new Error("This workspace contains multiple deck HTML files. Revela 0.8 expects one deck per workspace. Move extra decks to separate workspaces.")
21
+ throw new Error(`Multiple deck HTML files found in decks/: ${htmlFiles.join(", ")}. Pass the deck path explicitly.`)
34
22
  }
35
23
 
36
- return resolveDeckFile(workspaceRoot, workspaceDeckSlug(workspaceRoot), htmlFiles[0], "file-path")
24
+ return resolveDeckFile(workspaceRoot, workspaceDeckSlug(workspaceRoot), htmlFiles[0], "discovered")
37
25
  }
38
26
 
39
27
  function listDeckHtmlFiles(workspaceRoot: string): string[] {
@@ -51,18 +39,20 @@ function resolveDeckFile(
51
39
  file: string,
52
40
  source: EditableDeck["source"],
53
41
  ): EditableDeck {
54
- if (!isDeckHtmlPath(file)) {
55
- throw new Error(`${DECKS_STATE_FILE} deck outputPath must be decks/*.html, got ${file || "missing"}.`)
56
- }
57
-
58
42
  const root = resolve(workspaceRoot)
59
43
  const absoluteFile = resolve(root, file)
60
44
  if (!isInside(root, absoluteFile)) {
61
45
  throw new Error(`Resolved deck file is outside the workspace: ${file}`)
62
46
  }
47
+ if (!isDeckHtmlPath(workspaceRelative(root, absoluteFile)) && !/\.html?$/i.test(absoluteFile)) {
48
+ throw new Error(`Deck path must be an HTML file: ${file || "missing"}.`)
49
+ }
63
50
  if (!existsSync(absoluteFile)) {
64
51
  throw new Error(`Deck HTML not found: ${workspaceRelative(root, absoluteFile)}`)
65
52
  }
53
+ if (!statSync(absoluteFile).isFile()) {
54
+ throw new Error(`Deck path is not a file: ${workspaceRelative(root, absoluteFile)}`)
55
+ }
66
56
 
67
57
  return {
68
58
  slug,
@@ -54,8 +54,8 @@ export function openInspectDeck(target: string, options: OpenInspectDeckOptions)
54
54
  return {
55
55
  deck,
56
56
  url,
57
- source: deck.source === "render-target" ? "render target" : deck.source === "decks-state" ? "DECKS.json" : deck.source === "file-path" ? "file path" : "fallback path",
58
- stateNote: preflight.changed ? "Deck state was prepared in DECKS.json for inspection." : "Deck state already points to this inspection target.",
57
+ source: deck.source === "file-path" ? "file path" : "discovered deck file",
58
+ stateNote: preflight.changed ? "Deck file preflight updated runtime state." : "Deck inspection uses the selected HTML artifact directly.",
59
59
  preflightChanged: preflight.changed,
60
60
  reusedSession: session.reused,
61
61
  openedBrowser: shouldOpen,
@@ -12,6 +12,10 @@ const MIME_TO_EXT: Record<string, string> = {
12
12
 
13
13
  const ALLOWED_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".svg", ".webp", ".gif", ".ico"])
14
14
  const DEFAULT_DOWNLOAD_TIMEOUT_MS = 10_000
15
+ const PRODUCT_USER_AGENT = "Revela/0.17 asset-save"
16
+ const BROWSER_USER_AGENT =
17
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " +
18
+ "(KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36"
15
19
 
16
20
  function normalizeExtension(ext: string): string {
17
21
  const value = ext.toLowerCase()
@@ -47,6 +51,24 @@ export async function downloadImageFromUrl(
47
51
  throw new Error("INVALID_URL")
48
52
  }
49
53
 
54
+ const userAgents = [PRODUCT_USER_AGENT, BROWSER_USER_AGENT]
55
+ let lastError: unknown
56
+ for (const userAgent of userAgents) {
57
+ try {
58
+ return await downloadWithUserAgent(parsed, userAgent, options)
59
+ } catch (error) {
60
+ lastError = error
61
+ }
62
+ }
63
+
64
+ throw lastError instanceof Error ? lastError : new Error(String(lastError))
65
+ }
66
+
67
+ async function downloadWithUserAgent(
68
+ parsed: URL,
69
+ userAgent: string,
70
+ options: { timeoutMs?: number },
71
+ ): Promise<{ buffer: Buffer; contentType: string | null; extension: string }> {
50
72
  const controller = new AbortController()
51
73
  let timedOut = false
52
74
  const timer = setTimeout(() => {
@@ -60,9 +82,7 @@ export async function downloadImageFromUrl(
60
82
  response = await fetch(parsed, {
61
83
  headers: {
62
84
  Accept: "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
63
- "User-Agent":
64
- "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " +
65
- "(KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
85
+ "User-Agent": userAgent,
66
86
  },
67
87
  signal: controller.signal,
68
88
  })
package/lib/media/save.ts CHANGED
@@ -141,6 +141,7 @@ function saveFailureResult(
141
141
  path: null,
142
142
  manifestPath: relative(workspaceDir, manifestPath),
143
143
  updated: true,
144
+ failureReason,
144
145
  }
145
146
  }
146
147
 
@@ -59,6 +59,7 @@ export type MediaSaveResult =
59
59
  path: string | null
60
60
  manifestPath: string
61
61
  updated: boolean
62
+ failureReason?: string
62
63
  }
63
64
  | {
64
65
  ok: false