@cyber-dash-tech/revela 0.15.4 → 0.16.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.
@@ -44,6 +44,23 @@ export function renderNarrativeMapHtmlWithDisplay(map: NarrativeMap, display: Va
44
44
  .layout { display:grid; grid-template-columns:minmax(0,1fr) minmax(360px,430px); gap:18px; margin-top:18px; align-items:start; }
45
45
  .flow,.detail-panel { background:rgba(255,253,248,.92); border:1px solid var(--line); border-radius:24px; box-shadow:var(--shadow); }
46
46
  .flow { padding:20px; }
47
+ .workbench { margin-top:18px; background:rgba(255,253,248,.92); border:1px solid var(--line); border-radius:24px; box-shadow:var(--shadow); padding:18px 20px; }
48
+ .workbench h2 { margin:0; font-size:18px; letter-spacing:-.025em; }
49
+ .workbench-summary { display:grid; grid-template-columns:repeat(auto-fit,minmax(190px,1fr)); gap:10px; margin-top:14px; }
50
+ .summary-item { border:1px solid var(--line); border-radius:14px; background:#fff; padding:11px 12px; }
51
+ .summary-label { display:block; color:var(--muted); font-size:11px; font-weight:850; letter-spacing:.05em; text-transform:uppercase; }
52
+ .summary-value { display:block; margin-top:4px; color:#51483f; font-size:14px; font-weight:850; }
53
+ .filter-row { display:flex; flex-wrap:wrap; gap:8px; margin-top:14px; }
54
+ .filter-button { cursor:pointer; border:1px solid var(--line); border-radius:999px; background:#fff; color:var(--muted); padding:8px 11px; font-size:12px; font-weight:850; }
55
+ .filter-button.active { border-color:var(--accent); color:var(--accent); background:#fff4ea; }
56
+ .filter-status { margin:10px 0 0; color:var(--muted); font-size:12px; font-weight:780; }
57
+ .filter-empty { display:none; margin-top:10px; border:1px dashed var(--line); border-radius:14px; padding:12px; color:var(--muted); background:#fffaf3; font-size:13px; }
58
+ .coverage-grid { margin-top:16px; display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:10px; }
59
+ .coverage-item { border:1px solid var(--line); border-radius:16px; background:#fff; padding:13px; }
60
+ .coverage-item h3 { margin:0; font-size:14px; line-height:1.2; }
61
+ .coverage-meta { display:flex; flex-wrap:wrap; gap:6px; margin-top:9px; }
62
+ .coverage-detail { margin:9px 0 0; color:var(--muted); font-size:12px; line-height:1.45; }
63
+ .coverage-detail strong { color:#51483f; }
47
64
  .flow-head { display:flex; justify-content:space-between; gap:14px; align-items:flex-start; margin-bottom:18px; }
48
65
  .flow-head h2 { margin:0; font-size:18px; letter-spacing:-.025em; }
49
66
  .flow-note { margin:4px 0 0; color:var(--muted); font-size:13px; line-height:1.45; }
@@ -64,6 +81,10 @@ export function renderNarrativeMapHtmlWithDisplay(map: NarrativeMap, display: Va
64
81
  .claim-section { border-top:1px solid #eee4d8; padding-top:9px; }
65
82
  .section-label { display:block; margin-bottom:3px; color:var(--accent); font-size:10px; font-weight:900; letter-spacing:.08em; text-transform:uppercase; }
66
83
  .section-text { display:block; color:#51483f; font-size:13px; line-height:1.46; white-space:pre-line; }
84
+ .next-actions { display:flex; flex-direction:column; gap:8px; }
85
+ .next-action { border:1px solid #eee4d8; border-radius:12px; padding:9px; background:#fffaf3; }
86
+ .next-action strong { display:block; color:#51483f; font-size:13px; }
87
+ .next-action code { display:inline-block; margin-top:5px; color:#9c4d1d; font-size:12px; }
67
88
  .relation-strip { margin-top:12px; display:grid; gap:7px; }
68
89
  .relation { display:grid; grid-template-columns:auto minmax(0,1fr); gap:8px; align-items:flex-start; color:var(--muted); font-size:13px; line-height:1.35; }
69
90
  .relation-badge { flex:0 0 auto; border-radius:999px; padding:3px 7px; background:#fff4e8; color:#9c4d1d; border:1px solid #efcfb8; font-size:10px; font-weight:850; text-transform:uppercase; letter-spacing:.04em; }
@@ -121,12 +142,16 @@ export function renderNarrativeMapHtmlWithDisplay(map: NarrativeMap, display: Va
121
142
  <div class="detail-body" id="detail-body">${initial?.detailHtml ?? emptyCard(display.labels.claimFlow, display.labels.noClaims)}</div>
122
143
  </aside>
123
144
  </div>
145
+ ${renderWorkbench(map, display)}
124
146
  </main>
125
147
  <div class="hidden-detail">
126
148
  ${nodes.map((node) => `<template id="detail-${escapeAttr(node.id)}" data-title="${escapeHtml(node.title)}" data-subtitle="${escapeHtml(claimSubtitle(node.claim, display))}">${node.detailHtml}</template>`).join("")}
127
149
  </div>
128
150
  <script>
129
151
  const buttons = Array.from(document.querySelectorAll('.claim-card'));
152
+ const filters = Array.from(document.querySelectorAll('.filter-button'));
153
+ const filterStatus = document.getElementById('filter-status');
154
+ const filterEmpty = document.getElementById('filter-empty');
130
155
  const title = document.getElementById('detail-title');
131
156
  const sub = document.getElementById('detail-sub');
132
157
  const body = document.getElementById('detail-body');
@@ -139,6 +164,19 @@ export function renderNarrativeMapHtmlWithDisplay(map: NarrativeMap, display: Va
139
164
  buttons.forEach((button) => button.classList.toggle('active', button.dataset.nodeId === id));
140
165
  }
141
166
  buttons.forEach((button) => button.addEventListener('click', () => selectClaim(button.dataset.nodeId)));
167
+ filters.forEach((button) => button.addEventListener('click', () => {
168
+ const filter = button.dataset.filterId || 'all';
169
+ filters.forEach((item) => item.classList.toggle('active', item === button));
170
+ buttons.forEach((claimButton) => {
171
+ const flags = (claimButton.dataset.filters || '').split(' ');
172
+ claimButton.closest('.claim-step').style.display = filter === 'all' || flags.includes(filter) ? '' : 'none';
173
+ });
174
+ const visibleButtons = buttons.filter((claimButton) => claimButton.closest('.claim-step').style.display !== 'none');
175
+ const activeButton = buttons.find((claimButton) => claimButton.classList.contains('active'));
176
+ if (visibleButtons.length > 0 && (!activeButton || activeButton.closest('.claim-step').style.display === 'none')) selectClaim(visibleButtons[0].dataset.nodeId);
177
+ if (filterStatus) filterStatus.textContent = (button.dataset.filterLabel || filter) + ': ' + visibleButtons.length;
178
+ if (filterEmpty) filterEmpty.style.display = visibleButtons.length === 0 ? 'block' : 'none';
179
+ }));
142
180
  </script>
143
181
  </body>
144
182
  </html>`
@@ -158,7 +196,7 @@ function renderStep(node: FlowNode, map: NarrativeMap, display: ValidatedNarrati
158
196
  const outgoing = map.claimRelations.filter((relation) => relation.fromClaimId === node.claim.id)
159
197
  return `<div class="claim-step">
160
198
  <div class="step-rail"><div class="step-dot">${index + 1}</div><div class="step-line"></div></div>
161
- <button class="claim-card ${escapeAttr(node.claim.evidenceStatus)}${active ? " active" : ""}" data-node-id="${escapeAttr(node.id)}" type="button">
199
+ <button class="claim-card ${escapeAttr(node.claim.evidenceStatus)}${active ? " active" : ""}" data-node-id="${escapeAttr(node.id)}" data-filters="${escapeHtml(node.claim.workbenchFlags.join(" "))}" type="button">
162
200
  <span class="claim-title">${escapeHtml(node.title)}</span>
163
201
  <span class="claim-meta"><span class="tag">${escapeHtml(localizeValue(node.claim.kind, display))}</span><span class="tag">${escapeHtml(localizeValue(node.claim.importance, display))}</span><span class="tag">${escapeHtml(localizeValue(node.claim.evidenceStatus, display))}</span><span class="tag">${escapeHtml(node.claim.id)}</span></span>
164
202
  ${renderDisplayCardSummary(node.displayCard, display)}
@@ -213,9 +251,66 @@ function claimDetail(claim: NarrativeMapClaim, map: NarrativeMap, display: Valid
213
251
  ...(gaps.length ? [[display.labels.researchGaps, gaps.map((item) => `${item.question} [${item.status}/${item.priority}]`).join("<br>")] as [string, string]] : []),
214
252
  ...(slideRefs.length ? [[display.labels.coveredSlides, slideRefs.map((ref) => localizeSlideRef(ref, display)).join("<br>")] as [string, string]] : []),
215
253
  ...(coverageGaps.length ? [[systemTerm("artifactCoverage", display), coverageGaps.map((artifact) => `${artifact.type}: ${artifact.coverageStatus}${artifact.staleReasons.length ? ` - ${artifact.staleReasons.join("; ")}` : ""}`).join("<br>")] as [string, string]] : []),
254
+ ...(claim.nextActions.length ? [[systemTerm("nextActions", display), renderNextActions(claim, display), true] as [string, string, boolean]] : []),
216
255
  ])
217
256
  }
218
257
 
258
+ function renderWorkbench(map: NarrativeMap, display: ValidatedNarrativeDisplayModel): string {
259
+ return `<section class="workbench" aria-label="Story workbench">
260
+ <h2>${escapeHtml(systemTerm("storyWorkbench", display))}</h2>
261
+ <p class="flow-note">${escapeHtml(workbenchNote(display))}</p>
262
+ ${renderWorkbenchSummary(map, display)}
263
+ <div class="filter-row" aria-label="Story filters">
264
+ ${map.workbench.filters.map((filter, index) => `<button type="button" class="filter-button${index === 0 ? " active" : ""}" data-filter-id="${escapeAttr(filter.id)}" data-filter-label="${escapeHtml(localizeFilter(filter.label, display))}">${escapeHtml(localizeFilter(filter.label, display))} (${filter.count})</button>`).join("")}
265
+ </div>
266
+ <p class="filter-status" id="filter-status">${escapeHtml(localizeFilter(map.workbench.filters[0]?.label ?? "All claims", display))}: ${map.workbench.filters[0]?.count ?? 0}</p>
267
+ <div class="filter-empty" id="filter-empty">${escapeHtml(noClaimsMatchFilter(display))}</div>
268
+ <div class="coverage-grid">
269
+ ${map.workbench.artifactCoverage.length ? map.workbench.artifactCoverage.map((item) => renderCoverageItem(item, display)).join("") : renderNoRenderTargetCard(map, display)}
270
+ </div>
271
+ </section>`
272
+ }
273
+
274
+ function renderWorkbenchSummary(map: NarrativeMap, display: ValidatedNarrativeDisplayModel): string {
275
+ const summary = map.workbench.summary
276
+ return `<div class="workbench-summary" aria-label="Story readiness summary">
277
+ <div class="summary-item"><span class="summary-label">${escapeHtml(systemTerm("approval", display))}</span><span class="summary-value">${escapeHtml(localizeValue(summary.approval, display))}</span></div>
278
+ <div class="summary-item"><span class="summary-label">${escapeHtml(readinessSummaryTerm("evidenceBlockers", display))}</span><span class="summary-value">${summary.evidenceBlockersCount}</span></div>
279
+ <div class="summary-item"><span class="summary-label">${escapeHtml(readinessSummaryTerm("artifactStatus", display))}</span><span class="summary-value">${escapeHtml(localizeValue(summary.artifactStatus, display))}</span></div>
280
+ <div class="summary-item"><span class="summary-label">${escapeHtml(readinessSummaryTerm("primaryNextCommand", display))}</span><span class="summary-value"><code>${escapeHtml(summary.primaryNextCommand)}</code></span></div>
281
+ <div class="summary-item"><span class="summary-label">${escapeHtml(readinessSummaryTerm("primaryNextReason", display))}</span><span class="summary-value">${escapeHtml(summary.primaryNextReason)}</span></div>
282
+ </div>`
283
+ }
284
+
285
+ function renderNoRenderTargetCard(map: NarrativeMap, display: ValidatedNarrativeDisplayModel): string {
286
+ const action = map.workbench.renderTargetAction
287
+ if (!action) return emptyCard(systemTerm("artifactCoverage", display), systemTerm("noRenderTargets", display))
288
+ return `<article class="coverage-item">
289
+ <h3>${escapeHtml(systemTerm("artifactCoverage", display))}</h3>
290
+ <p class="coverage-detail">${escapeHtml(systemTerm("noRenderTargets", display))}</p>
291
+ <p class="coverage-detail"><strong>${escapeHtml(systemTerm("notes", display))}:</strong> ${escapeHtml(localizeAction(action.label, display))} - ${escapeHtml(action.reason)}</p>
292
+ <p class="coverage-detail"><strong>${escapeHtml(systemTerm("recommendedNextCommand", display))}:</strong> <code>${escapeHtml(action.command)}</code></p>
293
+ </article>`
294
+ }
295
+
296
+ function renderCoverageItem(item: NarrativeMap["workbench"]["artifactCoverage"][number], display: ValidatedNarrativeDisplayModel): string {
297
+ const title = item.outputPath ?? item.artifactId
298
+ const slides = item.affectedSlides.map((slide) => `${localizeSlideRef(`slide ${slide.slideIndex}`, display)}: ${slide.slideTitle} (${slide.claimId}, ${slide.role}/${slide.location})`).join("<br>")
299
+ return `<article class="coverage-item">
300
+ <h3>${escapeHtml(title)}</h3>
301
+ <div class="coverage-meta"><span class="pill ${escapeAttr(item.coverageStatus)}">${escapeHtml(localizeValue(item.coverageStatus, display))}</span><span class="tag">${escapeHtml(item.type)}</span>${item.contractStatus ? `<span class="tag">${escapeHtml(item.contractStatus)}</span>` : ""}</div>
302
+ <p class="coverage-detail"><strong>${escapeHtml(systemTerm("missingClaims", display))}:</strong> ${escapeHtml(item.missingClaimIds.join(", ") || systemTerm("none", display))}</p>
303
+ <p class="coverage-detail"><strong>${escapeHtml(systemTerm("affectedClaims", display))}:</strong> ${escapeHtml(item.affectedClaimIds.join(", ") || systemTerm("none", display))}</p>
304
+ <p class="coverage-detail"><strong>${escapeHtml(systemTerm("affectedSlides", display))}:</strong> ${slides ? allowBreaks(slides) : escapeHtml(systemTerm("none", display))}</p>
305
+ <p class="coverage-detail"><strong>${escapeHtml(systemTerm("notes", display))}:</strong> ${escapeHtml([item.statusNote, ...item.staleReasons].filter(Boolean).join("; ") || systemTerm("none", display))}</p>
306
+ <p class="coverage-detail"><strong>${escapeHtml(systemTerm("recommendedNextCommand", display))}:</strong> <code>${escapeHtml(item.recommendedNextCommand)}</code></p>
307
+ </article>`
308
+ }
309
+
310
+ function renderNextActions(claim: NarrativeMapClaim, display: ValidatedNarrativeDisplayModel): string {
311
+ return `<span class="next-actions">${claim.nextActions.map((action) => `<span class="next-action"><strong>${escapeHtml(localizeAction(action.label, display))}</strong>${escapeHtml(action.reason)}<br><code>${escapeHtml(action.command)}</code></span>`).join("")}</span>`
312
+ }
313
+
219
314
  function relationText(relation: NarrativeMapClaimRelation, display: ValidatedNarrativeDisplayModel): string {
220
315
  const from = displayClaimText(relation.fromClaimId, relation.fromClaimText, display)
221
316
  const to = displayClaimText(relation.toClaimId, relation.toClaimText, display)
@@ -259,8 +354,8 @@ function missingRationale(display: ValidatedNarrativeDisplayModel): string {
259
354
  return "Causal rationale is not recorded."
260
355
  }
261
356
 
262
- function detailCards(rows: Array<[string, string]>): string {
263
- return rows.map(([label, value]) => `<div class="detail-card"><h3>${escapeHtml(label)}</h3><p>${allowBreaks(value)}</p></div>`).join("")
357
+ function detailCards(rows: Array<[string, string] | [string, string, boolean]>): string {
358
+ return rows.map(([label, value, raw]) => `<div class="detail-card"><h3>${escapeHtml(label)}</h3><p>${raw ? value : allowBreaks(value)}</p></div>`).join("")
264
359
  }
265
360
 
266
361
  function emptyCard(label: string, value: string): string {
@@ -292,14 +387,46 @@ function sectionLabels(display: ValidatedNarrativeDisplayModel): Record<string,
292
387
  return { role: "Role", narrativeJob: "Narrative job", evidenceSummary: "Evidence summary", riskOrGapSummary: "Risk / gap" }
293
388
  }
294
389
 
390
+ function workbenchNote(display: ValidatedNarrativeDisplayModel): string {
391
+ return display.labels.workbenchNote
392
+ }
393
+
394
+ function localizeFilter(value: string, display: ValidatedNarrativeDisplayModel): string {
395
+ const zh: Record<string, string> = { "All claims": "全部主张", "Missing evidence": "证据缺失", "Partial evidence": "部分证据", "Stale artifacts": "过期产物", "Open gaps": "开放缺口", Risks: "风险", "High-priority objections": "高优先级异议" }
396
+ const ja: Record<string, string> = { "All claims": "すべてのクレーム", "Missing evidence": "根拠不足", "Partial evidence": "一部根拠", "Stale artifacts": "古い成果物", "Open gaps": "未解決ギャップ", Risks: "リスク", "High-priority objections": "高優先度の反論" }
397
+ const table = isChineseLanguage(display.language) ? zh : isJapaneseLanguage(display.language) ? ja : {}
398
+ return table[value] ?? value
399
+ }
400
+
401
+ function localizeAction(value: string, display: ValidatedNarrativeDisplayModel): string {
402
+ const zh: Record<string, string> = { "Research this gap": "研究这个缺口", "Attach findings": "附加研究发现", "Narrow claim": "收窄主张", "Approve narrative": "批准叙事", "Make deck": "制作 deck", "Remake stale artifact": "重新生成过期产物" }
403
+ const ja: Record<string, string> = { "Research this gap": "このギャップを調査", "Attach findings": "調査結果を紐付け", "Narrow claim": "クレームを絞る", "Approve narrative": "ナラティブを承認", "Make deck": "デッキを作成", "Remake stale artifact": "古い成果物を再生成" }
404
+ const table = isChineseLanguage(display.language) ? zh : isJapaneseLanguage(display.language) ? ja : {}
405
+ return table[value] ?? value
406
+ }
407
+
408
+ function readinessSummaryTerm(value: string, display: ValidatedNarrativeDisplayModel): string {
409
+ const zh: Record<string, string> = { evidenceBlockers: "证据阻塞", artifactStatus: "产物状态", primaryNextCommand: "首要建议命令", primaryNextReason: "首要建议原因" }
410
+ const ja: Record<string, string> = { evidenceBlockers: "根拠ブロッカー", artifactStatus: "成果物ステータス", primaryNextCommand: "最優先コマンド", primaryNextReason: "最優先理由" }
411
+ const en: Record<string, string> = { evidenceBlockers: "Evidence blockers", artifactStatus: "Artifact status", primaryNextCommand: "Primary next command", primaryNextReason: "Primary next reason" }
412
+ return (isChineseLanguage(display.language) ? zh : isJapaneseLanguage(display.language) ? ja : en)[value] ?? value
413
+ }
414
+
415
+ function noClaimsMatchFilter(display: ValidatedNarrativeDisplayModel): string {
416
+ if (isChineseLanguage(display.language)) return "没有主张匹配这个过滤器。"
417
+ if (isJapaneseLanguage(display.language)) return "このフィルターに一致するクレームはありません。"
418
+ return "No claims match this filter."
419
+ }
420
+
295
421
  function systemTerm(term: string, display: ValidatedNarrativeDisplayModel): string {
296
- const zh: Record<string, string> = { approval: "审批", claims: "主张", relations: "关系", inferred: "未确认", relation: "关系", from: "来自", to: "指向", rationale: "说明", strength: "强度", findingsFile: "研究文件", location: "位置", quote: "引用", caveat: "注意事项", artifacts: "产物", attention: "需关注", artifactCoverage: "产物覆盖" }
297
- const ja: Record<string, string> = { approval: "承認", claims: "クレーム", relations: "関係", inferred: "未確認", relation: "関係", from: "起点", to: "終点", rationale: "理由", strength: "強度", findingsFile: "調査ファイル", location: "場所", quote: "引用", caveat: "留意点", artifacts: "成果物", attention: "要確認", artifactCoverage: "成果物カバレッジ" }
298
- const en: Record<string, string> = { approval: "approval", claims: "claims", relations: "relations", inferred: "unconfirmed", relation: "relation", from: "from", to: "to", rationale: "rationale", strength: "strength", findingsFile: "findings file", location: "location", quote: "quote", caveat: "caveat", artifacts: "artifacts", attention: "need attention", artifactCoverage: "Artifact coverage" }
422
+ const zh: Record<string, string> = { approval: "审批", claims: "主张", relations: "关系", inferred: "未确认", relation: "关系", from: "来自", to: "指向", rationale: "说明", strength: "强度", findingsFile: "研究文件", location: "位置", quote: "引用", caveat: "注意事项", artifacts: "产物", attention: "需关注", artifactCoverage: display.labels.artifactCoverage, storyWorkbench: display.labels.storyWorkbench, noRenderTargets: display.labels.noRenderTargets, nextActions: display.labels.nextActions, missingClaims: display.labels.missingClaims, affectedClaims: display.labels.affectedClaims, affectedSlides: display.labels.affectedSlides, notes: display.labels.notes, recommendedNextCommand: display.labels.recommendedNextCommand, none: display.labels.none }
423
+ const ja: Record<string, string> = { approval: "承認", claims: "クレーム", relations: "関係", inferred: "未確認", relation: "関係", from: "起点", to: "終点", rationale: "理由", strength: "強度", findingsFile: "調査ファイル", location: "場所", quote: "引用", caveat: "留意点", artifacts: "成果物", attention: "要確認", artifactCoverage: display.labels.artifactCoverage, storyWorkbench: display.labels.storyWorkbench, noRenderTargets: display.labels.noRenderTargets, nextActions: display.labels.nextActions, missingClaims: display.labels.missingClaims, affectedClaims: display.labels.affectedClaims, affectedSlides: display.labels.affectedSlides, notes: display.labels.notes, recommendedNextCommand: display.labels.recommendedNextCommand, none: display.labels.none }
424
+ const en: Record<string, string> = { approval: "approval", claims: "claims", relations: "relations", inferred: "unconfirmed", relation: "relation", from: "from", to: "to", rationale: "rationale", strength: "strength", findingsFile: "findings file", location: "location", quote: "quote", caveat: "caveat", artifacts: "artifacts", attention: "need attention", artifactCoverage: display.labels.artifactCoverage, storyWorkbench: display.labels.storyWorkbench, noRenderTargets: display.labels.noRenderTargets, nextActions: display.labels.nextActions, missingClaims: display.labels.missingClaims, affectedClaims: display.labels.affectedClaims, affectedSlides: display.labels.affectedSlides, notes: display.labels.notes, recommendedNextCommand: display.labels.recommendedNextCommand, none: display.labels.none }
299
425
  return (isChineseLanguage(display.language) ? zh : isJapaneseLanguage(display.language) ? ja : en)[term] ?? term
300
426
  }
301
427
 
302
428
  function localizeValue(value: string, display: ValidatedNarrativeDisplayModel): string {
429
+ if (value === "no_target") return isChineseLanguage(display.language) ? "无 render target" : isJapaneseLanguage(display.language) ? "render target なし" : "no target"
303
430
  const zh: Record<string, string> = {
304
431
  current: "当前", stale: "已过期", missing: "缺失", approved: "已批准", ready_for_approval: "待批准", needs_research: "需要研究", needs_user_confirmation: "需要用户确认", blocked: "受阻", draft: "草稿",
305
432
  supported: "已支持", partial: "部分支持", weak: "弱支持", not_required: "无需证据", central: "核心", supporting: "支撑", background: "背景",
@@ -23,6 +23,7 @@ export interface NarrativeMap {
23
23
  risks: NarrativeMapRisk[]
24
24
  researchGaps: NarrativeMapResearchGap[]
25
25
  artifactCoverage: NarrativeMapArtifact[]
26
+ workbench: NarrativeMapWorkbench
26
27
  nextActions: string[]
27
28
  }
28
29
 
@@ -38,7 +39,60 @@ export interface NarrativeMapSnapshot {
38
39
  approval: "current" | "stale" | "missing"
39
40
  }
40
41
 
41
- export type NarrativeMapClaim = ClaimEvidenceRecord
42
+ export interface NarrativeMapClaim extends ClaimEvidenceRecord {
43
+ nextActions: NarrativeMapNextAction[]
44
+ workbenchFlags: NarrativeMapFilterId[]
45
+ }
46
+
47
+ export type NarrativeMapActionKind = "research" | "attach_findings" | "narrow_claim" | "approve_narrative" | "make_deck" | "remake_artifact"
48
+
49
+ export type NarrativeMapArtifactStatus = "current" | "stale" | "partial" | "missing" | "no_target"
50
+
51
+ export interface NarrativeMapNextAction {
52
+ kind: NarrativeMapActionKind
53
+ label: string
54
+ command: string
55
+ reason: string
56
+ }
57
+
58
+ export type NarrativeMapFilterId = "all" | "missing_evidence" | "partial_evidence" | "stale_artifacts" | "open_gaps" | "risks" | "high_priority_objections"
59
+
60
+ export interface NarrativeMapWorkbenchFilter {
61
+ id: NarrativeMapFilterId
62
+ label: string
63
+ count: number
64
+ claimIds: string[]
65
+ }
66
+
67
+ export interface NarrativeMapArtifactWorkItem {
68
+ artifactId: string
69
+ type: RenderTarget["type"]
70
+ outputPath?: string
71
+ coverageStatus: NarrativeMapArtifact["coverageStatus"]
72
+ contractStatus?: RenderTarget["contractStatus"]
73
+ affectedClaimIds: string[]
74
+ missingClaimIds: string[]
75
+ affectedSlides: Array<{ claimId: string; slideIndex: number; slideTitle: string; role: string; location: string }>
76
+ staleReasons: string[]
77
+ statusNote: string
78
+ recommendedNextCommand: string
79
+ }
80
+
81
+ export interface NarrativeMapWorkbenchSummary {
82
+ approval: NarrativeMapSnapshot["approval"]
83
+ evidenceBlockersCount: number
84
+ artifactStatus: NarrativeMapArtifactStatus
85
+ primaryNextCommand: string
86
+ primaryNextReason: string
87
+ readinessNextActions: string[]
88
+ }
89
+
90
+ export interface NarrativeMapWorkbench {
91
+ summary: NarrativeMapWorkbenchSummary
92
+ filters: NarrativeMapWorkbenchFilter[]
93
+ artifactCoverage: NarrativeMapArtifactWorkItem[]
94
+ renderTargetAction?: NarrativeMapNextAction
95
+ }
42
96
 
43
97
  export type NarrativeMapEvidence = ClaimEvidenceBindingRecord
44
98
 
@@ -91,8 +145,28 @@ export function buildNarrativeMap(state: DecksState): NarrativeMap {
91
145
  const reviewed = reviewNarrativeState({ ...state, narrative })
92
146
  const readiness = reviewed.result
93
147
  const narrativeHash = computeNarrativeHash(narrative)
94
- const board = getClaimEvidenceBoard({ ...state, narrative })
148
+ const rawBoard = getClaimEvidenceBoard({ ...state, narrative })
95
149
  const objectionRisk = getObjectionRiskClaimIndex({ ...state, narrative })
150
+ const researchGaps = (narrative.researchGaps ?? []).map((gap) => ({ ...gap, targetText: targetText(narrative, gap) }))
151
+ const artifactCoverage = getArtifactClaimRefs({ ...state, narrative }).map((artifact) => ({
152
+ id: artifact.artifactId,
153
+ type: artifact.type,
154
+ outputPath: artifact.outputPath,
155
+ contractStatus: artifact.contractStatus,
156
+ sourceNodeIds: artifact.sourceNodeIds,
157
+ claimIds: artifact.claimIds,
158
+ narrativeIds: artifact.narrativeIds,
159
+ slideRefs: artifact.slideRefs,
160
+ coverageStatus: artifact.coverageStatus,
161
+ affectedClaimIds: artifact.affectedClaimIds,
162
+ missingClaimIds: artifact.missingClaimIds,
163
+ staleReasons: artifact.staleReasons,
164
+ stale: artifact.stale,
165
+ staleReason: artifact.staleReason,
166
+ note: artifact.note,
167
+ }))
168
+ const claims = withWorkbenchClaimData(rawBoard.claims, narrative, readiness.approval?.current === true, objectionRisk, researchGaps, artifactCoverage)
169
+ const claimFlow = claimRecordsInNarrativeOrder(narrative, claims)
96
170
 
97
171
  return {
98
172
  version: 1,
@@ -107,33 +181,259 @@ export function buildNarrativeMap(state: DecksState): NarrativeMap {
107
181
  thesis: narrative.thesis?.statement,
108
182
  approval: readiness.approval?.current ? "current" : readiness.approval?.stale ? "stale" : "missing",
109
183
  },
110
- claims: board.claims,
111
- claimFlow: claimRecordsInNarrativeOrder(narrative, board.claims),
184
+ claims,
185
+ claimFlow,
112
186
  claimRelations: mapClaimRelations(narrative),
113
187
  objections: objectionRisk.objections,
114
188
  risks: objectionRisk.risks,
115
- researchGaps: (narrative.researchGaps ?? []).map((gap) => ({ ...gap, targetText: targetText(narrative, gap) })),
116
- artifactCoverage: getArtifactClaimRefs({ ...state, narrative }).map((artifact) => ({
117
- id: artifact.artifactId,
189
+ researchGaps,
190
+ artifactCoverage,
191
+ workbench: buildWorkbench(claimFlow, artifactCoverage, readiness.approval?.current === true, readiness.nextActions, readiness.approval?.current ? "current" : readiness.approval?.stale ? "stale" : "missing"),
192
+ nextActions: readiness.nextActions,
193
+ }
194
+ }
195
+
196
+ function withWorkbenchClaimData(
197
+ claimsByStatus: Record<NarrativeClaim["evidenceStatus"], ClaimEvidenceRecord[]>,
198
+ narrative: NarrativeStateV1,
199
+ approved: boolean,
200
+ objectionRisk: ReturnType<typeof getObjectionRiskClaimIndex>,
201
+ researchGaps: NarrativeMapResearchGap[],
202
+ artifacts: NarrativeMapArtifact[],
203
+ ): Record<NarrativeClaim["evidenceStatus"], NarrativeMapClaim[]> {
204
+ return Object.fromEntries(Object.entries(claimsByStatus).map(([status, claims]) => [
205
+ status,
206
+ claims.map((claim) => ({
207
+ ...claim,
208
+ nextActions: claimNextActions(claim, narrative, approved, objectionRisk, researchGaps, artifacts),
209
+ workbenchFlags: claimWorkbenchFlags(claim, objectionRisk, researchGaps, artifacts),
210
+ })),
211
+ ])) as Record<NarrativeClaim["evidenceStatus"], NarrativeMapClaim[]>
212
+ }
213
+
214
+ function claimNextActions(
215
+ claim: ClaimEvidenceRecord,
216
+ narrative: NarrativeStateV1,
217
+ approved: boolean,
218
+ objectionRisk: ReturnType<typeof getObjectionRiskClaimIndex>,
219
+ researchGaps: NarrativeMapResearchGap[],
220
+ artifacts: NarrativeMapArtifact[],
221
+ ): NarrativeMapNextAction[] {
222
+ const actions: NarrativeMapNextAction[] = []
223
+ const claimGaps = researchGaps.filter((gap) => gap.targetType === "claim" && gap.targetId === claim.id)
224
+ const hasSavedFindings = claimGaps.some((gap) => gap.status === "findings_saved" || gap.status === "attached")
225
+ const needsEvidence = claim.evidenceRequired && (claim.evidenceStatus === "missing" || claim.evidenceStatus === "weak")
226
+ const partialEvidence = claim.evidenceRequired && claim.evidenceStatus === "partial"
227
+ const affectedArtifacts = artifacts.filter((artifact) => artifact.coverageStatus !== "current" && (artifact.affectedClaimIds.includes(claim.id) || artifact.missingClaimIds.includes(claim.id) || artifact.claimIds.includes(claim.id)))
228
+
229
+ if (needsEvidence || claimGaps.some((gap) => gap.status === "open" || gap.status === "in_progress")) actions.push({
230
+ kind: "research",
231
+ label: "Research this gap",
232
+ command: "/revela research",
233
+ reason: needsEvidence ? "Required evidence is missing or weak for this claim." : "An open research gap targets this claim.",
234
+ })
235
+ if (hasSavedFindings) actions.push({
236
+ kind: "attach_findings",
237
+ label: "Attach findings",
238
+ command: "/revela research",
239
+ reason: "Saved or attached findings still need canonical evidence binding.",
240
+ })
241
+ if (partialEvidence || Boolean(claim.unsupportedScope)) actions.push({
242
+ kind: "narrow_claim",
243
+ label: "Narrow claim",
244
+ command: "/revela story",
245
+ reason: claim.unsupportedScope || "Evidence only partially supports the claim scope.",
246
+ })
247
+ if (!approved) actions.push({
248
+ kind: "approve_narrative",
249
+ label: "Approve narrative",
250
+ command: "/revela story",
251
+ reason: "The current narrative is not approved for artifact rendering.",
252
+ })
253
+ if (approved && artifacts.length === 0) actions.push({
254
+ kind: "make_deck",
255
+ label: "Make deck",
256
+ command: "/revela make --deck",
257
+ reason: "No render target is recorded for this approved narrative.",
258
+ })
259
+ if (approved && affectedArtifacts.length > 0) actions.push({
260
+ kind: "remake_artifact",
261
+ label: "Remake stale artifact",
262
+ command: "/revela make --deck",
263
+ reason: "Artifact coverage is stale, partial, or missing for this claim.",
264
+ })
265
+ if (approved && actions.length === 0 && claim.importance === "central") actions.push({
266
+ kind: "make_deck",
267
+ label: "Make deck",
268
+ command: "/revela make --deck",
269
+ reason: "Central claim is ready to hand off to an artifact.",
270
+ })
271
+
272
+ return dedupeActions(actions)
273
+ }
274
+
275
+ function claimWorkbenchFlags(
276
+ claim: ClaimEvidenceRecord,
277
+ objectionRisk: ReturnType<typeof getObjectionRiskClaimIndex>,
278
+ researchGaps: NarrativeMapResearchGap[],
279
+ artifacts: NarrativeMapArtifact[],
280
+ ): NarrativeMapFilterId[] {
281
+ const flags: NarrativeMapFilterId[] = ["all"]
282
+ if (claim.evidenceRequired && claim.evidenceStatus === "missing") flags.push("missing_evidence")
283
+ if (claim.evidenceRequired && (claim.evidenceStatus === "partial" || claim.evidenceStatus === "weak")) flags.push("partial_evidence")
284
+ if (researchGaps.some((gap) => gap.targetType === "claim" && gap.targetId === claim.id && (gap.status === "open" || gap.status === "in_progress" || gap.status === "findings_saved"))) flags.push("open_gaps")
285
+ if (objectionRisk.risks.some((risk) => risk.claimId === claim.id)) flags.push("risks")
286
+ if (objectionRisk.objections.some((objection) => objection.claimId === claim.id && objection.priority === "high")) flags.push("high_priority_objections")
287
+ if (artifacts.some((artifact) => artifact.coverageStatus !== "current" && (artifact.affectedClaimIds.includes(claim.id) || artifact.missingClaimIds.includes(claim.id) || artifact.claimIds.includes(claim.id)))) flags.push("stale_artifacts")
288
+ return [...new Set(flags)]
289
+ }
290
+
291
+ function buildWorkbench(
292
+ claims: NarrativeMapClaim[],
293
+ artifacts: NarrativeMapArtifact[],
294
+ approved: boolean,
295
+ readinessNextActions: string[],
296
+ approval: NarrativeMapSnapshot["approval"],
297
+ ): NarrativeMapWorkbench {
298
+ const filterLabels: Record<NarrativeMapFilterId, string> = {
299
+ all: "All claims",
300
+ missing_evidence: "Missing evidence",
301
+ partial_evidence: "Partial evidence",
302
+ stale_artifacts: "Stale artifacts",
303
+ open_gaps: "Open gaps",
304
+ risks: "Risks",
305
+ high_priority_objections: "High-priority objections",
306
+ }
307
+ const filterIds: NarrativeMapFilterId[] = ["all", "missing_evidence", "partial_evidence", "stale_artifacts", "open_gaps", "risks", "high_priority_objections"]
308
+ const renderTargetAction = artifacts.length === 0 ? {
309
+ kind: approved ? "make_deck" as const : "approve_narrative" as const,
310
+ label: approved ? "Make deck" : "Approve narrative",
311
+ command: approved ? "/revela make --deck" : "/revela story",
312
+ reason: approved ? "No render target is recorded for this approved narrative." : "Narrative approval is required before rendering artifacts.",
313
+ } : undefined
314
+ return {
315
+ summary: buildWorkbenchSummary(claims, artifacts, approval, readinessNextActions, renderTargetAction?.command),
316
+ filters: filterIds.map((id) => {
317
+ const claimIds = claims.filter((claim) => claim.workbenchFlags.includes(id)).map((claim) => claim.id)
318
+ return { id, label: filterLabels[id], count: claimIds.length, claimIds }
319
+ }),
320
+ artifactCoverage: artifacts.map((artifact) => ({
321
+ artifactId: artifact.id,
118
322
  type: artifact.type,
119
323
  outputPath: artifact.outputPath,
120
- contractStatus: artifact.contractStatus,
121
- sourceNodeIds: artifact.sourceNodeIds,
122
- claimIds: artifact.claimIds,
123
- narrativeIds: artifact.narrativeIds,
124
- slideRefs: artifact.slideRefs,
125
324
  coverageStatus: artifact.coverageStatus,
325
+ contractStatus: artifact.contractStatus,
126
326
  affectedClaimIds: artifact.affectedClaimIds,
127
327
  missingClaimIds: artifact.missingClaimIds,
328
+ affectedSlides: artifact.slideRefs
329
+ .filter((ref) => artifact.affectedClaimIds.includes(ref.claimId) || artifact.missingClaimIds.includes(ref.claimId) || artifact.coverageStatus !== "current")
330
+ .map((ref) => ({ claimId: ref.claimId, slideIndex: ref.slideIndex, slideTitle: ref.slideTitle, role: ref.role, location: ref.location })),
128
331
  staleReasons: artifact.staleReasons,
129
- stale: artifact.stale,
130
- staleReason: artifact.staleReason,
131
- note: artifact.note,
332
+ statusNote: artifactStatusNote(artifact, artifacts),
333
+ recommendedNextCommand: recommendedArtifactCommand(artifact, artifacts),
132
334
  })),
133
- nextActions: readiness.nextActions,
335
+ renderTargetAction,
336
+ }
337
+ }
338
+
339
+ function buildWorkbenchSummary(
340
+ claims: NarrativeMapClaim[],
341
+ artifacts: NarrativeMapArtifact[],
342
+ approval: NarrativeMapSnapshot["approval"],
343
+ readinessNextActions: string[],
344
+ renderTargetCommand?: string,
345
+ ): NarrativeMapWorkbenchSummary {
346
+ const evidenceBlockersCount = claims.filter((claim) => claim.evidenceRequired && (claim.evidenceStatus === "missing" || claim.evidenceStatus === "weak" || claim.evidenceStatus === "partial")).length
347
+ const artifactStatus = aggregateArtifactStatus(artifacts)
348
+ const primaryNext = primaryNextDecision({ approval, evidenceBlockersCount, artifactStatus, artifacts, renderTargetCommand })
349
+ return {
350
+ approval,
351
+ evidenceBlockersCount,
352
+ artifactStatus,
353
+ primaryNextCommand: primaryNext.command,
354
+ primaryNextReason: primaryNext.reason,
355
+ readinessNextActions,
356
+ }
357
+ }
358
+
359
+ function primaryNextDecision(input: { approval: NarrativeMapSnapshot["approval"]; evidenceBlockersCount: number; artifactStatus: NarrativeMapArtifactStatus; artifacts: NarrativeMapArtifact[]; renderTargetCommand?: string }): { command: string; reason: string } {
360
+ if (input.evidenceBlockersCount > 0) return {
361
+ command: "/revela research",
362
+ reason: `${input.evidenceBlockersCount} evidence-required claim${input.evidenceBlockersCount === 1 ? "" : "s"} still need stronger support before artifact handoff.`,
363
+ }
364
+ if (input.approval !== "current") return {
365
+ command: "/revela story",
366
+ reason: input.approval === "stale" ? "Narrative approval is stale after meaning changed." : "Narrative approval is missing before artifact rendering.",
367
+ }
368
+ if (input.artifactStatus === "no_target") return {
369
+ command: input.renderTargetCommand ?? "/revela make --deck",
370
+ reason: "Approved narrative has no recorded render target yet.",
371
+ }
372
+ if (input.artifactStatus !== "current") {
373
+ const command = input.artifacts.map((artifact) => recommendedArtifactCommand(artifact, input.artifacts)).find((item) => item !== "/revela review --deck") ?? "/revela make --deck"
374
+ return {
375
+ command,
376
+ reason: artifactStatusReason(input.artifactStatus, command),
377
+ }
378
+ }
379
+ return {
380
+ command: "/revela review --deck",
381
+ reason: "Current HTML deck coverage is recorded; review is the next read-only workspace.",
134
382
  }
135
383
  }
136
384
 
385
+ function aggregateArtifactStatus(artifacts: NarrativeMapArtifact[]): NarrativeMapArtifactStatus {
386
+ if (artifacts.length === 0) return "no_target"
387
+ if (artifacts.some((artifact) => artifact.coverageStatus === "stale")) return "stale"
388
+ if (artifacts.some((artifact) => artifact.coverageStatus === "missing")) return "missing"
389
+ if (artifacts.some((artifact) => artifact.coverageStatus === "partial")) return "partial"
390
+ return "current"
391
+ }
392
+
393
+ function recommendedArtifactCommand(artifact: NarrativeMapArtifact, artifacts: NarrativeMapArtifact[]): string {
394
+ if (artifact.coverageStatus === "current") return artifact.type === "html_deck" ? "/revela review --deck" : exportCommand(artifact.type) ?? "/revela review --deck"
395
+ if (artifact.type === "html_deck") return "/revela make --deck"
396
+ if ((artifact.type === "pdf" || artifact.type === "pptx") && activeHtmlIsCurrent(artifact, artifacts)) return exportCommand(artifact.type) ?? "/revela make --deck"
397
+ return "/revela make --deck"
398
+ }
399
+
400
+ function artifactStatusNote(artifact: NarrativeMapArtifact, artifacts: NarrativeMapArtifact[]): string {
401
+ if (artifact.coverageStatus === "current") return artifact.type === "html_deck" ? "Current HTML deck is ready for review or export." : `Current ${artifact.type.toUpperCase()} export is recorded.`
402
+ const command = recommendedArtifactCommand(artifact, artifacts)
403
+ if ((artifact.type === "pdf" || artifact.type === "pptx") && command.startsWith("/revela export")) return `HTML deck is current; refresh the ${artifact.type.toUpperCase()} export.`
404
+ if (artifact.coverageStatus === "partial") return "Artifact is partial; remake the deck so all central or evidence-required claims are covered."
405
+ if (artifact.coverageStatus === "missing") return "Artifact is missing required narrative coverage; remake the deck from the approved narrative."
406
+ return "Artifact is stale relative to the current narrative; remake the deck from the approved narrative."
407
+ }
408
+
409
+ function artifactStatusReason(status: Exclude<NarrativeMapArtifactStatus, "current" | "no_target">, command: string): string {
410
+ if (command.startsWith("/revela export")) return "HTML deck coverage is current, but a derived export is stale and should be refreshed."
411
+ if (status === "partial") return "At least one render target only partially covers central or evidence-required claims."
412
+ if (status === "missing") return "At least one render target is missing required narrative coverage."
413
+ return "At least one render target is stale relative to the current narrative."
414
+ }
415
+
416
+ function activeHtmlIsCurrent(artifact: NarrativeMapArtifact, artifacts: NarrativeMapArtifact[]): boolean {
417
+ const expectedHtmlPath = artifact.outputPath?.replace(/\.(pdf|pptx)$/i, ".html")
418
+ return artifacts.some((item) => item.type === "html_deck" && item.coverageStatus === "current" && (!expectedHtmlPath || item.outputPath === expectedHtmlPath))
419
+ }
420
+
421
+ function exportCommand(type: RenderTarget["type"]): string | undefined {
422
+ if (type === "pdf") return "/revela export --deck pdf"
423
+ if (type === "pptx") return "/revela export --deck pptx"
424
+ return undefined
425
+ }
426
+
427
+ function dedupeActions(actions: NarrativeMapNextAction[]): NarrativeMapNextAction[] {
428
+ const seen = new Set<string>()
429
+ return actions.filter((action) => {
430
+ const key = `${action.kind}:${action.command}`
431
+ if (seen.has(key)) return false
432
+ seen.add(key)
433
+ return true
434
+ })
435
+ }
436
+
137
437
  function claimRecordsInNarrativeOrder(narrative: NarrativeStateV1, claimsByStatus: Record<NarrativeClaim["evidenceStatus"], NarrativeMapClaim[]>): NarrativeMapClaim[] {
138
438
  const records = new Map(Object.values(claimsByStatus).flat().map((claim) => [claim.id, claim]))
139
439
  return narrative.claims.map((claim) => records.get(claim.id)).filter((claim): claim is NarrativeMapClaim => Boolean(claim))
@@ -197,6 +497,7 @@ export function formatNarrativeMap(map: NarrativeMap): string {
197
497
  for (const evidence of claim.evidence) {
198
498
  lines.push(` Evidence: ${evidenceLine(evidence)}`)
199
499
  }
500
+ if (claim.nextActions.length > 0) lines.push(` Next actions: ${claim.nextActions.map((action) => `${action.label} (${action.command})`).join("; ")}`)
200
501
  }
201
502
  }
202
503
 
@@ -245,6 +546,22 @@ export function formatNarrativeMap(map: NarrativeMap): string {
245
546
  if (artifact.note) lines.push(` Note: ${artifact.note}`)
246
547
  }
247
548
 
549
+ lines.push("", "## Story Workbench")
550
+ lines.push(`- Summary approval: ${map.workbench.summary.approval}`)
551
+ lines.push(`- Summary evidence blockers: ${map.workbench.summary.evidenceBlockersCount}`)
552
+ lines.push(`- Summary artifact status: ${map.workbench.summary.artifactStatus}`)
553
+ lines.push(`- Summary primary next command: ${map.workbench.summary.primaryNextCommand}`)
554
+ lines.push(`- Summary primary next reason: ${map.workbench.summary.primaryNextReason}`)
555
+ for (const filter of map.workbench.filters) lines.push(`- Filter ${filter.id}: ${filter.count}${filter.claimIds.length ? ` (${filter.claimIds.join(", ")})` : ""}`)
556
+ if (map.workbench.renderTargetAction) lines.push(`- Render target action: ${map.workbench.renderTargetAction.label} (${map.workbench.renderTargetAction.command})`)
557
+ for (const item of map.workbench.artifactCoverage) {
558
+ lines.push(`- Artifact work item: ${item.type}: ${item.outputPath ?? item.artifactId} [${item.coverageStatus}] -> ${item.recommendedNextCommand}`)
559
+ lines.push(` Status note: ${item.statusNote}`)
560
+ if (item.missingClaimIds.length) lines.push(` Missing claims: ${item.missingClaimIds.join(", ")}`)
561
+ if (item.affectedClaimIds.length) lines.push(` Affected claims: ${item.affectedClaimIds.join(", ")}`)
562
+ if (item.affectedSlides.length) lines.push(` Affected slides: ${item.affectedSlides.map((slide) => `${slide.slideIndex}:${slide.claimId}`).join(", ")}`)
563
+ }
564
+
248
565
  lines.push("", "## Next Actions")
249
566
  if (map.nextActions.length === 0) lines.push("- None")
250
567
  else for (const action of map.nextActions) lines.push(`- ${action}`)