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 +7 -0
- deg/cli.py +469 -0
- deg/compiler.py +143 -0
- deg/constraints.py +294 -0
- deg/contradiction.py +199 -0
- deg/dismissed.py +45 -0
- deg/embeddings.py +192 -0
- deg/graph.py +584 -0
- deg/ingest.py +175 -0
- deg/integrity.py +287 -0
- deg/maintenance.py +391 -0
- deg/mcp_server.py +190 -0
- deg/retrieval.py +276 -0
- deg/schemas.py +117 -0
- deg/setup.py +207 -0
- deg/skill/SKILL.md +256 -0
- deg/skill/deg_rules.yaml +17 -0
- deg/skill/hooks.json +39 -0
- deg/skill/mcp.json +9 -0
- deg/staleness.py +138 -0
- deg/state.py +47 -0
- deg/temporal.py +62 -0
- deg/triggers.py +80 -0
- deg/trust.py +242 -0
- degraph-1.0.0.dist-info/METADATA +165 -0
- degraph-1.0.0.dist-info/RECORD +29 -0
- degraph-1.0.0.dist-info/WHEEL +4 -0
- degraph-1.0.0.dist-info/entry_points.txt +2 -0
- degraph-1.0.0.dist-info/licenses/LICENSE +21 -0
deg/__init__.py
ADDED
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)
|