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.
- devmemory/__about__.py +3 -0
- devmemory/__init__.py +14 -0
- devmemory/__main__.py +6 -0
- devmemory/adapters/__init__.py +6 -0
- devmemory/adapters/databricks.py +346 -0
- devmemory/adapters/entire.py +444 -0
- devmemory/adapters/git.py +408 -0
- devmemory/adapters/graph.py +251 -0
- devmemory/adapters/metrics.py +150 -0
- devmemory/adapters/tests.py +227 -0
- devmemory/analysis/__init__.py +19 -0
- devmemory/analysis/base.py +128 -0
- devmemory/analysis/chain.py +53 -0
- devmemory/analysis/llm.py +236 -0
- devmemory/analysis/rules.py +110 -0
- devmemory/api/__init__.py +10 -0
- devmemory/api/app.py +390 -0
- devmemory/api/mappers.py +187 -0
- devmemory/api/schemas.py +201 -0
- devmemory/cli/__init__.py +1 -0
- devmemory/cli/_errors.py +36 -0
- devmemory/cli/_render.py +79 -0
- devmemory/cli/analytics.py +136 -0
- devmemory/cli/analyze.py +58 -0
- devmemory/cli/app.py +163 -0
- devmemory/cli/checkpoint.py +199 -0
- devmemory/cli/compare.py +104 -0
- devmemory/cli/doctor.py +151 -0
- devmemory/cli/history.py +56 -0
- devmemory/cli/impact.py +95 -0
- devmemory/cli/init.py +91 -0
- devmemory/cli/mcp.py +66 -0
- devmemory/cli/memory.py +70 -0
- devmemory/cli/restore.py +91 -0
- devmemory/cli/search.py +48 -0
- devmemory/cli/serve.py +64 -0
- devmemory/cli/show.py +139 -0
- devmemory/cli/status.py +72 -0
- devmemory/cli/task.py +333 -0
- devmemory/config.py +302 -0
- devmemory/domain/__init__.py +5 -0
- devmemory/domain/enums.py +151 -0
- devmemory/domain/errors.py +188 -0
- devmemory/domain/models.py +452 -0
- devmemory/domain/taskloop.py +212 -0
- devmemory/environment.py +67 -0
- devmemory/logging.py +148 -0
- devmemory/mcp/__init__.py +12 -0
- devmemory/mcp/server.py +225 -0
- devmemory/paths.py +112 -0
- devmemory/pipeline/__init__.py +7 -0
- devmemory/pipeline/checkpoint.py +443 -0
- devmemory/pipeline/feature_detect.py +53 -0
- devmemory/pipeline/regression.py +141 -0
- devmemory/pipeline/runlog.py +73 -0
- devmemory/pipeline/status_rules.py +44 -0
- devmemory/py.typed +0 -0
- devmemory/services/__init__.py +9 -0
- devmemory/services/agent_context.py +287 -0
- devmemory/services/analysis.py +116 -0
- devmemory/services/analytics.py +328 -0
- devmemory/services/brief.py +53 -0
- devmemory/services/context.py +88 -0
- devmemory/services/databricks_sync.py +121 -0
- devmemory/services/features.py +85 -0
- devmemory/services/impact.py +47 -0
- devmemory/services/memory.py +212 -0
- devmemory/services/projects.py +226 -0
- devmemory/services/restore.py +194 -0
- devmemory/services/taskloop/__init__.py +39 -0
- devmemory/services/taskloop/collectors.py +263 -0
- devmemory/services/taskloop/engine.py +426 -0
- devmemory/services/taskloop/requirements.py +358 -0
- devmemory/services/trace.py +152 -0
- devmemory/services/versions.py +287 -0
- devmemory/storage/__init__.py +9 -0
- devmemory/storage/artifacts.py +113 -0
- devmemory/storage/db.py +205 -0
- devmemory/storage/graph_impacts.py +63 -0
- devmemory/storage/migrations/0001_init.sql +15 -0
- devmemory/storage/migrations/0002_versions.sql +210 -0
- devmemory/storage/migrations/0003_graph.sql +14 -0
- devmemory/storage/migrations/0004_taskloop.sql +82 -0
- devmemory/storage/migrations/0005_project_brief.sql +12 -0
- devmemory/storage/repositories.py +286 -0
- devmemory/storage/tasks.py +342 -0
- devmemory/storage/versions.py +604 -0
- devmemory/web/static/assets/index-CbV5njRH.js +78 -0
- devmemory/web/static/assets/index-DD-7ceZx.css +1 -0
- devmemory/web/static/index.html +18 -0
- devmemory_cli-0.1.0.dev0.dist-info/METADATA +174 -0
- devmemory_cli-0.1.0.dev0.dist-info/RECORD +95 -0
- devmemory_cli-0.1.0.dev0.dist-info/WHEEL +4 -0
- devmemory_cli-0.1.0.dev0.dist-info/entry_points.txt +3 -0
- 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
|
+
]
|