degraph 1.0.0__py3-none-any.whl

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.
deg/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """DEG: Decision-Evidence Graph — traceable, challengeable decision provenance for AI agents."""
2
+
3
+ from deg.graph import Graph
4
+ from deg.schemas import Decision, Evidence, State
5
+
6
+ __version__ = "1.0.0"
7
+ __all__ = ["Graph", "Decision", "Evidence", "State"]
deg/cli.py ADDED
@@ -0,0 +1,469 @@
1
+ """CLI interface for DEG: deg init, decide, evidence, status, challenge, compile."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ import click
9
+ import yaml
10
+
11
+
12
+ @click.group()
13
+ def main():
14
+ """DEG: Decision-Evidence Graph — traceable decision provenance for AI agents."""
15
+ pass
16
+
17
+
18
+ @main.command()
19
+ @click.option("--goal", "-g", default="", help="Project goal")
20
+ @click.option("--phase", "-p", default="", help="Current phase")
21
+ def init(goal: str, phase: str):
22
+ """Initialize DEG structure in current directory."""
23
+ from deg.graph import Graph
24
+ g = Graph(Path.cwd())
25
+ g.init(project_goal=goal, current_phase=phase)
26
+ click.echo(f"✅ DEG initialized in {Path.cwd()}")
27
+ click.echo(f" Created: decisions/ evidence/ .deg/")
28
+
29
+
30
+ @main.command()
31
+ @click.argument("title")
32
+ @click.option("--evidence", "-e", multiple=True, help="Evidence IDs that inform this decision")
33
+ @click.option("--revalidation", "-r", default=30, type=int, help="Days before revalidation")
34
+ @click.option("--scope", "-s", default="", help="Scope of this decision")
35
+ @click.option("--context", "-c", default="", help="Context for why this was needed")
36
+ def decide(title: str, evidence: tuple, revalidation: int, scope: str, context: str):
37
+ """Record a decision with provenance."""
38
+ from deg.graph import Graph
39
+ g = Graph()
40
+ deg_id = g.decide(
41
+ title=title,
42
+ evidence=list(evidence) if evidence else None,
43
+ revalidation_days=revalidation,
44
+ scope=scope,
45
+ context=context,
46
+ )
47
+ click.echo(f"✅ Decision recorded: {deg_id}")
48
+ click.echo(f" Title: {title}")
49
+ if evidence:
50
+ click.echo(f" Evidence: {', '.join(evidence)}")
51
+
52
+
53
+ @main.command()
54
+ @click.argument("claim")
55
+ @click.option("--source", "-s", default="", help="Source reference")
56
+ @click.option("--confidence", "-c", default=0.8, type=float, help="Confidence 0-1")
57
+ @click.option("--type", "source_type", default="literature", help="Source type: literature|experiment|observation|derived")
58
+ @click.option("--invalidates", "-i", default=None, help="Evidence ID this supersedes")
59
+ @click.option("--informs", multiple=True, help="Decision IDs this informs")
60
+ def evidence(claim: str, source: str, confidence: float, source_type: str, invalidates: str, informs: tuple):
61
+ """Record an evidence finding."""
62
+ from deg.graph import Graph
63
+ g = Graph()
64
+ deg_id = g.evidence(
65
+ claim=claim,
66
+ source=source,
67
+ confidence=confidence,
68
+ source_type=source_type,
69
+ invalidates=invalidates,
70
+ informs=list(informs) if informs else None,
71
+ )
72
+ click.echo(f"✅ Evidence recorded: {deg_id}")
73
+ click.echo(f" Claim: {claim}")
74
+ if invalidates:
75
+ click.echo(f" ⚠️ Invalidates: {invalidates}")
76
+
77
+
78
+ @main.command()
79
+ def status():
80
+ """Show project state, stale alerts, and active decisions."""
81
+ from deg.graph import Graph
82
+ g = Graph()
83
+ s = g.status()
84
+ click.echo(yaml.dump(s, default_flow_style=False, sort_keys=False))
85
+
86
+
87
+ @main.command()
88
+ @click.argument("decision_id")
89
+ def challenge(decision_id: str):
90
+ """Revalidation assessment for a decision."""
91
+ from deg.graph import Graph
92
+ g = Graph()
93
+ result = g.challenge(decision_id)
94
+ click.echo(yaml.dump(result, default_flow_style=False, sort_keys=False))
95
+
96
+
97
+ @main.command()
98
+ @click.argument("node_id")
99
+ def trace(node_id: str):
100
+ """Trace provenance chain for a node."""
101
+ from deg.graph import Graph
102
+ g = Graph()
103
+ chain = g.trace(node_id)
104
+ for item in chain:
105
+ indent = " " * item.get("depth", 0)
106
+ click.echo(f"{indent}{'→ ' if item['depth'] > 0 else ''}{item['id']} [{item.get('type', '')}] {item.get('title', '')}")
107
+
108
+
109
+ @main.command(name="impact")
110
+ @click.argument("node_id")
111
+ def impact_cmd(node_id: str):
112
+ """Show cascade impact if a node changes."""
113
+ from deg.graph import Graph
114
+ g = Graph()
115
+ affected = g.impact(node_id)
116
+ if affected:
117
+ click.echo(f"⚠️ {len(affected)} downstream nodes affected:")
118
+ for nid in affected:
119
+ click.echo(f" → {nid}")
120
+ else:
121
+ click.echo("✅ No downstream dependencies.")
122
+
123
+
124
+ @main.command(name="search")
125
+ @click.argument("query")
126
+ @click.option("-k", default=5, type=int, help="Number of results")
127
+ def search_cmd(query: str, k: int):
128
+ """Search across decisions and evidence."""
129
+ from deg.graph import Graph
130
+ g = Graph()
131
+ results = g.search(query, k=k)
132
+ if results:
133
+ for r in results:
134
+ click.echo(f" {r['id']} [{r.get('type', '')}] {r.get('title', '')}")
135
+ else:
136
+ click.echo("No results found.")
137
+
138
+
139
+ @main.command()
140
+ def compile():
141
+ """Rebuild .deg/ compiled graph from source markdown."""
142
+ from deg.graph import Graph
143
+ g = Graph()
144
+ result = g.compile()
145
+ click.echo(f"✅ Compiled: {result['nodes']} nodes, {result['edges']} edges → {result['path']}")
146
+ if result["errors"]:
147
+ for e in result["errors"]:
148
+ click.echo(f" ⚠️ {e}")
149
+
150
+
151
+ @main.command()
152
+ @click.argument("summary")
153
+ @click.option("--next", "next_actions", multiple=True, help="Next actions for the following session")
154
+ @click.option("--agent", default="", help="Agent identifier")
155
+ def close(summary: str, next_actions: tuple, agent: str):
156
+ """Close session and update STATE.yaml for handoff."""
157
+ from deg.graph import Graph
158
+ g = Graph()
159
+ state = g.close_session(summary, list(next_actions) if next_actions else None, agent)
160
+ click.echo("✅ Session closed. STATE.yaml updated.")
161
+ click.echo(f" Summary: {summary}")
162
+
163
+
164
+ @main.command()
165
+ @click.argument("option")
166
+ @click.option("--decision", "-d", required=True, help="Decision ID to add rejection to")
167
+ @click.option("--because", "-b", required=True, help="Why this was rejected")
168
+ @click.option("--reconsider-if", "-r", default=None, help="Condition to reconsider")
169
+ def reject(option: str, decision: str, because: str, reconsider_if: str | None):
170
+ """Record a rejected alternative for a decision."""
171
+ from deg.graph import Graph
172
+ g = Graph()
173
+ g.reject(option, decision, because, reconsider_if)
174
+ click.echo(f"✅ Rejected '{option}' for {decision}")
175
+ if reconsider_if:
176
+ click.echo(f" Reconsider if: {reconsider_if}")
177
+
178
+
179
+ @main.command()
180
+ @click.argument("path", type=click.Path(exists=True))
181
+ @click.option("--root", default=None, help="DEG project root")
182
+ def ingest(path: str, root: str | None):
183
+ """Ingest existing markdown file(s) into DEG (heuristic, no LLM)."""
184
+ from pathlib import Path as P
185
+ from deg.ingest import ingest_file, ingest_directory
186
+ from deg.graph import Graph
187
+
188
+ target = P(path)
189
+ project_root = P(root) if root else None
190
+
191
+ if target.is_file():
192
+ result = ingest_file(target, project_root)
193
+ click.echo(f"✅ Ingested {result['file']} → D:{result.get('decision_id', '?')} E:{result.get('evidence_id', '?')}")
194
+ elif target.is_dir():
195
+ results = ingest_directory(target, project_root)
196
+ success = [r for r in results if "error" not in r]
197
+ errors = [r for r in results if "error" in r]
198
+ click.echo(f"✅ Ingested {len(success)} files from {target}")
199
+ for e in errors:
200
+ click.echo(f" ⚠️ {e['file']}: {e['error']}")
201
+ else:
202
+ click.echo(f"❌ {path} is not a file or directory")
203
+
204
+
205
+ @main.command()
206
+ @click.option("--global", "global_install", is_flag=True, help="Install globally for all projects")
207
+ def setup(global_install: bool):
208
+ """Set up DEG as a Claude Code skill in this project.
209
+
210
+ Configures: CLAUDE.md instructions, MCP server, hooks for auto-capture.
211
+ After setup, Claude Code will auto-record decisions during conversations.
212
+ """
213
+ from deg.setup import setup_project
214
+ result = setup_project(global_install=global_install)
215
+ click.echo(f"✅ DEG skill configured in {result['root']}")
216
+ for action in result['actions']:
217
+ click.echo(f" • {action}")
218
+ click.echo()
219
+ click.echo("Next steps:")
220
+ click.echo(" 1. Restart Claude Code in this project")
221
+ click.echo(" 2. Claude will auto-load DEG status at session start")
222
+ click.echo(" 3. Claude will auto-record decisions via MCP tools")
223
+
224
+
225
+ @main.command()
226
+ def digest():
227
+ """Print staleness digest: stale decisions with actionable research suggestions."""
228
+ from deg.graph import Graph
229
+ g = Graph()
230
+ s = g.status()
231
+ stale_alerts = s.get("stale_alerts", [])
232
+
233
+ if not stale_alerts:
234
+ click.echo("All decisions current. No action needed.")
235
+ return
236
+
237
+ click.echo("## DEG Staleness Digest\n")
238
+
239
+ for alert in stale_alerts:
240
+ node_id = alert.get("id", alert.get("node_id", ""))
241
+ if not node_id:
242
+ continue
243
+
244
+ # Get full challenge info
245
+ challenge_info = g.challenge(node_id)
246
+ if "error" in challenge_info:
247
+ continue
248
+
249
+ title = challenge_info.get("title", "")
250
+ health = challenge_info.get("health", {})
251
+ evidence_validity = challenge_info.get("evidence_validity", {})
252
+ alternatives = challenge_info.get("alternatives", {})
253
+
254
+ # Determine staleness description from triggers
255
+ triggers = alert.get("triggers", [])
256
+ days_overdue = "?"
257
+ for trig in triggers:
258
+ reason = trig.get("reason", "")
259
+ # Extract days from reason like "40 days since last verification (threshold: 1)"
260
+ if "days since" in reason:
261
+ try:
262
+ days_overdue = reason.split(" days")[0].strip()
263
+ except (IndexError, ValueError):
264
+ pass
265
+
266
+ health_label = health.get("health", "unknown")
267
+ wlnk = health.get("confidence", "?")
268
+ weakest_link = health.get("weakest_link")
269
+
270
+ click.echo(f"### ⚠️ {node_id}: {title}")
271
+ click.echo(f"- **Stale since:** {days_overdue} days past revalidation")
272
+ click.echo(f"- **Health:** {health_label} (WLNK: {wlnk})")
273
+
274
+ # Find weakest evidence
275
+ if weakest_link and weakest_link in evidence_validity:
276
+ ev_info = evidence_validity[weakest_link]
277
+ click.echo(f"- **Weakest evidence:** {weakest_link} (confidence: {ev_info.get('confidence', '?')})")
278
+ elif weakest_link:
279
+ click.echo(f"- **Weakest evidence:** {weakest_link}")
280
+
281
+ # Alternatives
282
+ if alternatives:
283
+ click.echo("- **Alternatives to reconsider:**")
284
+ for opt, info in alternatives.items():
285
+ reconsider = info.get("reconsider_if", "")
286
+ if reconsider:
287
+ click.echo(f" - {opt} — reconsider if: {reconsider}")
288
+ else:
289
+ click.echo(f" - {opt}")
290
+
291
+ # Research suggestions
292
+ cr = g.continue_research(node_id)
293
+ if cr and "message" not in cr:
294
+ searches = cr.get("next_searches", cr.get("search_terms", []))
295
+ if searches:
296
+ click.echo(f"- **Suggested research:** {', '.join(searches)}")
297
+ click.echo()
298
+
299
+ # Show valid decisions
300
+ all_decisions = [nid for nid, n in g._nodes.items() if n.get("type") == "decision"]
301
+ stale_ids = {a.get("id", a.get("node_id", "")) for a in stale_alerts}
302
+ valid_decisions = [d for d in all_decisions if d not in stale_ids and g._nodes[d].get("status") == "active"]
303
+
304
+ if valid_decisions:
305
+ click.echo("### ✅ No action needed")
306
+ for d_id in valid_decisions:
307
+ node = g._nodes[d_id]
308
+ click.echo(f"- {d_id}: still valid")
309
+
310
+
311
+ @main.command()
312
+ @click.option("--last", "last_n", default=10, type=int, help="Number of recent entries to show")
313
+ def timeline(last_n: int):
314
+ """Print chronological decision history with supporting evidence."""
315
+ from deg.graph import Graph
316
+ g = Graph()
317
+
318
+ if not g._nodes:
319
+ click.echo("No nodes found.")
320
+ return
321
+
322
+ # Collect all decisions, sorted by created date descending
323
+ decisions = []
324
+ for nid, node in g._nodes.items():
325
+ if node.get("type") != "decision":
326
+ continue
327
+ created = node.get("created", node.get("created_at", ""))
328
+ decisions.append((str(created), nid, node))
329
+
330
+ decisions.sort(key=lambda x: x[0], reverse=True)
331
+ decisions = decisions[:last_n]
332
+
333
+ if not decisions:
334
+ click.echo("No decisions found.")
335
+ return
336
+
337
+ for created_str, nid, node in decisions:
338
+ status = node.get("status", "active")
339
+ title = node.get("title", "")
340
+ click.echo(f"{created_str} {nid} [{status}] {title}")
341
+
342
+ # Show supporting evidence
343
+ for ev_id in node.get("informed_by", []):
344
+ ev = g._nodes.get(ev_id)
345
+ if ev:
346
+ conf = ev.get("confidence", "?")
347
+ claim = ev.get("claim", "")
348
+ click.echo(f" ← {ev_id} ({conf}) {claim}")
349
+
350
+
351
+ @main.command()
352
+ @click.option("--root", default=None, help="Project root (default: CWD)")
353
+ def serve(root: str | None):
354
+ """Start MCP server for AI agent integration."""
355
+ import asyncio
356
+ try:
357
+ from deg.mcp_server import run_server
358
+ click.echo("🚀 DEG MCP server starting (stdio mode)...")
359
+ click.echo(" Connect via Claude Code, Cursor, or any MCP client.")
360
+ asyncio.run(run_server(root))
361
+ except ImportError:
362
+ click.echo("❌ MCP not installed. Run: pip install deg[mcp]")
363
+ raise SystemExit(1)
364
+
365
+
366
+ @main.command()
367
+ @click.option("--git", is_flag=True, help="Check git-diff staleness (files referenced by decisions changed)")
368
+ @click.option("--staged", is_flag=True, help="Check staged files only (pre-commit hook compatible)")
369
+ def check(git, staged):
370
+ """Run constraint checks on the decision graph. Exit code 1 if errors found."""
371
+ import sys
372
+ from deg.graph import Graph
373
+ from deg.constraints import run_all_checks
374
+ from deg.staleness import check_git_staleness, check_staged_staleness
375
+
376
+ g = Graph()
377
+
378
+ if not g._nodes:
379
+ click.echo("No nodes found. Graph is empty — all checks pass.")
380
+ return
381
+
382
+ violations = run_all_checks(g._nodes, g._edges)
383
+
384
+ staleness_findings = []
385
+ if git:
386
+ staleness_findings.extend(check_git_staleness(g.root, g._nodes))
387
+ if staged:
388
+ staleness_findings.extend(check_staged_staleness(g.root, g._nodes))
389
+
390
+ # Group violations by severity
391
+ errors = [v for v in violations if v.severity == "error"]
392
+ warnings = [v for v in violations if v.severity == "warning"]
393
+ infos = [v for v in violations if v.severity == "info"]
394
+
395
+ has_errors = len(errors) > 0
396
+
397
+ if not violations and not staleness_findings:
398
+ click.echo("All checks pass.")
399
+ return
400
+
401
+ # Print errors first
402
+ if errors:
403
+ click.echo(click.style(f"\n{'ERROR'} ({len(errors)}):", fg="red", bold=True))
404
+ for v in errors:
405
+ click.echo(click.style(f" ✗ {v.message}", fg="red"))
406
+
407
+ if warnings:
408
+ click.echo(click.style(f"\n{'WARNING'} ({len(warnings)}):", fg="yellow", bold=True))
409
+ for v in warnings:
410
+ click.echo(click.style(f" ⚠ {v.message}", fg="yellow"))
411
+
412
+ if infos:
413
+ click.echo(click.style(f"\n{'INFO'} ({len(infos)}):", fg="blue"))
414
+ for v in infos:
415
+ click.echo(f" ℹ {v.message}")
416
+
417
+ if staleness_findings:
418
+ click.echo(click.style(f"\n{'STALENESS'} ({len(staleness_findings)}):", fg="magenta", bold=True))
419
+ for sf in staleness_findings:
420
+ click.echo(f" → {sf.get('decision_id', '?')}: {sf.get('reason', '?')} [{', '.join(sf.get('changed_files', []))}]")
421
+
422
+ # Summary
423
+ total = len(violations) + len(staleness_findings)
424
+ click.echo(f"\n{total} finding(s): {len(errors)} error, {len(warnings)} warning, {len(infos)} info, {len(staleness_findings)} staleness")
425
+
426
+ if has_errors:
427
+ sys.exit(1)
428
+
429
+
430
+ @main.command()
431
+ def maintenance():
432
+ """Run self-maintenance checks: orphans, duplicates, cycles, stale items."""
433
+ from deg.graph import Graph
434
+ from deg.maintenance import run_maintenance
435
+
436
+ g = Graph()
437
+
438
+ if not g._nodes:
439
+ click.echo("No nodes found. Graph is empty — nothing to maintain.")
440
+ return
441
+
442
+ findings = run_maintenance(g._nodes, g._edges)
443
+
444
+ if not findings:
445
+ click.echo("Graph is healthy. No maintenance issues found.")
446
+ return
447
+
448
+ # Group by category
449
+ categories = {}
450
+ for f in findings:
451
+ cat = f.category if hasattr(f, "category") else "other"
452
+ categories.setdefault(cat, []).append(f)
453
+
454
+ total = len(findings)
455
+ click.echo(f"Found {total} maintenance issue(s):\n")
456
+
457
+ for cat, items in categories.items():
458
+ click.echo(click.style(f" {cat.upper()} ({len(items)}):", bold=True))
459
+ for item in items:
460
+ msg = item.message if hasattr(item, "message") else str(item)
461
+ suggestion = item.suggestion if hasattr(item, "suggestion") else ""
462
+ click.echo(f" • {msg}")
463
+ if suggestion:
464
+ click.echo(click.style(f" → {suggestion}", fg="cyan"))
465
+ click.echo()
466
+
467
+
468
+ if __name__ == "__main__":
469
+ main()
deg/compiler.py ADDED
@@ -0,0 +1,143 @@
1
+ """Compile markdown decision/evidence files into the .deg/ sidecar graph."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections import defaultdict
7
+ from pathlib import Path
8
+
9
+ import yaml
10
+
11
+ from deg.schemas import Decision, Evidence
12
+
13
+
14
+ def parse_frontmatter(path: Path) -> dict | None:
15
+ """Extract YAML frontmatter from a markdown file."""
16
+ text = path.read_text(encoding="utf-8")
17
+ if not text.startswith("---"):
18
+ return None
19
+ parts = text.split("---", 2)
20
+ if len(parts) < 3:
21
+ return None
22
+ try:
23
+ meta = yaml.safe_load(parts[1])
24
+ except yaml.YAMLError:
25
+ return None
26
+ if not isinstance(meta, dict):
27
+ return None
28
+ meta["_body"] = parts[2].strip()
29
+ meta["_file"] = str(path)
30
+ return meta
31
+
32
+
33
+ def compile_graph(project_root: Path) -> dict:
34
+ """Parse all markdown files, build graph structures, write .deg/ directory."""
35
+ decisions_dir = project_root / "decisions"
36
+ evidence_dir = project_root / "evidence"
37
+ deg_dir = project_root / ".deg"
38
+ deg_dir.mkdir(exist_ok=True)
39
+
40
+ nodes: dict[str, dict] = {}
41
+ edges: list[dict] = []
42
+ successors: dict[str, list[str]] = defaultdict(list)
43
+ errors: list[str] = []
44
+
45
+ # Parse decisions
46
+ if decisions_dir.exists():
47
+ for md_file in sorted(decisions_dir.glob("*.md")):
48
+ meta = parse_frontmatter(md_file)
49
+ if meta is None:
50
+ errors.append(f"Cannot parse: {md_file}")
51
+ continue
52
+ node_id = meta.get("deg_id", md_file.stem)
53
+ nodes[node_id] = meta
54
+
55
+ # Parse evidence
56
+ if evidence_dir.exists():
57
+ for md_file in sorted(evidence_dir.glob("*.md")):
58
+ meta = parse_frontmatter(md_file)
59
+ if meta is None:
60
+ errors.append(f"Cannot parse: {md_file}")
61
+ continue
62
+ node_id = meta.get("deg_id", md_file.stem)
63
+ nodes[node_id] = meta
64
+
65
+ # Build edges from relationships
66
+ for node_id, meta in nodes.items():
67
+ # informed_by edges (decision → evidence)
68
+ for target_id in meta.get("informed_by", []):
69
+ edges.append({"from": node_id, "to": target_id, "type": "informed_by"})
70
+ successors[target_id].append(node_id)
71
+
72
+ # depends_on edges (decision → decision)
73
+ for target_id in meta.get("depends_on", []):
74
+ edges.append({"from": node_id, "to": target_id, "type": "depends_on"})
75
+ successors[target_id].append(node_id)
76
+
77
+ # informs edges (evidence → decision)
78
+ for target_id in meta.get("informs", []):
79
+ edges.append({"from": node_id, "to": target_id, "type": "informs"})
80
+ successors[node_id].append(target_id)
81
+
82
+ # Auto-populate depended_on_by
83
+ for node_id, meta in nodes.items():
84
+ meta["depended_on_by"] = successors.get(node_id, [])
85
+
86
+ # Validate: check for dangling references
87
+ all_ids = set(nodes.keys())
88
+ for edge in edges:
89
+ if edge["to"] not in all_ids and "/" not in edge["to"]:
90
+ errors.append(f"Dangling reference: {edge['from']} → {edge['to']}")
91
+
92
+ # Write compiled artifacts
93
+ nodes_file = deg_dir / "nodes.jsonl"
94
+ with open(nodes_file, "w") as f:
95
+ for nid, n in nodes.items():
96
+ record = {k: v for k, v in n.items() if not k.startswith("_")}
97
+ record["id"] = nid
98
+ f.write(json.dumps(record, default=str) + "\n")
99
+
100
+ edges_file = deg_dir / "edges.jsonl"
101
+ with open(edges_file, "w") as f:
102
+ for e in edges:
103
+ f.write(json.dumps(e) + "\n")
104
+
105
+ graph_file = deg_dir / "graph.json"
106
+ with open(graph_file, "w") as f:
107
+ json.dump(dict(successors), f, indent=2)
108
+
109
+ return {
110
+ "nodes": len(nodes),
111
+ "edges": len(edges),
112
+ "errors": errors,
113
+ "path": str(deg_dir),
114
+ }
115
+
116
+
117
+ def load_compiled(project_root: Path) -> tuple[dict, list[dict], dict[str, list[str]]]:
118
+ """Load the compiled graph from .deg/ directory."""
119
+ deg_dir = project_root / ".deg"
120
+
121
+ nodes = {}
122
+ nodes_file = deg_dir / "nodes.jsonl"
123
+ if nodes_file.exists():
124
+ for line in nodes_file.read_text().splitlines():
125
+ if line.strip():
126
+ record = json.loads(line)
127
+ nodes[record["id"]] = record
128
+
129
+ edges = []
130
+ edges_file = deg_dir / "edges.jsonl"
131
+ if edges_file.exists():
132
+ for line in edges_file.read_text().splitlines():
133
+ if line.strip():
134
+ edges.append(json.loads(line))
135
+
136
+ successors = defaultdict(list)
137
+ graph_file = deg_dir / "graph.json"
138
+ if graph_file.exists():
139
+ raw = json.loads(graph_file.read_text())
140
+ for k, v in raw.items():
141
+ successors[k] = v
142
+
143
+ return nodes, edges, dict(successors)