@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.
Files changed (44) hide show
  1. package/README.md +54 -28
  2. package/README.zh-CN.md +54 -28
  3. package/lib/commands/designs.ts +2 -2
  4. package/lib/commands/domains.ts +2 -2
  5. package/lib/commands/enable.ts +19 -19
  6. package/lib/commands/help.ts +5 -3
  7. package/lib/commands/init.ts +30 -19
  8. package/lib/commands/inspect.ts +1 -1
  9. package/lib/commands/pdf.ts +33 -5
  10. package/lib/commands/pptx.ts +14 -9
  11. package/lib/commands/refine.ts +1 -1
  12. package/lib/commands/review.ts +115 -1
  13. package/lib/deck-html/contract.ts +252 -0
  14. package/lib/decks-state.ts +111 -28
  15. package/lib/document-materials/extract.ts +20 -0
  16. package/lib/edit/resolve-deck.ts +13 -2
  17. package/lib/inspect/open.ts +3 -1
  18. package/lib/narrative-state/hash.ts +52 -0
  19. package/lib/narrative-state/normalize.ts +307 -0
  20. package/lib/narrative-state/project-compat.ts +14 -0
  21. package/lib/narrative-state/readiness.ts +289 -0
  22. package/lib/narrative-state/render-plan.ts +207 -0
  23. package/lib/narrative-state/types.ts +139 -0
  24. package/lib/prompt-builder.ts +59 -26
  25. package/lib/qa/export-gate.ts +8 -1
  26. package/lib/refine/open.ts +3 -1
  27. package/lib/workspace-state/actions.ts +71 -0
  28. package/lib/workspace-state/compat.ts +10 -0
  29. package/lib/workspace-state/evidence-status.ts +267 -0
  30. package/lib/workspace-state/graph.ts +544 -0
  31. package/lib/workspace-state/render-targets.ts +182 -0
  32. package/lib/workspace-state/rendered-artifacts.ts +43 -0
  33. package/lib/workspace-state/repository.ts +43 -0
  34. package/lib/workspace-state/research-attachments.ts +130 -0
  35. package/lib/workspace-state/review-snapshots.ts +127 -0
  36. package/lib/workspace-state/types.ts +122 -0
  37. package/package.json +1 -1
  38. package/plugin.ts +53 -3
  39. package/skill/NARRATIVE_SKILL.md +64 -0
  40. package/tools/decks.ts +233 -6
  41. package/tools/pdf.ts +9 -1
  42. package/tools/pptx.ts +10 -0
  43. package/tools/research-save.ts +15 -0
  44. package/tools/workspace-scan.ts +29 -1
@@ -0,0 +1,207 @@
1
+ import { upsertDeck, upsertSlides, type DecksState, type EvidenceRef, type RequiredInputs, type SlideSpec } from "../decks-state"
2
+ import { computeNarrativeHash } from "./hash"
3
+ import { normalizeNarrativeState } from "./normalize"
4
+ import { narrativeToBrief } from "./project-compat"
5
+ import type { NarrativeClaim, NarrativeEvidenceBinding, NarrativeStateV1 } from "./types"
6
+
7
+ export interface CompileDeckPlanOptions {
8
+ now?: string
9
+ }
10
+
11
+ export interface CompileDeckPlanResult {
12
+ compiled: boolean
13
+ skipped: boolean
14
+ reason?: string
15
+ narrativeHash: string
16
+ slideCount: number
17
+ slides: SlideSpec[]
18
+ }
19
+
20
+ export function compileDeckPlanFromNarrative(state: DecksState, options: CompileDeckPlanOptions = {}): { state: DecksState; result: CompileDeckPlanResult } {
21
+ const narrative = normalizeNarrativeState(state)
22
+ const narrativeHash = computeNarrativeHash(narrative)
23
+ const approval = hasCurrentApprovalOrOverride(narrative, narrativeHash)
24
+ if (!approval) {
25
+ return {
26
+ state: { ...state, narrative },
27
+ result: {
28
+ compiled: false,
29
+ skipped: true,
30
+ reason: "narrative must be approved or explicitly overridden before compiling a deck plan",
31
+ narrativeHash,
32
+ slideCount: 0,
33
+ slides: [],
34
+ },
35
+ }
36
+ }
37
+
38
+ const deckKey = state.activeDeck ?? Object.keys(state.decks)[0]
39
+ const deck = deckKey ? state.decks[deckKey] : undefined
40
+ const slug = deck?.slug ?? state.activeDeck ?? "deck"
41
+ const slides = buildSlides(narrative)
42
+ const requiredInputs: Partial<RequiredInputs> = {
43
+ topicClarified: true,
44
+ audienceClarified: Boolean(narrative.audience.primary),
45
+ languageDecided: Boolean(deck?.language),
46
+ sourceMaterialsIdentified: (state.workspace.sourceMaterials ?? []).length > 0 || narrative.evidenceBindings.length > 0,
47
+ researchNeedAssessed: true,
48
+ researchFindingsRead: narrative.evidenceBindings.some((binding) => Boolean(binding.findingsFile)),
49
+ slidePlanConfirmed: false,
50
+ designLayoutsFetched: false,
51
+ }
52
+ let next = upsertDeck({ ...state, narrative }, {
53
+ ...deck,
54
+ slug,
55
+ goal: deck?.goal || narrative.thesis?.statement || narrative.decision.action,
56
+ audience: narrative.audience.primary || deck?.audience,
57
+ outputPath: deck?.outputPath,
58
+ narrativeBrief: narrativeToBrief(narrative),
59
+ requiredInputs: {
60
+ ...(deck?.requiredInputs ?? {}),
61
+ ...requiredInputs,
62
+ } as RequiredInputs,
63
+ writeReadiness: deck?.writeReadiness ?? { status: "blocked" as const, blockers: [] },
64
+ })
65
+ next = upsertSlides(next, slug, slides)
66
+ next.narrative = { ...narrative, updatedAt: options.now ?? narrative.updatedAt }
67
+
68
+ return {
69
+ state: next,
70
+ result: {
71
+ compiled: true,
72
+ skipped: false,
73
+ narrativeHash,
74
+ slideCount: slides.length,
75
+ slides,
76
+ },
77
+ }
78
+ }
79
+
80
+ function buildSlides(narrative: NarrativeStateV1): SlideSpec[] {
81
+ const slides: SlideSpec[] = []
82
+ const centralClaims = narrative.claims.filter((claim) => claim.importance === "central")
83
+ const supportingClaims = narrative.claims.filter((claim) => claim.importance !== "central")
84
+ const evidenceByClaim = new Map<string, NarrativeEvidenceBinding[]>()
85
+ for (const binding of narrative.evidenceBindings) {
86
+ const list = evidenceByClaim.get(binding.claimId) ?? []
87
+ list.push(binding)
88
+ evidenceByClaim.set(binding.claimId, list)
89
+ }
90
+
91
+ slides.push({
92
+ index: slides.length + 1,
93
+ title: "Decision Context",
94
+ purpose: "Frame the audience belief shift and decision required before presenting the recommendation.",
95
+ narrativeRole: "context",
96
+ layout: "cover",
97
+ qa: false,
98
+ components: [],
99
+ content: {
100
+ headline: narrative.thesis?.statement || narrative.decision.action || "Narrative context",
101
+ body: [
102
+ narrative.audience.beliefBefore ? `Before: ${narrative.audience.beliefBefore}` : "Before belief needs confirmation.",
103
+ narrative.audience.beliefAfter ? `After: ${narrative.audience.beliefAfter}` : "After belief needs confirmation.",
104
+ ],
105
+ bullets: narrative.decision.action ? [`Decision: ${narrative.decision.action}`] : [],
106
+ },
107
+ evidence: [],
108
+ status: "planned",
109
+ })
110
+
111
+ for (const claim of centralClaims) slides.push(claimSlide(slides.length + 1, claim, evidenceByClaim.get(claim.id) ?? []))
112
+ if (supportingClaims.length > 0) {
113
+ slides.push({
114
+ index: slides.length + 1,
115
+ title: "Supporting Logic",
116
+ purpose: "Connect supporting claims to the central recommendation without overloading the main proof slides.",
117
+ narrativeRole: "evidence",
118
+ layout: "card-grid",
119
+ qa: true,
120
+ components: ["card"],
121
+ content: {
122
+ headline: "Supporting claims and boundaries",
123
+ bullets: supportingClaims.slice(0, 5).map((claim) => claim.text),
124
+ },
125
+ evidence: supportingClaims.flatMap((claim) => (evidenceByClaim.get(claim.id) ?? []).map(evidenceRefFromBinding)),
126
+ status: "planned",
127
+ })
128
+ }
129
+
130
+ if (narrative.risks.length > 0 || narrative.objections.length > 0) {
131
+ slides.push({
132
+ index: slides.length + 1,
133
+ title: "Risks And Objections",
134
+ purpose: "Make caveats and stakeholder objections visible before asking for a decision.",
135
+ narrativeRole: "risk",
136
+ layout: "two-col",
137
+ qa: true,
138
+ components: ["card"],
139
+ content: {
140
+ headline: "What could break the recommendation",
141
+ bullets: [
142
+ ...narrative.risks.slice(0, 3).map((risk) => risk.mitigation ? `${risk.text} Mitigation: ${risk.mitigation}` : risk.text),
143
+ ...narrative.objections.slice(0, 3).map((objection) => objection.response ? `${objection.text} Response: ${objection.response}` : objection.text),
144
+ ],
145
+ },
146
+ evidence: [],
147
+ status: "planned",
148
+ })
149
+ }
150
+
151
+ slides.push({
152
+ index: slides.length + 1,
153
+ title: "Decision Ask",
154
+ purpose: "Close with the explicit decision or action requested from the audience.",
155
+ narrativeRole: "ask",
156
+ layout: "closing",
157
+ qa: false,
158
+ components: [],
159
+ content: {
160
+ headline: narrative.decision.action || "Confirm the decision",
161
+ bullets: narrative.decision.consequenceOfNoDecision ? [`If no decision: ${narrative.decision.consequenceOfNoDecision}`] : [],
162
+ },
163
+ evidence: [],
164
+ status: "planned",
165
+ })
166
+
167
+ return slides
168
+ }
169
+
170
+ function claimSlide(index: number, claim: NarrativeClaim, bindings: NarrativeEvidenceBinding[]): SlideSpec {
171
+ return {
172
+ index,
173
+ title: titleFromClaim(claim),
174
+ purpose: `Prove or bound this ${claim.importance} ${claim.kind} claim for the audience.`,
175
+ narrativeRole: claim.kind === "risk" || claim.kind === "assumption" ? "risk" : claim.kind === "ask" ? "ask" : claim.kind === "recommendation" ? "recommendation" : "evidence",
176
+ layout: "two-col",
177
+ qa: true,
178
+ components: ["card"],
179
+ content: {
180
+ headline: claim.text,
181
+ bullets: [claim.supportedScope, claim.unsupportedScope ? `Unsupported scope: ${claim.unsupportedScope}` : undefined, ...(claim.caveats ?? [])].filter((item): item is string => Boolean(item)),
182
+ },
183
+ evidence: bindings.map(evidenceRefFromBinding),
184
+ status: "planned",
185
+ }
186
+ }
187
+
188
+ function evidenceRefFromBinding(binding: NarrativeEvidenceBinding): EvidenceRef {
189
+ return {
190
+ source: binding.source,
191
+ quote: binding.quote,
192
+ url: binding.url,
193
+ sourcePath: binding.sourcePath,
194
+ location: binding.location,
195
+ findingsFile: binding.findingsFile,
196
+ caveat: binding.caveat || binding.unsupportedScope,
197
+ }
198
+ }
199
+
200
+ function titleFromClaim(claim: NarrativeClaim): string {
201
+ const words = claim.text.split(/\s+/).filter(Boolean).slice(0, 6).join(" ")
202
+ return words || claim.kind
203
+ }
204
+
205
+ function hasCurrentApprovalOrOverride(narrative: NarrativeStateV1, narrativeHash: string): boolean {
206
+ return narrative.approvals.some((approval) => approval.narrativeHash === narrativeHash && (approval.scope === "narrative" && approval.approvedBy === "user" || approval.scope === "render_override" || approval.approvedBy === "override"))
207
+ }
@@ -0,0 +1,139 @@
1
+ export type NarrativeStatus = "draft" | "needs_research" | "needs_user_confirmation" | "ready_for_approval" | "approved"
2
+
3
+ export type NarrativeClaimKind = "context" | "problem" | "opportunity" | "evidence" | "recommendation" | "risk" | "assumption" | "ask"
4
+
5
+ export type NarrativeEvidenceStatus = "supported" | "partial" | "weak" | "missing" | "not_required"
6
+
7
+ export interface NarrativeStateV1 {
8
+ version: 1
9
+ id: string
10
+ status: NarrativeStatus
11
+ audience: AudienceIntent
12
+ decision: DecisionIntent
13
+ thesis?: NarrativeThesis
14
+ claims: NarrativeClaim[]
15
+ evidenceBindings: NarrativeEvidenceBinding[]
16
+ objections: NarrativeObjection[]
17
+ risks: NarrativeRisk[]
18
+ approvals: NarrativeApproval[]
19
+ updatedAt: string
20
+ }
21
+
22
+ export interface AudienceIntent {
23
+ primary: string
24
+ secondary?: string[]
25
+ beliefBefore: string
26
+ beliefAfter: string
27
+ decisionContext?: string
28
+ successCriteria?: string[]
29
+ }
30
+
31
+ export interface DecisionIntent {
32
+ action: string
33
+ owner?: string
34
+ deadline?: string
35
+ decisionType?: "approve" | "invest" | "prioritize" | "align" | "choose" | "understand" | "other"
36
+ consequenceOfNoDecision?: string
37
+ }
38
+
39
+ export interface NarrativeThesis {
40
+ id: string
41
+ statement: string
42
+ confidence: "high" | "medium" | "low"
43
+ caveat?: string
44
+ }
45
+
46
+ export interface NarrativeClaim {
47
+ id: string
48
+ kind: NarrativeClaimKind
49
+ text: string
50
+ importance: "central" | "supporting" | "background"
51
+ evidenceRequired: boolean
52
+ evidenceStatus: NarrativeEvidenceStatus
53
+ supportedScope?: string
54
+ unsupportedScope?: string
55
+ caveats?: string[]
56
+ }
57
+
58
+ export interface NarrativeEvidenceBinding {
59
+ id: string
60
+ claimId: string
61
+ source: string
62
+ sourcePath?: string
63
+ findingsFile?: string
64
+ quote?: string
65
+ location?: string
66
+ url?: string
67
+ caveat?: string
68
+ supportScope?: string
69
+ unsupportedScope?: string
70
+ strength: "strong" | "partial" | "weak"
71
+ }
72
+
73
+ export interface NarrativeObjection {
74
+ id: string
75
+ text: string
76
+ claimId?: string
77
+ priority: "high" | "medium" | "low"
78
+ response?: string
79
+ }
80
+
81
+ export interface NarrativeRisk {
82
+ id: string
83
+ text: string
84
+ claimId?: string
85
+ severity: "high" | "medium" | "low"
86
+ mitigation?: string
87
+ }
88
+
89
+ export interface NarrativeApproval {
90
+ id: string
91
+ narrativeHash: string
92
+ approvedAt: string
93
+ approvedBy: "user" | "override"
94
+ scope: "narrative" | "render_override"
95
+ note?: string
96
+ }
97
+
98
+ export type NarrativeReadinessStatus = "blocked" | "needs_research" | "needs_user_confirmation" | "ready_for_approval" | "approved"
99
+
100
+ export type NarrativeReadinessIssueType =
101
+ | "missing_audience"
102
+ | "missing_belief_shift"
103
+ | "missing_decision"
104
+ | "missing_thesis"
105
+ | "claim_chain_gap"
106
+ | "missing_evidence"
107
+ | "weak_evidence"
108
+ | "unsupported_scope"
109
+ | "unhandled_objection"
110
+ | "missing_risk"
111
+ | "approval_missing"
112
+ | "approval_stale"
113
+ | "artifact_stale"
114
+ | "research_findings_unattached"
115
+
116
+ export interface NarrativeReadinessIssue {
117
+ type: NarrativeReadinessIssueType
118
+ severity: "blocker" | "warning"
119
+ message: string
120
+ suggestedAction: string
121
+ claimId?: string
122
+ claimText?: string
123
+ source?: string
124
+ }
125
+
126
+ export interface NarrativeReadinessResult {
127
+ status: NarrativeReadinessStatus
128
+ narrativeHash: string
129
+ reviewedAt: string
130
+ blockers: string[]
131
+ warnings: string[]
132
+ issues: NarrativeReadinessIssue[]
133
+ approval?: {
134
+ current: boolean
135
+ stale: boolean
136
+ latest?: NarrativeApproval
137
+ }
138
+ nextActions: string[]
139
+ }
@@ -1,9 +1,14 @@
1
1
  /**
2
- * Prompt builder — assembles the three-layer system prompt.
2
+ * Prompt builder — assembles Revela system prompts.
3
3
  *
4
- * Layer 1: SKILL.md — core protocol (conversation flow, HTML rules, quality)
5
- * Layer 2: DOMAIN.md domain knowledge (report structure, terminology)
6
- * Layer 3: DESIGN.md visual style (colors, fonts, animations, layout)
4
+ * Narrative mode:
5
+ * Layer 1: NARRATIVE_SKILL.md audience/decision/claim/evidence readiness
6
+ * Layer 2: DOMAIN.md domain reasoning guidance when present
7
+ *
8
+ * Deck-render mode:
9
+ * Layer 1: SKILL.md — legacy deck render protocol (HTML rules, quality)
10
+ * Layer 2: DOMAIN.md — domain structure/terminology
11
+ * Layer 3: DESIGN.md — visual style (colors, fonts, animations, layout)
7
12
  *
8
13
  * When the active DESIGN.md has @section markers, only the global section,
9
14
  * layouts section, and a generated component index are injected into the
@@ -37,22 +42,43 @@ import { childLog } from "./log"
37
42
 
38
43
  const promptLog = childLog("prompt-builder")
39
44
 
40
- /** Path to SKILL.md shipped with this package. */
45
+ export type PromptMode = "narrative" | "deck-render"
46
+
47
+ export interface BuildPromptOptions {
48
+ mode?: PromptMode
49
+ designName?: string
50
+ domainName?: string
51
+ }
52
+
53
+ /** Path to deck-render SKILL.md shipped with this package. */
41
54
  const SKILL_MD_PATH = resolve(__dirname, "..", "skill", "SKILL.md")
55
+ const NARRATIVE_SKILL_MD_PATH = resolve(__dirname, "..", "skill", "NARRATIVE_SKILL.md")
42
56
 
43
57
  /**
44
- * Build the combined system prompt and write it to _active-prompt.md.
58
+ * Build the active system prompt and write it to _active-prompt.md.
59
+ *
60
+ * Backward-compatible call form:
61
+ * - buildPrompt() builds the default narrative prompt.
62
+ * - buildPrompt("aurora", "general") builds the default narrative prompt with active metadata overrides.
63
+ *
64
+ * New call form:
65
+ * - buildPrompt({ mode: "narrative" }) avoids design/HTML instructions.
66
+ * - buildPrompt({ mode: "deck-render" }) preserves the legacy deck render prompt.
45
67
  *
46
- * @param designName - Override design (defaults to active)
47
- * @param domainName - Override domain (defaults to active)
48
68
  * @returns The path to the written file.
49
69
  */
50
- export function buildPrompt(designName?: string, domainName?: string): string {
51
- const design = designName || activeDesign()
52
- const domain = domainName || activeDomain()
53
-
54
- // Layer 1 — SKILL.md
55
- const coreSkill = readFileSync(SKILL_MD_PATH, "utf-8")
70
+ export function buildPrompt(options?: BuildPromptOptions): string
71
+ export function buildPrompt(designName?: string, domainName?: string): string
72
+ export function buildPrompt(optionsOrDesignName?: BuildPromptOptions | string, legacyDomainName?: string): string {
73
+ const options = typeof optionsOrDesignName === "object" && optionsOrDesignName !== null
74
+ ? optionsOrDesignName
75
+ : { designName: optionsOrDesignName, domainName: legacyDomainName }
76
+ const mode: PromptMode = options.mode || "narrative"
77
+ const design = options.designName || activeDesign()
78
+ const domain = options.domainName || activeDomain()
79
+
80
+ // Layer 1 — core skill for the selected prompt mode.
81
+ const coreSkill = readFileSync(mode === "deck-render" ? SKILL_MD_PATH : NARRATIVE_SKILL_MD_PATH, "utf-8")
56
82
 
57
83
  // Check for preview.html
58
84
  const designDir = join(DESIGNS_DIR, design)
@@ -73,30 +99,37 @@ export function buildPrompt(designName?: string, domainName?: string): string {
73
99
  })
74
100
  }
75
101
 
76
- // Layer 3 — DESIGN.md: marker-aware or full-text fallback
77
- const designSkill = buildDesignLayer(design)
102
+ // Layer 3 — DESIGN.md: deck-render only. Narrative mode must not inject
103
+ // visual CSS, layout catalogs, component indexes, or HTML skeleton rules.
104
+ const designSkill = mode === "deck-render" ? buildDesignLayer(design) : ""
78
105
 
79
106
  // Assemble header
80
- const header =
81
- `<!-- Active design: ${design} -->\n` +
82
- `<!-- Active domain: ${domain} -->\n` +
83
- `<!-- Design files: ${designDir}/ -->\n` +
84
- `<!-- - DESIGN.md metadata + style instructions (injected below) -->\n` +
85
- `${previewLine}\n\n`
86
-
87
- // Three-layer concatenation: Header SKILL Domain Design
107
+ const header = mode === "deck-render"
108
+ ? `<!-- Revela prompt mode: deck-render -->\n` +
109
+ `<!-- Active design: ${design} -->\n` +
110
+ `<!-- Active domain: ${domain} -->\n` +
111
+ `<!-- Design files: ${designDir}/ -->\n` +
112
+ `<!-- - DESIGN.md — metadata + style instructions (injected below) -->\n` +
113
+ `${previewLine}\n\n`
114
+ : `<!-- Revela prompt mode: narrative -->\n` +
115
+ `<!-- Active domain: ${domain} -->\n` +
116
+ `<!-- Design layer intentionally omitted in narrative mode. Use deck-render mode before writing deck artifacts. -->\n\n`
117
+
118
+ // Concatenation: Header → Skill → Domain → Design (deck-render only)
88
119
  const parts = [header, coreSkill]
89
120
  if (domainSkill) {
90
121
  parts.push(`\n\n---\n\n${domainSkill}`)
91
122
  }
92
- parts.push(`\n\n---\n\n${designSkill}`)
123
+ if (designSkill) {
124
+ parts.push(`\n\n---\n\n${designSkill}`)
125
+ }
93
126
 
94
127
  const prompt = parts.join("")
95
128
 
96
129
  // Write to _active-prompt.md
97
130
  mkdirSync(CONFIG_DIR, { recursive: true })
98
131
  writeFileSync(ACTIVE_PROMPT_FILE, prompt, "utf-8")
99
- promptLog.info("prompt rebuilt", { design, domain, bytes: prompt.length })
132
+ promptLog.info("prompt rebuilt", { mode, design, domain, bytes: prompt.length })
100
133
 
101
134
  return ACTIVE_PROMPT_FILE
102
135
  }
@@ -1,6 +1,13 @@
1
1
  import { formatReport, runQA } from "./index"
2
+ import { assertDeckHtmlContractValid } from "../deck-html/contract"
3
+
4
+ export interface ExportQAGateOptions {
5
+ workspaceRoot?: string
6
+ }
7
+
8
+ export async function assertExportQAPassed(filePath: string, options: ExportQAGateOptions = {}): Promise<void> {
9
+ if (options.workspaceRoot) assertDeckHtmlContractValid(options.workspaceRoot, filePath)
2
10
 
3
- export async function assertExportQAPassed(filePath: string): Promise<void> {
4
11
  const report = await runQA(filePath)
5
12
  if (report.totalIssues === 0) return
6
13
 
@@ -2,6 +2,7 @@ import { existsSync } from "fs"
2
2
  import { ACTIVE_PROMPT_FILE } from "../config"
3
3
  import { ctx } from "../ctx"
4
4
  import { seedBuiltinDesigns } from "../design/designs"
5
+ import { assertDeckHtmlContractValid } from "../deck-html/contract"
5
6
  import { seedBuiltinDomains } from "../domain/domains"
6
7
  import { ensureEditableDeckState } from "../edit/deck-state"
7
8
  import { openUrl } from "../edit/open"
@@ -33,6 +34,7 @@ export interface OpenRefineDeckOptions {
33
34
  export function openRefineDeck(target: string, options: OpenRefineDeckOptions): OpenRefineDeckResult {
34
35
  const deck = resolveEditableDeck(options.workspaceRoot, target)
35
36
  const preflight = ensureEditableDeckState(options.workspaceRoot, deck)
37
+ assertDeckHtmlContractValid(options.workspaceRoot, deck.absoluteFile)
36
38
  const mode = options.mode ?? "edit"
37
39
 
38
40
  ctx.enabled = true
@@ -57,7 +59,7 @@ export function openRefineDeck(target: string, options: OpenRefineDeckOptions):
57
59
  return {
58
60
  deck,
59
61
  url,
60
- source: deck.source === "decks-state" ? "DECKS.json" : deck.source === "file-path" ? "file path" : "fallback path",
62
+ source: deck.source === "render-target" ? "render target" : deck.source === "decks-state" ? "DECKS.json" : deck.source === "file-path" ? "file path" : "fallback path",
61
63
  stateNote: preflight.changed ? "Deck state was prepared in DECKS.json for refinement." : "Deck state already points to this refinement target.",
62
64
  preflightChanged: preflight.changed,
63
65
  reusedSession: session.reused,
@@ -0,0 +1,71 @@
1
+ import { createHash } from "crypto"
2
+ import type { DecksState } from "../decks-state"
3
+ import type { WorkspaceAction, WorkspaceActionType } from "./types"
4
+
5
+ export const MAX_WORKSPACE_ACTIONS = 500
6
+
7
+ export interface WorkspaceActionInput {
8
+ type: WorkspaceActionType
9
+ actor?: string
10
+ inputs?: Record<string, unknown>
11
+ outputs?: Record<string, unknown>
12
+ status?: WorkspaceAction["status"]
13
+ summary?: string
14
+ nodeIds?: string[]
15
+ timestamp?: string
16
+ }
17
+
18
+ export function recordWorkspaceAction(state: DecksState, input: WorkspaceActionInput): DecksState {
19
+ const actions = state.actions ?? []
20
+ const timestamp = input.timestamp ?? new Date().toISOString()
21
+ const action: WorkspaceAction = {
22
+ id: workspaceActionId(input.type, timestamp, actions.length, input),
23
+ type: input.type,
24
+ timestamp,
25
+ status: input.status ?? "success",
26
+ ...(input.actor ? { actor: input.actor } : {}),
27
+ ...(input.inputs ? { inputs: compactActionPayload(input.inputs) } : {}),
28
+ ...(input.outputs ? { outputs: compactActionPayload(input.outputs) } : {}),
29
+ ...(input.summary ? { summary: input.summary } : {}),
30
+ ...(input.nodeIds && input.nodeIds.length > 0 ? { nodeIds: [...new Set(input.nodeIds)].sort() } : {}),
31
+ }
32
+
33
+ state.actions = [...actions, action].slice(-MAX_WORKSPACE_ACTIONS)
34
+ return state
35
+ }
36
+
37
+ export function compactActionPayload(input: Record<string, unknown>): Record<string, unknown> {
38
+ const output: Record<string, unknown> = {}
39
+ for (const [key, value] of Object.entries(input)) {
40
+ const compacted = compactActionValue(value)
41
+ if (compacted !== undefined) output[key] = compacted
42
+ }
43
+ return output
44
+ }
45
+
46
+ export function workspaceActionId(type: WorkspaceActionType, timestamp: string, sequence: number, input: Omit<WorkspaceActionInput, "timestamp">): string {
47
+ return `action:${timestamp}:${type}:${stableHash(JSON.stringify({ sequence, input: compactActionPayload(input as Record<string, unknown>) }))}`
48
+ }
49
+
50
+ function compactActionValue(value: unknown): unknown {
51
+ if (value === undefined || value === null) return undefined
52
+ if (typeof value === "string") {
53
+ const trimmed = value.trim()
54
+ if (!trimmed) return undefined
55
+ return trimmed.length > 500 ? `${trimmed.slice(0, 500).trimEnd()}... [truncated]` : trimmed
56
+ }
57
+ if (typeof value === "number" || typeof value === "boolean") return value
58
+ if (Array.isArray(value)) {
59
+ const items = value.map(compactActionValue).filter((item) => item !== undefined)
60
+ return items.length > 0 ? items.slice(0, 50) : undefined
61
+ }
62
+ if (typeof value === "object") {
63
+ const compacted = compactActionPayload(value as Record<string, unknown>)
64
+ return Object.keys(compacted).length > 0 ? compacted : undefined
65
+ }
66
+ return undefined
67
+ }
68
+
69
+ function stableHash(value: string): string {
70
+ return createHash("sha1").update(value).digest("hex").slice(0, 10)
71
+ }
@@ -0,0 +1,10 @@
1
+ import type { DecksState } from "../decks-state"
2
+ import type { DecksStateV1Projection, WorkspaceState } from "./types"
3
+
4
+ export function isDecksStateV1(state: WorkspaceState): state is DecksState {
5
+ return state.version === 1
6
+ }
7
+
8
+ export function asDecksStateV1Projection(state: DecksState): DecksStateV1Projection {
9
+ return state
10
+ }