@cyber-dash-tech/revela 0.16.4 → 0.17.1
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 +39 -6
- package/lib/commands/research.ts +36 -20
- package/lib/commands/review.ts +35 -28
- 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/media/download.ts +23 -3
- package/lib/media/save.ts +1 -0
- package/lib/media/types.ts +1 -0
- package/lib/narrative-state/display.ts +74 -4
- package/lib/narrative-state/map-html.ts +242 -107
- package/lib/narrative-state/render-plan.ts +238 -35
- 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/qa/checks.ts +206 -5
- package/lib/qa/measure.ts +63 -1
- package/lib/refine/server.ts +157 -20
- 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/skill/SKILL.md +6 -1
- package/tools/decks.ts +363 -67
- package/tools/narrative-view.ts +16 -0
- package/tools/research-save.ts +3 -0
- package/tools/workspace-scan.ts +1 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { DecksState } from "../decks-state"
|
|
2
|
+
import { hasNarrativeVault } from "./paths"
|
|
3
|
+
|
|
4
|
+
export const VAULT_MIGRATION_PRESERVED_IN_DECKS_JSON = [
|
|
5
|
+
"approvals",
|
|
6
|
+
"renderTargets",
|
|
7
|
+
"reviews",
|
|
8
|
+
"deck specs",
|
|
9
|
+
"artifact coverage",
|
|
10
|
+
"actions",
|
|
11
|
+
"sourceMaterials",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
export interface NarrativeVaultMigrationHint {
|
|
15
|
+
available: boolean
|
|
16
|
+
reason: string
|
|
17
|
+
suggestedAction?: "exportNarrativeVault"
|
|
18
|
+
preservedInDecksJson: string[]
|
|
19
|
+
nextActions: string[]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function getNarrativeVaultMigrationHint(workspaceRoot: string, state: DecksState): NarrativeVaultMigrationHint {
|
|
23
|
+
if (hasNarrativeVault(workspaceRoot)) {
|
|
24
|
+
return {
|
|
25
|
+
available: false,
|
|
26
|
+
reason: "revela-narrative/ already exists; Markdown is the canonical narrative source.",
|
|
27
|
+
preservedInDecksJson: VAULT_MIGRATION_PRESERVED_IN_DECKS_JSON,
|
|
28
|
+
nextActions: ["Use targeted vault actions or edit Markdown nodes, then run compileNarrativeVault."],
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (state.narrative) {
|
|
33
|
+
return {
|
|
34
|
+
available: true,
|
|
35
|
+
reason: "DECKS.json contains a canonical narrative mirror, but no revela-narrative/ vault exists yet.",
|
|
36
|
+
suggestedAction: "exportNarrativeVault",
|
|
37
|
+
preservedInDecksJson: VAULT_MIGRATION_PRESERVED_IN_DECKS_JSON,
|
|
38
|
+
nextActions: [
|
|
39
|
+
"Run revela-decks action exportNarrativeVault to create editable Markdown narrative files.",
|
|
40
|
+
"Continue keeping approvals, render targets, reviews, artifact coverage, actions, and source material records in DECKS.json.",
|
|
41
|
+
"Review the returned diagnosticReport before making or exporting artifacts.",
|
|
42
|
+
],
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
available: false,
|
|
48
|
+
reason: "No canonical narrative is available to export yet.",
|
|
49
|
+
preservedInDecksJson: VAULT_MIGRATION_PRESERVED_IN_DECKS_JSON,
|
|
50
|
+
nextActions: ["Initialize narrative intent first, then export a Markdown vault when stable narrative state exists."],
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"
|
|
2
|
+
import { dirname, join } from "path"
|
|
3
|
+
import type { AudienceIntent, DecisionIntent, NarrativeClaim, NarrativeClaimRelation, NarrativeClaimRelationType, NarrativeEvidenceBinding, NarrativeObjection, NarrativeResearchGap, NarrativeRisk, NarrativeStateV1, NarrativeThesis } from "../narrative-state/types"
|
|
4
|
+
import { NARRATIVE_VAULT_RELATION_TYPES } from "./constants"
|
|
5
|
+
import { narrativeVaultPath } from "./paths"
|
|
6
|
+
import { readNarrativeVaultDocuments } from "./read"
|
|
7
|
+
|
|
8
|
+
export type UpsertVaultEvidenceInput = Partial<NarrativeEvidenceBinding> & {
|
|
9
|
+
id: string
|
|
10
|
+
claimId: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type UpdateVaultResearchGapInput = Partial<NarrativeResearchGap> & {
|
|
14
|
+
id: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type UpsertVaultClaimInput = Partial<NarrativeClaim> & {
|
|
18
|
+
id: string
|
|
19
|
+
relations?: Array<Pick<NarrativeClaimRelation, "relation" | "toClaimId" | "rationale">>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface UpsertVaultRelationInput {
|
|
23
|
+
id: string
|
|
24
|
+
fromId: string
|
|
25
|
+
toId: string
|
|
26
|
+
relation: NarrativeClaimRelationType
|
|
27
|
+
rationale?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type UpsertVaultObjectionInput = Partial<NarrativeObjection> & {
|
|
31
|
+
id: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type UpsertVaultRiskInput = Partial<NarrativeRisk> & {
|
|
35
|
+
id: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface UpdateVaultCoreInput {
|
|
39
|
+
status?: NarrativeStateV1["status"]
|
|
40
|
+
audience?: Partial<AudienceIntent>
|
|
41
|
+
decision?: Partial<DecisionIntent>
|
|
42
|
+
thesis?: Partial<NarrativeThesis>
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface VaultNodeMutationResult {
|
|
46
|
+
ok: boolean
|
|
47
|
+
file?: string
|
|
48
|
+
files?: string[]
|
|
49
|
+
nodeId?: string
|
|
50
|
+
missingFields?: string[]
|
|
51
|
+
error?: string
|
|
52
|
+
skipped?: boolean
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function upsertVaultClaimNode(workspaceRoot: string, input: UpsertVaultClaimInput): VaultNodeMutationResult {
|
|
56
|
+
const existingPath = existingNodePath(workspaceRoot, "claim", input.id)
|
|
57
|
+
const existing = existingPath ? readNarrativeVaultDocuments(workspaceRoot).documents.find((doc) => doc.relativePath === existingPath) : undefined
|
|
58
|
+
const missing = existing ? [] : missingNewClaimFields(input)
|
|
59
|
+
if (missing.length > 0) return { ok: false, nodeId: input.id, missingFields: missing, error: `Claim node is missing required fields for creation: ${missing.join(", ")}.` }
|
|
60
|
+
|
|
61
|
+
const root = narrativeVaultPath(workspaceRoot)
|
|
62
|
+
const relativePath = existingPath ?? join("claims", `${safeFileName(input.id)}.md`)
|
|
63
|
+
const frontmatter = {
|
|
64
|
+
...(existing?.frontmatter ?? {}),
|
|
65
|
+
type: "claim",
|
|
66
|
+
id: input.id,
|
|
67
|
+
kind: input.kind ?? existing?.frontmatter.kind,
|
|
68
|
+
importance: input.importance ?? existing?.frontmatter.importance,
|
|
69
|
+
evidenceRequired: input.evidenceRequired ?? existing?.frontmatter.evidenceRequired,
|
|
70
|
+
supportedScope: input.supportedScope ?? existing?.frontmatter.supportedScope,
|
|
71
|
+
unsupportedScope: input.unsupportedScope ?? existing?.frontmatter.unsupportedScope,
|
|
72
|
+
text: input.text ?? existing?.frontmatter.text,
|
|
73
|
+
caveats: input.caveats ? undefined : existing?.frontmatter.caveats,
|
|
74
|
+
}
|
|
75
|
+
const overrides: Record<string, string> = {}
|
|
76
|
+
if (input.caveats) overrides.caveats = formatList(input.caveats)
|
|
77
|
+
const body = buildNodeBody(input.text ?? (stringValue(existing?.frontmatter.text) || existing?.body.trim() || ""), existing?.sections, overrides)
|
|
78
|
+
writeVaultNode(root, relativePath, frontmatter, body)
|
|
79
|
+
return { ok: true, file: relativePath, nodeId: input.id }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function upsertVaultRelation(_workspaceRoot: string, input: UpsertVaultRelationInput): VaultNodeMutationResult {
|
|
83
|
+
const missing = missingRelationFields(input)
|
|
84
|
+
if (missing.length > 0) return { ok: false, nodeId: input.id, missingFields: missing, error: `Relation edge is missing required fields: ${missing.join(", ")}.` }
|
|
85
|
+
if (!isRelationType(input.relation)) return { ok: false, nodeId: input.id, error: `Invalid relation type: ${input.relation}.` }
|
|
86
|
+
return { ok: false, skipped: true, nodeId: input.id, error: "Relation registry helpers are disabled. Add or edit the source node's ## Relations wikilink instead; compileNarrativeVault will generate the relation id." }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function removeVaultRelation(_workspaceRoot: string, id: string): VaultNodeMutationResult {
|
|
90
|
+
const edgeId = id.trim()
|
|
91
|
+
if (!edgeId) return { ok: false, missingFields: ["id"], error: "Relation id is required for removal." }
|
|
92
|
+
return { ok: false, skipped: true, nodeId: edgeId, error: "Relation registry helpers are disabled. Remove the matching line from the source node's ## Relations section instead." }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function upsertVaultObjectionNode(workspaceRoot: string, input: UpsertVaultObjectionInput): VaultNodeMutationResult {
|
|
96
|
+
const existingPath = existingNodePath(workspaceRoot, "objection", input.id)
|
|
97
|
+
const existing = existingPath ? readNarrativeVaultDocuments(workspaceRoot).documents.find((doc) => doc.relativePath === existingPath) : undefined
|
|
98
|
+
const missing = existing ? [] : missingNewTextNodeFields(input)
|
|
99
|
+
if (missing.length > 0) return { ok: false, nodeId: input.id, missingFields: missing, error: `Objection node is missing required fields for creation: ${missing.join(", ")}.` }
|
|
100
|
+
|
|
101
|
+
const root = narrativeVaultPath(workspaceRoot)
|
|
102
|
+
const relativePath = existingPath ?? join("objections", `${safeFileName(input.id)}.md`)
|
|
103
|
+
const frontmatter = {
|
|
104
|
+
...(existing?.frontmatter ?? {}),
|
|
105
|
+
type: "objection",
|
|
106
|
+
id: input.id,
|
|
107
|
+
text: input.text ?? existing?.frontmatter.text,
|
|
108
|
+
claimId: input.claimId ?? existing?.frontmatter.claimId,
|
|
109
|
+
priority: input.priority ?? existing?.frontmatter.priority,
|
|
110
|
+
response: input.response ?? existing?.frontmatter.response,
|
|
111
|
+
}
|
|
112
|
+
const overrides: Record<string, string> = input.response ? { response: input.response } : {}
|
|
113
|
+
const claimId = input.claimId ?? stringValue(existing?.frontmatter.claimId)
|
|
114
|
+
if (claimId) overrides.relations = relationLines([{ relation: "answers", targetId: claimId }])
|
|
115
|
+
const body = buildNodeBody(input.text ?? (stringValue(existing?.frontmatter.text) || existing?.body.trim() || ""), existing?.sections, overrides)
|
|
116
|
+
writeVaultNode(root, relativePath, frontmatter, body)
|
|
117
|
+
return { ok: true, file: relativePath, nodeId: input.id }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function upsertVaultRiskNode(workspaceRoot: string, input: UpsertVaultRiskInput): VaultNodeMutationResult {
|
|
121
|
+
const existingPath = existingNodePath(workspaceRoot, "risk", input.id)
|
|
122
|
+
const existing = existingPath ? readNarrativeVaultDocuments(workspaceRoot).documents.find((doc) => doc.relativePath === existingPath) : undefined
|
|
123
|
+
const missing = existing ? [] : missingNewTextNodeFields(input)
|
|
124
|
+
if (missing.length > 0) return { ok: false, nodeId: input.id, missingFields: missing, error: `Risk node is missing required fields for creation: ${missing.join(", ")}.` }
|
|
125
|
+
|
|
126
|
+
const root = narrativeVaultPath(workspaceRoot)
|
|
127
|
+
const relativePath = existingPath ?? join("risks", `${safeFileName(input.id)}.md`)
|
|
128
|
+
const frontmatter = {
|
|
129
|
+
...(existing?.frontmatter ?? {}),
|
|
130
|
+
type: "risk",
|
|
131
|
+
id: input.id,
|
|
132
|
+
text: input.text ?? existing?.frontmatter.text,
|
|
133
|
+
claimId: input.claimId ?? existing?.frontmatter.claimId,
|
|
134
|
+
severity: input.severity ?? existing?.frontmatter.severity,
|
|
135
|
+
mitigation: input.mitigation ?? existing?.frontmatter.mitigation,
|
|
136
|
+
}
|
|
137
|
+
const overrides: Record<string, string> = input.mitigation ? { mitigation: input.mitigation } : {}
|
|
138
|
+
const claimId = input.claimId ?? stringValue(existing?.frontmatter.claimId)
|
|
139
|
+
if (claimId) overrides.relations = relationLines([{ relation: "constrains", targetId: claimId }])
|
|
140
|
+
const body = buildNodeBody(input.text ?? (stringValue(existing?.frontmatter.text) || existing?.body.trim() || ""), existing?.sections, overrides)
|
|
141
|
+
writeVaultNode(root, relativePath, frontmatter, body)
|
|
142
|
+
return { ok: true, file: relativePath, nodeId: input.id }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function updateVaultCoreNodes(workspaceRoot: string, input: UpdateVaultCoreInput): VaultNodeMutationResult {
|
|
146
|
+
const root = narrativeVaultPath(workspaceRoot)
|
|
147
|
+
const read = readNarrativeVaultDocuments(workspaceRoot)
|
|
148
|
+
const files: string[] = []
|
|
149
|
+
if (input.status) {
|
|
150
|
+
const existing = read.documents.find((doc) => doc.relativePath === "index.md")
|
|
151
|
+
writeVaultNode(root, "index.md", { ...(existing?.frontmatter ?? {}), type: "index", id: stringValue(existing?.frontmatter.id) || "narrative:vault", status: input.status }, existing?.body ? `${existing.body.trim()}\n` : "")
|
|
152
|
+
files.push("index.md")
|
|
153
|
+
}
|
|
154
|
+
if (input.audience) {
|
|
155
|
+
const existing = read.documents.find((doc) => doc.relativePath === "audience.md")
|
|
156
|
+
const frontmatter = { ...(existing?.frontmatter ?? {}), type: "audience", ...input.audience }
|
|
157
|
+
writeVaultNode(root, "audience.md", frontmatter, `${input.audience.primary ?? existing?.body.trim() ?? ""}\n`)
|
|
158
|
+
files.push("audience.md")
|
|
159
|
+
}
|
|
160
|
+
if (input.decision) {
|
|
161
|
+
const existing = read.documents.find((doc) => doc.relativePath === "decision.md")
|
|
162
|
+
const frontmatter = { ...(existing?.frontmatter ?? {}), type: "decision", ...input.decision }
|
|
163
|
+
writeVaultNode(root, "decision.md", frontmatter, `${input.decision.action ?? existing?.body.trim() ?? ""}\n`)
|
|
164
|
+
files.push("decision.md")
|
|
165
|
+
}
|
|
166
|
+
if (input.thesis) {
|
|
167
|
+
const existing = read.documents.find((doc) => doc.relativePath === "thesis.md")
|
|
168
|
+
const frontmatter = { ...(existing?.frontmatter ?? {}), type: "thesis", id: input.thesis.id ?? existing?.frontmatter.id ?? "thesis:main", confidence: input.thesis.confidence ?? existing?.frontmatter.confidence, caveat: input.thesis.caveat ?? existing?.frontmatter.caveat }
|
|
169
|
+
writeVaultNode(root, "thesis.md", frontmatter, `${input.thesis.statement ?? existing?.body.trim() ?? ""}\n`)
|
|
170
|
+
files.push("thesis.md")
|
|
171
|
+
}
|
|
172
|
+
if (files.length === 0) return { ok: false, missingFields: ["status|audience|decision|thesis"], error: "No core narrative fields were provided." }
|
|
173
|
+
return { ok: true, file: files[0], files, nodeId: "narrative:core" }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function upsertVaultEvidenceNode(workspaceRoot: string, input: UpsertVaultEvidenceInput): VaultNodeMutationResult {
|
|
177
|
+
const missing = missingEvidenceFields(input)
|
|
178
|
+
if (missing.length > 0) return { ok: false, nodeId: input.id, missingFields: missing, error: `Evidence node is missing required source trace fields: ${missing.join(", ")}.` }
|
|
179
|
+
|
|
180
|
+
const root = narrativeVaultPath(workspaceRoot)
|
|
181
|
+
const relativePath = existingNodePath(workspaceRoot, "evidence", input.id) ?? join("evidence", `${safeFileName(input.id)}.md`)
|
|
182
|
+
const existing = readNarrativeVaultDocuments(workspaceRoot).documents.find((doc) => doc.relativePath === relativePath)
|
|
183
|
+
const frontmatter = {
|
|
184
|
+
...(existing?.frontmatter ?? {}),
|
|
185
|
+
type: "evidence",
|
|
186
|
+
id: input.id,
|
|
187
|
+
claimId: input.claimId,
|
|
188
|
+
source: input.source,
|
|
189
|
+
sourcePath: input.sourcePath,
|
|
190
|
+
findingsFile: input.findingsFile,
|
|
191
|
+
quote: input.quote,
|
|
192
|
+
location: input.location,
|
|
193
|
+
url: input.url,
|
|
194
|
+
caveat: input.caveat,
|
|
195
|
+
supportScope: input.supportScope,
|
|
196
|
+
unsupportedScope: input.unsupportedScope,
|
|
197
|
+
strength: input.strength,
|
|
198
|
+
}
|
|
199
|
+
const body = buildNodeBody(input.quote?.trim() ?? existing?.body.trim() ?? "", existing?.sections, { relations: relationLines([{ relation: "supports", targetId: input.claimId }]) })
|
|
200
|
+
writeVaultNode(root, relativePath, frontmatter, body)
|
|
201
|
+
return { ok: true, file: relativePath, nodeId: input.id }
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function updateVaultResearchGapNode(workspaceRoot: string, input: UpdateVaultResearchGapInput, options: { now?: string } = {}): VaultNodeMutationResult {
|
|
205
|
+
const now = options.now ?? new Date().toISOString()
|
|
206
|
+
const existingPath = existingNodePath(workspaceRoot, "research-gap", input.id)
|
|
207
|
+
const existing = existingPath ? readNarrativeVaultDocuments(workspaceRoot).documents.find((doc) => doc.relativePath === existingPath) : undefined
|
|
208
|
+
const missing = existing ? [] : missingNewResearchGapFields(input)
|
|
209
|
+
if (missing.length > 0) return { ok: false, nodeId: input.id, missingFields: missing, error: `Research gap node is missing required fields for creation: ${missing.join(", ")}.` }
|
|
210
|
+
|
|
211
|
+
const root = narrativeVaultPath(workspaceRoot)
|
|
212
|
+
const relativePath = existingPath ?? join("research-gaps", `${safeFileName(input.id)}.md`)
|
|
213
|
+
const currentStatus = stringValue(existing?.frontmatter.status)
|
|
214
|
+
const nextStatus = input.status ?? (currentStatus || "open")
|
|
215
|
+
const frontmatter = {
|
|
216
|
+
...(existing?.frontmatter ?? {}),
|
|
217
|
+
type: "research-gap",
|
|
218
|
+
id: input.id,
|
|
219
|
+
targetType: input.targetType ?? existing?.frontmatter.targetType,
|
|
220
|
+
targetId: input.targetId ?? existing?.frontmatter.targetId,
|
|
221
|
+
question: input.question ?? existing?.frontmatter.question,
|
|
222
|
+
status: nextStatus,
|
|
223
|
+
priority: input.priority ?? existing?.frontmatter.priority,
|
|
224
|
+
findingsFile: input.findingsFile ?? existing?.frontmatter.findingsFile,
|
|
225
|
+
evidenceBindingIds: input.evidenceBindingIds ?? existing?.frontmatter.evidenceBindingIds,
|
|
226
|
+
createdFromIssueType: input.createdFromIssueType ?? existing?.frontmatter.createdFromIssueType,
|
|
227
|
+
notes: input.notes ?? existing?.frontmatter.notes,
|
|
228
|
+
createdAt: existing?.frontmatter.createdAt ?? input.createdAt ?? now,
|
|
229
|
+
updatedAt: now,
|
|
230
|
+
closedAt: nextStatus === "closed" ? input.closedAt ?? existing?.frontmatter.closedAt ?? now : input.closedAt ?? existing?.frontmatter.closedAt,
|
|
231
|
+
}
|
|
232
|
+
const question = input.question ?? (stringValue(existing?.frontmatter.question) || existing?.body.trim() || "")
|
|
233
|
+
const notes = input.notes ?? (stringValue(existing?.frontmatter.notes) || existing?.sections.notes?.trim() || "")
|
|
234
|
+
const relationTargets = [
|
|
235
|
+
...(input.evidenceBindingIds ?? arrayValue(existing?.frontmatter.evidenceBindingIds)).map((targetId) => ({ relation: "depends_on" as const, targetId })),
|
|
236
|
+
...((input.targetId ?? stringValue(existing?.frontmatter.targetId)) ? [{ relation: "depends_on" as const, targetId: input.targetId ?? stringValue(existing?.frontmatter.targetId) }] : []),
|
|
237
|
+
]
|
|
238
|
+
const body = buildNodeBody(question, existing?.sections, {
|
|
239
|
+
...(notes ? { notes } : {}),
|
|
240
|
+
...(relationTargets.length > 0 ? { relations: relationLines(relationTargets) } : {}),
|
|
241
|
+
})
|
|
242
|
+
writeVaultNode(root, relativePath, frontmatter, body)
|
|
243
|
+
return { ok: true, file: relativePath, nodeId: input.id }
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function missingEvidenceFields(input: UpsertVaultEvidenceInput): string[] {
|
|
247
|
+
const missing: string[] = []
|
|
248
|
+
for (const key of ["id", "claimId", "source", "quote", "supportScope", "unsupportedScope", "caveat", "strength"] as const) {
|
|
249
|
+
if (!String(input[key] ?? "").trim()) missing.push(key)
|
|
250
|
+
}
|
|
251
|
+
if (!input.sourcePath?.trim() && !input.url?.trim() && !input.findingsFile?.trim()) missing.push("sourcePath|url|findingsFile")
|
|
252
|
+
return missing
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function missingRelationFields(input: Partial<UpsertVaultRelationInput>): string[] {
|
|
256
|
+
const missing: string[] = []
|
|
257
|
+
for (const key of ["id", "fromId", "toId", "relation"] as const) {
|
|
258
|
+
if (!String(input[key] ?? "").trim()) missing.push(key)
|
|
259
|
+
}
|
|
260
|
+
return missing
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function isRelationType(value: string): value is NarrativeClaimRelationType {
|
|
264
|
+
return (NARRATIVE_VAULT_RELATION_TYPES as readonly string[]).includes(value)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function missingNewResearchGapFields(input: UpdateVaultResearchGapInput): string[] {
|
|
268
|
+
const missing: string[] = []
|
|
269
|
+
for (const key of ["id", "targetType", "targetId", "question"] as const) {
|
|
270
|
+
if (!String(input[key] ?? "").trim()) missing.push(key)
|
|
271
|
+
}
|
|
272
|
+
return missing
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function missingNewClaimFields(input: UpsertVaultClaimInput): string[] {
|
|
276
|
+
const missing = missingNewTextNodeFields(input)
|
|
277
|
+
for (const key of ["kind", "importance", "evidenceRequired"] as const) {
|
|
278
|
+
if (input[key] === undefined || !String(input[key]).trim()) missing.push(key)
|
|
279
|
+
}
|
|
280
|
+
return missing
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function missingNewTextNodeFields(input: { id?: string; text?: string }): string[] {
|
|
284
|
+
const missing: string[] = []
|
|
285
|
+
for (const key of ["id", "text"] as const) {
|
|
286
|
+
if (!String(input[key] ?? "").trim()) missing.push(key)
|
|
287
|
+
}
|
|
288
|
+
return missing
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function existingNodePath(workspaceRoot: string, type: string, id: string): string | undefined {
|
|
292
|
+
if (!existsSync(narrativeVaultPath(workspaceRoot))) return undefined
|
|
293
|
+
return readNarrativeVaultDocuments(workspaceRoot).documents.find((doc) => doc.frontmatter.type === type && doc.frontmatter.id === id)?.relativePath
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function writeVaultNode(root: string, relativePath: string, frontmatter: Record<string, unknown>, body: string): void {
|
|
297
|
+
const filePath = join(root, relativePath)
|
|
298
|
+
mkdirSync(dirname(filePath), { recursive: true })
|
|
299
|
+
writeFileSync(filePath, `${formatFrontmatter(frontmatter)}\n${body.endsWith("\n") ? body : `${body}\n`}`, "utf-8")
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function formatFrontmatter(values: Record<string, unknown>): string {
|
|
303
|
+
const lines = ["---"]
|
|
304
|
+
for (const [key, value] of Object.entries(values)) {
|
|
305
|
+
if (value === undefined || value === "" || (Array.isArray(value) && value.length === 0)) continue
|
|
306
|
+
if (Array.isArray(value)) {
|
|
307
|
+
lines.push(`${key}:`)
|
|
308
|
+
for (const item of value) lines.push(` - ${quote(String(item))}`)
|
|
309
|
+
} else if (typeof value === "boolean") {
|
|
310
|
+
lines.push(`${key}: ${value ? "true" : "false"}`)
|
|
311
|
+
} else {
|
|
312
|
+
lines.push(`${key}: ${quote(String(value))}`)
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
lines.push("---")
|
|
316
|
+
return lines.join("\n")
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function buildNodeBody(main: string, existingSections: Record<string, string> = {}, overrides: Record<string, string> = {}): string {
|
|
320
|
+
const sections = { ...existingSections, ...overrides }
|
|
321
|
+
const chunks = [main.trim()]
|
|
322
|
+
for (const [name, value] of Object.entries(sections)) {
|
|
323
|
+
const trimmed = value.trim()
|
|
324
|
+
if (!trimmed) continue
|
|
325
|
+
chunks.push(`## ${sectionTitle(name)}\n\n${trimmed}`)
|
|
326
|
+
}
|
|
327
|
+
return `${chunks.filter(Boolean).join("\n\n")}\n`
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function relationLines(items: Array<{ relation: NarrativeClaimRelationType; targetId: string }>): string {
|
|
331
|
+
return items
|
|
332
|
+
.filter((item) => item.targetId.trim())
|
|
333
|
+
.map((item) => `- ${item.relation}: [[${item.targetId.trim()}]]`)
|
|
334
|
+
.join("\n")
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function arrayValue(value: unknown): string[] {
|
|
338
|
+
if (Array.isArray(value)) return value.map((item) => String(item).trim()).filter(Boolean)
|
|
339
|
+
if (typeof value === "string" && value.trim()) return value.split(",").map((item) => item.trim()).filter(Boolean)
|
|
340
|
+
return []
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function formatList(items: string[]): string {
|
|
344
|
+
return items.map((item) => `- ${item}`).join("\n")
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function sectionTitle(name: string): string {
|
|
348
|
+
return name.split("-").map((part) => part ? `${part[0].toUpperCase()}${part.slice(1)}` : part).join(" ")
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function quote(value: string): string {
|
|
352
|
+
return JSON.stringify(value)
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function safeFileName(id: string): string {
|
|
356
|
+
return id.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "node"
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function stringValue(value: unknown): string {
|
|
360
|
+
return typeof value === "string" ? value.trim() : ""
|
|
361
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { existsSync } from "fs"
|
|
2
|
+
import { join } from "path"
|
|
3
|
+
import { NARRATIVE_VAULT_CACHE_DIR, NARRATIVE_VAULT_DIR } from "./constants"
|
|
4
|
+
|
|
5
|
+
export function narrativeVaultPath(workspaceRoot: string): string {
|
|
6
|
+
return join(workspaceRoot, NARRATIVE_VAULT_DIR)
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function narrativeVaultCachePath(workspaceRoot: string): string {
|
|
10
|
+
return join(workspaceRoot, NARRATIVE_VAULT_CACHE_DIR)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function hasNarrativeVault(workspaceRoot: string): boolean {
|
|
14
|
+
return existsSync(narrativeVaultPath(workspaceRoot))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function vaultRelativePath(filePath: string): string {
|
|
18
|
+
return filePath.split(/[/\\]+/).join("/")
|
|
19
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "fs"
|
|
2
|
+
import { join, relative } from "path"
|
|
3
|
+
import { NARRATIVE_VAULT_NODE_DIRS } from "./constants"
|
|
4
|
+
import { parseVaultFrontmatter } from "./frontmatter"
|
|
5
|
+
import { splitMarkdownSections } from "./markdown"
|
|
6
|
+
import { narrativeVaultPath, vaultRelativePath } from "./paths"
|
|
7
|
+
import { parseRelations } from "./relations"
|
|
8
|
+
import type { VaultDiagnostic, VaultDocument } from "./types"
|
|
9
|
+
|
|
10
|
+
export function listNarrativeVaultFiles(workspaceRoot: string): string[] {
|
|
11
|
+
const root = narrativeVaultPath(workspaceRoot)
|
|
12
|
+
if (!existsSync(root)) return []
|
|
13
|
+
const files = ["index.md", "audience.md", "decision.md", "thesis.md"].map((file) => join(root, file)).filter((file) => existsSync(file))
|
|
14
|
+
for (const dir of NARRATIVE_VAULT_NODE_DIRS) {
|
|
15
|
+
const folder = join(root, dir)
|
|
16
|
+
if (!existsSync(folder) || !statSync(folder).isDirectory()) continue
|
|
17
|
+
for (const entry of readdirSync(folder).sort()) {
|
|
18
|
+
const file = join(folder, entry)
|
|
19
|
+
if (entry.endsWith(".md") && statSync(file).isFile()) files.push(file)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return files
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function readNarrativeVaultDocuments(workspaceRoot: string): { documents: VaultDocument[]; diagnostics: VaultDiagnostic[] } {
|
|
26
|
+
const root = narrativeVaultPath(workspaceRoot)
|
|
27
|
+
const diagnostics: VaultDiagnostic[] = []
|
|
28
|
+
const documents = listNarrativeVaultFiles(workspaceRoot).map((path) => {
|
|
29
|
+
const markdown = readFileSync(path, "utf-8")
|
|
30
|
+
const parsed = parseVaultFrontmatter(markdown)
|
|
31
|
+
const split = splitMarkdownSections(parsed.body)
|
|
32
|
+
const id = stringField(parsed.frontmatter, "id")
|
|
33
|
+
const relationResult = id ? parseRelations(split.sections.relations ?? "", id, vaultRelativePath(relative(root, path))) : { relations: [], unknownTypes: [] }
|
|
34
|
+
for (const type of relationResult.unknownTypes) {
|
|
35
|
+
diagnostics.push({ severity: "error", code: "unknown_relation_type", message: `Unknown relation type: ${type}`, file: vaultRelativePath(relative(root, path)), nodeId: id })
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
path,
|
|
39
|
+
relativePath: vaultRelativePath(relative(root, path)),
|
|
40
|
+
frontmatter: parsed.frontmatter,
|
|
41
|
+
body: split.main,
|
|
42
|
+
sections: split.sections,
|
|
43
|
+
relations: relationResult.relations,
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
return { documents, diagnostics }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function stringField(frontmatter: Record<string, string | string[] | boolean>, key: string): string {
|
|
50
|
+
const value = frontmatter[key]
|
|
51
|
+
return typeof value === "string" ? value.trim() : ""
|
|
52
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { NARRATIVE_VAULT_RELATION_TYPES } from "./constants"
|
|
2
|
+
import type { VaultRelation } from "./types"
|
|
3
|
+
import type { NarrativeClaimRelationType } from "../narrative-state/types"
|
|
4
|
+
|
|
5
|
+
export function parseRelations(section: string, fromId: string, file: string): { relations: VaultRelation[]; unknownTypes: string[] } {
|
|
6
|
+
const relations: VaultRelation[] = []
|
|
7
|
+
const unknownTypes: string[] = []
|
|
8
|
+
for (const line of section.split("\n")) {
|
|
9
|
+
const match = /^\s*-\s*([a-z_]+):\s*\[\[([^\]|]+)(?:\|[^\]]+)?\]\](?:\s*-\s*(.+))?\s*$/.exec(line)
|
|
10
|
+
if (!match) continue
|
|
11
|
+
const relation = match[1]
|
|
12
|
+
if (!isRelationType(relation)) {
|
|
13
|
+
unknownTypes.push(relation)
|
|
14
|
+
continue
|
|
15
|
+
}
|
|
16
|
+
const toId = match[2].trim()
|
|
17
|
+
relations.push({ id: stableVaultRelationId(fromId, relation, toId), fromId, relation, toId, rationale: match[3]?.trim(), file, source: "inline" })
|
|
18
|
+
}
|
|
19
|
+
return { relations, unknownTypes }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function stableVaultRelationId(fromId: string, relation: string, toId: string): string {
|
|
23
|
+
return `rel-${slugId(fromId)}-${relation}-${slugId(toId)}`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isRelationType(value: string): value is NarrativeClaimRelationType {
|
|
27
|
+
return (NARRATIVE_VAULT_RELATION_TYPES as readonly string[]).includes(value)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function slugId(value: string): string {
|
|
31
|
+
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "node"
|
|
32
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { NarrativeApproval, NarrativeStateV1 } from "../narrative-state/types"
|
|
2
|
+
import { writeNarrativeVaultCache } from "./cache"
|
|
3
|
+
import { compileNarrativeVault } from "./compile"
|
|
4
|
+
import { hasNarrativeVault } from "./paths"
|
|
5
|
+
import type { NarrativeVaultCompileResult } from "./types"
|
|
6
|
+
|
|
7
|
+
export interface PreferredNarrativeLoadResult {
|
|
8
|
+
source: "vault" | "state"
|
|
9
|
+
narrative?: NarrativeStateV1
|
|
10
|
+
compileResult?: NarrativeVaultCompileResult
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function loadNarrativeFromPreferredSource(workspaceRoot: string, stateNarrative: NarrativeStateV1 | undefined, fallbackApprovals?: NarrativeApproval[]): PreferredNarrativeLoadResult {
|
|
14
|
+
if (!hasNarrativeVault(workspaceRoot)) return { source: "state", narrative: stateNarrative }
|
|
15
|
+
const approvals: NarrativeApproval[] = fallbackApprovals ?? stateNarrative?.approvals ?? []
|
|
16
|
+
const compileResult = compileNarrativeVault(workspaceRoot, { fallbackApprovals: approvals })
|
|
17
|
+
writeNarrativeVaultCache(workspaceRoot, compileResult)
|
|
18
|
+
return { source: "vault", narrative: compileResult.narrative, compileResult }
|
|
19
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync } from "fs"
|
|
2
|
+
import { join } from "path"
|
|
3
|
+
import { narrativeVaultPath } from "./paths"
|
|
4
|
+
|
|
5
|
+
export function narrativeVaultTimestampMs(workspaceRoot: string): number {
|
|
6
|
+
const root = narrativeVaultPath(workspaceRoot)
|
|
7
|
+
if (!existsSync(root)) return 0
|
|
8
|
+
return newestMarkdownMtime(root)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function newestMarkdownMtime(dir: string): number {
|
|
12
|
+
let newest = 0
|
|
13
|
+
let entries: string[]
|
|
14
|
+
try {
|
|
15
|
+
entries = readdirSync(dir)
|
|
16
|
+
} catch {
|
|
17
|
+
return 0
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
for (const entry of entries) {
|
|
21
|
+
const path = join(dir, entry)
|
|
22
|
+
let stat
|
|
23
|
+
try {
|
|
24
|
+
stat = statSync(path)
|
|
25
|
+
} catch {
|
|
26
|
+
continue
|
|
27
|
+
}
|
|
28
|
+
if (stat.isDirectory()) newest = Math.max(newest, newestMarkdownMtime(path))
|
|
29
|
+
else if (stat.isFile() && entry.endsWith(".md")) newest = Math.max(newest, stat.mtimeMs)
|
|
30
|
+
}
|
|
31
|
+
return newest
|
|
32
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { NarrativeClaimRelationType, NarrativeStateV1 } from "../narrative-state/types"
|
|
2
|
+
|
|
3
|
+
export type VaultNodeType = "index" | "audience" | "decision" | "thesis" | "claim" | "evidence" | "objection" | "risk" | "research-gap"
|
|
4
|
+
|
|
5
|
+
export type VaultDiagnosticSeverity = "error" | "warning"
|
|
6
|
+
|
|
7
|
+
export interface VaultDiagnostic {
|
|
8
|
+
severity: VaultDiagnosticSeverity
|
|
9
|
+
code: string
|
|
10
|
+
message: string
|
|
11
|
+
file?: string
|
|
12
|
+
nodeId?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface VaultRelation {
|
|
16
|
+
id?: string
|
|
17
|
+
fromId: string
|
|
18
|
+
relation: NarrativeClaimRelationType
|
|
19
|
+
toId: string
|
|
20
|
+
rationale?: string
|
|
21
|
+
file: string
|
|
22
|
+
source?: "inline"
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface VaultDocument {
|
|
26
|
+
path: string
|
|
27
|
+
relativePath: string
|
|
28
|
+
frontmatter: Record<string, string | string[] | boolean>
|
|
29
|
+
body: string
|
|
30
|
+
sections: Record<string, string>
|
|
31
|
+
relations: VaultRelation[]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface NarrativeVaultCompileResult {
|
|
35
|
+
ok: boolean
|
|
36
|
+
narrative?: NarrativeStateV1
|
|
37
|
+
diagnostics: VaultDiagnostic[]
|
|
38
|
+
graph: NarrativeVaultGraph
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface NarrativeVaultGraph {
|
|
42
|
+
nodes: Array<{ id: string; type: VaultNodeType; file: string }>
|
|
43
|
+
relations: VaultRelation[]
|
|
44
|
+
}
|