@0dai-dev/cli 4.3.6 → 4.3.8

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.
Files changed (77) hide show
  1. package/README.md +12 -11
  2. package/bin/0dai.js +133 -33
  3. package/lib/ai/manifest/mcp-exposure-contract.json +121 -0
  4. package/lib/ai/meta/manifest/mcp-tool-tiers.json +435 -0
  5. package/lib/ai/registry/mcp-catalog.json +98 -0
  6. package/lib/commands/auth.js +2 -1
  7. package/lib/commands/compliance.js +1 -1
  8. package/lib/commands/doctor.js +707 -12
  9. package/lib/commands/experience.js +40 -5
  10. package/lib/commands/feedback.js +157 -15
  11. package/lib/commands/gh.js +26 -0
  12. package/lib/commands/graph.js +9 -4
  13. package/lib/commands/heatmap.js +1 -1
  14. package/lib/commands/init.js +298 -27
  15. package/lib/commands/mcp.js +111 -33
  16. package/lib/commands/models.js +138 -41
  17. package/lib/commands/play.js +20 -4
  18. package/lib/commands/provider.js +30 -59
  19. package/lib/commands/quota.js +1 -1
  20. package/lib/commands/receipt.js +1 -1
  21. package/lib/commands/run.js +14 -6
  22. package/lib/commands/runner.js +31 -1
  23. package/lib/commands/status.js +176 -11
  24. package/lib/commands/swarm.js +130 -12
  25. package/lib/commands/trust.js +1 -1
  26. package/lib/commands/update.js +184 -38
  27. package/lib/commands/usage.js +1 -1
  28. package/lib/commands/validate.js +32 -3
  29. package/lib/commands/vault.js +43 -8
  30. package/lib/python/__init__.py +0 -0
  31. package/lib/python/agent_quotas.py +525 -0
  32. package/lib/python/anomaly_alert.py +397 -0
  33. package/lib/python/anti_pattern_detector.py +799 -0
  34. package/lib/python/auth.py +443 -0
  35. package/lib/python/capi_profile_guard.py +477 -0
  36. package/lib/python/compliance_report.py +581 -0
  37. package/lib/python/drift_detector.py +388 -0
  38. package/lib/python/experience_pipeline.py +1130 -0
  39. package/lib/python/graph.py +19 -0
  40. package/lib/python/graph_core.py +293 -0
  41. package/lib/python/graph_io.py +179 -0
  42. package/lib/python/graph_legacy.py +2052 -0
  43. package/lib/python/graph_legacy_helpers.py +221 -0
  44. package/lib/python/graph_outcomes_core.py +85 -0
  45. package/lib/python/graph_queries.py +171 -0
  46. package/lib/python/graph_slice.py +198 -0
  47. package/lib/python/graph_slicer.py +576 -0
  48. package/lib/python/graph_slicer_cli.py +60 -0
  49. package/lib/python/graph_validation.py +64 -0
  50. package/lib/python/heatmap.py +943 -0
  51. package/lib/python/json_utils.py +193 -0
  52. package/lib/python/mcp_exposure_check.py +247 -0
  53. package/lib/python/model_router.py +1434 -0
  54. package/lib/python/project_manager.py +621 -0
  55. package/lib/python/provider_profiles.py +1618 -0
  56. package/lib/python/provider_registry.py +1211 -0
  57. package/lib/python/provider_registry_cli.py +125 -0
  58. package/lib/python/receipt_png.py +727 -0
  59. package/lib/python/structural_memory.py +325 -0
  60. package/lib/python/swarm_cost.py +177 -0
  61. package/lib/python/usage_ledger.py +569 -0
  62. package/lib/scripts/mcp_tier_config.py +240 -0
  63. package/lib/shared.js +96 -12
  64. package/lib/tui/index.mjs +35174 -0
  65. package/lib/utils/activation_telemetry.js +1 -4
  66. package/lib/utils/constants.js +7 -1
  67. package/lib/utils/identity.js +184 -0
  68. package/lib/utils/mcp-auth.js +81 -15
  69. package/lib/utils/plan.js +1 -1
  70. package/lib/vault/index.js +19 -3
  71. package/lib/vault/storage.js +21 -2
  72. package/lib/wizard.js +5 -2
  73. package/package.json +9 -3
  74. package/scripts/build-python-bundle.js +106 -0
  75. package/scripts/build-tui.js +14 -1
  76. package/scripts/harvest_experience.py +523 -0
  77. package/scripts/postinstall.js +15 -9
@@ -0,0 +1,576 @@
1
+ #!/usr/bin/env python3
2
+ """Graph-aware context slicing — agents get relevant graph subsets, not everything.
3
+
4
+ Given a task type and file scope, performs BFS traversal from seed nodes,
5
+ applies task-type relevance scoring, enforces a token budget, and produces
6
+ a structured ContextSlice with nodes, edges, insights, and warnings.
7
+
8
+ Tier gate: Pro/Team get full traversal + insights. Free gets nodes only.
9
+
10
+ Issue: #73
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import datetime as _dt
15
+ import hashlib
16
+ import json
17
+ import logging
18
+ import pathlib
19
+ from collections import deque
20
+
21
+ log = logging.getLogger("0dai.graph_slicer")
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # Token estimation
25
+ # ---------------------------------------------------------------------------
26
+
27
+ CHARS_PER_TOKEN = 3 # conservative: 1 token ~ 3 chars for mixed code/English
28
+
29
+
30
+ def estimate_tokens(text: str) -> int:
31
+ """Rough token estimate. 1 token ~ 3 chars (conservative for code)."""
32
+ return max(1, len(text) // CHARS_PER_TOKEN)
33
+
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Task-type relevance weights
37
+ # ---------------------------------------------------------------------------
38
+
39
+ # Higher weight = more relevant for this task type.
40
+ # Node types map to graph NODE_TYPES but we use lowercase keys for matching.
41
+ RELEVANCE_WEIGHTS: dict[str, dict[str, float]] = {
42
+ "feat": {"Decision": 1.0, "Outcome": 0.8, "Component": 0.6, "Technology": 0.5, "Requirement": 0.7, "Risk": 0.4},
43
+ "fix": {"Outcome": 1.0, "Decision": 0.7, "Component": 0.9, "Risk": 0.8, "Technology": 0.5, "Endpoint": 0.6},
44
+ "refactor": {"Decision": 1.0, "Component": 0.8, "Outcome": 0.7, "Technology": 0.6, "Risk": 0.5},
45
+ "test": {"Component": 1.0, "TestPlan": 0.9, "Outcome": 0.5, "Decision": 0.3, "Endpoint": 0.7},
46
+ "docs": {"Component": 0.8, "Decision": 0.6, "Outcome": 0.3, "Endpoint": 0.5, "Technology": 0.4},
47
+ }
48
+
49
+ # Default weight for node types not explicitly listed
50
+ DEFAULT_RELEVANCE = 0.2
51
+
52
+
53
+ def _relevance_score(node_type: str, task_type: str) -> float:
54
+ """Return relevance score for a node type given a task type."""
55
+ weights = RELEVANCE_WEIGHTS.get(task_type, RELEVANCE_WEIGHTS["feat"])
56
+ return weights.get(node_type, DEFAULT_RELEVANCE)
57
+
58
+
59
+ # ---------------------------------------------------------------------------
60
+ # Seed node resolution
61
+ # ---------------------------------------------------------------------------
62
+
63
+ def _find_seed_nodes(graph: dict, scope: list[str]) -> list[str]:
64
+ """Find graph nodes matching the given file paths / module names.
65
+
66
+ Matching strategy:
67
+ 1. Exact node name match
68
+ 2. Node name ends with scope path component
69
+ 3. Node description contains the scope path
70
+ """
71
+ nodes = graph.get("nodes", {})
72
+ seeds: list[str] = []
73
+ seen: set[str] = set()
74
+
75
+ for scope_path in scope:
76
+ # Normalize: strip leading ./ or /
77
+ normalized = scope_path.lstrip("./").rstrip("/")
78
+ basename = pathlib.PurePosixPath(normalized).name
79
+
80
+ for nid, node in nodes.items():
81
+ if nid in seen:
82
+ continue
83
+ name = node.get("name", "")
84
+ desc = node.get("description", "")
85
+
86
+ # Exact name match
87
+ if name == normalized or name == basename:
88
+ seeds.append(nid)
89
+ seen.add(nid)
90
+ continue
91
+
92
+ # Name ends with scope component
93
+ if normalized and name.endswith(normalized):
94
+ seeds.append(nid)
95
+ seen.add(nid)
96
+ continue
97
+
98
+ # Description contains scope path
99
+ if normalized and normalized in desc:
100
+ seeds.append(nid)
101
+ seen.add(nid)
102
+ continue
103
+
104
+ # Slug-based match (node id contains filename slug)
105
+ slug = basename.replace(".", "_").replace("-", "_").lower()
106
+ if slug and len(slug) > 2 and slug in nid.lower():
107
+ seeds.append(nid)
108
+ seen.add(nid)
109
+
110
+ return seeds
111
+
112
+
113
+ # ---------------------------------------------------------------------------
114
+ # BFS traversal with relevance scoring
115
+ # ---------------------------------------------------------------------------
116
+
117
+ def _build_adjacency(edges: list[dict]) -> dict[str, list[tuple[str, dict]]]:
118
+ """Build bidirectional adjacency list from edges."""
119
+ adj: dict[str, list[tuple[str, dict]]] = {}
120
+ for edge in edges:
121
+ src = edge.get("from", "")
122
+ tgt = edge.get("to", "")
123
+ if src:
124
+ adj.setdefault(src, []).append((tgt, edge))
125
+ if tgt:
126
+ adj.setdefault(tgt, []).append((src, edge))
127
+ return adj
128
+
129
+
130
+ def _bfs_traverse(
131
+ graph: dict,
132
+ seed_ids: list[str],
133
+ max_depth: int,
134
+ task_type: str,
135
+ token_budget: int,
136
+ ) -> tuple[list[dict], list[dict]]:
137
+ """BFS from seeds, prioritized by relevance, bounded by depth and tokens.
138
+
139
+ Returns (selected_nodes_with_hop, selected_edges).
140
+ Each node dict includes a 'hop' field indicating distance from seed.
141
+ """
142
+ nodes = graph.get("nodes", {})
143
+ edges = graph.get("edges", [])
144
+ adj = _build_adjacency(edges)
145
+
146
+ # BFS state
147
+ visited: dict[str, int] = {} # node_id -> hop
148
+ queue: deque[tuple[str, int]] = deque()
149
+
150
+ # Initialize with seeds
151
+ for sid in seed_ids:
152
+ if sid in nodes and sid not in visited:
153
+ visited[sid] = 0
154
+ queue.append((sid, 0))
155
+
156
+ # BFS expansion
157
+ while queue:
158
+ current_id, current_hop = queue.popleft()
159
+ if current_hop >= max_depth:
160
+ continue
161
+
162
+ for neighbor_id, edge in adj.get(current_id, []):
163
+ if neighbor_id in visited:
164
+ continue
165
+ if neighbor_id not in nodes:
166
+ continue
167
+ visited[neighbor_id] = current_hop + 1
168
+ queue.append((neighbor_id, current_hop + 1))
169
+
170
+ # Score and sort nodes by relevance (seeds first, then by score descending)
171
+ scored_nodes: list[tuple[str, int, float]] = []
172
+ for nid, hop in visited.items():
173
+ node = nodes[nid]
174
+ node_type = node.get("type", "")
175
+ relevance = _relevance_score(node_type, task_type)
176
+ # Seeds get max score, others decay by hop distance
177
+ score = 10.0 if hop == 0 else relevance / (hop * 0.5 + 0.5)
178
+ scored_nodes.append((nid, hop, score))
179
+
180
+ scored_nodes.sort(key=lambda x: (-x[2], x[1]))
181
+
182
+ # Token budget enforcement
183
+ used_tokens = 0
184
+ selected_ids: set[str] = set()
185
+ result_nodes: list[dict] = []
186
+
187
+ for nid, hop, score in scored_nodes:
188
+ node = nodes[nid]
189
+ # Estimate tokens for this node
190
+ node_text = f"{node.get('type', '')} {node.get('name', '')} {node.get('description', '')[:100]}"
191
+ node_tokens = estimate_tokens(node_text)
192
+
193
+ if used_tokens + node_tokens > token_budget and hop > 0:
194
+ # Don't skip seed nodes even if over budget
195
+ break
196
+
197
+ selected_ids.add(nid)
198
+ result_nodes.append({
199
+ "id": nid,
200
+ "type": node.get("type", ""),
201
+ "name": node.get("name", ""),
202
+ "hop": hop,
203
+ "data": {
204
+ k: v for k, v in node.items()
205
+ if k not in ("id", "type", "name", "created_at", "updated_at")
206
+ and v # skip empty values
207
+ },
208
+ })
209
+ used_tokens += node_tokens
210
+
211
+ # Collect edges between selected nodes
212
+ result_edges: list[dict] = []
213
+ for edge in edges:
214
+ if edge.get("from") in selected_ids and edge.get("to") in selected_ids:
215
+ result_edges.append({
216
+ "source": edge["from"],
217
+ "target": edge["to"],
218
+ "type": edge.get("type", ""),
219
+ })
220
+
221
+ return result_nodes, result_edges
222
+
223
+
224
+ # ---------------------------------------------------------------------------
225
+ # Insight generation
226
+ # ---------------------------------------------------------------------------
227
+
228
+ def _generate_insights(
229
+ graph: dict,
230
+ selected_nodes: list[dict],
231
+ selected_edges: list[dict],
232
+ task_type: str,
233
+ ) -> list[str]:
234
+ """Generate human-readable insights from the selected subgraph."""
235
+ insights: list[str] = []
236
+ nodes_by_id = {n["id"]: n for n in selected_nodes}
237
+ all_nodes = graph.get("nodes", {})
238
+
239
+ for edge in selected_edges:
240
+ src_id = edge["source"]
241
+ tgt_id = edge["target"]
242
+ etype = edge["type"]
243
+ src = nodes_by_id.get(src_id, {})
244
+ tgt = nodes_by_id.get(tgt_id, {})
245
+ src_name = src.get("name", src_id)
246
+ tgt_name = tgt.get("name", tgt_id)
247
+
248
+ if etype == "depends_on":
249
+ insights.append(f"{src_name} depends on {tgt_name} — changes may cascade")
250
+ elif etype == "uses":
251
+ insights.append(f"{src_name} uses {tgt_name}")
252
+ elif etype == "affects":
253
+ insights.append(f"Decision '{src_name}' affects {tgt_name}")
254
+ elif etype == "evaluates":
255
+ # Outcome evaluates Decision
256
+ status = src.get("data", {}).get("status", "")
257
+ lessons = src.get("data", {}).get("lessons", "")
258
+ if status:
259
+ insights.append(f"Outcome for '{tgt_name}': {status}" + (f" — {lessons[:80]}" if lessons else ""))
260
+ elif etype == "chose":
261
+ insights.append(f"Previous decision: chose {tgt_name}")
262
+ elif etype == "mitigates":
263
+ insights.append(f"Decision '{src_name}' mitigates risk: {tgt_name}")
264
+ elif etype == "blocks":
265
+ insights.append(f"Risk '{src_name}' blocks {tgt_name}")
266
+
267
+ # Add outcome insights for Decision nodes
268
+ for node in selected_nodes:
269
+ if node["type"] == "Outcome":
270
+ data = node.get("data", {})
271
+ status = data.get("status", "unknown")
272
+ agent = data.get("agent", "")
273
+ cost = data.get("cost", "")
274
+ duration = data.get("duration", "")
275
+ parts = [f"Outcome: {node['name']} — {status}"]
276
+ if agent:
277
+ parts.append(f"agent: {agent}")
278
+ if cost:
279
+ parts.append(f"${cost}")
280
+ if duration:
281
+ parts.append(f"{duration}s")
282
+ insights.append(" ".join(parts))
283
+
284
+ return insights[:10] # Cap at 10 insights
285
+
286
+
287
+ # ---------------------------------------------------------------------------
288
+ # Warning generation
289
+ # ---------------------------------------------------------------------------
290
+
291
+ def _generate_warnings(
292
+ graph: dict,
293
+ selected_nodes: list[dict],
294
+ task_type: str,
295
+ ) -> list[str]:
296
+ """Generate warnings based on graph patterns."""
297
+ warnings: list[str] = []
298
+ all_nodes = graph.get("nodes", {})
299
+
300
+ for node in selected_nodes:
301
+ nid = node["id"]
302
+ full_node = all_nodes.get(nid, {})
303
+
304
+ # Check for frequent modifications (if updated_at is recent)
305
+ updated_at = full_node.get("updated_at", "")
306
+ if updated_at:
307
+ try:
308
+ updated = _dt.datetime.fromisoformat(updated_at.replace("Z", "+00:00"))
309
+ now = _dt.datetime.now(_dt.timezone.utc)
310
+ if (now - updated).total_seconds() < 86400: # 24h
311
+ warnings.append(f"{node['name']} was modified in the last 24h")
312
+ except (ValueError, TypeError):
313
+ pass
314
+
315
+ # Check for blocking risks
316
+ if node["type"] == "Risk":
317
+ status = full_node.get("status", "")
318
+ if status in ("open", "active", ""):
319
+ severity = full_node.get("severity", "medium")
320
+ warnings.append(f"Open risk: {node['name']} (severity: {severity})")
321
+
322
+ return warnings[:5] # Cap at 5 warnings
323
+
324
+
325
+ # ---------------------------------------------------------------------------
326
+ # Main context slice function
327
+ # ---------------------------------------------------------------------------
328
+
329
+ class ContextSlice:
330
+ """Structured result of graph context slicing."""
331
+
332
+ def __init__(
333
+ self,
334
+ seed_nodes: list[str],
335
+ task_type: str,
336
+ depth_reached: int,
337
+ token_count: int,
338
+ nodes: list[dict],
339
+ edges: list[dict],
340
+ insights: list[str],
341
+ warnings: list[str],
342
+ ):
343
+ self.slice_id = "slice_" + hashlib.sha256(
344
+ json.dumps(seed_nodes + [task_type]).encode()
345
+ ).hexdigest()[:12]
346
+ self.created_at = _dt.datetime.now(_dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
347
+ self.seed_nodes = seed_nodes
348
+ self.task_type = task_type
349
+ self.depth_reached = depth_reached
350
+ self.token_count = token_count
351
+ self.nodes = nodes
352
+ self.edges = edges
353
+ self.insights = insights
354
+ self.warnings = warnings
355
+
356
+ def to_dict(self) -> dict:
357
+ return {
358
+ "slice_id": self.slice_id,
359
+ "created_at": self.created_at,
360
+ "seed_nodes": self.seed_nodes,
361
+ "task_type": self.task_type,
362
+ "depth_reached": self.depth_reached,
363
+ "token_count": self.token_count,
364
+ "nodes": self.nodes,
365
+ "edges": self.edges,
366
+ "insights": self.insights,
367
+ "warnings": self.warnings,
368
+ }
369
+
370
+ def format_for_agent(self) -> str:
371
+ """Format slice as compact text for agent context injection."""
372
+ lines = ["## Project Context (from graph)"]
373
+
374
+ if self.insights:
375
+ for insight in self.insights:
376
+ lines.append(f"- {insight}")
377
+
378
+ if self.warnings:
379
+ lines.append("")
380
+ for warning in self.warnings:
381
+ lines.append(f"- \u26a0 {warning}")
382
+
383
+ if not self.insights and not self.warnings:
384
+ lines.append("- No relevant context found in project graph")
385
+
386
+ return "\n".join(lines)
387
+
388
+
389
+ def get_context_slice(
390
+ graph: dict,
391
+ task_type: str = "feat",
392
+ scope: list[str] | None = None,
393
+ max_depth: int = 3,
394
+ token_budget: int = 4000,
395
+ agent: str | None = None,
396
+ ) -> ContextSlice:
397
+ """Get a relevant context slice from the project graph.
398
+
399
+ Args:
400
+ graph: The full project graph dict (nodes, edges, meta).
401
+ task_type: One of "feat", "fix", "refactor", "test", "docs".
402
+ scope: File paths involved in the task (used to find seed nodes).
403
+ max_depth: Maximum BFS hops from seed nodes (default 3).
404
+ token_budget: Maximum tokens for the output context (default 4000).
405
+ agent: Optional agent name to tailor output.
406
+
407
+ Returns:
408
+ ContextSlice with nodes, edges, insights, and warnings.
409
+ """
410
+ if scope is None:
411
+ scope = []
412
+
413
+ nodes = graph.get("nodes", {})
414
+ edges = graph.get("edges", [])
415
+
416
+ if not nodes:
417
+ return ContextSlice(
418
+ seed_nodes=[], task_type=task_type, depth_reached=0,
419
+ token_count=0, nodes=[], edges=[], insights=[], warnings=[],
420
+ )
421
+
422
+ # Step 1: Find seed nodes from scope
423
+ seed_ids = _find_seed_nodes(graph, scope)
424
+
425
+ # If no seeds found, try to use all nodes matching task-relevant types
426
+ if not seed_ids and scope:
427
+ # Fallback: return empty slice with a note
428
+ return ContextSlice(
429
+ seed_nodes=[], task_type=task_type, depth_reached=0,
430
+ token_count=0, nodes=[], edges=[],
431
+ insights=["No matching nodes found for scope: " + ", ".join(scope)],
432
+ warnings=[],
433
+ )
434
+
435
+ # If no scope at all, use top-level component nodes as seeds
436
+ if not seed_ids:
437
+ for nid, node in nodes.items():
438
+ if node.get("type") == "Component" and "root" in nid:
439
+ seed_ids.append(nid)
440
+ break
441
+ if not seed_ids and nodes:
442
+ # Just pick first few nodes
443
+ seed_ids = list(nodes.keys())[:3]
444
+
445
+ # Step 2: BFS traversal with relevance scoring
446
+ selected_nodes, selected_edges = _bfs_traverse(
447
+ graph, seed_ids, max_depth, task_type, token_budget,
448
+ )
449
+
450
+ # Calculate actual depth reached
451
+ depth_reached = max((n["hop"] for n in selected_nodes), default=0)
452
+
453
+ # Calculate token count
454
+ total_tokens = sum(
455
+ estimate_tokens(f"{n['type']} {n['name']} {n.get('data', {}).get('description', '')[:100]}")
456
+ for n in selected_nodes
457
+ )
458
+
459
+ # Step 3: Generate insights
460
+ insights = _generate_insights(graph, selected_nodes, selected_edges, task_type)
461
+
462
+ # Step 4: Generate warnings
463
+ warnings = _generate_warnings(graph, selected_nodes, task_type)
464
+
465
+ # Add insight tokens to count
466
+ for text in insights + warnings:
467
+ total_tokens += estimate_tokens(text)
468
+
469
+ return ContextSlice(
470
+ seed_nodes=seed_ids,
471
+ task_type=task_type,
472
+ depth_reached=depth_reached,
473
+ token_count=total_tokens,
474
+ nodes=selected_nodes,
475
+ edges=selected_edges,
476
+ insights=insights,
477
+ warnings=warnings,
478
+ )
479
+
480
+
481
+ # ---------------------------------------------------------------------------
482
+ # Free-tier slice: nodes only, no traversal, no insights
483
+ # ---------------------------------------------------------------------------
484
+
485
+ def get_free_tier_slice(
486
+ graph: dict,
487
+ scope: list[str] | None = None,
488
+ ) -> dict:
489
+ """Return a minimal slice for free-tier users: matched nodes only."""
490
+ if scope is None:
491
+ scope = []
492
+ nodes = graph.get("nodes", {})
493
+ seed_ids = _find_seed_nodes(graph, scope)
494
+
495
+ result_nodes = []
496
+ for nid in seed_ids:
497
+ node = nodes.get(nid)
498
+ if node:
499
+ result_nodes.append({
500
+ "id": nid,
501
+ "type": node.get("type", ""),
502
+ "name": node.get("name", ""),
503
+ })
504
+
505
+ return {
506
+ "nodes": result_nodes,
507
+ "edges": [],
508
+ "insights": [],
509
+ "warnings": [],
510
+ "tier": "free",
511
+ "hint": "Upgrade to Pro for traversal, insights, and warnings: 0dai upgrade",
512
+ }
513
+
514
+
515
+ # ---------------------------------------------------------------------------
516
+ # CLI formatting
517
+ # ---------------------------------------------------------------------------
518
+
519
+ def format_cli_output(slice_data: ContextSlice | dict) -> str:
520
+ """Format a context slice for CLI display."""
521
+ if isinstance(slice_data, ContextSlice):
522
+ d = slice_data.to_dict()
523
+ else:
524
+ d = slice_data
525
+
526
+ lines = []
527
+
528
+ # Header
529
+ task_type = d.get("task_type", "unknown")
530
+ seeds = d.get("seed_nodes", [])
531
+ if seeds:
532
+ lines.append(f"Graph context for {task_type} of {', '.join(seeds[:3])}:")
533
+ else:
534
+ lines.append(f"Graph context for {task_type}:")
535
+ lines.append("")
536
+
537
+ # Nodes
538
+ node_list = d.get("nodes", [])
539
+ if node_list:
540
+ depth = d.get("depth_reached", 0)
541
+ tokens = d.get("token_count", 0)
542
+ lines.append(f"Related nodes (depth {depth}, {tokens} tokens):")
543
+ for node in node_list:
544
+ ntype = node.get("type", "")
545
+ name = node.get("name", node.get("id", ""))
546
+ hop = node.get("hop", "?")
547
+ icon = {"Component": "\U0001f4c1", "Decision": "\U0001f4a1", "Outcome": "\u2705",
548
+ "Technology": "\U0001f527", "Risk": "\u26a0\ufe0f", "Requirement": "\U0001f4cb",
549
+ "Endpoint": "\U0001f310", "TestPlan": "\U0001f9ea"}.get(ntype, "\U0001f4c4")
550
+ label = "seed" if hop == 0 else f"hop {hop}"
551
+ lines.append(f" {icon} {name} ({label})")
552
+ else:
553
+ lines.append("No related nodes found.")
554
+
555
+ # Insights
556
+ insights = d.get("insights", [])
557
+ if insights:
558
+ lines.append("")
559
+ lines.append("Insights:")
560
+ for insight in insights:
561
+ lines.append(f" \u2022 {insight}")
562
+
563
+ # Warnings
564
+ warnings = d.get("warnings", [])
565
+ if warnings:
566
+ lines.append("")
567
+ lines.append("\u26a0 Warnings:")
568
+ for warning in warnings:
569
+ lines.append(f" \u2022 {warning}")
570
+
571
+ # Free tier hint
572
+ if d.get("tier") == "free":
573
+ lines.append("")
574
+ lines.append(d.get("hint", ""))
575
+
576
+ return "\n".join(lines)
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env python3
2
+ """CLI wrapper for graph context slicing.
3
+
4
+ Called by: 0dai graph context --scope FILE --task TYPE --depth N --budget N --json
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import json
10
+ import pathlib
11
+ import sys
12
+
13
+ sys.path.insert(0, str(pathlib.Path(__file__).parent))
14
+
15
+ import graph_slicer
16
+ from graph import load_graph
17
+
18
+ MISSING_GRAPH_HINT = (
19
+ "No project graph found. Create ai/manifest/project_graph.json, or from a "
20
+ "repo checkout run: python3 scripts/generate_project_graph.py --target ."
21
+ )
22
+
23
+
24
+ def main(argv: list[str] | None = None) -> int:
25
+ parser = argparse.ArgumentParser(description="Graph-aware context slicing")
26
+ parser.add_argument("--target", type=str, default=".", help="Project root directory")
27
+ parser.add_argument("--scope", action="append", default=[], help="File paths (repeatable)")
28
+ parser.add_argument("--task", type=str, default="feat", help="Task type: feat|fix|refactor|test|docs")
29
+ parser.add_argument("--depth", type=int, default=3, help="Max BFS hops (default: 3)")
30
+ parser.add_argument("--budget", type=int, default=4000, help="Token budget (default: 4000)")
31
+ parser.add_argument("--json", action="store_true", help="Output as JSON")
32
+ args = parser.parse_args(argv)
33
+
34
+ target = pathlib.Path(args.target).resolve()
35
+ graph_path = target / "ai" / "manifest" / "project_graph.json"
36
+
37
+ if not graph_path.exists():
38
+ print(MISSING_GRAPH_HINT, file=sys.stderr)
39
+ return 1
40
+
41
+ graph = load_graph(graph_path, track_usage=False)
42
+
43
+ ctx = graph_slicer.get_context_slice(
44
+ graph=graph,
45
+ task_type=args.task,
46
+ scope=args.scope,
47
+ max_depth=args.depth,
48
+ token_budget=args.budget,
49
+ )
50
+
51
+ if args.json:
52
+ print(json.dumps(ctx.to_dict(), indent=2, ensure_ascii=False))
53
+ else:
54
+ print(graph_slicer.format_cli_output(ctx))
55
+
56
+ return 0
57
+
58
+
59
+ if __name__ == "__main__":
60
+ sys.exit(main())
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env python3
2
+ """Graph validation helpers."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from graph_core import EDGE_TYPES, NODE_TYPES, SCHEMA_VERSION, log
7
+
8
+
9
+ class GraphValidationError(ValueError):
10
+ """Raised when a graph fails structural validation."""
11
+
12
+
13
+ def validate_graph(graph: dict) -> list[str]:
14
+ errors: list[str] = []
15
+
16
+ for key in ("nodes", "edges", "meta"):
17
+ if key not in graph:
18
+ errors.append(f"missing top-level key: {key}")
19
+ if errors:
20
+ return errors
21
+
22
+ if not isinstance(graph["nodes"], dict):
23
+ errors.append("nodes must be a dict")
24
+ if not isinstance(graph["edges"], list):
25
+ errors.append("edges must be a list")
26
+ if errors:
27
+ return errors
28
+
29
+ meta_version = graph["meta"].get("schema_version")
30
+ if meta_version != SCHEMA_VERSION:
31
+ errors.append(
32
+ f"schema_version mismatch: expected {SCHEMA_VERSION}, got {meta_version}"
33
+ )
34
+
35
+ for node_id, node in graph["nodes"].items():
36
+ if node.get("id") != node_id:
37
+ errors.append(f"node id mismatch: key={node_id}, node.id={node.get('id')}")
38
+ node_type = node.get("type")
39
+ if node_type not in NODE_TYPES:
40
+ errors.append(f"node {node_id} has unknown type: {node_type!r}")
41
+ for field in ("name", "status", "created_at", "updated_at"):
42
+ if field not in node:
43
+ errors.append(f"node {node_id} missing required field: {field}")
44
+
45
+ seen_edges: set[tuple] = set()
46
+ for i, edge in enumerate(graph["edges"]):
47
+ for field in ("from", "to", "type"):
48
+ if field not in edge:
49
+ errors.append(f"edge[{i}] missing field: {field}")
50
+ continue
51
+ edge_type = edge.get("type")
52
+ if edge_type not in EDGE_TYPES:
53
+ errors.append(f"edge[{i}] has unknown type: {edge_type!r}")
54
+ src, tgt = edge.get("from"), edge.get("to")
55
+ if src not in graph["nodes"]:
56
+ log.warning("edge[%d] dangling source: %s", i, src)
57
+ if tgt not in graph["nodes"]:
58
+ log.warning("edge[%d] dangling target: %s", i, tgt)
59
+ key = (src, tgt, edge_type)
60
+ if key in seen_edges:
61
+ log.warning("edge[%d] duplicate: %s -> %s [%s]", i, src, tgt, edge_type)
62
+ seen_edges.add(key)
63
+
64
+ return errors