@unlaxer/dve-toolkit 4.1.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/CHANGELOG.md +17 -0
- package/cli/dve-tool.ts +734 -0
- package/config.ts +64 -0
- package/context/bundle.ts +142 -0
- package/graph/builder.ts +254 -0
- package/graph/cluster.ts +105 -0
- package/graph/query.ts +82 -0
- package/graph/schema.ts +169 -0
- package/install.sh +78 -0
- package/package.json +29 -0
- package/parser/annotation-parser.ts +77 -0
- package/parser/decision-parser.ts +104 -0
- package/parser/drift-detector.ts +45 -0
- package/parser/git-linker.ts +62 -0
- package/parser/glossary-builder.ts +116 -0
- package/parser/session-parser.ts +213 -0
- package/parser/spec-parser.ts +65 -0
- package/parser/state-detector.ts +379 -0
- package/scripts/audit-duplicates.sh +101 -0
- package/scripts/discover-decisions.sh +129 -0
- package/scripts/recover-all.sh +150 -0
- package/scripts/recover-dialogues.sh +190 -0
- package/server/api.ts +297 -0
- package/server/slack.ts +217 -0
- package/skills/dve-annotate.md +26 -0
- package/skills/dve-build.md +15 -0
- package/skills/dve-context.md +22 -0
- package/skills/dve-serve.md +17 -0
- package/skills/dve-status.md +18 -0
- package/skills/dve-trace.md +16 -0
- package/tsconfig.json +15 -0
- package/update.sh +73 -0
- package/version.txt +1 -0
package/config.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// DVE configuration — multi-project support
|
|
2
|
+
|
|
3
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
export interface ProjectConfig {
|
|
7
|
+
name: string;
|
|
8
|
+
path: string; // absolute path to project root
|
|
9
|
+
sessionsDir: string; // relative to path, default "dge/sessions"
|
|
10
|
+
decisionsDir: string;
|
|
11
|
+
specsDir: string;
|
|
12
|
+
annotationsDir: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface DVEConfig {
|
|
16
|
+
projects: ProjectConfig[];
|
|
17
|
+
outputDir: string; // where graph files go
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const DEFAULT_DIRS = {
|
|
21
|
+
sessionsDir: "dge/sessions",
|
|
22
|
+
decisionsDir: "dge/decisions",
|
|
23
|
+
specsDir: "dge/specs",
|
|
24
|
+
annotationsDir: "dve/annotations",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function loadConfig(configPath: string): DVEConfig | null {
|
|
28
|
+
if (!existsSync(configPath)) return null;
|
|
29
|
+
const raw = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
30
|
+
return {
|
|
31
|
+
outputDir: raw.outputDir ?? "dve/dist",
|
|
32
|
+
projects: (raw.projects ?? []).map((p: any) => ({
|
|
33
|
+
name: p.name ?? path.basename(p.path),
|
|
34
|
+
path: path.resolve(path.dirname(configPath), p.path),
|
|
35
|
+
sessionsDir: p.sessionsDir ?? DEFAULT_DIRS.sessionsDir,
|
|
36
|
+
decisionsDir: p.decisionsDir ?? DEFAULT_DIRS.decisionsDir,
|
|
37
|
+
specsDir: p.specsDir ?? DEFAULT_DIRS.specsDir,
|
|
38
|
+
annotationsDir: p.annotationsDir ?? DEFAULT_DIRS.annotationsDir,
|
|
39
|
+
})),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function singleProjectConfig(cwd: string): DVEConfig {
|
|
44
|
+
return {
|
|
45
|
+
outputDir: path.join(cwd, "dve", "dist"),
|
|
46
|
+
projects: [
|
|
47
|
+
{
|
|
48
|
+
name: path.basename(cwd),
|
|
49
|
+
path: cwd,
|
|
50
|
+
...DEFAULT_DIRS,
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function resolveProjectDirs(project: ProjectConfig) {
|
|
57
|
+
return {
|
|
58
|
+
sessionsDir: path.join(project.path, project.sessionsDir),
|
|
59
|
+
decisionsDir: path.join(project.path, project.decisionsDir),
|
|
60
|
+
specsDir: path.join(project.path, project.specsDir),
|
|
61
|
+
annotationsDir: path.join(project.path, project.annotationsDir),
|
|
62
|
+
cwd: project.path,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// ContextBundle generator — DVE → DGE bridge
|
|
2
|
+
|
|
3
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import type { DVEGraph, GraphNode, ContextBundle, Gap, Decision, Session } from "../graph/schema.js";
|
|
6
|
+
import { traceDecision } from "../graph/query.js";
|
|
7
|
+
|
|
8
|
+
export interface BundleOptions {
|
|
9
|
+
graph: DVEGraph;
|
|
10
|
+
originId: string;
|
|
11
|
+
constraints?: string[];
|
|
12
|
+
outputDir: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function findNode(graph: DVEGraph, id: string): GraphNode | undefined {
|
|
16
|
+
return graph.nodes.find((n) => n.id === id);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function sessionGaps(graph: DVEGraph, sessionId: string): GraphNode[] {
|
|
20
|
+
const gapIds = graph.edges
|
|
21
|
+
.filter((e) => e.source === sessionId && e.type === "discovers")
|
|
22
|
+
.map((e) => e.target);
|
|
23
|
+
return graph.nodes.filter((n) => gapIds.includes(n.id));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function relatedDecisions(graph: DVEGraph, sessionId: string): GraphNode[] {
|
|
27
|
+
const gaps = sessionGaps(graph, sessionId);
|
|
28
|
+
const ddIds = new Set<string>();
|
|
29
|
+
for (const gap of gaps) {
|
|
30
|
+
const resolves = graph.edges.filter((e) => e.source === gap.id && e.type === "resolves");
|
|
31
|
+
for (const edge of resolves) ddIds.add(edge.target);
|
|
32
|
+
}
|
|
33
|
+
return graph.nodes.filter((n) => ddIds.has(n.id));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function relatedAnnotations(graph: DVEGraph, targetId: string): GraphNode[] {
|
|
37
|
+
const annIds = graph.edges
|
|
38
|
+
.filter((e) => e.target === targetId && e.type === "annotates")
|
|
39
|
+
.map((e) => e.source);
|
|
40
|
+
return graph.nodes.filter((n) => annIds.includes(n.id));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function generateBundle(opts: BundleOptions): ContextBundle {
|
|
44
|
+
const { graph, originId, constraints = [], outputDir } = opts;
|
|
45
|
+
const origin = findNode(graph, originId);
|
|
46
|
+
if (!origin) throw new Error(`Node not found: ${originId}`);
|
|
47
|
+
|
|
48
|
+
// Find related session
|
|
49
|
+
let sessionNode: GraphNode | undefined;
|
|
50
|
+
let sessionData: Partial<Session> = {};
|
|
51
|
+
|
|
52
|
+
if (origin.type === "session") {
|
|
53
|
+
sessionNode = origin;
|
|
54
|
+
} else if (origin.type === "gap") {
|
|
55
|
+
const gapData = origin.data as Partial<Gap>;
|
|
56
|
+
sessionNode = findNode(graph, gapData.session_id ?? "");
|
|
57
|
+
} else if (origin.type === "decision") {
|
|
58
|
+
const ddData = origin.data as Partial<Decision>;
|
|
59
|
+
const sessionRef = ddData.session_refs?.[0];
|
|
60
|
+
if (sessionRef) sessionNode = findNode(graph, sessionRef);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (sessionNode) sessionData = sessionNode.data as Partial<Session>;
|
|
64
|
+
|
|
65
|
+
// Gather context
|
|
66
|
+
const gaps = sessionNode ? sessionGaps(graph, sessionNode.id) : [];
|
|
67
|
+
const decisions = sessionNode ? relatedDecisions(graph, sessionNode.id) : [];
|
|
68
|
+
const annotations = relatedAnnotations(graph, originId);
|
|
69
|
+
|
|
70
|
+
// Determine action
|
|
71
|
+
let suggestedAction: ContextBundle["suggested_action"] = "revisit";
|
|
72
|
+
if (origin.type === "gap") suggestedAction = "deep_dive";
|
|
73
|
+
if (constraints.length > 0) suggestedAction = "new_angle";
|
|
74
|
+
|
|
75
|
+
// Sessions date range
|
|
76
|
+
const dates = [sessionData.date].filter(Boolean) as string[];
|
|
77
|
+
const dateRange = dates.length > 0 ? dates.join(" ~ ") : "unknown";
|
|
78
|
+
|
|
79
|
+
// Build prompt template
|
|
80
|
+
const originLabel =
|
|
81
|
+
origin.type === "decision"
|
|
82
|
+
? `${origin.id} (${(origin.data as any).title})`
|
|
83
|
+
: origin.type === "gap"
|
|
84
|
+
? `Gap "${(origin.data as any).summary?.slice(0, 60)}"`
|
|
85
|
+
: `Session "${sessionData.theme}"`;
|
|
86
|
+
|
|
87
|
+
const priorDDs = decisions.map((d) => `${d.id}: ${(d.data as any).title}`);
|
|
88
|
+
const priorGaps = gaps.slice(0, 5).map((g) => {
|
|
89
|
+
const gd = g.data as Partial<Gap>;
|
|
90
|
+
return `${g.id.split("#")[1]}: ${gd.summary?.slice(0, 50)}`;
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
let prompt = `${originLabel} を再検討。\n`;
|
|
94
|
+
if (priorDDs.length > 0) prompt += `前回の決定: ${priorDDs.join(", ")}。\n`;
|
|
95
|
+
if (priorGaps.length > 0) prompt += `前回の Gap: ${priorGaps.join("; ")}。\n`;
|
|
96
|
+
if (sessionData.characters?.length) {
|
|
97
|
+
prompt += `前回のキャラ: ${sessionData.characters.join(", ")}。\n`;
|
|
98
|
+
}
|
|
99
|
+
if (constraints.length > 0) {
|
|
100
|
+
prompt += `追加制約: ${constraints.join("; ")}。\n`;
|
|
101
|
+
}
|
|
102
|
+
prompt += `この文脈を踏まえて DGE して。`;
|
|
103
|
+
|
|
104
|
+
const bundle: ContextBundle = {
|
|
105
|
+
type: "dve-context-bundle",
|
|
106
|
+
version: "1.0.0",
|
|
107
|
+
origin: {
|
|
108
|
+
node_type: origin.type as any,
|
|
109
|
+
node_id: origin.id,
|
|
110
|
+
file: (origin.data as any).file_path ?? "",
|
|
111
|
+
},
|
|
112
|
+
summary: {
|
|
113
|
+
theme: sessionData.theme ?? "unknown",
|
|
114
|
+
date_range: dateRange,
|
|
115
|
+
prior_decisions: priorDDs,
|
|
116
|
+
prior_gaps: gaps.map((g) => ({
|
|
117
|
+
id: g.id,
|
|
118
|
+
summary: (g.data as any).summary ?? "",
|
|
119
|
+
status: (g.data as any).status ?? "Active",
|
|
120
|
+
})),
|
|
121
|
+
characters_used: sessionData.characters ?? [],
|
|
122
|
+
session_count: 1,
|
|
123
|
+
},
|
|
124
|
+
new_constraints: constraints,
|
|
125
|
+
annotations: annotations.map((a) => ({
|
|
126
|
+
date: (a.data as any).date ?? "",
|
|
127
|
+
action: (a.data as any).action ?? "comment",
|
|
128
|
+
body: (a.data as any).body ?? "",
|
|
129
|
+
})),
|
|
130
|
+
suggested_action: suggestedAction,
|
|
131
|
+
prompt_template: prompt,
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// Save to file
|
|
135
|
+
mkdirSync(outputDir, { recursive: true });
|
|
136
|
+
const slug = originId.replace(/[^a-zA-Z0-9-]/g, "_");
|
|
137
|
+
const filename = `ctx-${new Date().toISOString().split("T")[0]}-${slug}.json`;
|
|
138
|
+
const outPath = path.join(outputDir, filename);
|
|
139
|
+
writeFileSync(outPath, JSON.stringify(bundle, null, 2));
|
|
140
|
+
|
|
141
|
+
return bundle;
|
|
142
|
+
}
|
package/graph/builder.ts
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
// Graph builder — assemble nodes + edges from parse results
|
|
2
|
+
|
|
3
|
+
import { readdirSync, existsSync } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { parseSession } from "../parser/session-parser.js";
|
|
6
|
+
import { parseDecision } from "../parser/decision-parser.js";
|
|
7
|
+
import { parseAnnotation } from "../parser/annotation-parser.js";
|
|
8
|
+
import { parseSpec } from "../parser/spec-parser.js";
|
|
9
|
+
import { gitLinkerEdges } from "../parser/git-linker.js";
|
|
10
|
+
import { buildGlossary } from "../parser/glossary-builder.js";
|
|
11
|
+
import type { DVEGraph, GraphNode, Edge } from "./schema.js";
|
|
12
|
+
|
|
13
|
+
export interface BuildOptions {
|
|
14
|
+
sessionsDir: string;
|
|
15
|
+
decisionsDir: string;
|
|
16
|
+
specsDir: string;
|
|
17
|
+
annotationsDir: string;
|
|
18
|
+
cwd: string;
|
|
19
|
+
enableGitLinker?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function scanMd(dir: string): string[] {
|
|
23
|
+
if (!existsSync(dir)) return [];
|
|
24
|
+
return readdirSync(dir)
|
|
25
|
+
.filter((f) => f.endsWith(".md") && f !== "index.md")
|
|
26
|
+
.map((f) => path.join(dir, f))
|
|
27
|
+
.sort();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function buildGraph(opts: BuildOptions): DVEGraph {
|
|
31
|
+
const nodes: GraphNode[] = [];
|
|
32
|
+
const edges: Edge[] = [];
|
|
33
|
+
const warnings: { file: string; message: string }[] = [];
|
|
34
|
+
|
|
35
|
+
// 1. Parse sessions
|
|
36
|
+
const sessionFiles = scanMd(opts.sessionsDir);
|
|
37
|
+
for (const file of sessionFiles) {
|
|
38
|
+
const { session, gaps } = parseSession(file);
|
|
39
|
+
if (session.node.id) {
|
|
40
|
+
nodes.push({
|
|
41
|
+
type: "session",
|
|
42
|
+
id: session.node.id!,
|
|
43
|
+
data: session.node as any,
|
|
44
|
+
confidence: session.confidence,
|
|
45
|
+
warnings: session.warnings,
|
|
46
|
+
});
|
|
47
|
+
for (const w of session.warnings) {
|
|
48
|
+
warnings.push({ file, message: w });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Dialogue node — sits between Session and Gaps
|
|
53
|
+
const dialogueId = `${session.node.id!}#dialogue`;
|
|
54
|
+
const hasDialogue = !!(session.node as any).content &&
|
|
55
|
+
/Scene|先輩|ナレーション|☕|👤|🎩|😰|⚔|🎨|📊/.test((session.node as any).content ?? "");
|
|
56
|
+
nodes.push({
|
|
57
|
+
type: "dialogue" as any,
|
|
58
|
+
id: dialogueId,
|
|
59
|
+
data: {
|
|
60
|
+
session_id: session.node.id!,
|
|
61
|
+
has_content: hasDialogue,
|
|
62
|
+
scene_count: ((session.node as any).content?.match(/##.*Scene/g) ?? []).length,
|
|
63
|
+
char_count: ((session.node as any).content?.match(/☕|👤|🎩|😰|⚔|🎨|📊|🏥|😈|🧑💼/g) ?? []).length,
|
|
64
|
+
} as any,
|
|
65
|
+
confidence: hasDialogue ? 1.0 : 0.3,
|
|
66
|
+
warnings: hasDialogue ? [] : ["会話劇テキスト未保存"],
|
|
67
|
+
});
|
|
68
|
+
// Session → Dialogue
|
|
69
|
+
edges.push({
|
|
70
|
+
source: session.node.id!,
|
|
71
|
+
target: dialogueId,
|
|
72
|
+
type: "contains",
|
|
73
|
+
confidence: "explicit",
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
for (const gap of gaps) {
|
|
77
|
+
if (gap.node.id) {
|
|
78
|
+
nodes.push({
|
|
79
|
+
type: "gap",
|
|
80
|
+
id: gap.node.id!,
|
|
81
|
+
data: gap.node as any,
|
|
82
|
+
confidence: gap.confidence,
|
|
83
|
+
warnings: gap.warnings,
|
|
84
|
+
});
|
|
85
|
+
// Dialogue → Gap (instead of Session → Gap)
|
|
86
|
+
edges.push({
|
|
87
|
+
source: dialogueId,
|
|
88
|
+
target: gap.node.id!,
|
|
89
|
+
type: "discovers",
|
|
90
|
+
confidence: "explicit",
|
|
91
|
+
});
|
|
92
|
+
for (const w of gap.warnings) {
|
|
93
|
+
warnings.push({ file, message: `${gap.node.id}: ${w}` });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// 2. Parse decisions
|
|
100
|
+
const ddFiles = scanMd(opts.decisionsDir);
|
|
101
|
+
for (const file of ddFiles) {
|
|
102
|
+
const dd = parseDecision(file);
|
|
103
|
+
if (dd.node.id) {
|
|
104
|
+
nodes.push({
|
|
105
|
+
type: "decision",
|
|
106
|
+
id: dd.node.id!,
|
|
107
|
+
data: dd.node as any,
|
|
108
|
+
confidence: dd.confidence,
|
|
109
|
+
warnings: dd.warnings,
|
|
110
|
+
});
|
|
111
|
+
for (const w of dd.warnings) {
|
|
112
|
+
warnings.push({ file, message: `${dd.node.id}: ${w}` });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// resolves edges: find gaps that this DD references
|
|
116
|
+
// Match session_refs to gaps in those sessions
|
|
117
|
+
for (const sessionRef of dd.node.session_refs ?? []) {
|
|
118
|
+
const sessionGaps = nodes.filter(
|
|
119
|
+
(n) => n.type === "gap" && (n.data as any).session_id === sessionRef
|
|
120
|
+
);
|
|
121
|
+
// If DD has specific gap_refs (#N), match them
|
|
122
|
+
if (dd.node.gap_refs && dd.node.gap_refs.length > 0) {
|
|
123
|
+
for (const gapRef of dd.node.gap_refs) {
|
|
124
|
+
const gapNum = gapRef.replace("#", "");
|
|
125
|
+
const matchingGap = sessionGaps.find((g) =>
|
|
126
|
+
g.id.endsWith(`#G-${gapNum.padStart(3, "0")}`)
|
|
127
|
+
);
|
|
128
|
+
if (matchingGap) {
|
|
129
|
+
edges.push({
|
|
130
|
+
source: matchingGap.id,
|
|
131
|
+
target: dd.node.id!,
|
|
132
|
+
type: "resolves",
|
|
133
|
+
confidence: "explicit",
|
|
134
|
+
evidence: `DD references Gap ${gapRef}`,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
} else {
|
|
139
|
+
// No specific gap refs — link DD to session (inferred)
|
|
140
|
+
edges.push({
|
|
141
|
+
source: sessionRef,
|
|
142
|
+
target: dd.node.id!,
|
|
143
|
+
type: "resolves",
|
|
144
|
+
confidence: "inferred",
|
|
145
|
+
evidence: "DD references session without specific gap numbers",
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// supersedes edges
|
|
151
|
+
for (const sup of dd.node.supersedes ?? []) {
|
|
152
|
+
edges.push({
|
|
153
|
+
source: dd.node.id!,
|
|
154
|
+
target: sup,
|
|
155
|
+
type: "supersedes",
|
|
156
|
+
confidence: "explicit",
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// 3. Parse annotations
|
|
163
|
+
const annFiles = scanMd(opts.annotationsDir);
|
|
164
|
+
for (const file of annFiles) {
|
|
165
|
+
const ann = parseAnnotation(file);
|
|
166
|
+
if (ann.node.id) {
|
|
167
|
+
nodes.push({
|
|
168
|
+
type: "annotation",
|
|
169
|
+
id: ann.node.id!,
|
|
170
|
+
data: ann.node as any,
|
|
171
|
+
confidence: ann.confidence,
|
|
172
|
+
warnings: ann.warnings,
|
|
173
|
+
});
|
|
174
|
+
// annotates edge
|
|
175
|
+
if (ann.node.target?.id) {
|
|
176
|
+
edges.push({
|
|
177
|
+
source: ann.node.id!,
|
|
178
|
+
target: ann.node.target.id,
|
|
179
|
+
type: "annotates",
|
|
180
|
+
confidence: "explicit",
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// 4. Parse specs
|
|
187
|
+
const specFiles = scanMd(opts.specsDir);
|
|
188
|
+
for (const file of specFiles) {
|
|
189
|
+
const spec = parseSpec(file);
|
|
190
|
+
if (spec.node.id) {
|
|
191
|
+
nodes.push({
|
|
192
|
+
type: "spec",
|
|
193
|
+
id: spec.node.id!,
|
|
194
|
+
data: spec.node as any,
|
|
195
|
+
confidence: spec.confidence,
|
|
196
|
+
warnings: spec.warnings,
|
|
197
|
+
});
|
|
198
|
+
// produces edges: Decision → Spec
|
|
199
|
+
for (const ddRef of spec.node.decision_refs ?? []) {
|
|
200
|
+
if (nodes.some((n) => n.id === ddRef)) {
|
|
201
|
+
edges.push({
|
|
202
|
+
source: ddRef,
|
|
203
|
+
target: spec.node.id!,
|
|
204
|
+
type: "produces",
|
|
205
|
+
confidence: "inferred",
|
|
206
|
+
evidence: `Spec references ${ddRef}`,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// 5. Git linker
|
|
214
|
+
if (opts.enableGitLinker !== false) {
|
|
215
|
+
const ddIds = new Set(nodes.filter((n) => n.type === "decision").map((n) => n.id));
|
|
216
|
+
const gitEdges = gitLinkerEdges(opts.cwd, ddIds);
|
|
217
|
+
for (const edge of gitEdges) {
|
|
218
|
+
// Add commit as external ref node if not exists
|
|
219
|
+
if (!nodes.some((n) => n.id === edge.target)) {
|
|
220
|
+
nodes.push({
|
|
221
|
+
type: "annotation" as any, // reuse for external refs
|
|
222
|
+
id: edge.target,
|
|
223
|
+
data: { type: "commit", ref: edge.target, evidence: edge.evidence } as any,
|
|
224
|
+
confidence: 0.8,
|
|
225
|
+
warnings: [],
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
edges.push(edge);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Stats
|
|
233
|
+
const stats = {
|
|
234
|
+
sessions: nodes.filter((n) => n.type === "session").length,
|
|
235
|
+
gaps: nodes.filter((n) => n.type === "gap").length,
|
|
236
|
+
decisions: nodes.filter((n) => n.type === "decision").length,
|
|
237
|
+
annotations: nodes.filter((n) => n.type === "annotation").length,
|
|
238
|
+
specs: nodes.filter((n) => n.type === "spec").length,
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
// Build glossary from completed graph
|
|
242
|
+
const partialGraph = { version: "1.0.0", generated_at: "", stats, nodes, edges, warnings } as DVEGraph;
|
|
243
|
+
const glossary = buildGlossary(partialGraph, opts.cwd);
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
version: "1.0.0",
|
|
247
|
+
generated_at: new Date().toISOString(),
|
|
248
|
+
stats,
|
|
249
|
+
nodes,
|
|
250
|
+
edges,
|
|
251
|
+
warnings,
|
|
252
|
+
glossary: glossary.entries,
|
|
253
|
+
};
|
|
254
|
+
}
|
package/graph/cluster.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Clustering — group decisions by supersedes chains and theme similarity
|
|
2
|
+
|
|
3
|
+
import type { DVEGraph, GraphNode, Decision } from "./schema.js";
|
|
4
|
+
|
|
5
|
+
export interface Cluster {
|
|
6
|
+
id: string;
|
|
7
|
+
label: string;
|
|
8
|
+
ddIds: string[];
|
|
9
|
+
gapCount: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Group DDs by supersedes chains (connected components)
|
|
13
|
+
export function clusterBySupersedes(graph: DVEGraph): Cluster[] {
|
|
14
|
+
const ddNodes = graph.nodes.filter((n) => n.type === "decision");
|
|
15
|
+
const supersedesEdges = graph.edges.filter((e) => e.type === "supersedes");
|
|
16
|
+
|
|
17
|
+
// Union-Find
|
|
18
|
+
const parent: Record<string, string> = {};
|
|
19
|
+
for (const dd of ddNodes) parent[dd.id] = dd.id;
|
|
20
|
+
|
|
21
|
+
function find(x: string): string {
|
|
22
|
+
if (parent[x] !== x) parent[x] = find(parent[x]);
|
|
23
|
+
return parent[x];
|
|
24
|
+
}
|
|
25
|
+
function union(a: string, b: string) {
|
|
26
|
+
const ra = find(a), rb = find(b);
|
|
27
|
+
if (ra !== rb) parent[ra] = rb;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
for (const edge of supersedesEdges) {
|
|
31
|
+
if (parent[edge.source] !== undefined && parent[edge.target] !== undefined) {
|
|
32
|
+
union(edge.source, edge.target);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Group by root
|
|
37
|
+
const groups: Record<string, string[]> = {};
|
|
38
|
+
for (const dd of ddNodes) {
|
|
39
|
+
const root = find(dd.id);
|
|
40
|
+
if (!groups[root]) groups[root] = [];
|
|
41
|
+
groups[root].push(dd.id);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Build clusters
|
|
45
|
+
const clusters: Cluster[] = [];
|
|
46
|
+
for (const [root, ddIds] of Object.entries(groups)) {
|
|
47
|
+
// Find the latest DD's title as cluster label
|
|
48
|
+
const latestDD = ddIds
|
|
49
|
+
.map((id) => graph.nodes.find((n) => n.id === id))
|
|
50
|
+
.filter(Boolean)
|
|
51
|
+
.sort((a, b) => ((b!.data as any).date ?? "").localeCompare((a!.data as any).date ?? ""))
|
|
52
|
+
[0];
|
|
53
|
+
|
|
54
|
+
const label = (latestDD?.data as any)?.title ?? root;
|
|
55
|
+
|
|
56
|
+
// Count related gaps
|
|
57
|
+
const gapCount = ddIds.reduce((sum, ddId) => {
|
|
58
|
+
return sum + graph.edges.filter((e) => e.target === ddId && e.type === "resolves").length;
|
|
59
|
+
}, 0);
|
|
60
|
+
|
|
61
|
+
clusters.push({
|
|
62
|
+
id: `cluster-${root}`,
|
|
63
|
+
label: label.slice(0, 60),
|
|
64
|
+
ddIds,
|
|
65
|
+
gapCount,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return clusters.sort((a, b) => b.gapCount - a.gapCount);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Simple keyword-based theme clustering for DDs not in supersedes chains
|
|
73
|
+
export function clusterByTheme(graph: DVEGraph): Cluster[] {
|
|
74
|
+
const ddNodes = graph.nodes.filter((n) => n.type === "decision");
|
|
75
|
+
const keywords: Record<string, string[]> = {};
|
|
76
|
+
|
|
77
|
+
for (const dd of ddNodes) {
|
|
78
|
+
const title = ((dd.data as any).title ?? "").toLowerCase();
|
|
79
|
+
// Extract significant words (3+ chars, not common)
|
|
80
|
+
const words = title.split(/[\s\-_\/]+/).filter((w: string) => w.length >= 3);
|
|
81
|
+
for (const word of words) {
|
|
82
|
+
if (!keywords[word]) keywords[word] = [];
|
|
83
|
+
keywords[word].push(dd.id);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Find keyword groups with 2+ DDs
|
|
88
|
+
const clusters: Cluster[] = [];
|
|
89
|
+
const used = new Set<string>();
|
|
90
|
+
for (const [word, ids] of Object.entries(keywords)) {
|
|
91
|
+
if (ids.length < 2) continue;
|
|
92
|
+
const uniqueIds = ids.filter((id) => !used.has(id));
|
|
93
|
+
if (uniqueIds.length < 2) continue;
|
|
94
|
+
|
|
95
|
+
for (const id of uniqueIds) used.add(id);
|
|
96
|
+
clusters.push({
|
|
97
|
+
id: `theme-${word}`,
|
|
98
|
+
label: word,
|
|
99
|
+
ddIds: uniqueIds,
|
|
100
|
+
gapCount: 0,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return clusters;
|
|
105
|
+
}
|
package/graph/query.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Graph queries — traceDecision, impactOf, orphanGaps, overturned
|
|
2
|
+
|
|
3
|
+
import type { DVEGraph, GraphNode, Edge, Gap, Decision } from "./schema.js";
|
|
4
|
+
|
|
5
|
+
// Trace a decision back to its originating sessions and gaps
|
|
6
|
+
export function traceDecision(graph: DVEGraph, ddId: string): GraphNode[] {
|
|
7
|
+
const chain: GraphNode[] = [];
|
|
8
|
+
const visited = new Set<string>();
|
|
9
|
+
|
|
10
|
+
function walkBack(nodeId: string) {
|
|
11
|
+
if (visited.has(nodeId)) return;
|
|
12
|
+
visited.add(nodeId);
|
|
13
|
+
|
|
14
|
+
const node = graph.nodes.find((n) => n.id === nodeId);
|
|
15
|
+
if (node) chain.push(node);
|
|
16
|
+
|
|
17
|
+
// Find edges where this node is the target
|
|
18
|
+
const incoming = graph.edges.filter((e) => e.target === nodeId);
|
|
19
|
+
for (const edge of incoming) {
|
|
20
|
+
walkBack(edge.source);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
walkBack(ddId);
|
|
25
|
+
return chain;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Forward traversal — find all nodes affected by a change to this node
|
|
29
|
+
export function impactOf(graph: DVEGraph, nodeId: string): GraphNode[] {
|
|
30
|
+
const impacted: GraphNode[] = [];
|
|
31
|
+
const visited = new Set<string>();
|
|
32
|
+
|
|
33
|
+
function walkForward(nid: string) {
|
|
34
|
+
if (visited.has(nid)) return;
|
|
35
|
+
visited.add(nid);
|
|
36
|
+
|
|
37
|
+
const outgoing = graph.edges.filter((e) => e.source === nid);
|
|
38
|
+
for (const edge of outgoing) {
|
|
39
|
+
const target = graph.nodes.find((n) => n.id === edge.target);
|
|
40
|
+
if (target && !visited.has(target.id)) {
|
|
41
|
+
impacted.push(target);
|
|
42
|
+
walkForward(target.id);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
walkForward(nodeId);
|
|
48
|
+
return impacted;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Orphan gaps — gaps not linked to any decision
|
|
52
|
+
export function orphanGaps(graph: DVEGraph): GraphNode[] {
|
|
53
|
+
const gapNodes = graph.nodes.filter((n) => n.type === "gap");
|
|
54
|
+
const resolvedGapIds = new Set(
|
|
55
|
+
graph.edges.filter((e) => e.type === "resolves").map((e) => e.source)
|
|
56
|
+
);
|
|
57
|
+
return gapNodes.filter((g) => !resolvedGapIds.has(g.id));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Overturned decisions + their impact
|
|
61
|
+
export function overturned(graph: DVEGraph): { decision: GraphNode; impact: GraphNode[] }[] {
|
|
62
|
+
const overturnedDDs = graph.nodes.filter(
|
|
63
|
+
(n) => n.type === "decision" && (n.data as Decision).status === "overturned"
|
|
64
|
+
);
|
|
65
|
+
return overturnedDDs.map((dd) => ({
|
|
66
|
+
decision: dd,
|
|
67
|
+
impact: impactOf(graph, dd.id),
|
|
68
|
+
}));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Search nodes by keyword
|
|
72
|
+
export function search(graph: DVEGraph, keyword: string): GraphNode[] {
|
|
73
|
+
const lower = keyword.toLowerCase();
|
|
74
|
+
return graph.nodes.filter((n) => {
|
|
75
|
+
const data = n.data as any;
|
|
76
|
+
const searchFields = [
|
|
77
|
+
data.title, data.theme, data.summary, data.body, data.rationale,
|
|
78
|
+
n.id,
|
|
79
|
+
].filter(Boolean);
|
|
80
|
+
return searchFields.some((f: string) => f.toLowerCase().includes(lower));
|
|
81
|
+
});
|
|
82
|
+
}
|