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,604 @@
1
+ """The development-version repository: persistence + hydration + full-text index.
2
+
3
+ The heaviest data-access object - a version fans out to changed files, tests,
4
+ metrics, regressions, analysis, artifacts, doc flags, and checkpoint links, all
5
+ written and read here.
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 AssociationMethod, ChangeType, MetricDirection, VersionStatus
15
+ from devmemory.domain.models import (
16
+ Analysis,
17
+ Artifact,
18
+ ChangedFile,
19
+ DevelopmentEvent,
20
+ DevelopmentVersion,
21
+ DocFlag,
22
+ EnvironmentInfo,
23
+ Metric,
24
+ Regression,
25
+ TestOutcome,
26
+ )
27
+ from devmemory.storage.db import Database
28
+ from devmemory.storage.repositories import CheckpointRepository
29
+
30
+
31
+ def _utcnow() -> datetime:
32
+ return datetime.now(UTC)
33
+
34
+
35
+ def _dt(value: str | None) -> datetime | None:
36
+ if not value:
37
+ return None
38
+ try:
39
+ return datetime.fromisoformat(value)
40
+ except ValueError:
41
+ return None
42
+
43
+
44
+ def _iso(value: datetime | None) -> str | None:
45
+ return value.isoformat() if value else None
46
+
47
+
48
+ class VersionRepository:
49
+ def __init__(self, db: Database) -> None:
50
+ self._db = db
51
+ self._checkpoints = CheckpointRepository(db)
52
+
53
+ # -- numbering / lookup ------------------------------------------------
54
+
55
+ def next_version_number(self, project_id: str) -> int:
56
+ row = self._db.query_one(
57
+ "SELECT COALESCE(MAX(version_number), 0) + 1 AS n FROM versions WHERE project_id = ?",
58
+ (project_id,),
59
+ )
60
+ return int(row["n"]) if row else 1
61
+
62
+ def find_by_commit(self, project_id: str, git_commit: str) -> DevelopmentVersion | None:
63
+ row = self._db.query_one(
64
+ "SELECT * FROM versions WHERE project_id = ? AND git_commit = ?",
65
+ (project_id, git_commit),
66
+ )
67
+ return self._hydrate(row) if row else None
68
+
69
+ def get(self, version_id: str) -> DevelopmentVersion | None:
70
+ row = self._db.query_one("SELECT * FROM versions WHERE version_id = ?", (version_id,))
71
+ return self._hydrate(row) if row else None
72
+
73
+ def get_by_number(self, project_id: str, number: int) -> DevelopmentVersion | None:
74
+ row = self._db.query_one(
75
+ "SELECT * FROM versions WHERE project_id = ? AND version_number = ?",
76
+ (project_id, number),
77
+ )
78
+ return self._hydrate(row) if row else None
79
+
80
+ def resolve(self, project_id: str, ref: str) -> DevelopmentVersion | None:
81
+ """Accept ``v7`` / ``V7`` / ``7`` / a git sha prefix."""
82
+ token = ref.strip().lower()
83
+ if token.startswith("v"):
84
+ token = token[1:]
85
+ if token.isdigit():
86
+ return self.get_by_number(project_id, int(token))
87
+ row = self._db.query_one(
88
+ "SELECT * FROM versions WHERE project_id = ? AND git_commit LIKE ?",
89
+ (project_id, f"{ref}%"),
90
+ )
91
+ return self._hydrate(row) if row else None
92
+
93
+ def latest(self, project_id: str) -> DevelopmentVersion | None:
94
+ row = self._db.query_one(
95
+ "SELECT * FROM versions WHERE project_id = ? ORDER BY version_number DESC LIMIT 1",
96
+ (project_id,),
97
+ )
98
+ return self._hydrate(row) if row else None
99
+
100
+ def count(self, project_id: str) -> int:
101
+ row = self._db.query_one(
102
+ "SELECT COUNT(*) AS c FROM versions WHERE project_id = ?", (project_id,)
103
+ )
104
+ return int(row["c"]) if row else 0
105
+
106
+ def page(
107
+ self,
108
+ project_id: str,
109
+ *,
110
+ limit: int = 100,
111
+ offset: int = 0,
112
+ ascending: bool = True,
113
+ ) -> list[DevelopmentVersion]:
114
+ order = "ASC" if ascending else "DESC" # literal, not user input
115
+ rows = self._db.query(
116
+ "SELECT * FROM versions WHERE project_id = ? " # noqa: S608
117
+ f"ORDER BY version_number {order} LIMIT ? OFFSET ?",
118
+ (project_id, limit, offset),
119
+ )
120
+ return [self._hydrate(r) for r in rows]
121
+
122
+ def previous_relevant(
123
+ self, project_id: str, before_number: int, *, feature_id: str | None = None
124
+ ) -> DevelopmentVersion | None:
125
+ """The version to compare against for regression detection."""
126
+ if feature_id:
127
+ row = self._db.query_one(
128
+ "SELECT * FROM versions WHERE project_id = ? AND version_number < ? "
129
+ "AND feature_id = ? ORDER BY version_number DESC LIMIT 1",
130
+ (project_id, before_number, feature_id),
131
+ )
132
+ if row:
133
+ return self._hydrate(row)
134
+ row = self._db.query_one(
135
+ "SELECT * FROM versions WHERE project_id = ? AND version_number < ? "
136
+ "ORDER BY version_number DESC LIMIT 1",
137
+ (project_id, before_number),
138
+ )
139
+ return self._hydrate(row) if row else None
140
+
141
+ def for_feature(self, feature_id: str) -> list[DevelopmentVersion]:
142
+ rows = self._db.query(
143
+ "SELECT * FROM versions WHERE feature_id = ? ORDER BY version_number ASC",
144
+ (feature_id,),
145
+ )
146
+ return [self._hydrate(r) for r in rows]
147
+
148
+ # -- write ----------------------------------------------------------
149
+
150
+ def create(
151
+ self,
152
+ version: DevelopmentVersion,
153
+ *,
154
+ source_event: DevelopmentEvent | None = None,
155
+ ) -> DevelopmentVersion:
156
+ """Persist a fully-assembled version and all its children in one transaction."""
157
+ with self._db.transaction() as conn:
158
+ self._insert_version(conn, version, source_event)
159
+ self._insert_changed_files(conn, version)
160
+ self._insert_tests(conn, version)
161
+ self._insert_metrics(conn, version)
162
+ self._insert_regressions(conn, version)
163
+ self._insert_analysis(conn, version)
164
+ self._insert_doc_flags(conn, version)
165
+ self._index_fts(conn, version)
166
+
167
+ if version.primary_checkpoint is not None:
168
+ self._checkpoints.upsert(version.project_id, version.primary_checkpoint)
169
+ self._checkpoints.link(
170
+ version.version_id,
171
+ version.primary_checkpoint.checkpoint_id,
172
+ is_primary=True,
173
+ )
174
+ return self.get(version.version_id) or version
175
+
176
+ def replace(
177
+ self,
178
+ version: DevelopmentVersion,
179
+ *,
180
+ source_event: DevelopmentEvent | None = None,
181
+ ) -> DevelopmentVersion:
182
+ """Re-record an existing version in place (same id/number), replacing all
183
+ derived data. Used by ``devmemory checkpoint --force``.
184
+ """
185
+ with self._db.transaction() as conn:
186
+ # Child rows cascade on the version delete; do the version last.
187
+ for table in (
188
+ "changed_files",
189
+ "tests",
190
+ "metrics",
191
+ "regressions",
192
+ "analysis",
193
+ "doc_flags",
194
+ "version_checkpoints",
195
+ ):
196
+ conn.execute(f"DELETE FROM {table} WHERE version_id = ?", (version.version_id,)) # noqa: S608
197
+ conn.execute("DELETE FROM versions WHERE version_id = ?", (version.version_id,))
198
+ self._insert_version(conn, version, source_event)
199
+ self._insert_changed_files(conn, version)
200
+ self._insert_tests(conn, version)
201
+ self._insert_metrics(conn, version)
202
+ self._insert_regressions(conn, version)
203
+ self._insert_analysis(conn, version)
204
+ self._insert_doc_flags(conn, version)
205
+ self._index_fts(conn, version)
206
+
207
+ if version.primary_checkpoint is not None:
208
+ self._checkpoints.upsert(version.project_id, version.primary_checkpoint)
209
+ self._checkpoints.link(
210
+ version.version_id,
211
+ version.primary_checkpoint.checkpoint_id,
212
+ is_primary=True,
213
+ )
214
+ return self.get(version.version_id) or version
215
+
216
+ def replace_analysis(self, version_id: str, analysis: Analysis) -> None:
217
+ version = self.get(version_id)
218
+ if version is None:
219
+ return
220
+ with self._db.transaction() as conn:
221
+ conn.execute("DELETE FROM analysis WHERE version_id = ?", (version_id,))
222
+ version.analysis = analysis
223
+ self._insert_analysis(conn, version)
224
+ self._index_fts(conn, version)
225
+
226
+ def add_artifact(self, artifact: Artifact) -> None:
227
+ with self._db.transaction() as conn:
228
+ conn.execute(
229
+ "INSERT OR REPLACE INTO artifacts "
230
+ "(artifact_id, version_id, path, type, size_bytes, sha256, created_at) "
231
+ "VALUES (?, ?, ?, ?, ?, ?, ?)",
232
+ (
233
+ artifact.artifact_id,
234
+ artifact.version_id,
235
+ artifact.path,
236
+ artifact.type,
237
+ artifact.size_bytes,
238
+ artifact.sha256,
239
+ _iso(artifact.created_at or _utcnow()),
240
+ ),
241
+ )
242
+
243
+ # -- insert helpers --------------------------------------------
244
+
245
+ def _insert_version(
246
+ self,
247
+ conn: sqlite3.Connection,
248
+ v: DevelopmentVersion,
249
+ event: DevelopmentEvent | None,
250
+ ) -> None:
251
+ conn.execute(
252
+ """
253
+ INSERT INTO versions (
254
+ version_number, version_id, project_id, intent, agent, model,
255
+ git_commit, parent_commit, branch, feature_id, status,
256
+ files_changed, lines_added, lines_removed,
257
+ entire_association_method, entire_association_confidence,
258
+ environment_json, source_event_json, run_id, created_at, committed_at
259
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
260
+ """,
261
+ (
262
+ v.version_number,
263
+ v.version_id,
264
+ v.project_id,
265
+ v.intent,
266
+ v.agent,
267
+ v.model,
268
+ v.git_commit,
269
+ v.parent_commit,
270
+ v.branch,
271
+ v.feature_id,
272
+ v.status.value,
273
+ v.files_changed,
274
+ v.lines_added,
275
+ v.lines_removed,
276
+ v.entire_association_method.value,
277
+ v.entire_association_confidence,
278
+ v.environment.model_dump_json() if v.environment else None,
279
+ event.model_dump_json() if event else None,
280
+ v.run_id,
281
+ _iso(v.created_at),
282
+ _iso(v.committed_at),
283
+ ),
284
+ )
285
+
286
+ def _insert_changed_files(self, conn: sqlite3.Connection, v: DevelopmentVersion) -> None:
287
+ conn.executemany(
288
+ "INSERT INTO changed_files "
289
+ "(version_id, path, old_path, change_type, additions, deletions, binary) "
290
+ "VALUES (?, ?, ?, ?, ?, ?, ?)",
291
+ [
292
+ (
293
+ v.version_id,
294
+ f.path,
295
+ f.old_path,
296
+ f.change_type.value,
297
+ f.additions,
298
+ f.deletions,
299
+ 1 if f.binary else 0,
300
+ )
301
+ for f in v.changed_files
302
+ ],
303
+ )
304
+
305
+ def _insert_tests(self, conn: sqlite3.Connection, v: DevelopmentVersion) -> None:
306
+ if v.tests is None or not v.tests.ran:
307
+ return
308
+ t = v.tests
309
+ conn.execute(
310
+ """
311
+ INSERT INTO tests (version_id, command, framework, total, passed, failed,
312
+ skipped, errors, exit_code, duration_seconds, failing_json,
313
+ output, collected_at)
314
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
315
+ """,
316
+ (
317
+ v.version_id,
318
+ t.command,
319
+ t.framework,
320
+ t.total,
321
+ t.passed,
322
+ t.failed,
323
+ t.skipped,
324
+ t.errors,
325
+ t.exit_code,
326
+ t.duration_seconds,
327
+ json.dumps(t.failing),
328
+ t.output,
329
+ _iso(_utcnow()),
330
+ ),
331
+ )
332
+
333
+ def _insert_metrics(self, conn: sqlite3.Connection, v: DevelopmentVersion) -> None:
334
+ conn.executemany(
335
+ "INSERT INTO metrics (version_id, name, before_value, after_value, unit, "
336
+ "direction, metadata_json) VALUES (?, ?, ?, ?, ?, ?, ?)",
337
+ [
338
+ (
339
+ v.version_id,
340
+ m.name,
341
+ m.before,
342
+ m.after,
343
+ m.unit,
344
+ m.direction.value,
345
+ json.dumps(m.metadata) if m.metadata else None,
346
+ )
347
+ for m in v.metrics
348
+ ],
349
+ )
350
+
351
+ def _insert_regressions(self, conn: sqlite3.Connection, v: DevelopmentVersion) -> None:
352
+ conn.executemany(
353
+ "INSERT INTO regressions (version_id, kind, metric, before_value, after_value, "
354
+ "change_percent, severity, detail) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
355
+ [
356
+ (
357
+ v.version_id,
358
+ r.kind,
359
+ r.metric,
360
+ r.before,
361
+ r.after,
362
+ r.change_percent,
363
+ r.severity,
364
+ r.detail,
365
+ )
366
+ for r in v.regressions
367
+ ],
368
+ )
369
+
370
+ def _insert_analysis(self, conn: sqlite3.Connection, v: DevelopmentVersion) -> None:
371
+ if v.analysis is None:
372
+ return
373
+ a = v.analysis
374
+ conn.execute(
375
+ """
376
+ INSERT INTO analysis (version_id, summary, reasoning, recommendation,
377
+ warnings_json, risk, provider, model, generated_at)
378
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
379
+ """,
380
+ (
381
+ v.version_id,
382
+ a.summary,
383
+ a.reasoning,
384
+ a.recommendation,
385
+ json.dumps(a.warnings),
386
+ a.risk,
387
+ a.provider,
388
+ a.model,
389
+ _iso(a.generated_at or _utcnow()),
390
+ ),
391
+ )
392
+
393
+ def _insert_doc_flags(self, conn: sqlite3.Connection, v: DevelopmentVersion) -> None:
394
+ conn.executemany(
395
+ "INSERT INTO doc_flags (version_id, doc_path, reason) VALUES (?, ?, ?)",
396
+ [(v.version_id, d.doc_path, d.reason) for d in v.doc_flags],
397
+ )
398
+
399
+ def _index_fts(self, conn: sqlite3.Connection, v: DevelopmentVersion) -> None:
400
+ conn.execute("DELETE FROM version_search WHERE version_id = ?", (v.version_id,))
401
+ files = " ".join(f.path for f in v.changed_files)
402
+ errors = " ".join(
403
+ (v.tests.failing if v.tests else []) + [r.detail or "" for r in v.regressions]
404
+ )
405
+ conn.execute(
406
+ """
407
+ INSERT INTO version_search (version_id, intent, feature, agent, model, status,
408
+ files, commit_sha, checkpoint_id, analysis,
409
+ recommendation, errors)
410
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
411
+ """,
412
+ (
413
+ v.version_id,
414
+ v.intent or "",
415
+ v.feature_id or "",
416
+ v.agent or "",
417
+ v.model or "",
418
+ v.status.value,
419
+ files,
420
+ v.git_commit,
421
+ " ".join(v.checkpoint_ids),
422
+ (v.analysis.summary if v.analysis else ""),
423
+ (v.analysis.recommendation if v.analysis and v.analysis.recommendation else ""),
424
+ errors,
425
+ ),
426
+ )
427
+
428
+ # -- read: hydration -----------------------------------------
429
+
430
+ def _hydrate(self, row: sqlite3.Row) -> DevelopmentVersion:
431
+ vid = row["version_id"]
432
+ q = self._db.query
433
+
434
+ changed_files = [
435
+ ChangedFile(
436
+ path=r["path"],
437
+ old_path=r["old_path"],
438
+ change_type=ChangeType(r["change_type"]),
439
+ additions=r["additions"],
440
+ deletions=r["deletions"],
441
+ binary=bool(r["binary"]),
442
+ )
443
+ for r in q("SELECT * FROM changed_files WHERE version_id = ? ORDER BY id", (vid,))
444
+ ]
445
+
446
+ test_row = self._db.query_one(
447
+ "SELECT * FROM tests WHERE version_id = ? ORDER BY id DESC LIMIT 1", (vid,)
448
+ )
449
+ tests = _test_from_row(test_row) if test_row else None
450
+
451
+ metrics = [
452
+ Metric(
453
+ name=r["name"],
454
+ before=r["before_value"],
455
+ after=r["after_value"],
456
+ unit=r["unit"],
457
+ direction=MetricDirection(r["direction"] or MetricDirection.HIGHER_IS_BETTER.value),
458
+ metadata=json.loads(r["metadata_json"]) if r["metadata_json"] else {},
459
+ )
460
+ for r in q("SELECT * FROM metrics WHERE version_id = ? ORDER BY id", (vid,))
461
+ ]
462
+
463
+ regressions = [
464
+ Regression(
465
+ version_id=vid,
466
+ kind=r["kind"],
467
+ metric=r["metric"],
468
+ before=r["before_value"],
469
+ after=r["after_value"],
470
+ change_percent=r["change_percent"],
471
+ severity=r["severity"],
472
+ detail=r["detail"],
473
+ )
474
+ for r in q("SELECT * FROM regressions WHERE version_id = ? ORDER BY id", (vid,))
475
+ ]
476
+
477
+ analysis_row = self._db.query_one("SELECT * FROM analysis WHERE version_id = ?", (vid,))
478
+ analysis = _analysis_from_row(analysis_row) if analysis_row else None
479
+
480
+ artifacts = [
481
+ Artifact(
482
+ artifact_id=r["artifact_id"],
483
+ version_id=vid,
484
+ path=r["path"],
485
+ type=r["type"],
486
+ size_bytes=r["size_bytes"],
487
+ sha256=r["sha256"],
488
+ created_at=_dt(r["created_at"]),
489
+ )
490
+ for r in q("SELECT * FROM artifacts WHERE version_id = ? ORDER BY created_at", (vid,))
491
+ ]
492
+
493
+ doc_flags = [
494
+ DocFlag(doc_path=r["doc_path"], reason=r["reason"])
495
+ for r in q("SELECT * FROM doc_flags WHERE version_id = ?", (vid,))
496
+ ]
497
+
498
+ checkpoint_links = self._checkpoints.for_version(vid)
499
+ primary = next((c for c, is_primary in checkpoint_links if is_primary), None)
500
+ if primary is None and checkpoint_links:
501
+ primary = checkpoint_links[0][0]
502
+
503
+ env_raw = row["environment_json"]
504
+ environment = EnvironmentInfo.model_validate_json(env_raw) if env_raw else None
505
+
506
+ return DevelopmentVersion(
507
+ version_id=vid,
508
+ version_number=row["version_number"],
509
+ project_id=row["project_id"],
510
+ intent=row["intent"],
511
+ agent=row["agent"],
512
+ model=row["model"],
513
+ git_commit=row["git_commit"],
514
+ parent_commit=row["parent_commit"],
515
+ branch=row["branch"],
516
+ feature_id=row["feature_id"],
517
+ status=VersionStatus(row["status"]),
518
+ files_changed=row["files_changed"],
519
+ lines_added=row["lines_added"],
520
+ lines_removed=row["lines_removed"],
521
+ changed_files=changed_files,
522
+ primary_checkpoint=primary,
523
+ checkpoint_ids=[c.checkpoint_id for c, _ in checkpoint_links],
524
+ entire_association_method=AssociationMethod(
525
+ row["entire_association_method"] or AssociationMethod.NONE.value
526
+ ),
527
+ entire_association_confidence=row["entire_association_confidence"] or 0.0,
528
+ tests=tests,
529
+ metrics=metrics,
530
+ regressions=regressions,
531
+ analysis=analysis,
532
+ artifacts=artifacts,
533
+ doc_flags=doc_flags,
534
+ environment=environment,
535
+ run_id=row["run_id"],
536
+ created_at=_dt(row["created_at"]) or _utcnow(),
537
+ committed_at=_dt(row["committed_at"]),
538
+ )
539
+
540
+ # -- search ------------------------------------------------
541
+
542
+ def search_ids(self, project_id: str, query: str, *, limit: int = 50) -> list[str]:
543
+ if not query.strip():
544
+ return []
545
+ try:
546
+ rows = self._db.query(
547
+ """
548
+ SELECT s.version_id FROM version_search s
549
+ JOIN versions v ON v.version_id = s.version_id
550
+ WHERE v.project_id = ? AND version_search MATCH ?
551
+ ORDER BY rank LIMIT ?
552
+ """,
553
+ (project_id, _fts_query(query), limit),
554
+ )
555
+ except sqlite3.OperationalError:
556
+ like = f"%{query}%"
557
+ rows = self._db.query(
558
+ """
559
+ SELECT version_id FROM versions
560
+ WHERE project_id = ? AND (intent LIKE ? OR agent LIKE ? OR git_commit LIKE ?)
561
+ ORDER BY version_number DESC LIMIT ?
562
+ """,
563
+ (project_id, like, like, like, limit),
564
+ )
565
+ return [r["version_id"] for r in rows]
566
+
567
+
568
+ def _fts_query(raw: str) -> str:
569
+ """Make user input safe for FTS5 MATCH: quote each bare term, keep it prefix-y."""
570
+ terms = [t for t in raw.replace('"', " ").split() if t]
571
+ return " ".join(f'"{t}"*' if t.isalnum() else f'"{t}"' for t in terms) or '""'
572
+
573
+
574
+ def _test_from_row(row: sqlite3.Row) -> TestOutcome:
575
+ return TestOutcome(
576
+ command=row["command"],
577
+ framework=row["framework"],
578
+ total=row["total"],
579
+ passed=row["passed"],
580
+ failed=row["failed"],
581
+ skipped=row["skipped"],
582
+ errors=row["errors"],
583
+ exit_code=row["exit_code"],
584
+ duration_seconds=row["duration_seconds"],
585
+ failing=json.loads(row["failing_json"]) if row["failing_json"] else [],
586
+ output=row["output"],
587
+ )
588
+
589
+
590
+ def _analysis_from_row(row: sqlite3.Row) -> Analysis:
591
+ return Analysis(
592
+ version_id=row["version_id"],
593
+ summary=row["summary"] or "",
594
+ reasoning=row["reasoning"],
595
+ recommendation=row["recommendation"],
596
+ warnings=json.loads(row["warnings_json"]) if row["warnings_json"] else [],
597
+ risk=row["risk"],
598
+ provider=row["provider"],
599
+ model=row["model"],
600
+ generated_at=_dt(row["generated_at"]),
601
+ )
602
+
603
+
604
+ __all__ = ["VersionRepository"]