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,342 @@
1
+ """Data access for the state-aware coding loop (migration 0004).
2
+
3
+ Follows the same rules as :mod:`devmemory.storage.repositories`: every SQL
4
+ statement for tasks / requirements / issues / test runs / state snapshots lives
5
+ here, and methods return :mod:`devmemory.domain.taskloop` models.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import sqlite3
12
+ from datetime import UTC, datetime
13
+
14
+ from devmemory.domain.enums import IssueStatus, RequirementStatus, TaskStatus, TestRunStatus
15
+ from devmemory.domain.taskloop import (
16
+ Issue,
17
+ NormalizedState,
18
+ Requirement,
19
+ StateSnapshot,
20
+ Task,
21
+ TaskTestRun,
22
+ )
23
+ from devmemory.storage.db import Database
24
+
25
+
26
+ def _utcnow() -> str:
27
+ return datetime.now(UTC).isoformat()
28
+
29
+
30
+ def _dt(value: str | None) -> datetime | None:
31
+ if not value:
32
+ return None
33
+ try:
34
+ return datetime.fromisoformat(value)
35
+ except ValueError:
36
+ return None
37
+
38
+
39
+ class TaskRepository:
40
+ def __init__(self, db: Database) -> None:
41
+ self._db = db
42
+
43
+ # -- tasks ---------------------------------------------------------------
44
+
45
+ def next_task_id(self) -> str:
46
+ row = self._db.query_one("SELECT COUNT(*) AS n FROM tasks")
47
+ return f"TASK-{(int(row['n']) if row else 0) + 1:03d}"
48
+
49
+ def create(self, task: Task) -> Task:
50
+ now = _utcnow()
51
+ with self._db.transaction() as conn:
52
+ conn.execute(
53
+ """
54
+ INSERT INTO tasks (id, goal, status, branch, base_commit, test_command,
55
+ push_policy, created_at, updated_at)
56
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
57
+ """,
58
+ (
59
+ task.id,
60
+ task.goal,
61
+ task.status.value,
62
+ task.branch,
63
+ task.base_commit,
64
+ task.test_command,
65
+ task.push_policy,
66
+ now,
67
+ now,
68
+ ),
69
+ )
70
+ for pos, req in enumerate(task.requirements):
71
+ conn.execute(
72
+ """
73
+ INSERT INTO requirements (task_id, req_id, description, status, reason,
74
+ evidence_json, evaluated_at, evaluated_by, position)
75
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
76
+ """,
77
+ (
78
+ task.id,
79
+ req.id,
80
+ req.description,
81
+ req.status.value,
82
+ req.reason,
83
+ json.dumps(req.evidence),
84
+ req.evaluated_at.isoformat() if req.evaluated_at else None,
85
+ req.evaluated_by,
86
+ pos,
87
+ ),
88
+ )
89
+ return self.get(task.id) # type: ignore[return-value]
90
+
91
+ def get(self, task_id: str) -> Task | None:
92
+ row = self._db.query_one("SELECT * FROM tasks WHERE id = ?", (task_id,))
93
+ if row is None:
94
+ return None
95
+ return Task(
96
+ id=row["id"],
97
+ goal=row["goal"],
98
+ status=TaskStatus(row["status"]),
99
+ branch=row["branch"],
100
+ base_commit=row["base_commit"],
101
+ test_command=row["test_command"],
102
+ push_policy=row["push_policy"],
103
+ created_at=_dt(row["created_at"]),
104
+ updated_at=_dt(row["updated_at"]),
105
+ requirements=self.requirements(task_id),
106
+ )
107
+
108
+ def list_tasks(self) -> list[Task]:
109
+ rows = self._db.query("SELECT id FROM tasks ORDER BY created_at")
110
+ return [t for r in rows if (t := self.get(r["id"])) is not None]
111
+
112
+ def latest(self) -> Task | None:
113
+ row = self._db.query_one("SELECT id FROM tasks ORDER BY created_at DESC LIMIT 1")
114
+ return self.get(row["id"]) if row else None
115
+
116
+ def set_status(self, task_id: str, status: TaskStatus) -> None:
117
+ self._db.execute(
118
+ "UPDATE tasks SET status = ?, updated_at = ? WHERE id = ?",
119
+ (status.value, _utcnow(), task_id),
120
+ )
121
+
122
+ # -- requirements ------------------------------------------------------
123
+
124
+ def requirements(self, task_id: str) -> list[Requirement]:
125
+ rows = self._db.query(
126
+ "SELECT * FROM requirements WHERE task_id = ? ORDER BY position, req_id",
127
+ (task_id,),
128
+ )
129
+ out: list[Requirement] = []
130
+ for row in rows:
131
+ try:
132
+ evidence = json.loads(row["evidence_json"]) or []
133
+ except (json.JSONDecodeError, TypeError):
134
+ evidence = []
135
+ out.append(
136
+ Requirement(
137
+ id=row["req_id"],
138
+ description=row["description"],
139
+ status=RequirementStatus(row["status"]),
140
+ reason=row["reason"],
141
+ evidence=list(evidence),
142
+ evaluated_at=_dt(row["evaluated_at"]),
143
+ evaluated_by=row["evaluated_by"],
144
+ )
145
+ )
146
+ return out
147
+
148
+ def update_requirement(self, task_id: str, req: Requirement) -> None:
149
+ self._db.execute(
150
+ """
151
+ UPDATE requirements
152
+ SET status = ?, reason = ?, evidence_json = ?, evaluated_at = ?, evaluated_by = ?
153
+ WHERE task_id = ? AND req_id = ?
154
+ """,
155
+ (
156
+ req.status.value,
157
+ req.reason,
158
+ json.dumps(req.evidence),
159
+ req.evaluated_at.isoformat() if req.evaluated_at else _utcnow(),
160
+ req.evaluated_by,
161
+ task_id,
162
+ req.id,
163
+ ),
164
+ )
165
+
166
+ # -- issues ----------------------------------------------------------
167
+
168
+ def add_issue(self, issue: Issue) -> Issue:
169
+ now = _utcnow()
170
+ with self._db.transaction() as conn:
171
+ cur = conn.execute(
172
+ """
173
+ INSERT INTO issues (task_id, description, kind, blocking, status, created_at)
174
+ VALUES (?, ?, ?, ?, 'OPEN', ?)
175
+ """,
176
+ (issue.task_id, issue.description, issue.kind, int(issue.blocking), now),
177
+ )
178
+ issue_id = int(cur.lastrowid or 0)
179
+ return issue.model_copy(update={"id": issue_id, "created_at": _dt(now)})
180
+
181
+ def open_issues(self, task_id: str) -> list[Issue]:
182
+ rows = self._db.query(
183
+ "SELECT * FROM issues WHERE task_id = ? AND status = 'OPEN' ORDER BY created_at",
184
+ (task_id,),
185
+ )
186
+ return [
187
+ Issue(
188
+ id=row["id"],
189
+ task_id=row["task_id"],
190
+ description=row["description"],
191
+ kind=row["kind"],
192
+ blocking=bool(row["blocking"]),
193
+ status=IssueStatus(row["status"]),
194
+ created_at=_dt(row["created_at"]),
195
+ resolved_at=_dt(row["resolved_at"]),
196
+ )
197
+ for row in rows
198
+ ]
199
+
200
+ def resolve_issue(self, issue_id: int) -> bool:
201
+ with self._db.transaction() as conn:
202
+ cur = conn.execute(
203
+ "UPDATE issues SET status = 'RESOLVED', resolved_at = ? WHERE id = ? AND status = 'OPEN'",
204
+ (_utcnow(), issue_id),
205
+ )
206
+ return cur.rowcount > 0
207
+
208
+ def resolve_issues_by_kind(self, task_id: str, kind: str) -> int:
209
+ """Auto-close engine-raised issues of one kind before re-raising fresh ones."""
210
+ with self._db.transaction() as conn:
211
+ cur = conn.execute(
212
+ "UPDATE issues SET status = 'RESOLVED', resolved_at = ? "
213
+ "WHERE task_id = ? AND kind = ? AND status = 'OPEN'",
214
+ (_utcnow(), task_id, kind),
215
+ )
216
+ return cur.rowcount
217
+
218
+ # -- test runs -----------------------------------------------------
219
+
220
+ def add_test_run(self, run: TaskTestRun) -> TaskTestRun:
221
+ now = _utcnow()
222
+ with self._db.transaction() as conn:
223
+ cur = conn.execute(
224
+ """
225
+ INSERT INTO task_test_runs (task_id, command, status, passed, failed, skipped,
226
+ exit_code, duration_seconds, output_path, created_at)
227
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
228
+ """,
229
+ (
230
+ run.task_id,
231
+ run.command,
232
+ run.status.value,
233
+ run.passed,
234
+ run.failed,
235
+ run.skipped,
236
+ run.exit_code,
237
+ run.duration_seconds,
238
+ run.output_path,
239
+ now,
240
+ ),
241
+ )
242
+ run_id = int(cur.lastrowid or 0)
243
+ return run.model_copy(update={"id": run_id, "created_at": _dt(now)})
244
+
245
+ def latest_test_run(self, task_id: str) -> TaskTestRun | None:
246
+ row = self._db.query_one(
247
+ "SELECT * FROM task_test_runs WHERE task_id = ? ORDER BY created_at DESC LIMIT 1",
248
+ (task_id,),
249
+ )
250
+ if row is None:
251
+ return None
252
+ return TaskTestRun(
253
+ id=row["id"],
254
+ task_id=row["task_id"],
255
+ command=row["command"],
256
+ status=TestRunStatus(row["status"]),
257
+ passed=row["passed"],
258
+ failed=row["failed"],
259
+ skipped=row["skipped"],
260
+ exit_code=row["exit_code"],
261
+ duration_seconds=row["duration_seconds"],
262
+ output_path=row["output_path"],
263
+ created_at=_dt(row["created_at"]),
264
+ )
265
+
266
+ # -- state snapshots (the observable loop) ------------------------
267
+
268
+ def append_snapshot(self, state: NormalizedState) -> StateSnapshot:
269
+ now = _utcnow()
270
+ with self._db.transaction() as conn:
271
+ cur = conn.execute(
272
+ """
273
+ INSERT INTO state_snapshots (task_id, overall_status, commit_sha, checkpoint_id,
274
+ state_json, created_at)
275
+ VALUES (?, ?, ?, ?, ?, ?)
276
+ """,
277
+ (
278
+ state.task.id,
279
+ state.overall_status.value,
280
+ state.git.commit_sha,
281
+ state.checkpoint.current_id,
282
+ state.model_dump_json(),
283
+ now,
284
+ ),
285
+ )
286
+ snap_id = int(cur.lastrowid or 0)
287
+ return StateSnapshot(
288
+ id=snap_id,
289
+ task_id=state.task.id,
290
+ overall_status=state.overall_status,
291
+ state=state,
292
+ created_at=_dt(now),
293
+ )
294
+
295
+ def latest_snapshot(self, task_id: str) -> StateSnapshot | None:
296
+ row = self._db.query_one(
297
+ "SELECT * FROM state_snapshots WHERE task_id = ? ORDER BY created_at DESC LIMIT 1",
298
+ (task_id,),
299
+ )
300
+ return _snapshot_from_row(row)
301
+
302
+ def snapshots(self, task_id: str, *, limit: int = 20) -> list[StateSnapshot]:
303
+ rows = self._db.query(
304
+ "SELECT * FROM state_snapshots WHERE task_id = ? ORDER BY created_at DESC LIMIT ?",
305
+ (task_id, limit),
306
+ )
307
+ return [s for row in rows if (s := _snapshot_from_row(row)) is not None]
308
+
309
+ # -- task <-> commit index --------------------------------------
310
+
311
+ def link_commit(self, task_id: str, commit_sha: str) -> None:
312
+ self._db.execute(
313
+ "INSERT OR IGNORE INTO task_commits (task_id, commit_sha, created_at) VALUES (?, ?, ?)",
314
+ (task_id, commit_sha, _utcnow()),
315
+ )
316
+
317
+ def task_commits(self, task_id: str) -> list[str]:
318
+ rows = self._db.query(
319
+ "SELECT commit_sha FROM task_commits WHERE task_id = ? ORDER BY created_at",
320
+ (task_id,),
321
+ )
322
+ return [r["commit_sha"] for r in rows]
323
+
324
+
325
+ def _snapshot_from_row(row: sqlite3.Row | None) -> StateSnapshot | None:
326
+ if row is None:
327
+ return None
328
+ try:
329
+ state = NormalizedState.model_validate_json(row["state_json"])
330
+ except ValueError:
331
+ return None
332
+ state.snapshot_id = row["id"] # the id is a property of the row, injected on read
333
+ return StateSnapshot(
334
+ id=row["id"],
335
+ task_id=row["task_id"],
336
+ overall_status=TaskStatus(row["overall_status"]),
337
+ state=state,
338
+ created_at=_dt(row["created_at"]),
339
+ )
340
+
341
+
342
+ __all__ = ["TaskRepository"]