devmemory-cli 0.1.0.dev0__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.
Files changed (95) hide show
  1. devmemory/__about__.py +3 -0
  2. devmemory/__init__.py +14 -0
  3. devmemory/__main__.py +6 -0
  4. devmemory/adapters/__init__.py +6 -0
  5. devmemory/adapters/databricks.py +346 -0
  6. devmemory/adapters/entire.py +444 -0
  7. devmemory/adapters/git.py +408 -0
  8. devmemory/adapters/graph.py +251 -0
  9. devmemory/adapters/metrics.py +150 -0
  10. devmemory/adapters/tests.py +227 -0
  11. devmemory/analysis/__init__.py +19 -0
  12. devmemory/analysis/base.py +128 -0
  13. devmemory/analysis/chain.py +53 -0
  14. devmemory/analysis/llm.py +236 -0
  15. devmemory/analysis/rules.py +110 -0
  16. devmemory/api/__init__.py +10 -0
  17. devmemory/api/app.py +390 -0
  18. devmemory/api/mappers.py +187 -0
  19. devmemory/api/schemas.py +201 -0
  20. devmemory/cli/__init__.py +1 -0
  21. devmemory/cli/_errors.py +36 -0
  22. devmemory/cli/_render.py +79 -0
  23. devmemory/cli/analytics.py +136 -0
  24. devmemory/cli/analyze.py +58 -0
  25. devmemory/cli/app.py +163 -0
  26. devmemory/cli/checkpoint.py +199 -0
  27. devmemory/cli/compare.py +104 -0
  28. devmemory/cli/doctor.py +151 -0
  29. devmemory/cli/history.py +56 -0
  30. devmemory/cli/impact.py +95 -0
  31. devmemory/cli/init.py +91 -0
  32. devmemory/cli/mcp.py +66 -0
  33. devmemory/cli/memory.py +70 -0
  34. devmemory/cli/restore.py +91 -0
  35. devmemory/cli/search.py +48 -0
  36. devmemory/cli/serve.py +64 -0
  37. devmemory/cli/show.py +139 -0
  38. devmemory/cli/status.py +72 -0
  39. devmemory/cli/task.py +333 -0
  40. devmemory/config.py +302 -0
  41. devmemory/domain/__init__.py +5 -0
  42. devmemory/domain/enums.py +151 -0
  43. devmemory/domain/errors.py +188 -0
  44. devmemory/domain/models.py +452 -0
  45. devmemory/domain/taskloop.py +212 -0
  46. devmemory/environment.py +67 -0
  47. devmemory/logging.py +148 -0
  48. devmemory/mcp/__init__.py +12 -0
  49. devmemory/mcp/server.py +225 -0
  50. devmemory/paths.py +112 -0
  51. devmemory/pipeline/__init__.py +7 -0
  52. devmemory/pipeline/checkpoint.py +443 -0
  53. devmemory/pipeline/feature_detect.py +53 -0
  54. devmemory/pipeline/regression.py +141 -0
  55. devmemory/pipeline/runlog.py +73 -0
  56. devmemory/pipeline/status_rules.py +44 -0
  57. devmemory/py.typed +0 -0
  58. devmemory/services/__init__.py +9 -0
  59. devmemory/services/agent_context.py +287 -0
  60. devmemory/services/analysis.py +116 -0
  61. devmemory/services/analytics.py +328 -0
  62. devmemory/services/brief.py +53 -0
  63. devmemory/services/context.py +88 -0
  64. devmemory/services/databricks_sync.py +121 -0
  65. devmemory/services/features.py +85 -0
  66. devmemory/services/impact.py +47 -0
  67. devmemory/services/memory.py +212 -0
  68. devmemory/services/projects.py +226 -0
  69. devmemory/services/restore.py +194 -0
  70. devmemory/services/taskloop/__init__.py +39 -0
  71. devmemory/services/taskloop/collectors.py +263 -0
  72. devmemory/services/taskloop/engine.py +426 -0
  73. devmemory/services/taskloop/requirements.py +358 -0
  74. devmemory/services/trace.py +152 -0
  75. devmemory/services/versions.py +287 -0
  76. devmemory/storage/__init__.py +9 -0
  77. devmemory/storage/artifacts.py +113 -0
  78. devmemory/storage/db.py +205 -0
  79. devmemory/storage/graph_impacts.py +63 -0
  80. devmemory/storage/migrations/0001_init.sql +15 -0
  81. devmemory/storage/migrations/0002_versions.sql +210 -0
  82. devmemory/storage/migrations/0003_graph.sql +14 -0
  83. devmemory/storage/migrations/0004_taskloop.sql +82 -0
  84. devmemory/storage/migrations/0005_project_brief.sql +12 -0
  85. devmemory/storage/repositories.py +286 -0
  86. devmemory/storage/tasks.py +342 -0
  87. devmemory/storage/versions.py +604 -0
  88. devmemory/web/static/assets/index-CbV5njRH.js +78 -0
  89. devmemory/web/static/assets/index-DD-7ceZx.css +1 -0
  90. devmemory/web/static/index.html +18 -0
  91. devmemory_cli-0.1.0.dev0.dist-info/METADATA +174 -0
  92. devmemory_cli-0.1.0.dev0.dist-info/RECORD +95 -0
  93. devmemory_cli-0.1.0.dev0.dist-info/WHEEL +4 -0
  94. devmemory_cli-0.1.0.dev0.dist-info/entry_points.txt +3 -0
  95. devmemory_cli-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,199 @@
1
+ """``devmemory checkpoint`` - record the current commit as a development version."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Annotated
7
+
8
+ import typer
9
+
10
+ from devmemory.cli._render import console, hint, status_text, success, warn
11
+ from devmemory.domain.enums import MetricDirection, VersionStatus
12
+ from devmemory.domain.errors import DevMemoryError
13
+ from devmemory.domain.models import Metric
14
+ from devmemory.pipeline.checkpoint import CheckpointRequest, run_checkpoint
15
+ from devmemory.services.context import ProjectContext
16
+
17
+ _LOWER_IS_BETTER_HINTS = (
18
+ "latency",
19
+ "duration",
20
+ "time",
21
+ "loss",
22
+ "error",
23
+ "cost",
24
+ "memory",
25
+ "p50",
26
+ "p95",
27
+ "p99",
28
+ "rt",
29
+ )
30
+
31
+
32
+ def checkpoint_command(
33
+ intent: Annotated[
34
+ str | None,
35
+ typer.Option("--intent", help="What was requested (defaults to the checkpoint)."),
36
+ ] = None,
37
+ feature: Annotated[
38
+ str | None, typer.Option("--feature", "-f", help="Feature area this change belongs to.")
39
+ ] = None,
40
+ agent: Annotated[
41
+ str | None, typer.Option("--agent", help="AI agent, if not read from the checkpoint.")
42
+ ] = None,
43
+ status: Annotated[
44
+ str | None,
45
+ typer.Option("--status", help="Override the derived status (SUCCESS, REGRESSION, ...)."),
46
+ ] = None,
47
+ tests_passed: Annotated[
48
+ int | None, typer.Option("--tests-passed", help="Passing test count (skips the runner).")
49
+ ] = None,
50
+ tests_failed: Annotated[
51
+ int | None, typer.Option("--tests-failed", help="Failing test count (skips the runner).")
52
+ ] = None,
53
+ run_tests: Annotated[
54
+ bool,
55
+ typer.Option("--run-tests/--no-run-tests", help="Run the configured test command."),
56
+ ] = True,
57
+ metrics: Annotated[
58
+ list[str] | None,
59
+ typer.Option(
60
+ "--metric", "-m", help="Metric as name=after or name=before:after (repeatable)."
61
+ ),
62
+ ] = None,
63
+ metrics_file: Annotated[
64
+ str | None,
65
+ typer.Option("--metrics-file", help="Path to a JSON metrics file to read."),
66
+ ] = None,
67
+ errors: Annotated[
68
+ list[str] | None, typer.Option("--error", "-e", help="An error encountered (repeatable).")
69
+ ] = None,
70
+ snapshot: Annotated[
71
+ bool,
72
+ typer.Option("--snapshot/--no-snapshot", help="Archive the source tree for this version."),
73
+ ] = True,
74
+ allow_no_entire: Annotated[
75
+ bool,
76
+ typer.Option("--allow-no-entire", help="Record even without an Entire checkpoint."),
77
+ ] = False,
78
+ force: Annotated[
79
+ bool, typer.Option("--force", help="Re-record the version for this commit in place.")
80
+ ] = False,
81
+ as_json: Annotated[bool, typer.Option("--json", help="Emit the result as JSON.")] = False,
82
+ ) -> None:
83
+ """Turn the current git commit + its Entire/test/metric context into a Development Version."""
84
+ request = CheckpointRequest(
85
+ intent=intent,
86
+ feature=feature,
87
+ agent=agent,
88
+ status=_parse_status(status),
89
+ tests_passed=tests_passed,
90
+ tests_failed=tests_failed,
91
+ run_tests=run_tests,
92
+ metrics=[_parse_metric(m) for m in (metrics or [])],
93
+ metrics_file=metrics_file,
94
+ errors=list(errors or []),
95
+ snapshot=snapshot,
96
+ allow_no_entire=allow_no_entire,
97
+ force=force,
98
+ )
99
+
100
+ with ProjectContext.load() as ctx:
101
+ result = run_checkpoint(ctx, request)
102
+
103
+ if as_json:
104
+ console.print_json(
105
+ json.dumps(
106
+ {
107
+ "created": result.created,
108
+ "version": result.version.model_dump(mode="json"),
109
+ "run_id": result.run_log.run_id,
110
+ "warnings": result.warnings,
111
+ }
112
+ )
113
+ )
114
+ return
115
+
116
+ v = result.version
117
+ if not result.created:
118
+ warn(f"{v.version_id} already records commit {v.git_commit[:12]} - nothing to do.")
119
+ hint("Pass --force to re-record it.")
120
+ return
121
+
122
+ console.print()
123
+ console.print(
124
+ f"[bold]{v.version_id}[/bold] ", status_text(v.status), f" [dim]{v.git_commit[:12]}[/dim]"
125
+ )
126
+ if v.intent:
127
+ console.print(f" intent {v.intent}")
128
+ if v.primary_checkpoint:
129
+ cp = v.primary_checkpoint
130
+ marker = (
131
+ ""
132
+ if not cp.is_uncertain
133
+ else f" [yellow](confidence {cp.association_confidence:.2f})[/yellow]"
134
+ )
135
+ console.print(
136
+ f" entire {cp.checkpoint_id} [dim]{cp.association_method.value}[/dim]{marker}"
137
+ )
138
+ if v.agent:
139
+ console.print(f" agent {v.agent}" + (f" [dim]{v.model}[/dim]" if v.model else ""))
140
+ if v.feature_id:
141
+ console.print(f" feature {v.feature_id.split(':', 1)[-1]}")
142
+ console.print(
143
+ f" changes {v.files_changed} files "
144
+ f"[green]+{v.lines_added}[/green] [red]-{v.lines_removed}[/red]"
145
+ )
146
+ if v.tests and v.tests.ran:
147
+ console.print(f" tests {v.tests.passed} passed / {v.tests.failed} failed")
148
+ for m in v.metrics:
149
+ arrow = "→"
150
+ console.print(
151
+ f" {m.name:<8} {m.before} {arrow} {m.after}" + (f" {m.unit}" if m.unit else "")
152
+ )
153
+
154
+ for w in result.warnings:
155
+ warn(w)
156
+ success(f"recorded {v.version_id}")
157
+ console.print(f"[dim]run log: {result.run_log.run_id}.json[/dim]")
158
+
159
+
160
+ def _parse_status(raw: str | None) -> VersionStatus | None:
161
+ if raw is None:
162
+ return None
163
+ try:
164
+ return VersionStatus(raw.strip().upper())
165
+ except ValueError as exc:
166
+ raise DevMemoryError(
167
+ f"Unknown status {raw!r}.",
168
+ hint=f"One of: {', '.join(s.value for s in VersionStatus)}",
169
+ ) from exc
170
+
171
+
172
+ def _parse_metric(raw: str) -> Metric:
173
+ if "=" not in raw:
174
+ raise DevMemoryError(f"Bad --metric {raw!r}; expected name=after or name=before:after.")
175
+ name, _, value = raw.partition("=")
176
+ name = name.strip()
177
+ before: float | None = None
178
+ if ":" in value:
179
+ before_s, _, after_s = value.partition(":")
180
+ before = _to_float(before_s, raw)
181
+ after = _to_float(after_s, raw)
182
+ else:
183
+ after = _to_float(value, raw)
184
+ direction = (
185
+ MetricDirection.LOWER_IS_BETTER
186
+ if any(h in name.lower() for h in _LOWER_IS_BETTER_HINTS)
187
+ else MetricDirection.HIGHER_IS_BETTER
188
+ )
189
+ return Metric(name=name, before=before, after=after, direction=direction)
190
+
191
+
192
+ def _to_float(text: str, raw: str) -> float:
193
+ try:
194
+ return float(text.strip())
195
+ except ValueError as exc:
196
+ raise DevMemoryError(f"Bad number in --metric {raw!r}.") from exc
197
+
198
+
199
+ __all__ = ["checkpoint_command"]
@@ -0,0 +1,104 @@
1
+ """``devmemory diff`` and ``devmemory compare`` - what changed between two versions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Annotated
7
+
8
+ import typer
9
+ from rich.panel import Panel
10
+ from rich.syntax import Syntax
11
+ from rich.table import Table
12
+
13
+ from devmemory.cli._render import console, status_text
14
+ from devmemory.services.context import ProjectContext
15
+ from devmemory.services.versions import version_diff
16
+
17
+
18
+ def diff_command(
19
+ from_ref: Annotated[str, typer.Argument(metavar="FROM", help="Base version (v6 / 6 / sha).")],
20
+ to_ref: Annotated[str, typer.Argument(metavar="TO", help="Target version.")],
21
+ ) -> None:
22
+ """Print the exact git diff between two development versions."""
23
+ with ProjectContext.load() as ctx:
24
+ result = version_diff(ctx, from_ref, to_ref)
25
+ console.print(
26
+ f"[dim]{result.from_version_id.upper()} {result.from_commit[:10]} → "
27
+ f"{result.to_version_id.upper()} {result.to_commit[:10]}[/dim]\n"
28
+ )
29
+ console.print(Syntax(result.diff_text or "(no changes)", "diff", theme="ansi_dark"))
30
+
31
+
32
+ def compare_command(
33
+ from_ref: Annotated[str, typer.Argument(metavar="FROM")],
34
+ to_ref: Annotated[str, typer.Argument(metavar="TO")],
35
+ show_diff: Annotated[bool, typer.Option("--diff", help="Also print the git diff.")] = False,
36
+ as_json: Annotated[bool, typer.Option("--json")] = False,
37
+ ) -> None:
38
+ """Compare two versions: files, lines, metrics, tests, status."""
39
+ with ProjectContext.load() as ctx:
40
+ result = version_diff(ctx, from_ref, to_ref)
41
+
42
+ if as_json:
43
+ console.print_json(json.dumps(result.model_dump(mode="json")))
44
+ return
45
+
46
+ console.print()
47
+ console.print(
48
+ f"[bold]{result.from_version_id.upper()}[/bold] → [bold]{result.to_version_id.upper()}[/bold]"
49
+ f" {status_text(result.status_from)} → {status_text(result.status_to)}"
50
+ )
51
+ console.print(
52
+ f" {result.stat.files_changed} files "
53
+ f"[green]+{result.stat.additions}[/green] [red]-{result.stat.deletions}[/red]\n"
54
+ )
55
+
56
+ if result.files:
57
+ ft = Table(box=None, show_header=False, pad_edge=False)
58
+ ft.add_column(no_wrap=True)
59
+ ft.add_column(overflow="fold")
60
+ ft.add_column(justify="right", no_wrap=True)
61
+ for f in result.files:
62
+ mark = {
63
+ "added": "[green]A[/green]",
64
+ "modified": "[yellow]M[/yellow]",
65
+ "deleted": "[red]D[/red]",
66
+ }.get(f.change_type.value, "[cyan]" + f.change_type.value[0].upper() + "[/cyan]")
67
+ ft.add_row(
68
+ mark,
69
+ f.path,
70
+ "binary"
71
+ if f.binary
72
+ else f"[green]+{f.additions}[/green] [red]-{f.deletions}[/red]",
73
+ )
74
+ console.print(Panel(ft, title="files", title_align="left", border_style="dim"))
75
+
76
+ if result.metric_changes:
77
+ mt = Table(box=None, show_header=False, pad_edge=False)
78
+ mt.add_column(no_wrap=True)
79
+ mt.add_column()
80
+ for name, change in result.metric_changes.items():
81
+ before, after, delta = change["before"], change["after"], change["delta"]
82
+ arrow = f"{before} → {after}" if before is not None else str(after)
83
+ tag = ""
84
+ if delta is not None:
85
+ tag = f" [{'green' if delta >= 0 else 'red'}]({'+' if delta >= 0 else ''}{delta:g})[/]"
86
+ mt.add_row(name, arrow + tag)
87
+ console.print(Panel(mt, title="metrics", title_align="left", border_style="dim"))
88
+
89
+ tc = result.test_changes
90
+ if tc.get("passed") is not None:
91
+ console.print(f" tests: passed {_signed(tc['passed'])}, failed {_signed(tc['failed'])}")
92
+
93
+ if show_diff and result.diff_text:
94
+ console.print()
95
+ console.print(Syntax(result.diff_text, "diff", theme="ansi_dark"))
96
+
97
+
98
+ def _signed(n: int | None) -> str:
99
+ if n is None:
100
+ return "?"
101
+ return f"+{n}" if n >= 0 else str(n)
102
+
103
+
104
+ __all__ = ["compare_command", "diff_command"]
@@ -0,0 +1,151 @@
1
+ """``devmemory doctor`` - check the environment and the project setup.
2
+
3
+ Reports credential *presence*, never values.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import platform
9
+ import shutil
10
+ import sqlite3
11
+ import subprocess
12
+ from pathlib import Path
13
+ from typing import Annotated
14
+
15
+ import typer
16
+ from rich.table import Table
17
+
18
+ from devmemory.cli._render import console
19
+ from devmemory.config import DevMemoryConfig, resolve_databricks_credentials, resolve_llm_api_key
20
+ from devmemory.paths import find_project_paths
21
+ from devmemory.storage.db import Database, discover_migrations
22
+
23
+ _OK = "[green]ok[/green]"
24
+ _WARN = "[yellow]warn[/yellow]"
25
+ _MISS = "[dim]absent[/dim]"
26
+
27
+
28
+ def doctor_command(
29
+ strict: Annotated[
30
+ bool, typer.Option("--strict", help="Exit non-zero if any check is not ok.")
31
+ ] = False,
32
+ ) -> None:
33
+ """Diagnose the DevMemory environment: toolchain, project, storage, integrations."""
34
+ rows: list[tuple[str, str, str]] = []
35
+
36
+ # -- toolchain -----------------------------------------------------
37
+ py = platform.python_version()
38
+ rows.append(("python", _OK if _pyver_ok(py) else _WARN, f"{py} ({platform.system()})"))
39
+ rows.append(_tool("git", "git", ["--version"]))
40
+
41
+ from devmemory.adapters.graph import _find_binary as _find_graph
42
+
43
+ entire = shutil.which("entire") or _fallback("entire")
44
+ rows.append(
45
+ ("entire cli", _OK if entire else _WARN, entire or "not found (checkpoints optional)")
46
+ )
47
+ graph = _find_graph()
48
+ rows.append(
49
+ ("entire graph", _OK if graph else _MISS, graph or "not installed (impact optional)")
50
+ )
51
+
52
+ # -- project -----------------------------------------------------
53
+ paths = find_project_paths()
54
+ if paths is None:
55
+ rows.append(("project", _WARN, "no .devmemory/ here - run `devmemory init`"))
56
+ _render(rows)
57
+ raise typer.Exit(1 if strict else 0)
58
+
59
+ rows.append(("project root", _OK, str(paths.repo_root)))
60
+ try:
61
+ config = DevMemoryConfig.load(paths)
62
+ rows.append(("config", _OK, f"{config.project_name} ({config.project_id})"))
63
+ except Exception as exc:
64
+ rows.append(("config", _WARN, str(exc)))
65
+ config = None
66
+
67
+ # -- storage ---------------------------------------------------
68
+ try:
69
+ db = Database(paths.db)
70
+ applied = db.schema_version()
71
+ latest = len(discover_migrations())
72
+ state = _OK if applied == latest else _WARN
73
+ rows.append(("database", state, f"schema {applied}/{latest}"))
74
+ db.close()
75
+ except sqlite3.DatabaseError as exc:
76
+ rows.append(("database", _WARN, str(exc)))
77
+
78
+ outbox = list(paths.outbox_dir.glob("*.json")) if paths.outbox_dir.is_dir() else []
79
+ rows.append(("databricks outbox", _OK if not outbox else _WARN, f"{len(outbox)} queued"))
80
+
81
+ # -- integrations (presence only) ----------------------------
82
+ if config is not None:
83
+ rows.append(_llm_row(config))
84
+ dbx = resolve_databricks_credentials()
85
+ rows.append(
86
+ (
87
+ "databricks creds",
88
+ _OK if dbx else _MISS,
89
+ "DATABRICKS_HOST/TOKEN/WAREHOUSE_ID set" if dbx else "not set (analytics stay local)",
90
+ )
91
+ )
92
+
93
+ _render(rows)
94
+ if strict and any(state == _WARN for _, state, _ in rows):
95
+ raise typer.Exit(1)
96
+
97
+
98
+ def _render(rows: list[tuple[str, str, str]]) -> None:
99
+ table = Table(show_header=False, box=None, pad_edge=False)
100
+ table.add_column(style="bold cyan", no_wrap=True)
101
+ table.add_column(no_wrap=True)
102
+ table.add_column(overflow="fold")
103
+ for name, state, detail in rows:
104
+ table.add_row(name, state, detail)
105
+ console.print()
106
+ console.print(table)
107
+
108
+
109
+ def _pyver_ok(version: str) -> bool:
110
+ major, minor, *_ = (int(p) for p in version.split("."))
111
+ return (major, minor) >= (3, 11)
112
+
113
+
114
+ def _tool(label: str, exe: str, args: list[str]) -> tuple[str, str, str]:
115
+ path = shutil.which(exe)
116
+ if path is None:
117
+ return (label, _WARN, "not found")
118
+ try:
119
+ out = subprocess.run( # noqa: S603 - fixed exe, arg list, no shell
120
+ [path, *args], capture_output=True, text=True, timeout=5, check=False
121
+ )
122
+ first = (out.stdout or out.stderr).strip().splitlines()
123
+ return (label, _OK, first[0] if first else path)
124
+ except (OSError, subprocess.SubprocessError):
125
+ return (label, _WARN, "error running")
126
+
127
+
128
+ def _fallback(name: str) -> str | None:
129
+ home = Path.home()
130
+ for candidate in (
131
+ home / ".local" / "bin" / name,
132
+ home / ".local" / "bin" / f"{name}.exe",
133
+ home / "AppData" / "Local" / "entire" / "plugins" / "bin" / f"{name}.exe",
134
+ home / ".local" / "share" / "entire" / "plugins" / "bin" / name,
135
+ ):
136
+ if candidate.is_file():
137
+ return str(candidate)
138
+ return None
139
+
140
+
141
+ def _llm_row(config: DevMemoryConfig) -> tuple[str, str, str]:
142
+ configured = [p for p in config.analysis.providers if p != "rules"]
143
+ have = [p for p in configured if resolve_llm_api_key(p)]
144
+ if not configured:
145
+ return ("llm analysis", _OK, "rules only (no LLM configured)")
146
+ if have:
147
+ return ("llm analysis", _OK, f"key present for: {', '.join(have)}")
148
+ return ("llm analysis", _WARN, f"providers {configured} configured but no key in env")
149
+
150
+
151
+ __all__ = ["doctor_command"]
@@ -0,0 +1,56 @@
1
+ """``devmemory history`` - the version timeline."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Annotated
7
+
8
+ import typer
9
+ from rich.table import Table
10
+
11
+ from devmemory.cli._render import console, status_text
12
+ from devmemory.services.context import ProjectContext
13
+ from devmemory.services.versions import list_versions
14
+
15
+
16
+ def history_command(
17
+ limit: Annotated[int, typer.Option("--limit", "-n", help="Most recent N versions.")] = 25,
18
+ as_json: Annotated[bool, typer.Option("--json", help="Emit as JSON.")] = False,
19
+ ) -> None:
20
+ """List development versions, newest last."""
21
+ with ProjectContext.load() as ctx:
22
+ versions = list_versions(ctx, limit=limit, ascending=False)
23
+ versions.reverse()
24
+
25
+ if as_json:
26
+ console.print_json(json.dumps([v.model_dump(mode="json") for v in versions]))
27
+ return
28
+
29
+ if not versions:
30
+ console.print("[dim]No versions yet. Run `devmemory checkpoint` after a commit.[/dim]")
31
+ return
32
+
33
+ table = Table(box=None, pad_edge=False, header_style="dim")
34
+ table.add_column("", style="bold", no_wrap=True)
35
+ table.add_column("status", no_wrap=True)
36
+ table.add_column("agent", no_wrap=True, max_width=14)
37
+ table.add_column("feature", no_wrap=True, max_width=18)
38
+ table.add_column("Δ", justify="right", no_wrap=True)
39
+ table.add_column("metrics", no_wrap=True, max_width=22)
40
+ table.add_column("intent", overflow="ellipsis", max_width=48)
41
+
42
+ for v in versions:
43
+ metrics = " ".join(f"{m.name}={m.after}" for m in v.metrics[:2])
44
+ table.add_row(
45
+ v.version_id.upper(),
46
+ status_text(v.status),
47
+ (v.agent or "-"),
48
+ (v.feature_id.split(":", 1)[-1] if v.feature_id else "-"),
49
+ f"[green]+{v.lines_added}[/green]/[red]-{v.lines_removed}[/red]",
50
+ metrics or "-",
51
+ v.intent or "-",
52
+ )
53
+ console.print(table)
54
+
55
+
56
+ __all__ = ["history_command"]
@@ -0,0 +1,95 @@
1
+ """``devmemory impact <version>`` - the change blast-radius for one version.
2
+
3
+ Uses the Entire ``graph`` plugin. Enable it with `entire plugin install graph`
4
+ and set `graph.enabled = true` to also collect impact during `devmemory
5
+ checkpoint`; otherwise this command computes it on demand.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from typing import Annotated
12
+
13
+ import typer
14
+ from rich.panel import Panel
15
+ from rich.table import Table
16
+
17
+ from devmemory.cli._render import console, hint, warn
18
+ from devmemory.services.context import ProjectContext
19
+ from devmemory.services.impact import version_impact
20
+ from devmemory.services.versions import get_version
21
+
22
+ _CHANGE_STYLE = {
23
+ "removed": "bold red",
24
+ "signature_changed": "yellow",
25
+ "renamed": "cyan",
26
+ "added": "green",
27
+ "body_changed": "dim",
28
+ }
29
+
30
+
31
+ def impact_command(
32
+ version: Annotated[str, typer.Argument(help="Version ref: v7, 7, or a commit prefix.")],
33
+ as_json: Annotated[bool, typer.Option("--json", help="Emit as JSON.")] = False,
34
+ no_compute: Annotated[
35
+ bool,
36
+ typer.Option("--stored-only", help="Only show a stored result; do not run the analysis."),
37
+ ] = False,
38
+ ) -> None:
39
+ """Show the entity-level change list and dependent counts for a version."""
40
+ with ProjectContext.load() as ctx:
41
+ v = get_version(ctx, version)
42
+ available = ctx.graph.is_available
43
+ impact = version_impact(ctx, version, compute_if_missing=not no_compute)
44
+
45
+ if as_json:
46
+ console.print_json(json.dumps(impact.model_dump(mode="json") if impact else None))
47
+ return
48
+
49
+ if impact is None:
50
+ if not available:
51
+ warn("The Entire `graph` plugin is not installed.")
52
+ hint("Install it: entire plugin install graph")
53
+ else:
54
+ warn(f"No impact analysis available for {v.version_id.upper()}.")
55
+ return
56
+
57
+ console.print()
58
+ console.print(
59
+ f"[bold cyan]{v.version_id.upper()}[/bold cyan] "
60
+ f"[dim]{impact.base_commit[:12]} → {impact.head_commit[:12] or 'HEAD'}[/dim]"
61
+ )
62
+ console.print(
63
+ f"{impact.entity_count} changed entities · max dependents {impact.max_dependents}\n"
64
+ )
65
+
66
+ if impact.hotspots:
67
+ table = Table(title="Hotspots", title_justify="left", box=None, header_style="dim")
68
+ table.add_column("change")
69
+ table.add_column("entity")
70
+ table.add_column("file", overflow="fold")
71
+ table.add_column("deps", justify="right")
72
+ for e in impact.hotspots:
73
+ style = _CHANGE_STYLE.get(e.change_type, "white")
74
+ table.add_row(
75
+ f"[{style}]{e.change_type}[/{style}]",
76
+ f"{e.kind} {e.name}",
77
+ e.path,
78
+ str(e.dependents_count),
79
+ )
80
+ console.print(table)
81
+ console.print()
82
+
83
+ risky = [e for e in impact.entities if e.is_risky]
84
+ if risky:
85
+ lines = "\n".join(
86
+ f" [bold red]{e.change_type}[/bold red] {e.name} "
87
+ f"[dim]({e.path}, {e.dependents_count} dependents)[/dim]"
88
+ for e in risky
89
+ )
90
+ console.print(
91
+ Panel(lines, title="review before keeping", title_align="left", border_style="red")
92
+ )
93
+
94
+
95
+ __all__ = ["impact_command"]