@beomjk/emdd 0.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/LICENSE +21 -0
- package/README.md +153 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +158 -0
- package/dist/commands/backlog.d.ts +8 -0
- package/dist/commands/backlog.js +35 -0
- package/dist/commands/check.d.ts +6 -0
- package/dist/commands/check.js +8 -0
- package/dist/commands/done.d.ts +7 -0
- package/dist/commands/done.js +34 -0
- package/dist/commands/graph.d.ts +5 -0
- package/dist/commands/graph.js +18 -0
- package/dist/commands/health.d.ts +1 -0
- package/dist/commands/health.js +36 -0
- package/dist/commands/index.d.ts +4 -0
- package/dist/commands/index.js +10 -0
- package/dist/commands/init.d.ts +4 -0
- package/dist/commands/init.js +40 -0
- package/dist/commands/link.d.ts +5 -0
- package/dist/commands/link.js +8 -0
- package/dist/commands/lint.d.ts +1 -0
- package/dist/commands/lint.js +27 -0
- package/dist/commands/new.d.ts +3 -0
- package/dist/commands/new.js +14 -0
- package/dist/commands/promote.d.ts +9 -0
- package/dist/commands/promote.js +9 -0
- package/dist/commands/update.d.ts +7 -0
- package/dist/commands/update.js +33 -0
- package/dist/graph/index-generator.d.ts +2 -0
- package/dist/graph/index-generator.js +57 -0
- package/dist/graph/loader.d.ts +15 -0
- package/dist/graph/loader.js +112 -0
- package/dist/graph/mermaid.d.ts +2 -0
- package/dist/graph/mermaid.js +35 -0
- package/dist/graph/operations.d.ts +34 -0
- package/dist/graph/operations.js +265 -0
- package/dist/graph/templates.d.ts +9 -0
- package/dist/graph/templates.js +102 -0
- package/dist/graph/types.d.ts +71 -0
- package/dist/graph/types.js +70 -0
- package/dist/graph/validator.d.ts +16 -0
- package/dist/graph/validator.js +120 -0
- package/dist/i18n/en.d.ts +1 -0
- package/dist/i18n/en.js +72 -0
- package/dist/i18n/index.d.ts +4 -0
- package/dist/i18n/index.js +26 -0
- package/dist/i18n/ko.d.ts +1 -0
- package/dist/i18n/ko.js +72 -0
- package/dist/mcp-server/cli.d.ts +2 -0
- package/dist/mcp-server/cli.js +6 -0
- package/dist/mcp-server/index.d.ts +3 -0
- package/dist/mcp-server/index.js +35 -0
- package/dist/mcp-server/prompts/consolidation.d.ts +2 -0
- package/dist/mcp-server/prompts/consolidation.js +64 -0
- package/dist/mcp-server/prompts/context-loading.d.ts +2 -0
- package/dist/mcp-server/prompts/context-loading.js +52 -0
- package/dist/mcp-server/prompts/episode-creation.d.ts +2 -0
- package/dist/mcp-server/prompts/episode-creation.js +75 -0
- package/dist/mcp-server/prompts/health-review.d.ts +2 -0
- package/dist/mcp-server/prompts/health-review.js +76 -0
- package/dist/mcp-server/tools/check.d.ts +2 -0
- package/dist/mcp-server/tools/check.js +11 -0
- package/dist/mcp-server/tools/create-edge.d.ts +2 -0
- package/dist/mcp-server/tools/create-edge.js +14 -0
- package/dist/mcp-server/tools/create-node.d.ts +2 -0
- package/dist/mcp-server/tools/create-node.js +14 -0
- package/dist/mcp-server/tools/health.d.ts +2 -0
- package/dist/mcp-server/tools/health.js +11 -0
- package/dist/mcp-server/tools/list-nodes.d.ts +2 -0
- package/dist/mcp-server/tools/list-nodes.js +18 -0
- package/dist/mcp-server/tools/promote.d.ts +2 -0
- package/dist/mcp-server/tools/promote.js +11 -0
- package/dist/mcp-server/tools/read-node.d.ts +2 -0
- package/dist/mcp-server/tools/read-node.js +15 -0
- package/dist/mcp-server/tools/util.d.ts +7 -0
- package/dist/mcp-server/tools/util.js +22 -0
- package/dist/rules/emdd-agent.md +40 -0
- package/dist/rules/emdd-rules.md +99 -0
- package/dist/rules/generators.d.ts +18 -0
- package/dist/rules/generators.js +104 -0
- package/package.json +51 -0
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Update frontmatter fields on a node.
|
|
3
|
+
*
|
|
4
|
+
* Automatically sets `updated` to today's date.
|
|
5
|
+
* Parses numeric strings for `confidence`.
|
|
6
|
+
*/
|
|
7
|
+
export declare function updateCommand(graphDir: string, nodeId: string, updates: Record<string, string>): Promise<void>;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import matter from 'gray-matter';
|
|
3
|
+
import { loadGraph } from '../graph/loader.js';
|
|
4
|
+
/**
|
|
5
|
+
* Update frontmatter fields on a node.
|
|
6
|
+
*
|
|
7
|
+
* Automatically sets `updated` to today's date.
|
|
8
|
+
* Parses numeric strings for `confidence`.
|
|
9
|
+
*/
|
|
10
|
+
export async function updateCommand(graphDir, nodeId, updates) {
|
|
11
|
+
const graph = await loadGraph(graphDir);
|
|
12
|
+
const node = graph.nodes.get(nodeId);
|
|
13
|
+
if (!node) {
|
|
14
|
+
throw new Error(`Node not found: ${nodeId}`);
|
|
15
|
+
}
|
|
16
|
+
const filePath = node.path;
|
|
17
|
+
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
18
|
+
const parsed = matter(raw);
|
|
19
|
+
// Apply updates
|
|
20
|
+
for (const [key, value] of Object.entries(updates)) {
|
|
21
|
+
if (key === 'confidence') {
|
|
22
|
+
parsed.data[key] = parseFloat(value);
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
parsed.data[key] = value;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
// Auto-update the `updated` field
|
|
29
|
+
parsed.data.updated = new Date().toISOString().slice(0, 10);
|
|
30
|
+
// Write back
|
|
31
|
+
const output = matter.stringify(parsed.content, parsed.data);
|
|
32
|
+
fs.writeFileSync(filePath, output);
|
|
33
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
const SECTION_ORDER = [
|
|
2
|
+
'hypothesis',
|
|
3
|
+
'experiment',
|
|
4
|
+
'finding',
|
|
5
|
+
'knowledge',
|
|
6
|
+
'question',
|
|
7
|
+
'decision',
|
|
8
|
+
'episode',
|
|
9
|
+
];
|
|
10
|
+
const SECTION_TITLES = {
|
|
11
|
+
hypothesis: 'Hypotheses',
|
|
12
|
+
experiment: 'Experiments',
|
|
13
|
+
finding: 'Findings',
|
|
14
|
+
knowledge: 'Knowledge',
|
|
15
|
+
question: 'Questions',
|
|
16
|
+
decision: 'Decisions',
|
|
17
|
+
episode: 'Episodes',
|
|
18
|
+
};
|
|
19
|
+
export function generateIndex(graph) {
|
|
20
|
+
const lines = [];
|
|
21
|
+
const totalNodes = graph.nodes.size;
|
|
22
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
23
|
+
// Frontmatter
|
|
24
|
+
lines.push('---');
|
|
25
|
+
lines.push(`generated: ${today}`);
|
|
26
|
+
lines.push(`node_count: ${totalNodes}`);
|
|
27
|
+
lines.push('---');
|
|
28
|
+
lines.push('');
|
|
29
|
+
lines.push('# EMDD Graph Index');
|
|
30
|
+
lines.push('');
|
|
31
|
+
lines.push(`Total nodes: ${totalNodes}`);
|
|
32
|
+
lines.push('');
|
|
33
|
+
// Group nodes by type
|
|
34
|
+
const byType = new Map();
|
|
35
|
+
for (const node of graph.nodes.values()) {
|
|
36
|
+
const list = byType.get(node.type) ?? [];
|
|
37
|
+
list.push(node);
|
|
38
|
+
byType.set(node.type, list);
|
|
39
|
+
}
|
|
40
|
+
// Sections
|
|
41
|
+
for (const ntype of SECTION_ORDER) {
|
|
42
|
+
const nodes = byType.get(ntype);
|
|
43
|
+
if (!nodes || nodes.length === 0)
|
|
44
|
+
continue;
|
|
45
|
+
lines.push(`## ${SECTION_TITLES[ntype]}`);
|
|
46
|
+
lines.push('');
|
|
47
|
+
lines.push('| ID | Title | Status | Updated |');
|
|
48
|
+
lines.push('|----|-------|--------|---------|');
|
|
49
|
+
for (const n of nodes.sort((a, b) => a.id.localeCompare(b.id))) {
|
|
50
|
+
const status = n.status ?? '?';
|
|
51
|
+
const updated = n.meta?.updated ?? '?';
|
|
52
|
+
lines.push(`| ${n.id} | ${n.title} | ${status} | ${updated} |`);
|
|
53
|
+
}
|
|
54
|
+
lines.push('');
|
|
55
|
+
}
|
|
56
|
+
return lines.join('\n');
|
|
57
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Node, Graph } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Walk up from startPath looking for a `graph/` directory.
|
|
4
|
+
* Throws if not found.
|
|
5
|
+
*/
|
|
6
|
+
export declare function resolveGraphDir(startPath?: string): string;
|
|
7
|
+
/**
|
|
8
|
+
* Parse a single .md file into a Node. Returns null on error or missing frontmatter.
|
|
9
|
+
*/
|
|
10
|
+
export declare function loadNode(filePath: string): Promise<Node | null>;
|
|
11
|
+
/**
|
|
12
|
+
* Load all .md nodes from a graph directory (including subdirectories).
|
|
13
|
+
* Excludes files/dirs starting with _.
|
|
14
|
+
*/
|
|
15
|
+
export declare function loadGraph(graphDir: string): Promise<Graph>;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import matter from 'gray-matter';
|
|
4
|
+
import { glob } from 'glob';
|
|
5
|
+
import { REVERSE_LABELS } from './types.js';
|
|
6
|
+
/**
|
|
7
|
+
* Walk up from startPath looking for a `graph/` directory.
|
|
8
|
+
* Throws if not found.
|
|
9
|
+
*/
|
|
10
|
+
export function resolveGraphDir(startPath) {
|
|
11
|
+
let current = path.resolve(startPath ?? process.cwd());
|
|
12
|
+
// If startPath itself is not a directory, go to parent
|
|
13
|
+
try {
|
|
14
|
+
if (!fs.statSync(current).isDirectory()) {
|
|
15
|
+
current = path.dirname(current);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
throw new Error('No graph/ directory found');
|
|
20
|
+
}
|
|
21
|
+
const root = path.parse(current).root;
|
|
22
|
+
while (current !== root) {
|
|
23
|
+
const candidate = path.join(current, 'graph');
|
|
24
|
+
try {
|
|
25
|
+
if (fs.statSync(candidate).isDirectory()) {
|
|
26
|
+
return candidate;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// not found, keep walking
|
|
31
|
+
}
|
|
32
|
+
current = path.dirname(current);
|
|
33
|
+
}
|
|
34
|
+
throw new Error('No graph/ directory found');
|
|
35
|
+
}
|
|
36
|
+
function normalizeRelation(relation) {
|
|
37
|
+
return REVERSE_LABELS[relation] ?? relation;
|
|
38
|
+
}
|
|
39
|
+
function parseLinks(raw) {
|
|
40
|
+
if (!Array.isArray(raw))
|
|
41
|
+
return [];
|
|
42
|
+
return raw
|
|
43
|
+
.filter((item) => typeof item === 'object' && item !== null)
|
|
44
|
+
.map((item) => ({
|
|
45
|
+
target: String(item.target ?? ''),
|
|
46
|
+
relation: normalizeRelation(String(item.relation ?? 'relates_to')),
|
|
47
|
+
}));
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Parse a single .md file into a Node. Returns null on error or missing frontmatter.
|
|
51
|
+
*/
|
|
52
|
+
export async function loadNode(filePath) {
|
|
53
|
+
let content;
|
|
54
|
+
try {
|
|
55
|
+
content = fs.readFileSync(filePath, 'utf-8');
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
let parsed;
|
|
61
|
+
try {
|
|
62
|
+
parsed = matter(content);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
const meta = parsed.data;
|
|
68
|
+
if (!meta || typeof meta !== 'object' || Object.keys(meta).length === 0) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
const id = meta.id ?? path.basename(filePath, '.md').replace(/^(\w+-\d+).*$/, '$1');
|
|
72
|
+
const type = meta.type;
|
|
73
|
+
if (!type)
|
|
74
|
+
return null;
|
|
75
|
+
return {
|
|
76
|
+
id: String(id),
|
|
77
|
+
type,
|
|
78
|
+
title: String(meta.title ?? ''),
|
|
79
|
+
path: filePath,
|
|
80
|
+
status: meta.status ? String(meta.status) : undefined,
|
|
81
|
+
confidence: typeof meta.confidence === 'number' ? meta.confidence : undefined,
|
|
82
|
+
tags: Array.isArray(meta.tags) ? meta.tags.map(String) : [],
|
|
83
|
+
links: parseLinks(meta.links),
|
|
84
|
+
meta,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Load all .md nodes from a graph directory (including subdirectories).
|
|
89
|
+
* Excludes files/dirs starting with _.
|
|
90
|
+
*/
|
|
91
|
+
export async function loadGraph(graphDir) {
|
|
92
|
+
const graph = {
|
|
93
|
+
nodes: new Map(),
|
|
94
|
+
errors: [],
|
|
95
|
+
warnings: [],
|
|
96
|
+
};
|
|
97
|
+
// Find all .md files in the graph directory and subdirectories
|
|
98
|
+
const pattern = path.join(graphDir, '**/*.md');
|
|
99
|
+
const files = await glob(pattern, { nodir: true });
|
|
100
|
+
for (const file of files.sort()) {
|
|
101
|
+
// Skip files or directories starting with _
|
|
102
|
+
const relative = path.relative(graphDir, file);
|
|
103
|
+
const parts = relative.split(path.sep);
|
|
104
|
+
if (parts.some((p) => p.startsWith('_')))
|
|
105
|
+
continue;
|
|
106
|
+
const node = await loadNode(file);
|
|
107
|
+
if (node) {
|
|
108
|
+
graph.nodes.set(node.id, node);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return graph;
|
|
112
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
const TITLE_MAX_LEN = 40;
|
|
2
|
+
function truncateTitle(title) {
|
|
3
|
+
if (title.length <= TITLE_MAX_LEN)
|
|
4
|
+
return title;
|
|
5
|
+
return title.slice(0, TITLE_MAX_LEN) + '...';
|
|
6
|
+
}
|
|
7
|
+
const STATUS_STYLES = {
|
|
8
|
+
CONFIRMED: 'fill:#90EE90',
|
|
9
|
+
REFUTED: 'fill:#FFB6C1',
|
|
10
|
+
DEPRECATED: 'fill:#D3D3D3',
|
|
11
|
+
};
|
|
12
|
+
export function generateMermaid(graph) {
|
|
13
|
+
const lines = ['graph TD'];
|
|
14
|
+
// Node definitions
|
|
15
|
+
for (const [id, node] of graph.nodes) {
|
|
16
|
+
const label = `${id}: ${truncateTitle(node.title)}`;
|
|
17
|
+
lines.push(` ${id}["${label}"]`);
|
|
18
|
+
}
|
|
19
|
+
// Edges
|
|
20
|
+
for (const [id, node] of graph.nodes) {
|
|
21
|
+
for (const link of node.links) {
|
|
22
|
+
if (!graph.nodes.has(link.target))
|
|
23
|
+
continue;
|
|
24
|
+
lines.push(` ${id} -->|${link.relation}| ${link.target}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
// Styles for special statuses
|
|
28
|
+
for (const [id, node] of graph.nodes) {
|
|
29
|
+
const style = node.status ? STATUS_STYLES[node.status] : undefined;
|
|
30
|
+
if (style) {
|
|
31
|
+
lines.push(` style ${id} ${style}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return lines.join('\n');
|
|
35
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Node, NodeFilter, NodeDetail, CreateNodeResult, CreateEdgeResult, HealthReport, CheckResult, PromoteCandidate } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* List all nodes in the graph, optionally filtered by type and/or status.
|
|
4
|
+
*/
|
|
5
|
+
export declare function listNodes(graphDir: string, filter?: NodeFilter): Promise<Node[]>;
|
|
6
|
+
/**
|
|
7
|
+
* Read a single node by ID, returning full detail including body text.
|
|
8
|
+
* Returns null if the node is not found.
|
|
9
|
+
*/
|
|
10
|
+
export declare function readNode(graphDir: string, nodeId: string): Promise<NodeDetail | null>;
|
|
11
|
+
/**
|
|
12
|
+
* Create a new node of the given type with the given slug.
|
|
13
|
+
* Returns the created node's ID, type, and file path.
|
|
14
|
+
*/
|
|
15
|
+
export declare function createNode(graphDir: string, type: string, slug: string, lang?: string): Promise<CreateNodeResult>;
|
|
16
|
+
/**
|
|
17
|
+
* Add an edge (link) from source to target with the given relation.
|
|
18
|
+
* Validates relation, source existence, and target existence.
|
|
19
|
+
*/
|
|
20
|
+
export declare function createEdge(graphDir: string, source: string, target: string, relation: string): Promise<CreateEdgeResult>;
|
|
21
|
+
/**
|
|
22
|
+
* Compute a health report for the graph.
|
|
23
|
+
*/
|
|
24
|
+
export declare function getHealth(graphDir: string): Promise<HealthReport>;
|
|
25
|
+
/**
|
|
26
|
+
* Check consolidation triggers in the graph.
|
|
27
|
+
* Replicates logic from commands/check.ts as a pure function.
|
|
28
|
+
*/
|
|
29
|
+
export declare function checkConsolidation(graphDir: string): Promise<CheckResult>;
|
|
30
|
+
/**
|
|
31
|
+
* Identify findings eligible for promotion to knowledge.
|
|
32
|
+
* Criteria: confidence >= 0.8 AND 2+ outgoing "supports" links.
|
|
33
|
+
*/
|
|
34
|
+
export declare function getPromotionCandidates(graphDir: string): Promise<PromoteCandidate[]>;
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import matter from 'gray-matter';
|
|
3
|
+
import { loadGraph } from './loader.js';
|
|
4
|
+
import { nextId, renderTemplate, nodePath } from './templates.js';
|
|
5
|
+
import { NODE_TYPES, ALL_VALID_RELATIONS, REVERSE_LABELS } from './types.js';
|
|
6
|
+
// ── listNodes ───────────────────────────────────────────────────────
|
|
7
|
+
/**
|
|
8
|
+
* List all nodes in the graph, optionally filtered by type and/or status.
|
|
9
|
+
*/
|
|
10
|
+
export async function listNodes(graphDir, filter) {
|
|
11
|
+
const graph = await loadGraph(graphDir);
|
|
12
|
+
let nodes = [...graph.nodes.values()];
|
|
13
|
+
if (filter?.type) {
|
|
14
|
+
nodes = nodes.filter(n => n.type === filter.type);
|
|
15
|
+
}
|
|
16
|
+
if (filter?.status) {
|
|
17
|
+
nodes = nodes.filter(n => n.status === filter.status);
|
|
18
|
+
}
|
|
19
|
+
return nodes;
|
|
20
|
+
}
|
|
21
|
+
// ── readNode ────────────────────────────────────────────────────────
|
|
22
|
+
/**
|
|
23
|
+
* Read a single node by ID, returning full detail including body text.
|
|
24
|
+
* Returns null if the node is not found.
|
|
25
|
+
*/
|
|
26
|
+
export async function readNode(graphDir, nodeId) {
|
|
27
|
+
const graph = await loadGraph(graphDir);
|
|
28
|
+
const node = graph.nodes.get(nodeId);
|
|
29
|
+
if (!node)
|
|
30
|
+
return null;
|
|
31
|
+
// Read file to extract body
|
|
32
|
+
const raw = fs.readFileSync(node.path, 'utf-8');
|
|
33
|
+
const parsed = matter(raw);
|
|
34
|
+
return {
|
|
35
|
+
...node,
|
|
36
|
+
body: parsed.content,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
// ── createNode ──────────────────────────────────────────────────────
|
|
40
|
+
/**
|
|
41
|
+
* Create a new node of the given type with the given slug.
|
|
42
|
+
* Returns the created node's ID, type, and file path.
|
|
43
|
+
*/
|
|
44
|
+
export async function createNode(graphDir, type, slug, lang) {
|
|
45
|
+
if (!NODE_TYPES.includes(type)) {
|
|
46
|
+
throw new Error(`Invalid node type: ${type}. Valid types: ${NODE_TYPES.join(', ')}`);
|
|
47
|
+
}
|
|
48
|
+
const nodeType = type;
|
|
49
|
+
const id = nextId(graphDir, nodeType);
|
|
50
|
+
const content = renderTemplate(nodeType, slug, {
|
|
51
|
+
id,
|
|
52
|
+
locale: lang ?? 'en',
|
|
53
|
+
});
|
|
54
|
+
const filePath = nodePath(graphDir, nodeType, id, slug);
|
|
55
|
+
// Ensure directory exists
|
|
56
|
+
const dir = filePath.substring(0, filePath.lastIndexOf('/'));
|
|
57
|
+
if (!fs.existsSync(dir)) {
|
|
58
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
59
|
+
}
|
|
60
|
+
fs.writeFileSync(filePath, content, 'utf-8');
|
|
61
|
+
return { id, type: nodeType, path: filePath };
|
|
62
|
+
}
|
|
63
|
+
// ── createEdge ──────────────────────────────────────────────────────
|
|
64
|
+
/**
|
|
65
|
+
* Add an edge (link) from source to target with the given relation.
|
|
66
|
+
* Validates relation, source existence, and target existence.
|
|
67
|
+
*/
|
|
68
|
+
export async function createEdge(graphDir, source, target, relation) {
|
|
69
|
+
// Validate relation
|
|
70
|
+
if (!ALL_VALID_RELATIONS.has(relation)) {
|
|
71
|
+
const valid = [...ALL_VALID_RELATIONS].sort().join(', ');
|
|
72
|
+
throw new Error(`Invalid relation: ${relation}. Valid: ${valid}`);
|
|
73
|
+
}
|
|
74
|
+
// Normalize reverse labels
|
|
75
|
+
const canonical = REVERSE_LABELS[relation] ?? relation;
|
|
76
|
+
const graph = await loadGraph(graphDir);
|
|
77
|
+
const sourceNode = graph.nodes.get(source);
|
|
78
|
+
if (!sourceNode) {
|
|
79
|
+
throw new Error(`Source node not found: ${source}`);
|
|
80
|
+
}
|
|
81
|
+
// Validate target exists (new behavior not in original link.ts)
|
|
82
|
+
const targetNode = graph.nodes.get(target);
|
|
83
|
+
if (!targetNode) {
|
|
84
|
+
throw new Error(`Target node not found: ${target}`);
|
|
85
|
+
}
|
|
86
|
+
const filePath = sourceNode.path;
|
|
87
|
+
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
88
|
+
const parsed = matter(raw);
|
|
89
|
+
// Ensure links array exists
|
|
90
|
+
if (!Array.isArray(parsed.data.links)) {
|
|
91
|
+
parsed.data.links = [];
|
|
92
|
+
}
|
|
93
|
+
// Add link
|
|
94
|
+
parsed.data.links.push({ target, relation: canonical });
|
|
95
|
+
// Auto-update the `updated` field
|
|
96
|
+
parsed.data.updated = new Date().toISOString().slice(0, 10);
|
|
97
|
+
// Write back
|
|
98
|
+
const output = matter.stringify(parsed.content, parsed.data);
|
|
99
|
+
fs.writeFileSync(filePath, output);
|
|
100
|
+
return { source, target, relation: canonical };
|
|
101
|
+
}
|
|
102
|
+
// ── getHealth ───────────────────────────────────────────────────────
|
|
103
|
+
/**
|
|
104
|
+
* Compute a health report for the graph.
|
|
105
|
+
*/
|
|
106
|
+
export async function getHealth(graphDir) {
|
|
107
|
+
const graph = await loadGraph(graphDir);
|
|
108
|
+
// Count nodes by type
|
|
109
|
+
const byType = {};
|
|
110
|
+
for (const nodeType of NODE_TYPES) {
|
|
111
|
+
byType[nodeType] = 0;
|
|
112
|
+
}
|
|
113
|
+
for (const node of graph.nodes.values()) {
|
|
114
|
+
byType[node.type] = (byType[node.type] ?? 0) + 1;
|
|
115
|
+
}
|
|
116
|
+
const totalNodes = graph.nodes.size;
|
|
117
|
+
// Status distribution per type
|
|
118
|
+
const statusDistribution = {};
|
|
119
|
+
for (const node of graph.nodes.values()) {
|
|
120
|
+
if (!statusDistribution[node.type]) {
|
|
121
|
+
statusDistribution[node.type] = {};
|
|
122
|
+
}
|
|
123
|
+
const s = node.status ?? 'unknown';
|
|
124
|
+
statusDistribution[node.type][s] = (statusDistribution[node.type][s] ?? 0) + 1;
|
|
125
|
+
}
|
|
126
|
+
// Average confidence
|
|
127
|
+
let confSum = 0;
|
|
128
|
+
let confCount = 0;
|
|
129
|
+
for (const node of graph.nodes.values()) {
|
|
130
|
+
if (node.confidence !== undefined) {
|
|
131
|
+
confSum += node.confidence;
|
|
132
|
+
confCount++;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const avgConfidence = confCount > 0 ? confSum / confCount : null;
|
|
136
|
+
// Open questions
|
|
137
|
+
const openQuestions = [...graph.nodes.values()].filter(n => n.type === 'question' && n.status === 'OPEN').length;
|
|
138
|
+
// Total edges and link density
|
|
139
|
+
let totalEdges = 0;
|
|
140
|
+
for (const node of graph.nodes.values()) {
|
|
141
|
+
totalEdges += node.links.length;
|
|
142
|
+
}
|
|
143
|
+
const linkDensity = totalNodes > 0 ? totalEdges / totalNodes : 0;
|
|
144
|
+
// Structural gaps
|
|
145
|
+
const gaps = [];
|
|
146
|
+
if (byType['hypothesis'] > 0 && byType['experiment'] === 0) {
|
|
147
|
+
gaps.push('No experiments — hypotheses lack testing');
|
|
148
|
+
}
|
|
149
|
+
if (byType['finding'] > 0 && byType['knowledge'] === 0) {
|
|
150
|
+
gaps.push('No knowledge nodes — findings not consolidated');
|
|
151
|
+
}
|
|
152
|
+
if (byType['experiment'] > 0 && byType['finding'] === 0) {
|
|
153
|
+
gaps.push('No findings — experiments lack documented results');
|
|
154
|
+
}
|
|
155
|
+
if (totalNodes > 0 && totalEdges === 0) {
|
|
156
|
+
gaps.push('No edges — graph is disconnected');
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
totalNodes,
|
|
160
|
+
totalEdges,
|
|
161
|
+
byType,
|
|
162
|
+
statusDistribution,
|
|
163
|
+
avgConfidence,
|
|
164
|
+
openQuestions,
|
|
165
|
+
linkDensity,
|
|
166
|
+
gaps,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
// ── checkConsolidation ──────────────────────────────────────────────
|
|
170
|
+
/**
|
|
171
|
+
* Check consolidation triggers in the graph.
|
|
172
|
+
* Replicates logic from commands/check.ts as a pure function.
|
|
173
|
+
*/
|
|
174
|
+
export async function checkConsolidation(graphDir) {
|
|
175
|
+
const graph = await loadGraph(graphDir);
|
|
176
|
+
const triggers = [];
|
|
177
|
+
const findings = [];
|
|
178
|
+
const episodes = [];
|
|
179
|
+
const openQuestions = [];
|
|
180
|
+
const questionCount = { total: 0 };
|
|
181
|
+
const promotedIds = new Set();
|
|
182
|
+
for (const [id, node] of graph.nodes) {
|
|
183
|
+
switch (node.type) {
|
|
184
|
+
case 'finding':
|
|
185
|
+
findings.push(id);
|
|
186
|
+
break;
|
|
187
|
+
case 'episode':
|
|
188
|
+
episodes.push(id);
|
|
189
|
+
break;
|
|
190
|
+
case 'question':
|
|
191
|
+
questionCount.total++;
|
|
192
|
+
if (node.status === 'OPEN') {
|
|
193
|
+
openQuestions.push(id);
|
|
194
|
+
}
|
|
195
|
+
break;
|
|
196
|
+
case 'knowledge':
|
|
197
|
+
for (const link of node.links) {
|
|
198
|
+
if (link.relation === 'promotes') {
|
|
199
|
+
promotedIds.add(link.target);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
// 1. Unpromoted findings threshold (5)
|
|
206
|
+
const unpromoted = findings.filter(id => !promotedIds.has(id));
|
|
207
|
+
if (unpromoted.length >= 5) {
|
|
208
|
+
triggers.push({
|
|
209
|
+
type: 'findings',
|
|
210
|
+
message: `Findings pending consolidation: ${unpromoted.length} (threshold: 5)`,
|
|
211
|
+
count: unpromoted.length,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
// 2. Episode accumulation threshold (3)
|
|
215
|
+
if (episodes.length >= 3) {
|
|
216
|
+
triggers.push({
|
|
217
|
+
type: 'episodes',
|
|
218
|
+
message: `Episodes since last consolidation: ${episodes.length} (threshold: 3)`,
|
|
219
|
+
count: episodes.length,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
// 3. All questions resolved
|
|
223
|
+
if (questionCount.total > 0 && openQuestions.length === 0) {
|
|
224
|
+
triggers.push({
|
|
225
|
+
type: 'questions',
|
|
226
|
+
message: 'All questions resolved — consider generating new ones',
|
|
227
|
+
count: 0,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
return { triggers };
|
|
231
|
+
}
|
|
232
|
+
// ── getPromotionCandidates ──────────────────────────────────────────
|
|
233
|
+
/**
|
|
234
|
+
* Identify findings eligible for promotion to knowledge.
|
|
235
|
+
* Criteria: confidence >= 0.8 AND 2+ outgoing "supports" links.
|
|
236
|
+
*/
|
|
237
|
+
export async function getPromotionCandidates(graphDir) {
|
|
238
|
+
const graph = await loadGraph(graphDir);
|
|
239
|
+
const candidates = [];
|
|
240
|
+
// Track already-promoted findings
|
|
241
|
+
const promotedIds = new Set();
|
|
242
|
+
for (const [, node] of graph.nodes) {
|
|
243
|
+
if (node.type === 'knowledge') {
|
|
244
|
+
for (const link of node.links) {
|
|
245
|
+
if (link.relation === 'promotes') {
|
|
246
|
+
promotedIds.add(link.target);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
for (const [id, node] of graph.nodes) {
|
|
252
|
+
if (node.type !== 'finding')
|
|
253
|
+
continue;
|
|
254
|
+
if (promotedIds.has(id))
|
|
255
|
+
continue;
|
|
256
|
+
const confidence = node.confidence ?? 0;
|
|
257
|
+
if (confidence < 0.8)
|
|
258
|
+
continue;
|
|
259
|
+
const supportsCount = node.links.filter(l => l.relation === 'supports').length;
|
|
260
|
+
if (supportsCount < 2)
|
|
261
|
+
continue;
|
|
262
|
+
candidates.push({ id, confidence, supports: supportsCount });
|
|
263
|
+
}
|
|
264
|
+
return candidates;
|
|
265
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { NodeType } from './types.js';
|
|
2
|
+
import type { Locale } from '../i18n/index.js';
|
|
3
|
+
export declare function renderTemplate(type: NodeType, slug: string, options?: {
|
|
4
|
+
locale?: Locale;
|
|
5
|
+
user?: string;
|
|
6
|
+
id?: string;
|
|
7
|
+
}): string;
|
|
8
|
+
export declare function nextId(graphDir: string, type: NodeType): string;
|
|
9
|
+
export declare function nodePath(graphDir: string, type: NodeType, id: string, slug: string): string;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import { NODE_TYPE_DIRS, ID_PREFIXES, VALID_STATUSES } from './types.js';
|
|
4
|
+
// ── Body templates per type and locale ─────────────────────────────
|
|
5
|
+
const BODY_TEMPLATES = {
|
|
6
|
+
en: {
|
|
7
|
+
hypothesis: '## Hypothesis\n\n\n\n## Rationale\n\n',
|
|
8
|
+
experiment: '## Design\n\n\n\n## Results\n\n',
|
|
9
|
+
finding: '## Summary\n\n\n\n## Evidence\n\n',
|
|
10
|
+
knowledge: '## Content\n\n\n\n## Source\n\n',
|
|
11
|
+
question: '## Question\n\n\n\n## Context\n\n',
|
|
12
|
+
decision: '## Decision\n\n\n\n## Rationale\n\n## Alternatives\n\n',
|
|
13
|
+
episode: '## Goals\n\n- [ ] \n\n## Notes\n\n',
|
|
14
|
+
},
|
|
15
|
+
ko: {
|
|
16
|
+
hypothesis: '## 가설\n\n\n\n## 근거\n\n',
|
|
17
|
+
experiment: '## 설계\n\n\n\n## 결과\n\n',
|
|
18
|
+
finding: '## 요약\n\n\n\n## 근거\n\n',
|
|
19
|
+
knowledge: '## 내용\n\n\n\n## 출처\n\n',
|
|
20
|
+
question: '## 질문\n\n\n\n## 맥락\n\n',
|
|
21
|
+
decision: '## 결정\n\n\n\n## 근거\n\n## 대안\n\n',
|
|
22
|
+
episode: '## 목표\n\n- [ ] \n\n## 메모\n\n',
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
// Types that get a confidence field
|
|
26
|
+
const CONFIDENCE_TYPES = {
|
|
27
|
+
hypothesis: 0.5,
|
|
28
|
+
finding: 0.5,
|
|
29
|
+
knowledge: 0.9,
|
|
30
|
+
};
|
|
31
|
+
// ── renderTemplate ─────────────────────────────────────────────────
|
|
32
|
+
export function renderTemplate(type, slug, options) {
|
|
33
|
+
const locale = options?.locale ?? 'en';
|
|
34
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
35
|
+
const defaultStatus = VALID_STATUSES[type][0];
|
|
36
|
+
// Build frontmatter data
|
|
37
|
+
const data = {
|
|
38
|
+
type,
|
|
39
|
+
title: slug,
|
|
40
|
+
status: defaultStatus,
|
|
41
|
+
created: today,
|
|
42
|
+
updated: today,
|
|
43
|
+
tags: [],
|
|
44
|
+
links: [],
|
|
45
|
+
};
|
|
46
|
+
if (options?.id) {
|
|
47
|
+
data.id = options.id;
|
|
48
|
+
}
|
|
49
|
+
const confidence = CONFIDENCE_TYPES[type];
|
|
50
|
+
if (confidence !== undefined) {
|
|
51
|
+
data.confidence = confidence;
|
|
52
|
+
}
|
|
53
|
+
// Build body
|
|
54
|
+
const body = BODY_TEMPLATES[locale]?.[type] ?? BODY_TEMPLATES['en'][type];
|
|
55
|
+
// Manually build YAML frontmatter to keep control over formatting
|
|
56
|
+
const lines = ['---'];
|
|
57
|
+
if (data.id)
|
|
58
|
+
lines.push(`id: ${data.id}`);
|
|
59
|
+
lines.push(`type: ${data.type}`);
|
|
60
|
+
lines.push(`title: "${data.title}"`);
|
|
61
|
+
lines.push(`status: ${data.status}`);
|
|
62
|
+
if (confidence !== undefined) {
|
|
63
|
+
lines.push(`confidence: ${data.confidence}`);
|
|
64
|
+
}
|
|
65
|
+
lines.push(`created: ${data.created}`);
|
|
66
|
+
lines.push(`updated: ${data.updated}`);
|
|
67
|
+
lines.push(`tags: []`);
|
|
68
|
+
lines.push(`links: []`);
|
|
69
|
+
lines.push('---');
|
|
70
|
+
lines.push('');
|
|
71
|
+
lines.push(body);
|
|
72
|
+
return lines.join('\n');
|
|
73
|
+
}
|
|
74
|
+
// ── nextId ─────────────────────────────────────────────────────────
|
|
75
|
+
export function nextId(graphDir, type) {
|
|
76
|
+
const prefix = ID_PREFIXES[type];
|
|
77
|
+
const typeDir = NODE_TYPE_DIRS[type];
|
|
78
|
+
const dirPath = path.join(graphDir, typeDir);
|
|
79
|
+
let maxNum = 0;
|
|
80
|
+
try {
|
|
81
|
+
const files = fs.readdirSync(dirPath);
|
|
82
|
+
const pattern = new RegExp(`^${prefix}-(\\d+)`);
|
|
83
|
+
for (const file of files) {
|
|
84
|
+
const match = file.match(pattern);
|
|
85
|
+
if (match) {
|
|
86
|
+
const num = parseInt(match[1], 10);
|
|
87
|
+
if (num > maxNum)
|
|
88
|
+
maxNum = num;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
// Directory doesn't exist or is empty — start at 0
|
|
94
|
+
}
|
|
95
|
+
const next = (maxNum + 1).toString().padStart(3, '0');
|
|
96
|
+
return `${prefix}-${next}`;
|
|
97
|
+
}
|
|
98
|
+
// ── nodePath ───────────────────────────────────────────────────────
|
|
99
|
+
export function nodePath(graphDir, type, id, slug) {
|
|
100
|
+
const typeDir = NODE_TYPE_DIRS[type];
|
|
101
|
+
return path.join(graphDir, typeDir, `${id}-${slug}.md`);
|
|
102
|
+
}
|