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
devmemory/cli/task.py ADDED
@@ -0,0 +1,333 @@
1
+ """``devmemory task`` - drive and inspect the state-aware coding loop.
2
+
3
+ devmemory task new "add auth with login + password reset" --test "pytest -q"
4
+ devmemory task state # the §19 dashboard for the latest task
5
+ devmemory task refresh # re-collect evidence, print the new state
6
+ devmemory task issue "blocked on missing SMTP creds" --blocking
7
+ devmemory task complete # completion evaluation (never blind READY)
8
+ devmemory task list / history
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from typing import Annotated
15
+
16
+ import typer
17
+ from rich.panel import Panel
18
+ from rich.table import Table
19
+
20
+ from devmemory.cli._render import console
21
+ from devmemory.domain.enums import RequirementStatus, TaskStatus
22
+ from devmemory.domain.taskloop import NormalizedState
23
+ from devmemory.services.context import ProjectContext
24
+ from devmemory.services.taskloop import engine
25
+
26
+ task_app = typer.Typer(
27
+ name="task",
28
+ help="The state-aware coding loop: task -> state -> code -> tests -> commit -> refresh.",
29
+ no_args_is_help=True,
30
+ )
31
+
32
+ _STATUS_STYLE = {
33
+ TaskStatus.IN_PROGRESS: "cyan",
34
+ TaskStatus.NEEDS_WORK: "yellow",
35
+ TaskStatus.READY: "bold green",
36
+ TaskStatus.BLOCKED: "bold red",
37
+ }
38
+ _REQ_STYLE = {
39
+ RequirementStatus.COMPLETE: "green",
40
+ RequirementStatus.PARTIAL: "yellow",
41
+ RequirementStatus.INCOMPLETE: "red",
42
+ RequirementStatus.UNKNOWN: "dim",
43
+ }
44
+
45
+
46
+ def _resolve_task_id(ctx: ProjectContext, task_id: str | None) -> str:
47
+ if task_id:
48
+ return task_id
49
+ latest = engine.latest_task(ctx)
50
+ if latest is None:
51
+ raise typer.BadParameter("no tasks yet - create one with `devmemory task new`")
52
+ return latest.id
53
+
54
+
55
+ @task_app.command("new")
56
+ def task_new(
57
+ goal: Annotated[str, typer.Argument(help="The human task, in plain language.")],
58
+ test_command: Annotated[
59
+ str | None, typer.Option("--test", "-t", help="Test command (default: tests.command).")
60
+ ] = None,
61
+ push_policy: Annotated[
62
+ str, typer.Option("--push-policy", help="manual | on_ready | never")
63
+ ] = "manual",
64
+ as_json: Annotated[bool, typer.Option("--json")] = False,
65
+ ) -> None:
66
+ """Create a task: normalize the goal into requirements and pin the base commit."""
67
+ with ProjectContext.load() as ctx:
68
+ task = engine.create_task(
69
+ ctx, goal=goal, test_command=test_command, push_policy=push_policy
70
+ )
71
+ state = engine.get_state(ctx, task.id)
72
+ if as_json:
73
+ console.print_json(state.model_dump_json())
74
+ return
75
+ console.print(f"[bold]{task.id}[/bold] created · {len(task.requirements)} requirements")
76
+ for r in task.requirements:
77
+ console.print(f" [cyan]{r.id}[/cyan] {r.description}")
78
+ console.print()
79
+ _render_state(state)
80
+
81
+
82
+ @task_app.command("state")
83
+ def task_state(
84
+ task_id: Annotated[str | None, typer.Argument()] = None,
85
+ as_json: Annotated[bool, typer.Option("--json")] = False,
86
+ ) -> None:
87
+ """Show the current stored state for a task (no refresh)."""
88
+ with ProjectContext.load() as ctx:
89
+ tid = _resolve_task_id(ctx, task_id)
90
+ state = engine.get_state(ctx, tid)
91
+ if as_json:
92
+ console.print_json(state.model_dump_json())
93
+ return
94
+ _render_state(state)
95
+
96
+
97
+ @task_app.command("refresh")
98
+ def task_refresh(
99
+ task_id: Annotated[str | None, typer.Argument()] = None,
100
+ as_json: Annotated[bool, typer.Option("--json")] = False,
101
+ ) -> None:
102
+ """Re-collect evidence, re-evaluate requirements, store a snapshot, print the state."""
103
+ with ProjectContext.load() as ctx:
104
+ tid = _resolve_task_id(ctx, task_id)
105
+ state = engine.refresh_state(ctx, tid)
106
+ if as_json:
107
+ console.print_json(state.model_dump_json())
108
+ return
109
+ _render_state(state)
110
+
111
+
112
+ @task_app.command("complete")
113
+ def task_complete(
114
+ task_id: Annotated[str | None, typer.Argument()] = None,
115
+ as_json: Annotated[bool, typer.Option("--json")] = False,
116
+ ) -> None:
117
+ """Run a completion evaluation. READY only if the evidence supports it."""
118
+ with ProjectContext.load() as ctx:
119
+ tid = _resolve_task_id(ctx, task_id)
120
+ state = engine.mark_complete(ctx, tid)
121
+ if as_json:
122
+ console.print_json(state.model_dump_json())
123
+ return
124
+ _render_state(state)
125
+ if state.overall_status is not TaskStatus.READY:
126
+ console.print(
127
+ f"\n[yellow]Not READY[/yellow] - {state.overall_status.value}. "
128
+ "Address the findings above and refresh again."
129
+ )
130
+
131
+
132
+ @task_app.command("issue")
133
+ def task_issue(
134
+ description: Annotated[str, typer.Argument(help="What is unresolved.")],
135
+ task_id: Annotated[str | None, typer.Option("--task")] = None,
136
+ blocking: Annotated[
137
+ bool, typer.Option("--blocking", help="Cannot continue without a human decision.")
138
+ ] = False,
139
+ ) -> None:
140
+ """Record an unresolved item. --blocking forces BLOCKED on the next refresh."""
141
+ with ProjectContext.load() as ctx:
142
+ tid = _resolve_task_id(ctx, task_id)
143
+ issue = engine.report_issue(ctx, task_id=tid, description=description, blocking=blocking)
144
+ tag = " [red](blocking)[/red]" if blocking else ""
145
+ console.print(f"issue #{issue.id} recorded for {tid}{tag}")
146
+
147
+
148
+ @task_app.command("resolve")
149
+ def task_resolve(
150
+ issue_id: Annotated[int, typer.Argument(help="Issue id from `task state`.")],
151
+ ) -> None:
152
+ """Mark an unresolved item resolved."""
153
+ with ProjectContext.load() as ctx:
154
+ ok = engine.resolve_issue(ctx, issue_id=issue_id)
155
+ console.print(f"issue #{issue_id} {'resolved' if ok else 'not found or already resolved'}")
156
+
157
+
158
+ @task_app.command("requirement")
159
+ def task_requirement(
160
+ requirement_id: Annotated[str, typer.Argument(help="e.g. R1")],
161
+ status: Annotated[
162
+ str, typer.Option("--status", "-s", help="complete|partial|incomplete|unknown")
163
+ ],
164
+ note: Annotated[str, typer.Option("--note", "-m", help="Why (auditable).")] = "",
165
+ task_id: Annotated[str | None, typer.Option("--task")] = None,
166
+ ) -> None:
167
+ """Record your own verdict for a requirement, then refresh.
168
+
169
+ The engine still verifies tests + tree state before it will report READY.
170
+ """
171
+ with ProjectContext.load() as ctx:
172
+ tid = _resolve_task_id(ctx, task_id)
173
+ state = engine.set_requirement_status(
174
+ ctx,
175
+ task_id=tid,
176
+ requirement_id=requirement_id,
177
+ status=RequirementStatus(status.upper()),
178
+ note=note,
179
+ )
180
+ _render_state(state)
181
+
182
+
183
+ @task_app.command("list")
184
+ def task_list() -> None:
185
+ """All tasks and their current status."""
186
+ from devmemory.storage.tasks import TaskRepository
187
+
188
+ with ProjectContext.load() as ctx:
189
+ tasks = TaskRepository(ctx.db).list_tasks()
190
+ if not tasks:
191
+ console.print("[dim]no tasks yet[/dim]")
192
+ return
193
+ table = Table(box=None, pad_edge=False, header_style="dim")
194
+ table.add_column("", style="bold")
195
+ table.add_column("status")
196
+ table.add_column("reqs", justify="right")
197
+ table.add_column("goal", overflow="ellipsis", max_width=60)
198
+ for t in tasks:
199
+ done = sum(1 for r in t.requirements if r.status is RequirementStatus.COMPLETE)
200
+ style = _STATUS_STYLE.get(t.status, "white")
201
+ table.add_row(
202
+ t.id,
203
+ f"[{style}]{t.status.value}[/{style}]",
204
+ f"{done}/{len(t.requirements)}",
205
+ t.goal,
206
+ )
207
+ console.print(table)
208
+
209
+
210
+ @task_app.command("history")
211
+ def task_history(
212
+ task_id: Annotated[str | None, typer.Argument()] = None,
213
+ limit: Annotated[int, typer.Option("--limit", "-n")] = 15,
214
+ ) -> None:
215
+ """The snapshot trail for a task: snapshot #1 -> #2 -> #3 ..."""
216
+ with ProjectContext.load() as ctx:
217
+ tid = _resolve_task_id(ctx, task_id)
218
+ snaps = engine.list_snapshots(ctx, tid, limit=limit)
219
+ if not snaps:
220
+ console.print("[dim]no snapshots yet - run `devmemory task refresh`[/dim]")
221
+ return
222
+ table = Table(box=None, pad_edge=False, header_style="dim")
223
+ table.add_column("#", justify="right")
224
+ table.add_column("when", no_wrap=True)
225
+ table.add_column("status")
226
+ table.add_column("commit", no_wrap=True)
227
+ table.add_column("tests")
228
+ table.add_column("reqs done", justify="right")
229
+ for s in reversed(snaps):
230
+ st = s.state
231
+ style = _STATUS_STYLE.get(s.overall_status, "white")
232
+ done = sum(1 for r in st.requirements if r.status is RequirementStatus.COMPLETE)
233
+ when = s.created_at.strftime("%m-%d %H:%M") if s.created_at else "-"
234
+ table.add_row(
235
+ str(s.id),
236
+ when,
237
+ f"[{style}]{s.overall_status.value}[/{style}]",
238
+ (st.git.commit_sha or "-")[:8],
239
+ f"{st.tests.passed}/{st.tests.failed}",
240
+ f"{done}/{len(st.requirements)}",
241
+ )
242
+ console.print(table)
243
+
244
+
245
+ # --- the §19 dashboard --------------------------------------------------
246
+
247
+
248
+ def _render_state(state: NormalizedState) -> None:
249
+ st = state.overall_status
250
+ style = _STATUS_STYLE.get(st, "white")
251
+ header = Table.grid(padding=(0, 2))
252
+ header.add_column(style="bold cyan")
253
+ header.add_column()
254
+ header.add_row("TASK", f"{state.task.id} {state.task.goal}")
255
+ header.add_row("STATUS", f"[{style}]{st.value}[/{style}]")
256
+
257
+ done = sum(1 for r in state.requirements if r.status is RequirementStatus.COMPLETE)
258
+ header.add_row("REQUIREMENTS", f"{done} / {len(state.requirements)}")
259
+ header.add_row(
260
+ "CHECKPOINT",
261
+ state.checkpoint.current_id or state.checkpoint.last_committed_id or "[dim]none[/dim]",
262
+ )
263
+ g = state.git
264
+ header.add_row(
265
+ "COMMIT",
266
+ (g.commit_sha or "[dim]none[/dim]")[:12]
267
+ + (f" [dim]{g.commit_subject}[/dim]" if g.commit_subject else "")
268
+ + ("" if g.working_tree_clean else " [yellow](dirty)[/yellow]"),
269
+ )
270
+ header.add_row(
271
+ "FILES CHANGED",
272
+ f"{g.files_changed} ([green]+{g.lines_added}[/green]/[red]-{g.lines_deleted}[/red])",
273
+ )
274
+ t = state.tests
275
+ header.add_row(
276
+ "TESTS",
277
+ f"[green]{t.passed} passed[/green] / [red]{t.failed} failed[/red] [dim]({t.status.value})[/dim]",
278
+ )
279
+ if state.impact.available:
280
+ header.add_row(
281
+ "IMPACT", f"{state.impact.affected_files} files / {state.impact.affected_tests} tests"
282
+ )
283
+ else:
284
+ header.add_row("IMPACT", f"[dim]unavailable - {state.impact.reason}[/dim]")
285
+
286
+ console.print(Panel(header, title="state", border_style=style, title_align="left"))
287
+
288
+ rt = Table(box=None, pad_edge=False, header_style="dim")
289
+ rt.add_column("")
290
+ rt.add_column("requirement", overflow="fold")
291
+ rt.add_column("", overflow="fold", style="dim")
292
+ for r in state.requirements:
293
+ rs = _REQ_STYLE.get(r.status, "white")
294
+ rt.add_row(
295
+ f"[{rs}]{r.status.value[:4]}[/{rs}]", f"[cyan]{r.id}[/cyan] {r.description}", r.reason
296
+ )
297
+ console.print(rt)
298
+
299
+ if state.unresolved:
300
+ console.print("\n[bold]UNRESOLVED[/bold]")
301
+ for i in state.unresolved:
302
+ tag = " [red](blocking)[/red]" if i.blocking else ""
303
+ console.print(f" #{i.id} [{i.kind}] {i.description}{tag}")
304
+
305
+ if state.recommended_focus:
306
+ console.print("\n[bold]NEXT FOCUS[/bold]")
307
+ for f in state.recommended_focus:
308
+ console.print(f" - {f}")
309
+
310
+ if state.findings and not state.recommended_focus:
311
+ console.print("\n[bold]FINDINGS[/bold]")
312
+ for f in state.findings:
313
+ console.print(f" - {f}")
314
+
315
+
316
+ def state_command(
317
+ task_id: Annotated[str | None, typer.Argument(help="Task id (default: latest).")] = None,
318
+ refresh: Annotated[
319
+ bool, typer.Option("--refresh", "-r", help="Refresh before showing.")
320
+ ] = False,
321
+ as_json: Annotated[bool, typer.Option("--json")] = False,
322
+ ) -> None:
323
+ """Show the current loop state (the §19 dashboard). Alias for `devmemory task state`."""
324
+ with ProjectContext.load() as ctx:
325
+ tid = _resolve_task_id(ctx, task_id)
326
+ state = engine.refresh_state(ctx, tid) if refresh else engine.get_state(ctx, tid)
327
+ if as_json:
328
+ console.print(json.dumps(state.model_dump(mode="json"), indent=2))
329
+ return
330
+ _render_state(state)
331
+
332
+
333
+ __all__ = ["state_command", "task_app"]
devmemory/config.py ADDED
@@ -0,0 +1,302 @@
1
+ """Project configuration.
2
+
3
+ Precedence, lowest to highest:
4
+
5
+ 1. Built-in defaults (this module).
6
+ 2. ``.devmemory/config.json`` - committed, shared, never contains secrets.
7
+ 3. ``.devmemory/config.local.json`` - git-ignored, per-developer overrides.
8
+ 4. Environment variables - the only place secrets are read from.
9
+
10
+ ``DevMemoryConfig`` models (2) and (3). Secrets (Databricks token, LLM API keys)
11
+ are resolved separately, at call time, by the adapters that need them - they are
12
+ never loaded into this object, never logged, and never written back to disk.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError
23
+
24
+ from devmemory.domain.enums import MetricDirection
25
+ from devmemory.domain.errors import ConfigError
26
+ from devmemory.paths import ProjectPaths
27
+
28
+ # --- section models ------------------------------------------------------------------
29
+
30
+
31
+ class _Section(BaseModel):
32
+ model_config = ConfigDict(extra="forbid", populate_by_name=True)
33
+
34
+
35
+ class EntireSettings(_Section):
36
+ """How DevMemory talks to the Entire CLI."""
37
+
38
+ enabled: bool = True
39
+ binary: str | None = Field(
40
+ default=None,
41
+ description="Absolute path to the `entire` binary. Defaults to PATH lookup.",
42
+ )
43
+ repo: str | None = Field(
44
+ default=None,
45
+ description="`owner/name` passed to `entire --repo` when auto-detection fails.",
46
+ )
47
+
48
+
49
+ class TestSettings(_Section):
50
+ """Optional test collection during `devmemory checkpoint`."""
51
+
52
+ command: str | None = Field(
53
+ default=None,
54
+ description="Shell command to run, e.g. `pytest -q`, `npm test`, `go test ./...`.",
55
+ )
56
+ parser: str = Field(
57
+ default="auto",
58
+ description="Result parser: auto | pytest | junitxml | generic.",
59
+ )
60
+ junit_xml: str | None = Field(
61
+ default=None,
62
+ description="Path to a JUnit XML report to parse instead of stdout.",
63
+ )
64
+ timeout_seconds: int = 900
65
+
66
+
67
+ class MetricSettings(_Section):
68
+ """Where project metrics come from and which direction is 'better'."""
69
+
70
+ file: str | None = Field(
71
+ default=None,
72
+ description="JSON file read after tests run; keys become metric names.",
73
+ )
74
+ command: str | None = Field(
75
+ default=None,
76
+ description="Command whose JSON stdout provides metrics.",
77
+ )
78
+ directions: dict[str, MetricDirection] = Field(default_factory=dict)
79
+
80
+
81
+ class ArtifactSettings(_Section):
82
+ enabled: bool = True
83
+ exclude: list[str] = Field(
84
+ default_factory=lambda: [
85
+ ".git",
86
+ ".devmemory",
87
+ ".venv",
88
+ "venv",
89
+ "node_modules",
90
+ "__pycache__",
91
+ ".pytest_cache",
92
+ ".mypy_cache",
93
+ ".ruff_cache",
94
+ ]
95
+ )
96
+
97
+
98
+ class DatabricksSettings(_Section):
99
+ """Non-secret Databricks settings. Host / token / warehouse come from env."""
100
+
101
+ enabled: bool = False
102
+ catalog: str = "devmemory"
103
+ schema_name: str = Field(default="analytics", alias="schema")
104
+
105
+
106
+ class RegressionSettings(_Section):
107
+ """Thresholds for the rule-based regression detector."""
108
+
109
+ metric_pct: float = 2.0
110
+ metric_abs_floor: float = 1e-9
111
+ high_pct: float = 15.0
112
+ medium_pct: float = 6.0
113
+
114
+
115
+ class AnalysisSettings(_Section):
116
+ """LLM-backed analysis. Providers are tried in order; `rules` never fails.
117
+
118
+ Analysis is interpretation, kept structurally separate from facts - it can
119
+ never overwrite Git / Entire / test / metric data.
120
+ """
121
+
122
+ enabled: bool = True
123
+ providers: list[str] = Field(
124
+ default_factory=lambda: ["rules"],
125
+ description="Ordered fallback chain, e.g. ['anthropic', 'openai', 'rules']. "
126
+ "Keys come from the environment. `rules` always succeeds.",
127
+ )
128
+ model: str | None = Field(
129
+ default=None,
130
+ description="Model id for the active LLM provider (provider default otherwise).",
131
+ )
132
+ include_diff: bool = Field(
133
+ default=False,
134
+ description="Send a truncated unified diff to the LLM. Off by default - "
135
+ "otherwise only normalized facts leave the machine.",
136
+ )
137
+ max_diff_bytes: int = 4000
138
+
139
+
140
+ class GraphSettings(_Section):
141
+ """Optional change-impact analysis via the Entire `graph` plugin.
142
+
143
+ Off by default - it needs `entire plugin install graph` and rebuilds a local
144
+ code graph per run (a few seconds). Fully local, no egress.
145
+ """
146
+
147
+ enabled: bool = False
148
+ binary: str | None = Field(
149
+ default=None,
150
+ description="Absolute path to the `entire-graph` binary. Defaults to a managed-dir lookup.",
151
+ )
152
+ timeout_seconds: int = 90
153
+ max_seconds: int = 120
154
+
155
+
156
+ class WebSettings(_Section):
157
+ host: str = "127.0.0.1"
158
+ port: int = 8760
159
+ enable_restore: bool = False
160
+
161
+
162
+ # --- root model ---------------------------------------------------------------------
163
+
164
+
165
+ class DevMemoryConfig(_Section):
166
+ """The complete project configuration (defaults + config.json + config.local.json)."""
167
+
168
+ project_id: str
169
+ project_name: str
170
+ entire: EntireSettings = Field(default_factory=EntireSettings)
171
+ tests: TestSettings = Field(default_factory=TestSettings)
172
+ metrics: MetricSettings = Field(default_factory=MetricSettings)
173
+ artifacts: ArtifactSettings = Field(default_factory=ArtifactSettings)
174
+ regression: RegressionSettings = Field(default_factory=RegressionSettings)
175
+ databricks: DatabricksSettings = Field(default_factory=DatabricksSettings)
176
+ analysis: AnalysisSettings = Field(default_factory=AnalysisSettings)
177
+ graph: GraphSettings = Field(default_factory=GraphSettings)
178
+ web: WebSettings = Field(default_factory=WebSettings)
179
+
180
+ # -- construction --------------------------------------------------------------
181
+
182
+ @classmethod
183
+ def default_for(cls, *, project_id: str, project_name: str) -> DevMemoryConfig:
184
+ return cls(project_id=project_id, project_name=project_name)
185
+
186
+ @classmethod
187
+ def load(cls, paths: ProjectPaths) -> DevMemoryConfig:
188
+ """Load and validate config for a project, merging the local override file."""
189
+ base = _read_json(paths.config)
190
+ if not base:
191
+ raise ConfigError(
192
+ f"No configuration found at {paths.config}.",
193
+ hint="Run `devmemory init` to create it.",
194
+ )
195
+ override = _read_json(paths.config_local)
196
+ merged = _deep_merge(base, override)
197
+ try:
198
+ return cls.model_validate(merged)
199
+ except ValidationError as exc:
200
+ raise ConfigError(f"Invalid DevMemory configuration: {exc}") from exc
201
+
202
+ # -- persistence -------------------------------------------------------------
203
+
204
+ def to_config_dict(self) -> dict[str, Any]:
205
+ """The JSON form written to ``config.json`` (by alias, secret-free by design)."""
206
+ return self.model_dump(mode="json", by_alias=True, exclude_none=True)
207
+
208
+ def save(self, paths: ProjectPaths) -> None:
209
+ paths.root.mkdir(parents=True, exist_ok=True)
210
+ _write_json_atomic(paths.config, self.to_config_dict())
211
+
212
+
213
+ # --- runtime secret resolution (env only) ------------------------------------------
214
+
215
+
216
+ class DatabricksCredentials(BaseModel):
217
+ host: str
218
+ token: str
219
+ warehouse_id: str
220
+
221
+
222
+ def resolve_databricks_credentials() -> DatabricksCredentials | None:
223
+ """Read Databricks credentials from the environment. Returns ``None`` if incomplete.
224
+
225
+ Recognised: ``DATABRICKS_HOST``, ``DATABRICKS_TOKEN``, ``DATABRICKS_WAREHOUSE_ID``.
226
+ """
227
+ host = os.environ.get("DATABRICKS_HOST")
228
+ token = os.environ.get("DATABRICKS_TOKEN")
229
+ warehouse = os.environ.get("DATABRICKS_WAREHOUSE_ID")
230
+ if host and token and warehouse:
231
+ return DatabricksCredentials(host=host, token=token, warehouse_id=warehouse)
232
+ return None
233
+
234
+
235
+ _LLM_API_KEY_ENV = {
236
+ "anthropic": "ANTHROPIC_API_KEY",
237
+ "openai": "OPENAI_API_KEY",
238
+ "gemini": "GEMINI_API_KEY",
239
+ "google": "GOOGLE_API_KEY",
240
+ }
241
+
242
+
243
+ def resolve_llm_api_key(provider: str) -> str | None:
244
+ """Return the API key for an LLM provider from the environment, if present."""
245
+ env_var = _LLM_API_KEY_ENV.get(provider.lower())
246
+ if env_var is None:
247
+ return None
248
+ key = os.environ.get(env_var)
249
+ if not key and provider.lower() == "gemini":
250
+ key = os.environ.get("GOOGLE_API_KEY")
251
+ return key or None
252
+
253
+
254
+ # --- helpers ---------------------------------------------------------------------
255
+
256
+
257
+ def _read_json(path: Path) -> dict[str, Any]:
258
+ if not path.is_file():
259
+ return {}
260
+ try:
261
+ data = json.loads(path.read_text(encoding="utf-8"))
262
+ except json.JSONDecodeError as exc:
263
+ raise ConfigError(f"{path} is not valid JSON: {exc}") from exc
264
+ if not isinstance(data, dict):
265
+ raise ConfigError(f"{path} must contain a JSON object, got {type(data).__name__}.")
266
+ return data
267
+
268
+
269
+ def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
270
+ """Recursively merge ``override`` onto ``base`` without mutating either."""
271
+ result = dict(base)
272
+ for key, value in override.items():
273
+ existing = result.get(key)
274
+ if isinstance(existing, dict) and isinstance(value, dict):
275
+ result[key] = _deep_merge(existing, value)
276
+ else:
277
+ result[key] = value
278
+ return result
279
+
280
+
281
+ def _write_json_atomic(path: Path, data: dict[str, Any]) -> None:
282
+ path.parent.mkdir(parents=True, exist_ok=True)
283
+ tmp = path.with_name(f"{path.name}.tmp")
284
+ tmp.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8")
285
+ tmp.replace(path)
286
+
287
+
288
+ __all__ = [
289
+ "AnalysisSettings",
290
+ "ArtifactSettings",
291
+ "DatabricksCredentials",
292
+ "DatabricksSettings",
293
+ "DevMemoryConfig",
294
+ "EntireSettings",
295
+ "GraphSettings",
296
+ "MetricSettings",
297
+ "RegressionSettings",
298
+ "TestSettings",
299
+ "WebSettings",
300
+ "resolve_databricks_credentials",
301
+ "resolve_llm_api_key",
302
+ ]
@@ -0,0 +1,5 @@
1
+ """Domain layer - concepts that belong to DevMemory itself.
2
+
3
+ Nothing in this package may import FastAPI, sqlite3, the Databricks SDK, git
4
+ subprocesses, or Entire internals. It holds data shapes and business rules only.
5
+ """