@cyber-dash-tech/revela 0.16.3 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -5
- package/README.zh-CN.md +7 -5
- package/lib/commands/brief.ts +9 -0
- package/lib/commands/help.ts +5 -2
- package/lib/commands/init.ts +42 -27
- package/lib/commands/narrative.ts +26 -2
- package/lib/commands/research.ts +36 -20
- package/lib/commands/review.ts +21 -18
- package/lib/ctx.ts +1 -1
- package/lib/decks-state.ts +38 -4
- package/lib/edit/prompt.ts +1 -1
- package/lib/hook-notifications.ts +53 -0
- package/lib/narrative-state/render-plan.ts +114 -27
- package/lib/narrative-state/research-binding-eval.ts +260 -0
- package/lib/narrative-state/research-gaps.ts +2 -88
- package/lib/narrative-vault/authoring-contract.ts +127 -0
- package/lib/narrative-vault/authoring-guard.ts +122 -0
- package/lib/narrative-vault/auto-compile.ts +134 -0
- package/lib/narrative-vault/bootstrap.ts +63 -0
- package/lib/narrative-vault/cache.ts +14 -0
- package/lib/narrative-vault/compile-mirror.ts +45 -0
- package/lib/narrative-vault/compile.ts +350 -0
- package/lib/narrative-vault/constants.ts +6 -0
- package/lib/narrative-vault/diagnostic-report.ts +117 -0
- package/lib/narrative-vault/export.ts +71 -0
- package/lib/narrative-vault/frontmatter.ts +41 -0
- package/lib/narrative-vault/hook-targets.ts +40 -0
- package/lib/narrative-vault/index.ts +18 -0
- package/lib/narrative-vault/inventory.ts +392 -0
- package/lib/narrative-vault/markdown-qa.ts +237 -0
- package/lib/narrative-vault/markdown.ts +34 -0
- package/lib/narrative-vault/migration.ts +52 -0
- package/lib/narrative-vault/mutate.ts +361 -0
- package/lib/narrative-vault/paths.ts +19 -0
- package/lib/narrative-vault/read.ts +52 -0
- package/lib/narrative-vault/relations.ts +32 -0
- package/lib/narrative-vault/source-loader.ts +19 -0
- package/lib/narrative-vault/timestamp.ts +32 -0
- package/lib/narrative-vault/types.ts +44 -0
- package/lib/refine/server.ts +472 -5
- package/lib/refine/visual-targets.ts +295 -0
- package/lib/source-materials.ts +98 -0
- package/lib/tool-result.ts +34 -0
- package/package.json +2 -2
- package/plugin.ts +60 -22
- package/skill/NARRATIVE_SKILL.md +25 -10
- package/tools/decks.ts +363 -67
- package/tools/research-save.ts +3 -0
- package/tools/workspace-scan.ts +1 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { DECKS_STATE_FILE } from "../decks-state"
|
|
2
|
+
import { compileCacheMirrorNarrativeVault } from "./compile-mirror"
|
|
3
|
+
import { narrativeVaultCachePath } from "./paths"
|
|
4
|
+
import type { VaultDiagnosticDisplay } from "./diagnostic-report"
|
|
5
|
+
import { runNarrativeMarkdownQa, type MarkdownQaReport } from "./markdown-qa"
|
|
6
|
+
|
|
7
|
+
export interface AutoCompileNarrativeVaultResult {
|
|
8
|
+
ok: boolean
|
|
9
|
+
mirrored: "updated" | "skipped_no_decks" | "preserved_failed_compile" | "failed"
|
|
10
|
+
cachePath: string
|
|
11
|
+
touched: string[]
|
|
12
|
+
markdownQa?: MarkdownQaReport
|
|
13
|
+
error?: string
|
|
14
|
+
markdown: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function autoCompileNarrativeVault(workspaceRoot: string, touched: string[]): AutoCompileNarrativeVaultResult {
|
|
18
|
+
const uniqueTouched = [...new Set(touched)].sort()
|
|
19
|
+
const cachePath = relativeCachePath(workspaceRoot)
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
const markdownQa = runNarrativeMarkdownQa(workspaceRoot, uniqueTouched)
|
|
23
|
+
const compiled = compileCacheMirrorNarrativeVault(workspaceRoot)
|
|
24
|
+
const mirrored = compiled.mirrorStatus
|
|
25
|
+
const ok = compiled.result.ok && markdownQa.ok
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
ok,
|
|
29
|
+
mirrored,
|
|
30
|
+
cachePath,
|
|
31
|
+
touched: uniqueTouched,
|
|
32
|
+
markdownQa,
|
|
33
|
+
markdown: formatAutoCompileReport({
|
|
34
|
+
ok,
|
|
35
|
+
mirrored,
|
|
36
|
+
cachePath,
|
|
37
|
+
touched: uniqueTouched,
|
|
38
|
+
markdownQa,
|
|
39
|
+
blockers: compiled.diagnosticReport.blockers,
|
|
40
|
+
warnings: compiled.diagnosticReport.warnings,
|
|
41
|
+
}),
|
|
42
|
+
}
|
|
43
|
+
} catch (e) {
|
|
44
|
+
const error = e instanceof Error ? e.message : String(e)
|
|
45
|
+
return {
|
|
46
|
+
ok: false,
|
|
47
|
+
mirrored: "failed",
|
|
48
|
+
cachePath,
|
|
49
|
+
touched: uniqueTouched,
|
|
50
|
+
error,
|
|
51
|
+
markdown: formatAutoCompileReport({ ok: false, mirrored: "failed", cachePath, touched: uniqueTouched, error }),
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function formatAutoCompileReport(input: {
|
|
57
|
+
ok: boolean
|
|
58
|
+
mirrored: AutoCompileNarrativeVaultResult["mirrored"]
|
|
59
|
+
cachePath: string
|
|
60
|
+
touched: string[]
|
|
61
|
+
markdownQa?: MarkdownQaReport
|
|
62
|
+
blockers?: VaultDiagnosticDisplay[]
|
|
63
|
+
warnings?: VaultDiagnosticDisplay[]
|
|
64
|
+
error?: string
|
|
65
|
+
}): string {
|
|
66
|
+
const lines = ["**[revela narrative vault]** Auto-compile completed.", ""]
|
|
67
|
+
lines.push(`Status: ${input.ok ? "ok" : "blocked"}`)
|
|
68
|
+
lines.push(`State: ${mirrorLabel(input.mirrored)}`)
|
|
69
|
+
lines.push(`Cache: \`${input.cachePath}\``)
|
|
70
|
+
lines.push(`Touched Markdown: ${formatTouched(input.touched)}`)
|
|
71
|
+
|
|
72
|
+
if (input.error) lines.push("", `Hook error: ${input.error}`)
|
|
73
|
+
appendMarkdownQa(lines, input.markdownQa)
|
|
74
|
+
appendDiagnostics(lines, "Blockers", input.blockers ?? [])
|
|
75
|
+
appendDiagnostics(lines, "Warnings", input.warnings ?? [])
|
|
76
|
+
return lines.join("\n")
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function appendMarkdownQa(lines: string[], report?: MarkdownQaReport): void {
|
|
80
|
+
if (!report) return
|
|
81
|
+
const total = report.repairCards.length
|
|
82
|
+
lines.push(`Markdown QA: ${report.ok ? "clean" : "blocked"}${total > 0 ? ` (${report.blockers.length} blocker(s), ${report.warnings.length} warning(s))` : ""}`)
|
|
83
|
+
appendRepairCards(lines, "Markdown QA blockers", report.blockers)
|
|
84
|
+
appendRepairCards(lines, "Markdown QA warnings", report.warnings)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function appendRepairCards(lines: string[], label: string, cards: MarkdownQaReport["repairCards"]): void {
|
|
88
|
+
const shown = cards.slice(0, 8)
|
|
89
|
+
if (shown.length === 0) return
|
|
90
|
+
lines.push("", `${label}:`)
|
|
91
|
+
for (const card of shown) {
|
|
92
|
+
const location = [card.file, card.nodeId].filter(Boolean).join(" / ")
|
|
93
|
+
lines.push(`- \`${card.issueCode}\`${location ? ` (${location})` : ""}: ${card.message} Smallest repair: ${card.smallestRepair}`)
|
|
94
|
+
}
|
|
95
|
+
if (cards.length > shown.length) lines.push(`- ... ${cards.length - shown.length} more`)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function appendDiagnostics(lines: string[], label: string, diagnostics: VaultDiagnosticDisplay[]): void {
|
|
99
|
+
const shown = diagnostics.slice(0, 8)
|
|
100
|
+
if (shown.length === 0) return
|
|
101
|
+
lines.push("", `${label}:`)
|
|
102
|
+
for (const diagnostic of shown) lines.push(`- ${formatDiagnostic(diagnostic)}`)
|
|
103
|
+
if (diagnostics.length > shown.length) lines.push(`- ... ${diagnostics.length - shown.length} more`)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function formatDiagnostic(diagnostic: VaultDiagnosticDisplay): string {
|
|
107
|
+
const location = [diagnostic.file, diagnostic.nodeId].filter(Boolean).join(" / ")
|
|
108
|
+
return `\`${diagnostic.code}\`${location ? ` (${location})` : ""}: ${diagnostic.message}`
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function formatTouched(touched: string[]): string {
|
|
112
|
+
const shown = touched.slice(0, 10).map((target) => `\`${target}\``)
|
|
113
|
+
if (touched.length > 10) shown.push(`... ${touched.length - 10} more`)
|
|
114
|
+
return shown.length > 0 ? shown.join(", ") : "none"
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function mirrorLabel(mirrored: AutoCompileNarrativeVaultResult["mirrored"]): string {
|
|
118
|
+
switch (mirrored) {
|
|
119
|
+
case "updated":
|
|
120
|
+
return `${DECKS_STATE_FILE} render state saved; runtime narrative hydrated from vault`
|
|
121
|
+
case "skipped_no_decks":
|
|
122
|
+
return `${DECKS_STATE_FILE} not found; no state created`
|
|
123
|
+
case "preserved_failed_compile":
|
|
124
|
+
return `${DECKS_STATE_FILE} render state preserved; last-good narrative cache kept because compile is blocked`
|
|
125
|
+
case "failed":
|
|
126
|
+
return "not updated because auto-compile failed"
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function relativeCachePath(workspaceRoot: string): string {
|
|
131
|
+
const cachePath = narrativeVaultCachePath(workspaceRoot).replace(/\\/g, "/")
|
|
132
|
+
const root = workspaceRoot.replace(/\\/g, "/").replace(/\/$/, "")
|
|
133
|
+
return cachePath.startsWith(root + "/") ? cachePath.slice(root.length + 1) : cachePath
|
|
134
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from "fs"
|
|
2
|
+
import { dirname, join } from "path"
|
|
3
|
+
import { narrativeVaultPath } from "./paths"
|
|
4
|
+
import type { AudienceIntent, DecisionIntent, NarrativeStateV1, NarrativeThesis } from "../narrative-state/types"
|
|
5
|
+
|
|
6
|
+
export interface InitNarrativeVaultInput {
|
|
7
|
+
id?: string
|
|
8
|
+
status?: NarrativeStateV1["status"]
|
|
9
|
+
audience?: Partial<AudienceIntent>
|
|
10
|
+
decision?: Partial<DecisionIntent>
|
|
11
|
+
thesis?: Partial<NarrativeThesis>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface InitNarrativeVaultResult {
|
|
15
|
+
ok: boolean
|
|
16
|
+
files: string[]
|
|
17
|
+
created: boolean
|
|
18
|
+
path: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const VAULT_NODE_DIRS = ["claims", "evidence", "objections", "risks", "research-gaps"]
|
|
22
|
+
|
|
23
|
+
export function initNarrativeVault(workspaceRoot: string, input: InitNarrativeVaultInput = {}): InitNarrativeVaultResult {
|
|
24
|
+
const root = narrativeVaultPath(workspaceRoot)
|
|
25
|
+
const files: string[] = []
|
|
26
|
+
mkdirSync(root, { recursive: true })
|
|
27
|
+
for (const dir of VAULT_NODE_DIRS) mkdirSync(join(root, dir), { recursive: true })
|
|
28
|
+
|
|
29
|
+
write(root, files, "index.md", frontmatter({ type: "index", id: input.id ?? "narrative:workspace", status: input.status ?? "draft" }))
|
|
30
|
+
write(root, files, "audience.md", `${frontmatter({ type: "audience", ...input.audience })}${input.audience?.primary ?? ""}\n`)
|
|
31
|
+
write(root, files, "decision.md", `${frontmatter({ type: "decision", ...input.decision })}${input.decision?.action ?? ""}\n`)
|
|
32
|
+
write(root, files, "thesis.md", `${frontmatter({ type: "thesis", id: input.thesis?.id ?? "thesis:main", confidence: input.thesis?.confidence ?? "medium", caveat: input.thesis?.caveat })}${input.thesis?.statement ?? ""}\n`)
|
|
33
|
+
|
|
34
|
+
return { ok: true, files, created: true, path: "revela-narrative" }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function write(root: string, files: string[], relativePath: string, content: string): void {
|
|
38
|
+
const filePath = join(root, relativePath)
|
|
39
|
+
mkdirSync(dirname(filePath), { recursive: true })
|
|
40
|
+
writeFileSync(filePath, content.endsWith("\n") ? content : `${content}\n`, "utf-8")
|
|
41
|
+
files.push(relativePath.split(/[/\\]+/).join("/"))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function frontmatter(values: Record<string, unknown>): string {
|
|
45
|
+
const lines = ["---"]
|
|
46
|
+
for (const [key, value] of Object.entries(values)) {
|
|
47
|
+
if (value === undefined || value === "" || (Array.isArray(value) && value.length === 0)) continue
|
|
48
|
+
if (Array.isArray(value)) {
|
|
49
|
+
lines.push(`${key}:`)
|
|
50
|
+
for (const item of value) lines.push(` - ${quote(String(item))}`)
|
|
51
|
+
} else if (typeof value === "boolean") {
|
|
52
|
+
lines.push(`${key}: ${value ? "true" : "false"}`)
|
|
53
|
+
} else {
|
|
54
|
+
lines.push(`${key}: ${quote(String(value))}`)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
lines.push("---", "")
|
|
58
|
+
return lines.join("\n")
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function quote(value: string): string {
|
|
62
|
+
return JSON.stringify(value)
|
|
63
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from "fs"
|
|
2
|
+
import { join } from "path"
|
|
3
|
+
import { narrativeVaultCachePath } from "./paths"
|
|
4
|
+
import type { NarrativeVaultCompileResult } from "./types"
|
|
5
|
+
|
|
6
|
+
export function writeNarrativeVaultCache(workspaceRoot: string, result: NarrativeVaultCompileResult): void {
|
|
7
|
+
const cacheDir = narrativeVaultCachePath(workspaceRoot)
|
|
8
|
+
mkdirSync(cacheDir, { recursive: true })
|
|
9
|
+
if (result.ok && result.narrative) {
|
|
10
|
+
writeFileSync(join(cacheDir, "compiled-narrative.json"), JSON.stringify(result.narrative, null, 2) + "\n", "utf-8")
|
|
11
|
+
writeFileSync(join(cacheDir, "graph.json"), JSON.stringify(result.graph, null, 2) + "\n", "utf-8")
|
|
12
|
+
}
|
|
13
|
+
writeFileSync(join(cacheDir, "diagnostics.json"), JSON.stringify(result.diagnostics, null, 2) + "\n", "utf-8")
|
|
14
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { existsSync } from "fs"
|
|
2
|
+
import { join } from "path"
|
|
3
|
+
import { DECKS_STATE_FILE, readDecksState, writeDecksState, type DecksState } from "../decks-state"
|
|
4
|
+
import type { NarrativeApproval } from "../narrative-state/types"
|
|
5
|
+
import { writeNarrativeVaultCache } from "./cache"
|
|
6
|
+
import { compileNarrativeVault } from "./compile"
|
|
7
|
+
import { formatVaultDiagnosticReport, type VaultDiagnosticReport } from "./diagnostic-report"
|
|
8
|
+
import type { NarrativeVaultCompileResult } from "./types"
|
|
9
|
+
|
|
10
|
+
export type NarrativeVaultMirrorStatus = "updated" | "skipped_no_decks" | "preserved_failed_compile"
|
|
11
|
+
|
|
12
|
+
export interface CompileCacheMirrorNarrativeVaultOptions {
|
|
13
|
+
state?: DecksState
|
|
14
|
+
fallbackApprovals?: NarrativeApproval[]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface CompileCacheMirrorNarrativeVaultResult {
|
|
18
|
+
result: NarrativeVaultCompileResult
|
|
19
|
+
diagnosticReport: VaultDiagnosticReport
|
|
20
|
+
mirrorStatus: NarrativeVaultMirrorStatus
|
|
21
|
+
state?: DecksState
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function compileCacheMirrorNarrativeVault(
|
|
25
|
+
workspaceRoot: string,
|
|
26
|
+
options: CompileCacheMirrorNarrativeVaultOptions = {},
|
|
27
|
+
): CompileCacheMirrorNarrativeVaultResult {
|
|
28
|
+
const decksPath = join(workspaceRoot, DECKS_STATE_FILE)
|
|
29
|
+
const hasDecks = Boolean(options.state) || existsSync(decksPath)
|
|
30
|
+
const state = options.state ?? (hasDecks ? readDecksState(workspaceRoot) : undefined)
|
|
31
|
+
const fallbackApprovals = options.fallbackApprovals ?? state?.narrativeApprovals ?? state?.narrative?.approvals ?? []
|
|
32
|
+
const result = compileNarrativeVault(workspaceRoot, { fallbackApprovals })
|
|
33
|
+
const diagnosticReport = formatVaultDiagnosticReport(result.diagnostics)
|
|
34
|
+
|
|
35
|
+
writeNarrativeVaultCache(workspaceRoot, result)
|
|
36
|
+
|
|
37
|
+
if (!hasDecks || !state) return { result, diagnosticReport, mirrorStatus: "skipped_no_decks" }
|
|
38
|
+
if (result.ok && result.narrative) {
|
|
39
|
+
state.narrative = result.narrative
|
|
40
|
+
writeDecksState(workspaceRoot, state)
|
|
41
|
+
return { result, diagnosticReport, mirrorStatus: "updated", state }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return { result, diagnosticReport, mirrorStatus: "preserved_failed_compile", state }
|
|
45
|
+
}
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import { normalizeCanonicalNarrativeState } from "../narrative-state/normalize"
|
|
2
|
+
import { computeNarrativeHash } from "../narrative-state/hash"
|
|
3
|
+
import type {
|
|
4
|
+
AudienceIntent,
|
|
5
|
+
DecisionIntent,
|
|
6
|
+
NarrativeApproval,
|
|
7
|
+
NarrativeClaim,
|
|
8
|
+
NarrativeClaimKind,
|
|
9
|
+
NarrativeEvidenceBinding,
|
|
10
|
+
NarrativeObjection,
|
|
11
|
+
NarrativeResearchGap,
|
|
12
|
+
NarrativeResearchGapTargetType,
|
|
13
|
+
NarrativeRisk,
|
|
14
|
+
NarrativeStateV1,
|
|
15
|
+
NarrativeStatus,
|
|
16
|
+
NarrativeThesis,
|
|
17
|
+
} from "../narrative-state/types"
|
|
18
|
+
import { firstParagraphOrBody, parseMarkdownList } from "./markdown"
|
|
19
|
+
import { readNarrativeVaultDocuments } from "./read"
|
|
20
|
+
import type { NarrativeVaultCompileResult, NarrativeVaultGraph, VaultDiagnostic, VaultDocument, VaultNodeType, VaultRelation } from "./types"
|
|
21
|
+
|
|
22
|
+
export interface CompileNarrativeVaultOptions {
|
|
23
|
+
fallbackApprovals?: NarrativeApproval[]
|
|
24
|
+
now?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function compileNarrativeVault(workspaceRoot: string, options: CompileNarrativeVaultOptions = {}): NarrativeVaultCompileResult {
|
|
28
|
+
const read = readNarrativeVaultDocuments(workspaceRoot)
|
|
29
|
+
const diagnostics: VaultDiagnostic[] = [...read.diagnostics]
|
|
30
|
+
const docs = read.documents
|
|
31
|
+
if (docs.length === 0) {
|
|
32
|
+
return {
|
|
33
|
+
ok: false,
|
|
34
|
+
diagnostics: [{ severity: "error", code: "empty_vault", message: "revela-narrative/ exists but contains no Markdown narrative nodes." }],
|
|
35
|
+
graph: { nodes: [], relations: [] },
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const nodeDocs = docs.filter((doc) => stringField(doc, "id"))
|
|
39
|
+
const duplicateIds = duplicateValues(nodeDocs.map((doc) => stringField(doc, "id")))
|
|
40
|
+
for (const id of duplicateIds) diagnostics.push({ severity: "error", code: "duplicate_id", message: `Duplicate narrative vault id: ${id}`, nodeId: id })
|
|
41
|
+
|
|
42
|
+
const byId = new Map<string, VaultDocument>()
|
|
43
|
+
for (const doc of nodeDocs) if (!byId.has(stringField(doc, "id"))) byId.set(stringField(doc, "id"), doc)
|
|
44
|
+
const claimIds = new Set(docs.filter((doc) => typeField(doc) === "claim").map((doc) => stringField(doc, "id")).filter(Boolean))
|
|
45
|
+
|
|
46
|
+
for (const doc of docs) {
|
|
47
|
+
if (!stringField(doc, "type")) diagnostics.push({ severity: "error", code: "missing_type", message: "Missing required frontmatter field: type", file: doc.relativePath })
|
|
48
|
+
else if (!isVaultNodeType(stringField(doc, "type"))) diagnostics.push({ severity: "error", code: "unknown_node_type", message: `Unknown narrative vault node type: ${stringField(doc, "type")}`, file: doc.relativePath, nodeId: stringField(doc, "id") })
|
|
49
|
+
if (requiresId(doc) && !stringField(doc, "id")) diagnostics.push({ severity: "error", code: "missing_id", message: "Missing required frontmatter field: id", file: doc.relativePath })
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const relations = docs.flatMap((doc) => doc.relations)
|
|
53
|
+
const relationTargets = targetsFromRelations(relations, byId)
|
|
54
|
+
for (const relation of relations) {
|
|
55
|
+
const from = byId.get(relation.fromId)
|
|
56
|
+
const to = byId.get(relation.toId)
|
|
57
|
+
if (!from) {
|
|
58
|
+
diagnostics.push({ severity: "error", code: "broken_relation_source", message: `Relation starts from unknown node: ${relation.fromId}`, file: relation.file, nodeId: relation.fromId })
|
|
59
|
+
continue
|
|
60
|
+
}
|
|
61
|
+
if (!to) {
|
|
62
|
+
diagnostics.push({ severity: "error", code: "broken_link", message: `Relation points to unknown node: ${relation.toId}`, file: relation.file, nodeId: relation.fromId })
|
|
63
|
+
continue
|
|
64
|
+
}
|
|
65
|
+
const illegalReason = illegalRelationReason(typeField(from), typeField(to), relation.relation)
|
|
66
|
+
if (illegalReason) diagnostics.push({ severity: "error", code: "illegal_relation_target", message: illegalReason, file: relation.file, nodeId: relation.fromId })
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
for (const doc of docs.filter((item) => typeField(item) === "evidence")) {
|
|
70
|
+
const evidenceId = stringField(doc, "id")
|
|
71
|
+
const claimId = relationTargets.get(evidenceId)?.targetId || stringField(doc, "claimId") || ""
|
|
72
|
+
if (!claimId) {
|
|
73
|
+
diagnostics.push({ severity: "error", code: "evidence_claim_missing", message: `Evidence ${evidenceId || doc.relativePath} is missing a claim-support relation or compatibility claimId.`, file: doc.relativePath, nodeId: evidenceId })
|
|
74
|
+
} else if (!claimIds.has(claimId)) {
|
|
75
|
+
diagnostics.push({ severity: "error", code: "evidence_claim_missing", message: `Evidence ${evidenceId || doc.relativePath} references unknown claim ${claimId}.`, file: doc.relativePath, nodeId: evidenceId })
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const narrative: Partial<NarrativeStateV1> = {
|
|
80
|
+
version: 1,
|
|
81
|
+
id: stringField(findType(docs, "index"), "id") || "narrative:workspace",
|
|
82
|
+
status: statusField(findType(docs, "index"), "status") ?? "draft",
|
|
83
|
+
audience: compileAudience(findType(docs, "audience")),
|
|
84
|
+
decision: compileDecision(findType(docs, "decision")),
|
|
85
|
+
thesis: compileThesis(findType(docs, "thesis")),
|
|
86
|
+
claims: docs.filter((doc) => typeField(doc) === "claim").map(compileClaim),
|
|
87
|
+
evidenceBindings: docs.filter((doc) => typeField(doc) === "evidence").map((doc) => compileEvidence(doc, relationTargets)),
|
|
88
|
+
objections: docs.filter((doc) => typeField(doc) === "objection").map((doc) => compileObjection(doc, relationTargets)),
|
|
89
|
+
risks: docs.filter((doc) => typeField(doc) === "risk").map((doc) => compileRisk(doc, relationTargets)),
|
|
90
|
+
researchGaps: docs.filter((doc) => typeField(doc) === "research-gap").map((doc) => compileResearchGap(doc, options.now, relationTargets)),
|
|
91
|
+
claimRelations: relations
|
|
92
|
+
.filter((relation) => byId.get(relation.fromId) && typeField(byId.get(relation.fromId)) === "claim" && byId.get(relation.toId) && typeField(byId.get(relation.toId)) === "claim")
|
|
93
|
+
.map((relation) => ({ id: relation.id ?? `${relation.fromId}:${relation.relation}:${relation.toId}`, fromClaimId: relation.fromId, toClaimId: relation.toId, relation: relation.relation, rationale: relation.rationale })),
|
|
94
|
+
approvals: options.fallbackApprovals ?? [],
|
|
95
|
+
updatedAt: options.now ?? new Date().toISOString(),
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const normalized = normalizeCanonicalNarrativeState(narrative, "vault")
|
|
99
|
+
if (!normalized) diagnostics.push({ severity: "error", code: "compile_failed", message: "Narrative vault could not be normalized." })
|
|
100
|
+
if (normalized) addSemanticDiagnostics(normalized, diagnostics)
|
|
101
|
+
if (normalized && normalized.approvals.length > 0) {
|
|
102
|
+
const currentHash = computeNarrativeHash(normalized)
|
|
103
|
+
const latest = normalized.approvals[normalized.approvals.length - 1]
|
|
104
|
+
if (latest && latest.narrativeHash !== currentHash) diagnostics.push({ severity: "warning", code: "stale_approval_hash", message: "Latest narrative approval hash is stale.", nodeId: normalized.id })
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const graph: NarrativeVaultGraph = {
|
|
108
|
+
nodes: nodeDocs.map((doc) => ({ id: stringField(doc, "id"), type: typeField(doc), file: doc.relativePath })),
|
|
109
|
+
relations,
|
|
110
|
+
}
|
|
111
|
+
return { ok: !diagnostics.some((diagnostic) => diagnostic.severity === "error") && Boolean(normalized), narrative: normalized, diagnostics, graph }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function compileAudience(doc: VaultDocument | undefined): AudienceIntent {
|
|
115
|
+
return {
|
|
116
|
+
primary: stringField(doc, "primary") || firstParagraphOrBody(doc?.body ?? ""),
|
|
117
|
+
secondary: arrayField(doc, "secondary"),
|
|
118
|
+
beliefBefore: stringField(doc, "beliefBefore"),
|
|
119
|
+
beliefAfter: stringField(doc, "beliefAfter"),
|
|
120
|
+
decisionContext: stringField(doc, "decisionContext"),
|
|
121
|
+
successCriteria: arrayField(doc, "successCriteria"),
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function compileDecision(doc: VaultDocument | undefined): DecisionIntent {
|
|
126
|
+
return {
|
|
127
|
+
action: stringField(doc, "action") || firstParagraphOrBody(doc?.body ?? ""),
|
|
128
|
+
owner: stringField(doc, "owner"),
|
|
129
|
+
deadline: stringField(doc, "deadline"),
|
|
130
|
+
decisionType: enumField(doc, "decisionType", ["approve", "invest", "prioritize", "align", "choose", "understand", "other"]),
|
|
131
|
+
consequenceOfNoDecision: stringField(doc, "consequenceOfNoDecision"),
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function compileThesis(doc: VaultDocument | undefined): NarrativeThesis | undefined {
|
|
136
|
+
if (!doc) return undefined
|
|
137
|
+
return { id: stringField(doc, "id") || "thesis:main", statement: stringField(doc, "statement") || firstParagraphOrBody(doc.body), confidence: enumField(doc, "confidence", ["high", "medium", "low"]) ?? "medium", caveat: stringField(doc, "caveat") }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function compileClaim(doc: VaultDocument): NarrativeClaim {
|
|
141
|
+
return {
|
|
142
|
+
id: stringField(doc, "id"),
|
|
143
|
+
kind: enumField(doc, "kind", ["context", "problem", "opportunity", "evidence", "recommendation", "risk", "assumption", "ask"]) ?? "evidence",
|
|
144
|
+
text: stringField(doc, "text") || firstParagraphOrBody(doc.body),
|
|
145
|
+
importance: enumField(doc, "importance", ["central", "supporting", "background"]) ?? "supporting",
|
|
146
|
+
evidenceRequired: booleanField(doc, "evidenceRequired", true),
|
|
147
|
+
evidenceStatus: "missing",
|
|
148
|
+
supportedScope: stringField(doc, "supportedScope"),
|
|
149
|
+
unsupportedScope: stringField(doc, "unsupportedScope"),
|
|
150
|
+
caveats: [...arrayField(doc, "caveats"), ...parseMarkdownList(doc.sections.caveats ?? "")],
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
type RelationTarget = { targetId: string; targetType?: NarrativeResearchGapTargetType; evidenceBindingIds?: string[] }
|
|
155
|
+
|
|
156
|
+
function compileEvidence(doc: VaultDocument, relationTargets = new Map<string, RelationTarget>()): NarrativeEvidenceBinding {
|
|
157
|
+
return {
|
|
158
|
+
id: stringField(doc, "id"),
|
|
159
|
+
claimId: relationTargets.get(stringField(doc, "id"))?.targetId || stringField(doc, "claimId") || "",
|
|
160
|
+
source: stringField(doc, "source") || stringField(doc, "sourcePath") || stringField(doc, "findingsFile") || stringField(doc, "url"),
|
|
161
|
+
sourcePath: stringField(doc, "sourcePath"),
|
|
162
|
+
findingsFile: stringField(doc, "findingsFile"),
|
|
163
|
+
quote: stringField(doc, "quote") || firstParagraphOrBody(doc.body),
|
|
164
|
+
location: stringField(doc, "location"),
|
|
165
|
+
url: stringField(doc, "url"),
|
|
166
|
+
caveat: stringField(doc, "caveat"),
|
|
167
|
+
supportScope: stringField(doc, "supportScope"),
|
|
168
|
+
unsupportedScope: stringField(doc, "unsupportedScope"),
|
|
169
|
+
strength: enumField(doc, "strength", ["strong", "partial", "weak"]) ?? "weak",
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function compileObjection(doc: VaultDocument, relationTargets = new Map<string, RelationTarget>()): NarrativeObjection {
|
|
174
|
+
return { id: stringField(doc, "id"), text: stringField(doc, "text") || firstParagraphOrBody(doc.body), claimId: relationTargets.get(stringField(doc, "id"))?.targetId || stringField(doc, "claimId") || "", priority: enumField(doc, "priority", ["high", "medium", "low"]) ?? "medium", response: stringField(doc, "response") || firstParagraphOrBody(doc.sections.response ?? "") }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function compileRisk(doc: VaultDocument, relationTargets = new Map<string, RelationTarget>()): NarrativeRisk {
|
|
178
|
+
return { id: stringField(doc, "id"), text: stringField(doc, "text") || firstParagraphOrBody(doc.body), claimId: relationTargets.get(stringField(doc, "id"))?.targetId || stringField(doc, "claimId") || "", severity: enumField(doc, "severity", ["high", "medium", "low"]) ?? "medium", mitigation: stringField(doc, "mitigation") || firstParagraphOrBody(doc.sections.mitigation ?? "") }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function compileResearchGap(doc: VaultDocument, now = new Date().toISOString(), relationTargets = new Map<string, RelationTarget>()): NarrativeResearchGap {
|
|
182
|
+
const relationTarget = relationTargets.get(stringField(doc, "id"))
|
|
183
|
+
return {
|
|
184
|
+
id: stringField(doc, "id"),
|
|
185
|
+
targetType: relationTarget?.targetType ?? enumField(doc, "targetType", ["claim", "objection", "risk", "decision", "narrative"]) ?? "narrative",
|
|
186
|
+
targetId: relationTarget?.targetId || stringField(doc, "targetId") || "",
|
|
187
|
+
question: stringField(doc, "question") || firstParagraphOrBody(doc.body),
|
|
188
|
+
status: enumField(doc, "status", ["open", "in_progress", "findings_saved", "attached", "evidence_bound", "closed"]) ?? "open",
|
|
189
|
+
priority: enumField(doc, "priority", ["high", "medium", "low"]) ?? "medium",
|
|
190
|
+
findingsFile: stringField(doc, "findingsFile"),
|
|
191
|
+
evidenceBindingIds: unique([...(relationTarget?.evidenceBindingIds ?? []), ...arrayField(doc, "evidenceBindingIds")]),
|
|
192
|
+
notes: stringField(doc, "notes") || firstParagraphOrBody(doc.sections.notes ?? ""),
|
|
193
|
+
createdAt: stringField(doc, "createdAt") || now,
|
|
194
|
+
updatedAt: stringField(doc, "updatedAt") || now,
|
|
195
|
+
closedAt: stringField(doc, "closedAt"),
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function addSemanticDiagnostics(narrative: NarrativeStateV1, diagnostics: VaultDiagnostic[]): void {
|
|
200
|
+
const claimIds = new Set(narrative.claims.map((claim) => claim.id))
|
|
201
|
+
const evidenceIds = new Set(narrative.evidenceBindings.map((binding) => binding.id))
|
|
202
|
+
for (const binding of narrative.evidenceBindings) {
|
|
203
|
+
if (!binding.source || !binding.quote || !binding.supportScope || !binding.unsupportedScope || !binding.caveat) diagnostics.push({ severity: "warning", code: "evidence_trace_incomplete", message: `Evidence node ${binding.id} is missing source trace, quote, scope, unsupported scope, or caveat.`, nodeId: binding.id })
|
|
204
|
+
if (!claimIds.has(binding.claimId)) diagnostics.push({ severity: "error", code: "evidence_claim_missing", message: `Evidence ${binding.id} references unknown claim ${binding.claimId}.`, nodeId: binding.id })
|
|
205
|
+
}
|
|
206
|
+
for (const claim of narrative.claims) {
|
|
207
|
+
if (claim.importance === "central" && !narrative.claimRelations?.some((relation) => relation.fromClaimId === claim.id || relation.toClaimId === claim.id) && narrative.claims.length > 1) diagnostics.push({ severity: "warning", code: "orphan_central_claim", message: `Central claim ${claim.id} has no claim relations.`, nodeId: claim.id })
|
|
208
|
+
if (claim.evidenceRequired && !narrative.evidenceBindings.some((binding) => binding.claimId === claim.id)) diagnostics.push({ severity: "warning", code: "claim_missing_evidence", message: `Evidence-required claim ${claim.id} has no evidence binding.`, nodeId: claim.id })
|
|
209
|
+
}
|
|
210
|
+
for (const gap of narrative.researchGaps ?? []) {
|
|
211
|
+
if (gap.status !== "closed") diagnostics.push({ severity: "warning", code: "research_gap_unresolved", message: `Research gap ${gap.id} is unresolved.`, nodeId: gap.id })
|
|
212
|
+
for (const id of gap.evidenceBindingIds ?? []) if (!evidenceIds.has(id)) diagnostics.push({ severity: "warning", code: "gap_evidence_missing", message: `Research gap ${gap.id} references unknown evidence ${id}.`, nodeId: gap.id })
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function findType(docs: VaultDocument[], type: VaultNodeType): VaultDocument | undefined {
|
|
217
|
+
return docs.find((doc) => typeField(doc) === type)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function typeField(doc: VaultDocument | undefined): VaultNodeType {
|
|
221
|
+
const value = stringField(doc, "type")
|
|
222
|
+
if (isVaultNodeType(value)) return value
|
|
223
|
+
return "index"
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function isVaultNodeType(value: string): value is VaultNodeType {
|
|
227
|
+
return value === "research-gap" || ["index", "audience", "decision", "thesis", "claim", "evidence", "objection", "risk"].includes(value)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function illegalRelationReason(fromType: VaultNodeType, toType: VaultNodeType, relation: string): string | undefined {
|
|
231
|
+
if (fromType !== "claim") {
|
|
232
|
+
const allowedNonClaim: Record<string, VaultNodeType[]> = {
|
|
233
|
+
evidence: relation === "supports" ? ["claim"] : [],
|
|
234
|
+
objection: relation === "answers" || relation === "contrasts_with" ? ["claim"] : [],
|
|
235
|
+
risk: relation === "constrains" ? ["claim"] : [],
|
|
236
|
+
"research-gap": relation === "depends_on" ? ["claim", "evidence", "objection", "risk"] : [],
|
|
237
|
+
index: [],
|
|
238
|
+
audience: [],
|
|
239
|
+
decision: [],
|
|
240
|
+
thesis: [],
|
|
241
|
+
claim: [],
|
|
242
|
+
}
|
|
243
|
+
if (allowedNonClaim[fromType]?.includes(toType)) return undefined
|
|
244
|
+
return `Relation ${relation} from ${fromType} cannot target ${toType}.`
|
|
245
|
+
}
|
|
246
|
+
const allowedTargets: Record<string, VaultNodeType[]> = {
|
|
247
|
+
leads_to: ["claim"],
|
|
248
|
+
supports: ["claim"],
|
|
249
|
+
contrasts_with: ["claim"],
|
|
250
|
+
depends_on: ["claim", "evidence"],
|
|
251
|
+
constrains: ["claim", "risk"],
|
|
252
|
+
answers: ["claim", "objection"],
|
|
253
|
+
}
|
|
254
|
+
if (!allowedTargets[relation]?.includes(toType)) return `Relation ${relation} from a claim cannot target ${toType}.`
|
|
255
|
+
return undefined
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function targetsFromRelations(relations: VaultRelation[], byId: Map<string, VaultDocument>): Map<string, RelationTarget> {
|
|
259
|
+
const targets = new Map<string, RelationTarget>()
|
|
260
|
+
for (const relation of relations) {
|
|
261
|
+
const from = byId.get(relation.fromId)
|
|
262
|
+
const to = byId.get(relation.toId)
|
|
263
|
+
if (!from || !to) continue
|
|
264
|
+
const fromType = typeField(from)
|
|
265
|
+
const targetType = researchGapTargetType(typeField(to))
|
|
266
|
+
if (fromType === "evidence" && relation.relation === "supports" && targetType === "claim") targets.set(relation.fromId, { targetId: relation.toId, targetType })
|
|
267
|
+
if (fromType === "objection" && (relation.relation === "answers" || relation.relation === "contrasts_with") && targetType === "claim") targets.set(relation.fromId, { targetId: relation.toId, targetType })
|
|
268
|
+
if (fromType === "risk" && relation.relation === "constrains" && targetType === "claim") targets.set(relation.fromId, { targetId: relation.toId, targetType })
|
|
269
|
+
}
|
|
270
|
+
for (const relation of relations) {
|
|
271
|
+
const from = byId.get(relation.fromId)
|
|
272
|
+
const to = byId.get(relation.toId)
|
|
273
|
+
if (!from || !to || typeField(from) !== "research-gap" || relation.relation !== "depends_on") continue
|
|
274
|
+
const toType = typeField(to)
|
|
275
|
+
if (toType === "evidence") {
|
|
276
|
+
const evidenceTarget = targets.get(relation.toId)
|
|
277
|
+
mergeRelationTarget(targets, relation.fromId, {
|
|
278
|
+
targetId: evidenceTarget?.targetId || relation.toId,
|
|
279
|
+
targetType: evidenceTarget?.targetType ?? "narrative",
|
|
280
|
+
evidenceBindingIds: [relation.toId],
|
|
281
|
+
})
|
|
282
|
+
continue
|
|
283
|
+
}
|
|
284
|
+
const targetType = researchGapTargetType(toType)
|
|
285
|
+
if (targetType) mergeRelationTarget(targets, relation.fromId, { targetId: relation.toId, targetType })
|
|
286
|
+
}
|
|
287
|
+
return targets
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function mergeRelationTarget(targets: Map<string, RelationTarget>, id: string, next: RelationTarget): void {
|
|
291
|
+
const existing = targets.get(id)
|
|
292
|
+
if (!existing) {
|
|
293
|
+
targets.set(id, next)
|
|
294
|
+
return
|
|
295
|
+
}
|
|
296
|
+
targets.set(id, {
|
|
297
|
+
targetId: existing.targetType === "claim" ? existing.targetId : next.targetId || existing.targetId,
|
|
298
|
+
targetType: existing.targetType === "claim" ? existing.targetType : next.targetType ?? existing.targetType,
|
|
299
|
+
evidenceBindingIds: unique([...(existing.evidenceBindingIds ?? []), ...(next.evidenceBindingIds ?? [])]),
|
|
300
|
+
})
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function researchGapTargetType(type: VaultNodeType): NarrativeResearchGapTargetType | undefined {
|
|
304
|
+
if (type === "claim" || type === "objection" || type === "risk") return type
|
|
305
|
+
return undefined
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function requiresId(doc: VaultDocument): boolean {
|
|
309
|
+
return typeField(doc) !== "audience" && typeField(doc) !== "decision"
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function stringField(doc: VaultDocument | undefined, key: string): string {
|
|
313
|
+
const value = doc?.frontmatter[key]
|
|
314
|
+
return typeof value === "string" ? value.trim() : ""
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function arrayField(doc: VaultDocument | undefined, key: string): string[] {
|
|
318
|
+
const value = doc?.frontmatter[key]
|
|
319
|
+
if (Array.isArray(value)) return value.map((item) => item.trim()).filter(Boolean)
|
|
320
|
+
if (typeof value === "string" && value.trim()) return value.split(",").map((item) => item.trim()).filter(Boolean)
|
|
321
|
+
return []
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function booleanField(doc: VaultDocument | undefined, key: string, fallback: boolean): boolean {
|
|
325
|
+
const value = doc?.frontmatter[key]
|
|
326
|
+
return typeof value === "boolean" ? value : fallback
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function enumField<T extends string>(doc: VaultDocument | undefined, key: string, allowed: readonly T[]): T | undefined {
|
|
330
|
+
const value = stringField(doc, key)
|
|
331
|
+
return allowed.includes(value as T) ? value as T : undefined
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function statusField(doc: VaultDocument | undefined, key: string): NarrativeStatus | undefined {
|
|
335
|
+
return enumField(doc, key, ["draft", "needs_research", "needs_user_confirmation", "ready_for_approval", "approved"])
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function duplicateValues(values: string[]): string[] {
|
|
339
|
+
const seen = new Set<string>()
|
|
340
|
+
const duplicates = new Set<string>()
|
|
341
|
+
for (const value of values) {
|
|
342
|
+
if (seen.has(value)) duplicates.add(value)
|
|
343
|
+
seen.add(value)
|
|
344
|
+
}
|
|
345
|
+
return [...duplicates]
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function unique(values: string[]): string[] {
|
|
349
|
+
return [...new Set(values.filter(Boolean))]
|
|
350
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export const NARRATIVE_VAULT_DIR = "revela-narrative"
|
|
2
|
+
export const NARRATIVE_VAULT_CACHE_DIR = ".opencode/revela/narrative-cache"
|
|
3
|
+
|
|
4
|
+
export const NARRATIVE_VAULT_NODE_DIRS = ["claims", "evidence", "objections", "risks", "research-gaps"] as const
|
|
5
|
+
|
|
6
|
+
export const NARRATIVE_VAULT_RELATION_TYPES = ["leads_to", "supports", "depends_on", "contrasts_with", "constrains", "answers"] as const
|