@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,392 @@
|
|
|
1
|
+
import { readNarrativeVaultDocuments } from "./read"
|
|
2
|
+
import { stableVaultRelationId } from "./relations"
|
|
3
|
+
import type { NarrativeClaimRelationType } from "../narrative-state/types"
|
|
4
|
+
import type { VaultDiagnostic, VaultDocument, VaultNodeType, VaultRelation } from "./types"
|
|
5
|
+
|
|
6
|
+
export interface NarrativeVaultInventoryNode {
|
|
7
|
+
id: string
|
|
8
|
+
type: VaultNodeType
|
|
9
|
+
file: string
|
|
10
|
+
title: string
|
|
11
|
+
text: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface NarrativeVaultInventoryClaim extends NarrativeVaultInventoryNode {
|
|
15
|
+
kind: string
|
|
16
|
+
importance: string
|
|
17
|
+
evidenceRequired: boolean
|
|
18
|
+
evidenceStatus: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface NarrativeVaultInventoryEvidence extends NarrativeVaultInventoryNode {
|
|
22
|
+
claimId: string
|
|
23
|
+
source: string
|
|
24
|
+
sourcePath: string
|
|
25
|
+
url: string
|
|
26
|
+
findingsFile: string
|
|
27
|
+
strength: string
|
|
28
|
+
quote: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface NarrativeVaultInventoryResearchGap extends NarrativeVaultInventoryNode {
|
|
32
|
+
targetType: string
|
|
33
|
+
targetId: string
|
|
34
|
+
question: string
|
|
35
|
+
status: string
|
|
36
|
+
priority: string
|
|
37
|
+
findingsFile: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface NarrativeVaultInventoryObjection extends NarrativeVaultInventoryNode {
|
|
41
|
+
claimId: string
|
|
42
|
+
priority: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface NarrativeVaultInventoryRisk extends NarrativeVaultInventoryNode {
|
|
46
|
+
claimId: string
|
|
47
|
+
severity: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface NarrativeVaultInventoryRelation extends VaultRelation {
|
|
51
|
+
unresolved: boolean
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface NarrativeVaultRelationCandidate {
|
|
55
|
+
id: string
|
|
56
|
+
fromId: string
|
|
57
|
+
toId: string
|
|
58
|
+
relation: NarrativeClaimRelationType
|
|
59
|
+
rationale: string
|
|
60
|
+
source: "frontmatter"
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface NarrativeVaultRelationCoverage {
|
|
64
|
+
danglingEdges: NarrativeVaultInventoryRelation[]
|
|
65
|
+
unboundEvidence: string[]
|
|
66
|
+
unboundObjections: string[]
|
|
67
|
+
unboundRisks: string[]
|
|
68
|
+
unboundResearchGaps: string[]
|
|
69
|
+
fallbackOnlyBindings: Array<{ nodeId: string; file: string; field: string; relation: NarrativeClaimRelationType; targetId: string }>
|
|
70
|
+
isolatedClaims: string[]
|
|
71
|
+
orphanNodes: string[]
|
|
72
|
+
inlineRelations: Array<{ file: string; nodeId: string }>
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface NarrativeVaultInventoryUnresolvedRef {
|
|
76
|
+
kind: "relation" | "evidenceClaimId" | "gapTarget" | "objectionClaimId" | "riskClaimId"
|
|
77
|
+
fromId: string
|
|
78
|
+
targetId: string
|
|
79
|
+
file: string
|
|
80
|
+
field?: string
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface NarrativeVaultInventory {
|
|
84
|
+
ok: boolean
|
|
85
|
+
path: "revela-narrative"
|
|
86
|
+
counts: {
|
|
87
|
+
claims: number
|
|
88
|
+
evidence: number
|
|
89
|
+
researchGaps: number
|
|
90
|
+
objections: number
|
|
91
|
+
risks: number
|
|
92
|
+
relations: number
|
|
93
|
+
unresolvedRefs: number
|
|
94
|
+
}
|
|
95
|
+
claims: NarrativeVaultInventoryClaim[]
|
|
96
|
+
evidence: NarrativeVaultInventoryEvidence[]
|
|
97
|
+
researchGaps: NarrativeVaultInventoryResearchGap[]
|
|
98
|
+
objections: NarrativeVaultInventoryObjection[]
|
|
99
|
+
risks: NarrativeVaultInventoryRisk[]
|
|
100
|
+
relations: NarrativeVaultInventoryRelation[]
|
|
101
|
+
relationCoverage: NarrativeVaultRelationCoverage
|
|
102
|
+
relationSummary: {
|
|
103
|
+
inlineEdges: number
|
|
104
|
+
advisoryCandidates: number
|
|
105
|
+
}
|
|
106
|
+
relationCandidates: NarrativeVaultRelationCandidate[]
|
|
107
|
+
unresolvedRefs: NarrativeVaultInventoryUnresolvedRef[]
|
|
108
|
+
idHints: {
|
|
109
|
+
nextClaimIdExamples: string[]
|
|
110
|
+
nextEvidenceIdExamples: string[]
|
|
111
|
+
nextResearchGapIdExamples: string[]
|
|
112
|
+
}
|
|
113
|
+
diagnostics: VaultDiagnostic[]
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function buildNarrativeVaultInventory(workspaceRoot: string): NarrativeVaultInventory {
|
|
117
|
+
const { documents, diagnostics } = readNarrativeVaultDocuments(workspaceRoot)
|
|
118
|
+
const ids = new Set(documents.map((doc) => nodeId(doc)).filter(Boolean))
|
|
119
|
+
const claimIds = new Set(documents.filter((doc) => nodeType(doc) === "claim").map((doc) => nodeId(doc)).filter(Boolean))
|
|
120
|
+
const unresolvedRefs: NarrativeVaultInventoryUnresolvedRef[] = []
|
|
121
|
+
const relations: NarrativeVaultInventoryRelation[] = []
|
|
122
|
+
|
|
123
|
+
for (const doc of documents) {
|
|
124
|
+
const fromId = nodeId(doc)
|
|
125
|
+
for (const relation of doc.relations) {
|
|
126
|
+
const unresolved = !ids.has(relation.toId)
|
|
127
|
+
relations.push({ ...relation, unresolved })
|
|
128
|
+
if (unresolved) unresolvedRefs.push({ kind: "relation", fromId, targetId: relation.toId, file: relation.file })
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const claims: NarrativeVaultInventoryClaim[] = []
|
|
133
|
+
const evidence: NarrativeVaultInventoryEvidence[] = []
|
|
134
|
+
const researchGaps: NarrativeVaultInventoryResearchGap[] = []
|
|
135
|
+
const objections: NarrativeVaultInventoryObjection[] = []
|
|
136
|
+
const risks: NarrativeVaultInventoryRisk[] = []
|
|
137
|
+
|
|
138
|
+
for (const doc of documents) {
|
|
139
|
+
const id = nodeId(doc)
|
|
140
|
+
const type = nodeType(doc)
|
|
141
|
+
if (!id || !type) continue
|
|
142
|
+
const base = baseNode(doc, id, type)
|
|
143
|
+
if (type === "claim") {
|
|
144
|
+
claims.push({
|
|
145
|
+
...base,
|
|
146
|
+
kind: stringField(doc, "kind"),
|
|
147
|
+
importance: stringField(doc, "importance"),
|
|
148
|
+
evidenceRequired: booleanField(doc, "evidenceRequired"),
|
|
149
|
+
evidenceStatus: stringField(doc, "evidenceStatus"),
|
|
150
|
+
})
|
|
151
|
+
continue
|
|
152
|
+
}
|
|
153
|
+
if (type === "evidence") {
|
|
154
|
+
const claimId = stringField(doc, "claimId")
|
|
155
|
+
const inlineClaimId = relationTargetFor(relations, id, "supports", claimIds)
|
|
156
|
+
if (!claimId && !inlineClaimId) unresolvedRefs.push({ kind: "evidenceClaimId", fromId: id, targetId: claimId, file: doc.relativePath, field: "claimId" })
|
|
157
|
+
else if (claimId && !claimIds.has(claimId)) unresolvedRefs.push({ kind: "evidenceClaimId", fromId: id, targetId: claimId, file: doc.relativePath, field: "claimId" })
|
|
158
|
+
evidence.push({
|
|
159
|
+
...base,
|
|
160
|
+
claimId: inlineClaimId || claimId,
|
|
161
|
+
source: stringField(doc, "source"),
|
|
162
|
+
sourcePath: stringField(doc, "sourcePath"),
|
|
163
|
+
url: stringField(doc, "url"),
|
|
164
|
+
findingsFile: stringField(doc, "findingsFile"),
|
|
165
|
+
strength: stringField(doc, "strength"),
|
|
166
|
+
quote: firstText(stringField(doc, "quote") || doc.body),
|
|
167
|
+
})
|
|
168
|
+
continue
|
|
169
|
+
}
|
|
170
|
+
if (type === "research-gap") {
|
|
171
|
+
const targetType = stringField(doc, "targetType")
|
|
172
|
+
const targetId = stringField(doc, "targetId")
|
|
173
|
+
const inlineTargetId = relationTargetFor(relations, id, "depends_on", ids)
|
|
174
|
+
if (targetId && !isLooseTargetType(targetType) && !ids.has(targetId)) unresolvedRefs.push({ kind: "gapTarget", fromId: id, targetId, file: doc.relativePath, field: "targetId" })
|
|
175
|
+
researchGaps.push({
|
|
176
|
+
...base,
|
|
177
|
+
targetType,
|
|
178
|
+
targetId: inlineTargetId || targetId,
|
|
179
|
+
question: stringField(doc, "question") || firstText(doc.body),
|
|
180
|
+
status: stringField(doc, "status"),
|
|
181
|
+
priority: stringField(doc, "priority"),
|
|
182
|
+
findingsFile: stringField(doc, "findingsFile"),
|
|
183
|
+
})
|
|
184
|
+
continue
|
|
185
|
+
}
|
|
186
|
+
if (type === "objection") {
|
|
187
|
+
const claimId = stringField(doc, "claimId")
|
|
188
|
+
const inlineClaimId = relationTargetFor(relations, id, "answers", claimIds) || relationTargetFor(relations, id, "contrasts_with", claimIds)
|
|
189
|
+
if (!claimId && !inlineClaimId) unresolvedRefs.push({ kind: "objectionClaimId", fromId: id, targetId: claimId, file: doc.relativePath, field: "claimId" })
|
|
190
|
+
else if (claimId && !claimIds.has(claimId)) unresolvedRefs.push({ kind: "objectionClaimId", fromId: id, targetId: claimId, file: doc.relativePath, field: "claimId" })
|
|
191
|
+
objections.push({ ...base, claimId: inlineClaimId || claimId, priority: stringField(doc, "priority") })
|
|
192
|
+
continue
|
|
193
|
+
}
|
|
194
|
+
if (type === "risk") {
|
|
195
|
+
const claimId = stringField(doc, "claimId")
|
|
196
|
+
const inlineClaimId = relationTargetFor(relations, id, "constrains", claimIds)
|
|
197
|
+
if (!claimId && !inlineClaimId) unresolvedRefs.push({ kind: "riskClaimId", fromId: id, targetId: claimId, file: doc.relativePath, field: "claimId" })
|
|
198
|
+
else if (claimId && !claimIds.has(claimId)) unresolvedRefs.push({ kind: "riskClaimId", fromId: id, targetId: claimId, file: doc.relativePath, field: "claimId" })
|
|
199
|
+
risks.push({ ...base, claimId: inlineClaimId || claimId, severity: stringField(doc, "severity") })
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const ok = diagnostics.every((diagnostic) => diagnostic.severity !== "error") && unresolvedRefs.length === 0
|
|
204
|
+
const relationCoverage = buildRelationCoverage(documents, claims, evidence, objections, risks, researchGaps, relations, ids)
|
|
205
|
+
const relationCandidates = buildRelationCandidates(documents, relations, ids)
|
|
206
|
+
return {
|
|
207
|
+
ok,
|
|
208
|
+
path: "revela-narrative",
|
|
209
|
+
counts: {
|
|
210
|
+
claims: claims.length,
|
|
211
|
+
evidence: evidence.length,
|
|
212
|
+
researchGaps: researchGaps.length,
|
|
213
|
+
objections: objections.length,
|
|
214
|
+
risks: risks.length,
|
|
215
|
+
relations: relations.length,
|
|
216
|
+
unresolvedRefs: unresolvedRefs.length,
|
|
217
|
+
},
|
|
218
|
+
claims,
|
|
219
|
+
evidence,
|
|
220
|
+
researchGaps,
|
|
221
|
+
objections,
|
|
222
|
+
risks,
|
|
223
|
+
relations,
|
|
224
|
+
relationCoverage,
|
|
225
|
+
relationSummary: {
|
|
226
|
+
inlineEdges: relations.length,
|
|
227
|
+
advisoryCandidates: relationCandidates.length,
|
|
228
|
+
},
|
|
229
|
+
relationCandidates,
|
|
230
|
+
unresolvedRefs,
|
|
231
|
+
idHints: buildIdHints(ids),
|
|
232
|
+
diagnostics,
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function buildRelationCandidates(documents: VaultDocument[], relations: NarrativeVaultInventoryRelation[], ids: Set<string>): NarrativeVaultRelationCandidate[] {
|
|
237
|
+
const candidates: NarrativeVaultRelationCandidate[] = []
|
|
238
|
+
const addCandidate = (candidate: NarrativeVaultRelationCandidate) => {
|
|
239
|
+
if (!ids.has(candidate.fromId) || !ids.has(candidate.toId)) return
|
|
240
|
+
if (relations.some((edge) => edge.fromId === candidate.fromId && edge.toId === candidate.toId && edge.relation === candidate.relation)) return
|
|
241
|
+
if (candidates.some((edge) => edge.fromId === candidate.fromId && edge.toId === candidate.toId && edge.relation === candidate.relation)) return
|
|
242
|
+
candidates.push(candidate)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
for (const doc of documents) {
|
|
246
|
+
const id = nodeId(doc)
|
|
247
|
+
const type = nodeType(doc)
|
|
248
|
+
if (!id || !type) continue
|
|
249
|
+
if (type === "evidence") {
|
|
250
|
+
addFrontmatterCandidate(doc, id, "claimId", "supports", "Evidence frontmatter points at this claim.", addCandidate)
|
|
251
|
+
} else if (type === "objection") {
|
|
252
|
+
addFrontmatterCandidate(doc, id, "claimId", "answers", "Objection frontmatter points at this claim.", addCandidate)
|
|
253
|
+
} else if (type === "risk") {
|
|
254
|
+
addFrontmatterCandidate(doc, id, "claimId", "constrains", "Risk frontmatter points at this claim.", addCandidate)
|
|
255
|
+
} else if (type === "research-gap") {
|
|
256
|
+
addFrontmatterCandidate(doc, id, "targetId", "depends_on", "Research gap frontmatter points at this target.", addCandidate)
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return candidates.sort((a, b) => a.id.localeCompare(b.id))
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function addFrontmatterCandidate(
|
|
264
|
+
doc: VaultDocument,
|
|
265
|
+
fromId: string,
|
|
266
|
+
targetField: string,
|
|
267
|
+
relation: NarrativeClaimRelationType,
|
|
268
|
+
rationale: string,
|
|
269
|
+
addCandidate: (candidate: NarrativeVaultRelationCandidate) => void,
|
|
270
|
+
): void {
|
|
271
|
+
const toId = stringField(doc, targetField)
|
|
272
|
+
if (!toId) return
|
|
273
|
+
addCandidate({ id: candidateRelationId(fromId, relation, toId), fromId, toId, relation, rationale, source: "frontmatter" })
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function candidateRelationId(fromId: string, relation: NarrativeClaimRelationType, toId: string): string {
|
|
277
|
+
return stableVaultRelationId(fromId, relation, toId)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function buildRelationCoverage(
|
|
281
|
+
documents: VaultDocument[],
|
|
282
|
+
claims: NarrativeVaultInventoryClaim[],
|
|
283
|
+
evidence: NarrativeVaultInventoryEvidence[],
|
|
284
|
+
objections: NarrativeVaultInventoryObjection[],
|
|
285
|
+
risks: NarrativeVaultInventoryRisk[],
|
|
286
|
+
researchGaps: NarrativeVaultInventoryResearchGap[],
|
|
287
|
+
relations: NarrativeVaultInventoryRelation[],
|
|
288
|
+
ids: Set<string>,
|
|
289
|
+
): NarrativeVaultRelationCoverage {
|
|
290
|
+
const connected = new Set<string>()
|
|
291
|
+
for (const relation of relations) {
|
|
292
|
+
if (ids.has(relation.fromId)) connected.add(relation.fromId)
|
|
293
|
+
if (ids.has(relation.toId)) connected.add(relation.toId)
|
|
294
|
+
}
|
|
295
|
+
const relationToClaim = (id: string, relation: string) => relations.some((edge) => edge.fromId === id && edge.relation === relation && claims.some((claim) => claim.id === edge.toId))
|
|
296
|
+
const claimLinked = (id: string) => relations.some((edge) => (edge.fromId === id && claims.some((claim) => claim.id === edge.toId)) || (edge.toId === id && claims.some((claim) => claim.id === edge.fromId)))
|
|
297
|
+
const fallbackOnlyBindings = buildFallbackOnlyBindings(documents, relations, ids)
|
|
298
|
+
return {
|
|
299
|
+
danglingEdges: relations.filter((relation) => relation.unresolved),
|
|
300
|
+
unboundEvidence: evidence.filter((item) => !item.claimId && !relationToClaim(item.id, "supports")).map((item) => item.id),
|
|
301
|
+
unboundObjections: objections.filter((item) => !item.claimId && !relationToClaim(item.id, "answers") && !relationToClaim(item.id, "contrasts_with")).map((item) => item.id),
|
|
302
|
+
unboundRisks: risks.filter((item) => !item.claimId && !relationToClaim(item.id, "constrains")).map((item) => item.id),
|
|
303
|
+
unboundResearchGaps: researchGaps.filter((item) => !item.targetId && !relations.some((edge) => edge.fromId === item.id)).map((item) => item.id),
|
|
304
|
+
fallbackOnlyBindings,
|
|
305
|
+
isolatedClaims: claims.filter((claim) => claim.importance === "central" && claims.length > 1 && !claimLinked(claim.id)).map((claim) => claim.id),
|
|
306
|
+
orphanNodes: [...claims, ...evidence, ...objections, ...risks, ...researchGaps].filter((item) => !connected.has(item.id)).map((item) => item.id),
|
|
307
|
+
inlineRelations: documents.filter((doc) => doc.relations.length > 0).map((doc) => ({ file: doc.relativePath, nodeId: nodeId(doc) })),
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function buildFallbackOnlyBindings(documents: VaultDocument[], relations: NarrativeVaultInventoryRelation[], ids: Set<string>): NarrativeVaultRelationCoverage["fallbackOnlyBindings"] {
|
|
312
|
+
const items: NarrativeVaultRelationCoverage["fallbackOnlyBindings"] = []
|
|
313
|
+
const add = (doc: VaultDocument, field: string, relation: NarrativeClaimRelationType) => {
|
|
314
|
+
const node = nodeId(doc)
|
|
315
|
+
const targetId = stringField(doc, field)
|
|
316
|
+
if (!node || !targetId || !ids.has(targetId)) return
|
|
317
|
+
if (relations.some((edge) => edge.fromId === node && edge.toId === targetId && edge.relation === relation)) return
|
|
318
|
+
items.push({ nodeId: node, file: doc.relativePath, field, relation, targetId })
|
|
319
|
+
}
|
|
320
|
+
for (const doc of documents) {
|
|
321
|
+
const type = nodeType(doc)
|
|
322
|
+
if (type === "evidence") add(doc, "claimId", "supports")
|
|
323
|
+
else if (type === "objection") add(doc, "claimId", "answers")
|
|
324
|
+
else if (type === "risk") add(doc, "claimId", "constrains")
|
|
325
|
+
else if (type === "research-gap") add(doc, "targetId", "depends_on")
|
|
326
|
+
}
|
|
327
|
+
return items
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function relationTargetFor(relations: NarrativeVaultInventoryRelation[], fromId: string, relation: string, targetIds: Set<string>): string {
|
|
331
|
+
return relations.find((edge) => edge.fromId === fromId && edge.relation === relation && targetIds.has(edge.toId))?.toId ?? ""
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function baseNode(doc: VaultDocument, id: string, type: VaultNodeType): NarrativeVaultInventoryNode {
|
|
335
|
+
return {
|
|
336
|
+
id,
|
|
337
|
+
type,
|
|
338
|
+
file: doc.relativePath,
|
|
339
|
+
title: stringField(doc, "title"),
|
|
340
|
+
text: firstText(doc.body),
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function nodeId(doc: VaultDocument): string {
|
|
345
|
+
return stringField(doc, "id")
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function nodeType(doc: VaultDocument): VaultNodeType | "" {
|
|
349
|
+
const type = stringField(doc, "type")
|
|
350
|
+
return isVaultNodeType(type) ? type : ""
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function isVaultNodeType(type: string): type is VaultNodeType {
|
|
354
|
+
return ["index", "audience", "decision", "thesis", "claim", "evidence", "objection", "risk", "research-gap"].includes(type)
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function stringField(doc: VaultDocument, key: string): string {
|
|
358
|
+
const value = doc.frontmatter[key]
|
|
359
|
+
return typeof value === "string" ? value.trim() : ""
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function booleanField(doc: VaultDocument, key: string): boolean {
|
|
363
|
+
return doc.frontmatter[key] === true
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function firstText(markdown: string): string {
|
|
367
|
+
return markdown
|
|
368
|
+
.split(/\n+/)
|
|
369
|
+
.map((line) => line.replace(/^#+\s+/, "").trim())
|
|
370
|
+
.find((line) => line && !line.startsWith("- ")) ?? ""
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function isLooseTargetType(targetType: string): boolean {
|
|
374
|
+
return !targetType || targetType === "narrative" || targetType === "decision"
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function buildIdHints(ids: Set<string>): NarrativeVaultInventory["idHints"] {
|
|
378
|
+
return {
|
|
379
|
+
nextClaimIdExamples: [nextAvailableId("claim-market-context", ids), nextAvailableId("claim-recommendation", ids)],
|
|
380
|
+
nextEvidenceIdExamples: [nextAvailableId("evidence-source-quote", ids), nextAvailableId("evidence-research-finding", ids)],
|
|
381
|
+
nextResearchGapIdExamples: [nextAvailableId("gap-market-size", ids), nextAvailableId("gap-source-validation", ids)],
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function nextAvailableId(base: string, ids: Set<string>): string {
|
|
386
|
+
if (!ids.has(base)) return base
|
|
387
|
+
for (let index = 2; index < 100; index += 1) {
|
|
388
|
+
const candidate = `${base}-${index}`
|
|
389
|
+
if (!ids.has(candidate)) return candidate
|
|
390
|
+
}
|
|
391
|
+
return `${base}-${Date.now()}`
|
|
392
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "fs"
|
|
2
|
+
import { join } from "path"
|
|
3
|
+
import { readNarrativeVaultDocuments } from "./read"
|
|
4
|
+
import { inspectVaultMarkdown } from "./authoring-guard"
|
|
5
|
+
import { buildNarrativeVaultInventory, type NarrativeVaultInventoryUnresolvedRef } from "./inventory"
|
|
6
|
+
import { narrativeVaultPath } from "./paths"
|
|
7
|
+
import type { VaultDiagnosticDisplay } from "./diagnostic-report"
|
|
8
|
+
import type { VaultDocument } from "./types"
|
|
9
|
+
|
|
10
|
+
export interface MarkdownQaRepairCard {
|
|
11
|
+
severity: "error" | "warning"
|
|
12
|
+
file: string
|
|
13
|
+
nodeId?: string
|
|
14
|
+
issueCode: string
|
|
15
|
+
message: string
|
|
16
|
+
smallestRepair: string
|
|
17
|
+
examples?: string[]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface MarkdownQaReport {
|
|
21
|
+
ok: boolean
|
|
22
|
+
repairCards: MarkdownQaRepairCard[]
|
|
23
|
+
blockers: MarkdownQaRepairCard[]
|
|
24
|
+
warnings: MarkdownQaRepairCard[]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface MarkdownQaOptions {
|
|
28
|
+
touched?: string[]
|
|
29
|
+
scope?: "touched" | "affected" | "full"
|
|
30
|
+
strictness?: "authoring" | "readiness" | "render"
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function runNarrativeMarkdownQa(workspaceRoot: string, touchedOrOptions?: string[] | MarkdownQaOptions): MarkdownQaReport {
|
|
34
|
+
const options = Array.isArray(touchedOrOptions) ? { touched: touchedOrOptions, scope: "touched" as const, strictness: "authoring" as const } : touchedOrOptions ?? {}
|
|
35
|
+
const { documents } = readNarrativeVaultDocuments(workspaceRoot)
|
|
36
|
+
const scope = options.scope ?? (options.touched ? "touched" : "full")
|
|
37
|
+
const strictness = options.strictness ?? "authoring"
|
|
38
|
+
const touchedSet = options.touched ? new Set(options.touched.map(normalizeVaultFile).filter(Boolean)) : undefined
|
|
39
|
+
const selected = touchedSet ? documents.filter((doc) => touchedSet.has(doc.relativePath)) : documents
|
|
40
|
+
const inventory = buildNarrativeVaultInventory(workspaceRoot)
|
|
41
|
+
const repairCards: MarkdownQaRepairCard[] = []
|
|
42
|
+
|
|
43
|
+
for (const doc of selected) {
|
|
44
|
+
repairCards.push(...inspectVaultMarkdown(doc.relativePath, readFileSync(doc.path, "utf-8"))
|
|
45
|
+
.filter((diagnostic) => !isEvidenceClaimIdCoveredByInlineRelation(doc, diagnostic.code, inventory))
|
|
46
|
+
.map(cardFromDiagnostic))
|
|
47
|
+
repairCards.push(...evidenceTraceCards(doc))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
repairCards.push(...unsupportedRootMarkdownCards(workspaceRoot, touchedSet))
|
|
51
|
+
|
|
52
|
+
for (const unresolved of inventory.unresolvedRefs) {
|
|
53
|
+
if (touchedSet && !touchedSet.has(unresolved.file)) continue
|
|
54
|
+
repairCards.push(cardFromUnresolved(unresolved))
|
|
55
|
+
}
|
|
56
|
+
for (const diagnostic of inventory.diagnostics) {
|
|
57
|
+
if (touchedSet && diagnostic.file && !touchedSet.has(diagnostic.file)) continue
|
|
58
|
+
repairCards.push(cardFromDiagnostic({
|
|
59
|
+
severity: diagnostic.severity,
|
|
60
|
+
code: diagnostic.code,
|
|
61
|
+
message: diagnostic.message,
|
|
62
|
+
file: diagnostic.file,
|
|
63
|
+
nodeId: diagnostic.nodeId,
|
|
64
|
+
suggestedFix: diagnostic.code === "unknown_relation_type" ? "Use one of: leads_to, supports, depends_on, contrasts_with, constrains, answers." : "Repair the Markdown node.",
|
|
65
|
+
suggestedAction: "Repair the Markdown relation line and rerun markdownQa.",
|
|
66
|
+
}))
|
|
67
|
+
}
|
|
68
|
+
if (scope !== "touched") repairCards.push(...relationCoverageCards(inventory, strictness))
|
|
69
|
+
|
|
70
|
+
const deduped = dedupeCards(repairCards)
|
|
71
|
+
const blockers = deduped.filter((card) => card.severity === "error")
|
|
72
|
+
const warnings = deduped.filter((card) => card.severity === "warning")
|
|
73
|
+
return { ok: blockers.length === 0, repairCards: deduped, blockers, warnings }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isEvidenceClaimIdCoveredByInlineRelation(doc: VaultDocument, issueCode: string, inventory: ReturnType<typeof buildNarrativeVaultInventory>): boolean {
|
|
77
|
+
if (issueCode !== "evidence_claim_id_missing_authoring") return false
|
|
78
|
+
const id = stringField(doc, "id")
|
|
79
|
+
if (!id) return false
|
|
80
|
+
return inventory.relations.some((relation) => relation.fromId === id && relation.relation === "supports" && !relation.unresolved)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function relationCoverageCards(inventory: ReturnType<typeof buildNarrativeVaultInventory>, strictness: NonNullable<MarkdownQaOptions["strictness"]>): MarkdownQaRepairCard[] {
|
|
84
|
+
const cards: MarkdownQaRepairCard[] = []
|
|
85
|
+
const severity = strictness === "authoring" ? "warning" : "error"
|
|
86
|
+
for (const id of inventory.relationCoverage.unboundEvidence) cards.push(relationCoverageCard(severity, id, "unbound_evidence", "Evidence node has no claim-support relation.", "Add `## Relations` with `- supports: [[claim-id]]`, or bind the evidence through bindResearchFindings."))
|
|
87
|
+
for (const id of inventory.relationCoverage.unboundObjections) cards.push(relationCoverageCard(severity, id, "unbound_objection", "Objection node has no claim relation.", "Add `## Relations` with `- answers: [[claim-id]]` or `- contrasts_with: [[claim-id]]`, or set an explicit claim target if preserving existing shape."))
|
|
88
|
+
for (const id of inventory.relationCoverage.unboundRisks) cards.push(relationCoverageCard(severity, id, "unbound_risk", "Risk node has no claim relation.", "Add `## Relations` with `- constrains: [[claim-id]]`, or set an explicit claim target if preserving existing shape."))
|
|
89
|
+
for (const id of inventory.relationCoverage.unboundResearchGaps) cards.push(relationCoverageCard("warning", id, "unbound_research_gap", "Research gap has no target relation or target id.", "Add `## Relations` with `- depends_on: [[target-node-id]]` or a targetId after checking narrativeInventory."))
|
|
90
|
+
for (const item of inventory.relationCoverage.fallbackOnlyBindings) {
|
|
91
|
+
cards.push({
|
|
92
|
+
severity: "warning",
|
|
93
|
+
file: item.file,
|
|
94
|
+
nodeId: item.nodeId,
|
|
95
|
+
issueCode: "frontmatter_binding_without_relation",
|
|
96
|
+
message: `${item.field} is only expressed in frontmatter; wikilink-first vaults should express this graph edge in ## Relations.`,
|
|
97
|
+
smallestRepair: `Add \`## Relations\` with \`- ${item.relation}: [[${item.targetId}]]\`; keep frontmatter only as compatibility fallback if needed.`,
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
for (const id of inventory.relationCoverage.isolatedClaims) cards.push(relationCoverageCard(strictness === "render" ? "error" : "warning", id, "isolated_central_claim", "Central claim is not connected to the claim-flow graph.", "Add a claim-flow edge in the source node `## Relations`, or downgrade importance if it is background context."))
|
|
101
|
+
return cards
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function relationCoverageCard(severity: "error" | "warning", nodeId: string, issueCode: string, message: string, smallestRepair: string): MarkdownQaRepairCard {
|
|
105
|
+
return { severity, file: "revela-narrative", nodeId, issueCode, message, smallestRepair }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function cardFromDiagnostic(diagnostic: VaultDiagnosticDisplay): MarkdownQaRepairCard {
|
|
109
|
+
return {
|
|
110
|
+
severity: diagnostic.severity,
|
|
111
|
+
file: diagnostic.file ?? "revela-narrative",
|
|
112
|
+
nodeId: diagnostic.nodeId,
|
|
113
|
+
issueCode: diagnostic.code,
|
|
114
|
+
message: diagnostic.message,
|
|
115
|
+
smallestRepair: diagnostic.suggestedFix || diagnostic.suggestedAction || "Repair the Markdown node and rerun markdownQa.",
|
|
116
|
+
examples: examplesFor(diagnostic.code),
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function cardFromUnresolved(ref: NarrativeVaultInventoryUnresolvedRef): MarkdownQaRepairCard {
|
|
121
|
+
const target = ref.targetId || "<missing>"
|
|
122
|
+
if (ref.kind === "relation") {
|
|
123
|
+
return {
|
|
124
|
+
severity: "error",
|
|
125
|
+
file: ref.file,
|
|
126
|
+
nodeId: ref.fromId,
|
|
127
|
+
issueCode: "broken_relation_target",
|
|
128
|
+
message: `Relation points to unknown node ${target}.`,
|
|
129
|
+
smallestRepair: "Create the referenced node or patch the relation wikilink to an existing id from narrativeInventory.",
|
|
130
|
+
examples: ["- supports: [[claim-existing-id]]"],
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (ref.kind === "evidenceClaimId") {
|
|
134
|
+
return {
|
|
135
|
+
severity: "error",
|
|
136
|
+
file: ref.file,
|
|
137
|
+
nodeId: ref.fromId,
|
|
138
|
+
issueCode: ref.targetId ? "unresolved_evidence_claim_id" : "missing_evidence_claim_id",
|
|
139
|
+
message: ref.targetId ? `Evidence references unknown claimId ${target}.` : "Evidence is missing a claim-support relation.",
|
|
140
|
+
smallestRepair: "Add `## Relations` with `- supports: [[claim-id]]` after checking narrativeInventory, or create the missing claim node first.",
|
|
141
|
+
examples: ["- supports: [[claim-market-context]]"],
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
severity: "warning",
|
|
146
|
+
file: ref.file,
|
|
147
|
+
nodeId: ref.fromId,
|
|
148
|
+
issueCode: `unresolved_${ref.kind}`,
|
|
149
|
+
message: `${ref.field ?? "target"} references unknown node ${target}.`,
|
|
150
|
+
smallestRepair: "Check narrativeInventory and patch the target id, or create the missing target node if intentional.",
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function evidenceTraceCards(doc: VaultDocument): MarkdownQaRepairCard[] {
|
|
155
|
+
if (stringField(doc, "type") !== "evidence") return []
|
|
156
|
+
const cards: MarkdownQaRepairCard[] = []
|
|
157
|
+
const missing: string[] = []
|
|
158
|
+
if (!hasSource(doc)) missing.push("source|sourcePath|url|findingsFile")
|
|
159
|
+
if (!stringField(doc, "quote") && !firstBodyLine(doc.body)) missing.push("quote|snippet body")
|
|
160
|
+
for (const field of ["supportScope", "unsupportedScope", "caveat", "strength"]) {
|
|
161
|
+
if (!stringField(doc, field)) missing.push(field)
|
|
162
|
+
}
|
|
163
|
+
if (missing.length > 0) {
|
|
164
|
+
cards.push({
|
|
165
|
+
severity: "warning",
|
|
166
|
+
file: doc.relativePath,
|
|
167
|
+
nodeId: stringField(doc, "id"),
|
|
168
|
+
issueCode: "evidence_trace_fields_missing",
|
|
169
|
+
message: `Evidence node is missing trace field(s): ${missing.join(", ")}.`,
|
|
170
|
+
smallestRepair: "Add explicit source trace, quote/snippet, support scope, unsupported scope, caveat, and strength before treating the evidence as strong support.",
|
|
171
|
+
examples: ["sourcePath: proposal.md", "supportScope: Scope explicitly supported by the quote.", "unsupportedScope: What this evidence does not prove."],
|
|
172
|
+
})
|
|
173
|
+
}
|
|
174
|
+
return cards
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function unsupportedRootMarkdownCards(workspaceRoot: string, touchedSet?: Set<string>): MarkdownQaRepairCard[] {
|
|
178
|
+
const root = narrativeVaultPath(workspaceRoot)
|
|
179
|
+
if (!existsSync(root) || !statSync(root).isDirectory()) return []
|
|
180
|
+
const supportedRootFiles = new Set(["index.md", "audience.md", "decision.md", "thesis.md"])
|
|
181
|
+
const cards: MarkdownQaRepairCard[] = []
|
|
182
|
+
|
|
183
|
+
for (const entry of readdirSync(root).sort()) {
|
|
184
|
+
if (!entry.endsWith(".md") || supportedRootFiles.has(entry)) continue
|
|
185
|
+
const filePath = join(root, entry)
|
|
186
|
+
if (!statSync(filePath).isFile()) continue
|
|
187
|
+
const relativePath = entry.replace(/\\/g, "/")
|
|
188
|
+
if (touchedSet && !touchedSet.has(relativePath) && !touchedSet.has(`revela-narrative/${relativePath}`)) continue
|
|
189
|
+
cards.push({
|
|
190
|
+
severity: "warning",
|
|
191
|
+
file: relativePath,
|
|
192
|
+
issueCode: "unsupported_vault_root_markdown",
|
|
193
|
+
message: "Markdown file is in the vault root but only index.md, audience.md, decision.md, and thesis.md are supported there.",
|
|
194
|
+
smallestRepair: "Move claim/evidence/objection/risk/research-gap nodes into their supported subdirectory, or remove this unsupported root file if it was temporary.",
|
|
195
|
+
examples: ["claims/claim-market-context.md", "evidence/evidence-proposal-intent.md", "research-gaps/gap-market-size.md"],
|
|
196
|
+
})
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return cards
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function normalizeVaultFile(file: string): string {
|
|
203
|
+
return file.replace(/\\/g, "/").replace(/^revela-narrative\//, "")
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function stringField(doc: VaultDocument, key: string): string {
|
|
207
|
+
const value = doc.frontmatter[key]
|
|
208
|
+
return typeof value === "string" ? value.trim() : ""
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function hasSource(doc: VaultDocument): boolean {
|
|
212
|
+
return Boolean(stringField(doc, "source") || stringField(doc, "sourcePath") || stringField(doc, "url") || stringField(doc, "findingsFile"))
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function firstBodyLine(body: string): string {
|
|
216
|
+
return body.split(/\n+/).map((line) => line.trim()).find(Boolean) ?? ""
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function examplesFor(code: string): string[] | undefined {
|
|
220
|
+
if (code === "typed_wikilink_target") return ["- supports: [[claim-existing-id]]"]
|
|
221
|
+
if (code === "invalid_node_type_authoring") return ["type: research-gap"]
|
|
222
|
+
if (code === "duplicate_stable_heading") return ["Merge duplicate section bodies under one ## Caveats or ## Relations heading."]
|
|
223
|
+
if (code === "duplicate_frontmatter") return ["Keep exactly one leading --- frontmatter block."]
|
|
224
|
+
return undefined
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function dedupeCards(cards: MarkdownQaRepairCard[]): MarkdownQaRepairCard[] {
|
|
228
|
+
const seen = new Set<string>()
|
|
229
|
+
const result: MarkdownQaRepairCard[] = []
|
|
230
|
+
for (const card of cards) {
|
|
231
|
+
const key = [card.severity, card.file, card.nodeId ?? "", card.issueCode, card.message].join("\0")
|
|
232
|
+
if (seen.has(key)) continue
|
|
233
|
+
seen.add(key)
|
|
234
|
+
result.push(card)
|
|
235
|
+
}
|
|
236
|
+
return result
|
|
237
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export function splitMarkdownSections(body: string): { main: string; sections: Record<string, string> } {
|
|
2
|
+
const sections: Record<string, string> = {}
|
|
3
|
+
const lines = body.replace(/\r\n/g, "\n").split("\n")
|
|
4
|
+
let current = "main"
|
|
5
|
+
const buffers: Record<string, string[]> = { main: [] }
|
|
6
|
+
for (const line of lines) {
|
|
7
|
+
const match = /^##\s+(.+?)\s*$/.exec(line)
|
|
8
|
+
if (match) {
|
|
9
|
+
current = normalizeSectionName(match[1])
|
|
10
|
+
buffers[current] = []
|
|
11
|
+
continue
|
|
12
|
+
}
|
|
13
|
+
buffers[current].push(line)
|
|
14
|
+
}
|
|
15
|
+
for (const [key, value] of Object.entries(buffers)) {
|
|
16
|
+
if (key === "main") continue
|
|
17
|
+
sections[key] = value.join("\n").trim()
|
|
18
|
+
}
|
|
19
|
+
return { main: buffers.main.join("\n").trim(), sections }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function firstParagraphOrBody(value: string): string {
|
|
23
|
+
const cleaned = value.trim()
|
|
24
|
+
if (!cleaned) return ""
|
|
25
|
+
return cleaned.split(/\n\s*\n/)[0].replace(/\n+/g, " ").trim()
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function parseMarkdownList(section: string): string[] {
|
|
29
|
+
return section.split("\n").map((line) => line.trim()).filter((line) => line.startsWith("- ")).map((line) => line.slice(2).trim()).filter(Boolean)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function normalizeSectionName(name: string): string {
|
|
33
|
+
return name.trim().toLowerCase().replace(/\s+/g, "-")
|
|
34
|
+
}
|