gdmcode 0.1.0__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 (131) hide show
  1. gdmcode-0.1.0.dist-info/METADATA +240 -0
  2. gdmcode-0.1.0.dist-info/RECORD +131 -0
  3. gdmcode-0.1.0.dist-info/WHEEL +4 -0
  4. gdmcode-0.1.0.dist-info/entry_points.txt +2 -0
  5. src/__init__.py +1 -0
  6. src/_internal/__init__.py +0 -0
  7. src/_internal/constants.py +244 -0
  8. src/_internal/domain_skills.py +339 -0
  9. src/agent/__init__.py +0 -0
  10. src/agent/commit_classifier.py +91 -0
  11. src/agent/context_budget.py +391 -0
  12. src/agent/daemon.py +681 -0
  13. src/agent/dag_validator.py +153 -0
  14. src/agent/debug_loop.py +473 -0
  15. src/agent/impact_analyzer.py +149 -0
  16. src/agent/impact_graph.py +117 -0
  17. src/agent/loop.py +1410 -0
  18. src/agent/orchestrator.py +141 -0
  19. src/agent/regression_guard.py +251 -0
  20. src/agent/review_gate.py +648 -0
  21. src/agent/risk_scorer.py +169 -0
  22. src/agent/self_healing.py +145 -0
  23. src/agent/smart_test_selector.py +89 -0
  24. src/agent/system_prompt.py +226 -0
  25. src/agent/task_tracker.py +320 -0
  26. src/agent/test_validator.py +210 -0
  27. src/agent/tool_orchestrator.py +402 -0
  28. src/agent/transcript.py +230 -0
  29. src/agent/verification_loop.py +133 -0
  30. src/agent/work_director.py +136 -0
  31. src/agent/worktree_manager.py +53 -0
  32. src/artifacts/__init__.py +16 -0
  33. src/artifacts/artifact_store.py +456 -0
  34. src/artifacts/verification_graph.py +75 -0
  35. src/auth.py +411 -0
  36. src/cli.py +1290 -0
  37. src/commands.py +1398 -0
  38. src/config.py +762 -0
  39. src/cost_tracker.py +348 -0
  40. src/db/__init__.py +4 -0
  41. src/db/migrations.py +337 -0
  42. src/enterprise/__init__.py +3 -0
  43. src/enterprise/audit_log.py +182 -0
  44. src/enterprise/identity.py +90 -0
  45. src/enterprise/rbac.py +100 -0
  46. src/enterprise/team_config.py +125 -0
  47. src/enterprise/usage_analytics.py +261 -0
  48. src/exceptions.py +207 -0
  49. src/git_workflow.py +651 -0
  50. src/integrations/__init__.py +6 -0
  51. src/integrations/github_actions.py +106 -0
  52. src/integrations/mcp_server.py +333 -0
  53. src/integrations/sentry_integration.py +100 -0
  54. src/integrations/sentry_server.py +82 -0
  55. src/integrations/webhook_security.py +19 -0
  56. src/main.py +27 -0
  57. src/memory/__init__.py +0 -0
  58. src/memory/code_index.py +376 -0
  59. src/memory/compressor.py +378 -0
  60. src/memory/context_memory.py +135 -0
  61. src/memory/continuous_memory.py +234 -0
  62. src/memory/conventions.py +495 -0
  63. src/memory/db.py +1119 -0
  64. src/memory/document_index.py +205 -0
  65. src/memory/file_cache.py +128 -0
  66. src/memory/project_scanner.py +178 -0
  67. src/memory/session_store.py +201 -0
  68. src/models/__init__.py +0 -0
  69. src/models/client.py +715 -0
  70. src/models/definitions.py +459 -0
  71. src/models/router.py +418 -0
  72. src/models/schemas.py +389 -0
  73. src/permissions.py +294 -0
  74. src/remote/__init__.py +5 -0
  75. src/remote/command_filter.py +33 -0
  76. src/remote/models.py +31 -0
  77. src/remote/permission_handler.py +79 -0
  78. src/remote/phone_ui.py +48 -0
  79. src/remote/protocol.py +59 -0
  80. src/remote/qr.py +65 -0
  81. src/remote/server.py +586 -0
  82. src/remote/token_manager.py +61 -0
  83. src/remote/tunnel.py +212 -0
  84. src/repl.py +475 -0
  85. src/runtime/__init__.py +1 -0
  86. src/runtime/branch_farm.py +372 -0
  87. src/runtime/replay.py +351 -0
  88. src/sandbox/__init__.py +2 -0
  89. src/sandbox/hermetic.py +214 -0
  90. src/sandbox/policy.py +44 -0
  91. src/sdk/__init__.py +3 -0
  92. src/sdk/plugin_base.py +39 -0
  93. src/sdk/plugin_host.py +100 -0
  94. src/sdk/plugin_loader.py +101 -0
  95. src/security.py +409 -0
  96. src/server/__init__.py +7 -0
  97. src/server/bridge.py +427 -0
  98. src/server/bridge_cli.py +103 -0
  99. src/server/bridge_client.py +170 -0
  100. src/server/protocol_version.py +103 -0
  101. src/session/__init__.py +10 -0
  102. src/session/event_fanout.py +46 -0
  103. src/session/input_broker.py +38 -0
  104. src/session/permission_bridge.py +100 -0
  105. src/tools/__init__.py +160 -0
  106. src/tools/_atomic.py +72 -0
  107. src/tools/agent_tools.py +423 -0
  108. src/tools/ask_user_tool.py +83 -0
  109. src/tools/bash_tool.py +384 -0
  110. src/tools/browser_tool.py +352 -0
  111. src/tools/browser_tools.py +179 -0
  112. src/tools/dep_tools.py +210 -0
  113. src/tools/document_reader.py +167 -0
  114. src/tools/document_tool.py +240 -0
  115. src/tools/document_writer.py +171 -0
  116. src/tools/impact_tools.py +240 -0
  117. src/tools/playwright_tool.py +172 -0
  118. src/tools/quality_tools.py +366 -0
  119. src/tools/read_tools.py +318 -0
  120. src/tools/result_cache.py +157 -0
  121. src/tools/search_tools.py +310 -0
  122. src/tools/shell_tools.py +311 -0
  123. src/tools/write_tools.py +337 -0
  124. src/voice/__init__.py +25 -0
  125. src/voice/audio_capture.py +92 -0
  126. src/voice/audio_playback.py +68 -0
  127. src/voice/errors.py +14 -0
  128. src/voice/models.py +35 -0
  129. src/voice/providers.py +143 -0
  130. src/voice/vad.py +55 -0
  131. src/voice/voice_loop.py +156 -0
src/cost_tracker.py ADDED
@@ -0,0 +1,348 @@
1
+ """Cost tracker — per-turn token and USD cost accounting.
2
+
3
+ Supports all three providers (Grok / Gemini / Codex) and all model tiers.
4
+ Persists a running session total. Cheap to call — no I/O on hot path.
5
+
6
+ Usage::
7
+
8
+ tracker = CostTracker(session_id="abc", provider="grok")
9
+ tracker.record(tier="coder", input_tokens=1200, output_tokens=450)
10
+ tracker.record_tool_call("web_search")
11
+ print(tracker.session_total_usd) # → 0.00063
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ from dataclasses import dataclass, field
17
+ from typing import Literal
18
+
19
+ __all__ = ["CostTracker", "TurnCost", "ModelTier", "ProviderName"]
20
+
21
+ log = logging.getLogger(__name__)
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # Type aliases
25
+ # ---------------------------------------------------------------------------
26
+
27
+ ModelTier = Literal["scout", "coder", "thinker", "reasoner", "debate", "standard", "heavy"]
28
+ ProviderName = Literal["grok", "gemini", "codex"]
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # Price tables (USD per 1 million tokens)
32
+ # Updated: 2026-04 — update when providers change pricing
33
+ # ---------------------------------------------------------------------------
34
+
35
+ # fmt: off
36
+ _GROK_PRICES: dict[str, tuple[float, float, float]] = {
37
+ # tier: (input, output, cached_input)
38
+ "scout": (0.20, 0.50, 0.05),
39
+ "coder": (0.20, 0.50, 0.05),
40
+ "thinker": (2.00, 6.00, 0.20),
41
+ "reasoner": (2.00, 6.00, 0.20),
42
+ "debate": (2.00, 6.00, 0.20),
43
+ "standard": (3.00, 15.00, 0.20),
44
+ "heavy": (6.00, 30.00, 0.40),
45
+ }
46
+
47
+ _GEMINI_PRICES: dict[str, tuple[float, float, float]] = {
48
+ "scout": (0.15, 0.60, 0.04), # gemini-2.5-flash (free tier: 1K/day)
49
+ "coder": (0.075, 0.30, 0.02), # gemini-2.5-flash-8b
50
+ "thinker": (1.25, 10.00, 0.31), # gemini-2.5-pro ≤200K
51
+ "reasoner": (2.50, 10.00, 0.63), # gemini-2.5-pro >200K
52
+ "debate": (2.50, 10.00, 0.63), # falls back to Reasoner
53
+ "standard": (1.25, 10.00, 0.31),
54
+ "heavy": (2.50, 10.00, 0.63),
55
+ }
56
+
57
+ _CODEX_PRICES: dict[str, tuple[float, float, float]] = {
58
+ "scout": (0.40, 1.60, 0.10), # gpt-4.1-mini
59
+ "coder": (2.00, 8.00, 0.50), # gpt-4.1
60
+ "thinker": (1.10, 4.40, 0.28), # o4-mini
61
+ "reasoner": (2.00, 8.00, 0.50), # o3
62
+ "debate": (2.00, 8.00, 0.50), # o3 ×4 parallel
63
+ "standard": (2.00, 8.00, 0.50),
64
+ "heavy": (10.00, 40.00, 2.50), # o3-pro
65
+ }
66
+
67
+ # Tool call costs (per 1,000 calls)
68
+ _TOOL_CALL_COSTS_PER_K: dict[str, float] = {
69
+ "web_search": 5.00,
70
+ "x_search": 5.00,
71
+ "document_query": 2.50,
72
+ "file_attachment_search": 10.00,
73
+ }
74
+ # fmt: on
75
+
76
+ _PROVIDER_PRICES: dict[str, dict[str, tuple[float, float, float]]] = {
77
+ "grok": _GROK_PRICES,
78
+ "gemini": _GEMINI_PRICES,
79
+ "codex": _CODEX_PRICES,
80
+ }
81
+
82
+
83
+ # ---------------------------------------------------------------------------
84
+ # Data classes
85
+ # ---------------------------------------------------------------------------
86
+
87
+ @dataclass
88
+ class TurnCost:
89
+ """Cost breakdown for one agent turn."""
90
+
91
+ provider: ProviderName
92
+ tier: ModelTier
93
+ input_tokens: int
94
+ output_tokens: int
95
+ cached_tokens: int = 0
96
+ tool_calls: dict[str, int] = field(default_factory=dict) # name → count
97
+ usd: float = 0.0
98
+
99
+ def __post_init__(self) -> None:
100
+ if self.usd == 0.0:
101
+ self.usd = _compute_usd(
102
+ provider=self.provider,
103
+ tier=self.tier,
104
+ input_tokens=self.input_tokens,
105
+ output_tokens=self.output_tokens,
106
+ cached_tokens=self.cached_tokens,
107
+ tool_calls=self.tool_calls,
108
+ )
109
+
110
+
111
+ # ---------------------------------------------------------------------------
112
+ # Core class
113
+ # ---------------------------------------------------------------------------
114
+
115
+ class CostTracker:
116
+ """Tracks token usage and USD cost across an agent session.
117
+
118
+ Lightweight — no I/O on the hot path. Call ``flush_to_db()`` at
119
+ session end to persist the total.
120
+ """
121
+
122
+ def __init__(self, session_id: str, provider: ProviderName) -> None:
123
+ self._session_id = session_id
124
+ self._provider = provider
125
+ self._turns: list[TurnCost] = []
126
+ self._session_total_usd: float = 0.0
127
+ self._total_input: int = 0
128
+ self._total_output: int = 0
129
+ self._total_cached: int = 0
130
+ self._pending_tool_calls: dict[str, int] = {}
131
+ self._alerted_50: bool = False
132
+ self._alerted_80: bool = False
133
+
134
+ # ------------------------------------------------------------------
135
+ # Recording
136
+ # ------------------------------------------------------------------
137
+
138
+ def record(
139
+ self,
140
+ tier: ModelTier,
141
+ input_tokens: int,
142
+ output_tokens: int,
143
+ cached_tokens: int = 0,
144
+ tool_calls: dict[str, int] | None = None,
145
+ ) -> TurnCost:
146
+ """Record one turn's token usage and return its TurnCost."""
147
+ turn = TurnCost(
148
+ provider=self._provider,
149
+ tier=tier,
150
+ input_tokens=input_tokens,
151
+ output_tokens=output_tokens,
152
+ cached_tokens=cached_tokens,
153
+ tool_calls=tool_calls or {},
154
+ )
155
+ self._turns.append(turn)
156
+ self._session_total_usd += turn.usd
157
+ self._total_input += input_tokens
158
+ self._total_output += output_tokens
159
+ self._total_cached += cached_tokens
160
+ log.debug(
161
+ "turn cost: provider=%s tier=%s in=%d out=%d cached=%d → $%.5f",
162
+ self._provider, tier, input_tokens, output_tokens, cached_tokens, turn.usd,
163
+ )
164
+ return turn
165
+
166
+ # ------------------------------------------------------------------
167
+ # Queries
168
+ # ------------------------------------------------------------------
169
+
170
+ @property
171
+ def session_total_usd(self) -> float:
172
+ """Running session cost in USD."""
173
+ return self._session_total_usd
174
+
175
+ @property
176
+ def turn_count(self) -> int:
177
+ return len(self._turns)
178
+
179
+ def summary(self) -> dict[str, object]:
180
+ """Return a summary dict suitable for display."""
181
+ return {
182
+ "session_id": self._session_id,
183
+ "provider": self._provider,
184
+ "turns": self.turn_count,
185
+ "input_tokens": self._total_input,
186
+ "output_tokens": self._total_output,
187
+ "cached_tokens": self._total_cached,
188
+ "total_usd": round(self._session_total_usd, 6),
189
+ }
190
+
191
+ def exceeds(self, limit_usd: float) -> bool:
192
+ """True if session cost has exceeded *limit_usd*."""
193
+ return self._session_total_usd >= limit_usd
194
+
195
+ # ------------------------------------------------------------------
196
+ # Tool call tracking
197
+ # ------------------------------------------------------------------
198
+
199
+ def record_tool_call(self, tool_name: str, count: int = 1) -> None:
200
+ """Accumulate a tool call for inclusion in the next flush_event_to_db call."""
201
+ self._pending_tool_calls[tool_name] = (
202
+ self._pending_tool_calls.get(tool_name, 0) + count
203
+ )
204
+
205
+ # ------------------------------------------------------------------
206
+ # Budget enforcement
207
+ # ------------------------------------------------------------------
208
+
209
+ def check_budget(self, db: object) -> None: # type: ignore[type-arg]
210
+ """Raise BudgetExceededError if any hard-stop budget limit is breached.
211
+
212
+ Emits WARNING logs at 50 % and 80 % thresholds before raising.
213
+ No-ops if db has no applicable budget_limits rows.
214
+ """
215
+ from src.exceptions import BudgetExceededError
216
+ limit = self._effective_limit(db)
217
+ if limit is None:
218
+ return
219
+ pct = self._session_total_usd / limit if limit > 0 else 0.0
220
+ if pct >= 0.80 and not self._alerted_80:
221
+ self._alerted_80 = True
222
+ log.warning(
223
+ "⚠ Budget 80%% alert: spent $%.4f of $%.2f limit",
224
+ self._session_total_usd, limit,
225
+ )
226
+ elif pct >= 0.50 and not self._alerted_50:
227
+ self._alerted_50 = True
228
+ log.warning(
229
+ "⚠ Budget 50%% alert: spent $%.4f of $%.2f limit",
230
+ self._session_total_usd, limit,
231
+ )
232
+ if self._session_total_usd >= limit:
233
+ raise BudgetExceededError(
234
+ f"Budget limit ${limit:.2f} exceeded (spent ${self._session_total_usd:.2f})"
235
+ )
236
+
237
+ def _effective_limit(self, db: object) -> float | None: # type: ignore[type-arg]
238
+ """Return the most restrictive session-scoped hard-stop limit, or None."""
239
+ try:
240
+ row = db.execute_one( # type: ignore[union-attr]
241
+ """
242
+ SELECT limit_usd FROM budget_limits
243
+ WHERE scope_type = 'session' AND scope_id = ?
244
+ AND period = 'session' AND hard_stop = 1
245
+ ORDER BY limit_usd ASC LIMIT 1
246
+ """,
247
+ (self._session_id,),
248
+ )
249
+ return float(row["limit_usd"]) if row is not None else None
250
+ except Exception as exc: # noqa: BLE001
251
+ log.debug("_effective_limit query failed: %s", exc)
252
+ return None
253
+
254
+ # ------------------------------------------------------------------
255
+ # Persistence
256
+ # ------------------------------------------------------------------
257
+
258
+ def flush_event_to_db(
259
+ self,
260
+ db: object, # type: ignore[type-arg]
261
+ tier: str,
262
+ input_tokens: int,
263
+ output_tokens: int,
264
+ cached_tokens: int,
265
+ cost_usd: float,
266
+ event_id: str | None = None,
267
+ ) -> None:
268
+ """Persist one turn's cost to the cost_events append-only ledger.
269
+
270
+ Clears ``_pending_tool_calls`` after writing. Also updates the running
271
+ session total on the sessions table.
272
+ """
273
+ import json
274
+ try:
275
+ db.cost_event_insert( # type: ignore[union-attr]
276
+ session_id=self._session_id,
277
+ provider=self._provider,
278
+ tier=tier,
279
+ input_tokens=input_tokens,
280
+ output_tokens=output_tokens,
281
+ cached_tokens=cached_tokens,
282
+ cost_usd=cost_usd,
283
+ tool_calls_json=json.dumps(self._pending_tool_calls),
284
+ event_id=event_id,
285
+ )
286
+ self._pending_tool_calls.clear()
287
+ self.flush_to_db(db)
288
+ except Exception as exc: # noqa: BLE001
289
+ log.warning("flush_event_to_db failed: %s", exc)
290
+
291
+ @staticmethod
292
+ def monthly_spend(db: object, project_id: str | None = None) -> float: # type: ignore[type-arg]
293
+ """Return the rolling 30-day total spend from the cost_events ledger."""
294
+ try:
295
+ return db.cost_monthly_spend(project_id) # type: ignore[union-attr]
296
+ except Exception as exc: # noqa: BLE001
297
+ log.warning("monthly_spend failed: %s", exc)
298
+ return 0.0
299
+
300
+ def flush_to_db(self, db: object) -> None: # type: ignore[type-arg]
301
+ """Persist session cost total to the database (idempotent SET, not increment).
302
+
303
+ Safe to call multiple times — always writes the current absolute total.
304
+
305
+ Args:
306
+ db: GdmDatabase instance (imported lazily to avoid circular dep).
307
+ """
308
+ try:
309
+ db.execute( # type: ignore[union-attr]
310
+ "UPDATE sessions SET cost_usd = ? WHERE session_id = ?",
311
+ (self._session_total_usd, self._session_id),
312
+ )
313
+ except Exception as exc: # noqa: BLE001
314
+ log.warning("flush_to_db failed: %s", exc)
315
+
316
+
317
+ # ---------------------------------------------------------------------------
318
+ # Private helpers
319
+ # ---------------------------------------------------------------------------
320
+
321
+ def _compute_usd(
322
+ provider: str,
323
+ tier: str,
324
+ input_tokens: int,
325
+ output_tokens: int,
326
+ cached_tokens: int,
327
+ tool_calls: dict[str, int],
328
+ ) -> float:
329
+ """Compute USD cost for one turn."""
330
+ prices = _PROVIDER_PRICES.get(provider, _GROK_PRICES)
331
+ tier_prices = prices.get(tier, prices.get("coder", (2.00, 6.00, 0.20)))
332
+ in_price, out_price, cached_price = tier_prices
333
+
334
+ # Non-cached input tokens = total input − cached
335
+ real_input = max(0, input_tokens - cached_tokens)
336
+
337
+ usd = (
338
+ real_input * in_price / 1_000_000
339
+ + cached_tokens * cached_price / 1_000_000
340
+ + output_tokens * out_price / 1_000_000
341
+ )
342
+
343
+ # Add tool call costs
344
+ for tool_name, count in tool_calls.items():
345
+ cost_per_k = _TOOL_CALL_COSTS_PER_K.get(tool_name, 0.0)
346
+ usd += cost_per_k * count / 1_000
347
+
348
+ return usd
src/db/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """Database package — migration registry and connection helpers."""
2
+ from src.db.migrations import MigrationRegistry, Migration, registry
3
+
4
+ __all__ = ["MigrationRegistry", "Migration", "registry"]
src/db/migrations.py ADDED
@@ -0,0 +1,337 @@
1
+ """Central migration registry for GdmDatabase schema changes.
2
+
3
+ All schema migrations must register here. No module may hardcode an
4
+ absolute schema version number outside this file.
5
+
6
+ Usage::
7
+
8
+ from src.db.migrations import registry, Migration
9
+
10
+ registry.register(Migration(
11
+ version=7,
12
+ description="Add my_table",
13
+ upgrade=lambda conn: conn.execute("ALTER TABLE ..."),
14
+ ))
15
+
16
+ The GdmDatabase calls registry.apply_pending(conn) on every open.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import logging
21
+ import sqlite3
22
+ from dataclasses import dataclass, field
23
+ from typing import Callable
24
+
25
+ __all__ = ["Migration", "MigrationRegistry", "registry"]
26
+
27
+ log = logging.getLogger(__name__)
28
+
29
+
30
+ @dataclass
31
+ class Migration:
32
+ """A single schema migration step."""
33
+
34
+ version: int
35
+ description: str
36
+ upgrade: Callable[[sqlite3.Connection], None]
37
+ downgrade: Callable[[sqlite3.Connection], None] | None = None
38
+
39
+
40
+ class MigrationRegistry:
41
+ """Central registry for ordered, idempotent schema migrations.
42
+
43
+ Migrations are applied in ascending version order. Each migration is
44
+ applied exactly once and tracked in the schema_version table.
45
+ """
46
+
47
+ def __init__(self) -> None:
48
+ self._migrations: dict[int, Migration] = {}
49
+
50
+ def register(self, migration: Migration) -> None:
51
+ """Register a migration. Raises ValueError on duplicate version."""
52
+ if migration.version in self._migrations:
53
+ raise ValueError(
54
+ f"Duplicate migration version {migration.version}: "
55
+ f"'{self._migrations[migration.version].description}' "
56
+ f"vs '{migration.description}'"
57
+ )
58
+ self._migrations[migration.version] = migration
59
+
60
+ def current_version(self, conn: sqlite3.Connection) -> int:
61
+ """Return the highest applied migration version, or 0 if none."""
62
+ conn.execute(
63
+ "CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY)"
64
+ )
65
+ row = conn.execute("SELECT MAX(version) FROM schema_version").fetchone()
66
+ return int(row[0]) if row and row[0] is not None else 0
67
+
68
+ def apply_pending(self, conn: sqlite3.Connection) -> None:
69
+ """Apply all registered migrations not yet applied. Idempotent."""
70
+ current = self.current_version(conn)
71
+ pending = sorted(v for v in self._migrations if v > current)
72
+ for version in pending:
73
+ migration = self._migrations[version]
74
+ log.info("Applying migration v%d: %s", version, migration.description)
75
+ try:
76
+ migration.upgrade(conn)
77
+ conn.execute(
78
+ "INSERT OR IGNORE INTO schema_version (version) VALUES (?)",
79
+ (version,),
80
+ )
81
+ conn.commit()
82
+ log.info("Migration v%d applied successfully", version)
83
+ except Exception as exc:
84
+ log.error("Migration v%d failed: %s", version, exc)
85
+ try:
86
+ conn.execute("ROLLBACK")
87
+ except Exception:
88
+ pass
89
+ raise
90
+
91
+ def versions(self) -> list[int]:
92
+ """Return sorted list of all registered migration versions."""
93
+ return sorted(self._migrations)
94
+
95
+ def latest_version(self) -> int:
96
+ """Return the highest registered migration version, or 0 if empty."""
97
+ return max(self._migrations, default=0)
98
+
99
+
100
+ # ---------------------------------------------------------------------------
101
+ # Module-level singleton registry
102
+ # All modules register their migrations here at import time.
103
+ # ---------------------------------------------------------------------------
104
+ registry = MigrationRegistry()
105
+
106
+ # ---------------------------------------------------------------------------
107
+ # Baseline migrations (extracted from the existing GdmDatabase ad-hoc system)
108
+ # These represent the schema as it exists at v6.
109
+ # ---------------------------------------------------------------------------
110
+
111
+
112
+ def _noop(conn: sqlite3.Connection) -> None:
113
+ """No-op upgrade for baseline migrations where DDL is handled by CREATE IF NOT EXISTS."""
114
+ pass
115
+
116
+
117
+ # v1: Initial schema (handled by DDL in GdmDatabase)
118
+ registry.register(Migration(version=1, description="Initial schema (projects, sessions, memory, file_cache, conventions, code_index, errors, permissions, checkpoints, tasks, audit_log, btw_queue, spinner_state)", upgrade=_noop))
119
+
120
+ # v2: Add memory.tool_calls_json
121
+ def _upgrade_v2(conn: sqlite3.Connection) -> None:
122
+ try:
123
+ conn.execute("ALTER TABLE memory ADD COLUMN tool_calls_json TEXT")
124
+ except Exception:
125
+ pass # Already exists on fresh DBs
126
+
127
+ registry.register(Migration(version=2, description="Add memory.tool_calls_json", upgrade=_upgrade_v2))
128
+
129
+ # v3: Add session_events, tool_call_log, patch_log tables (DDL handles this)
130
+ registry.register(Migration(version=3, description="Add session_events, tool_call_log, patch_log tables", upgrade=_noop))
131
+
132
+ # v4: Add cost_events, budget_limits, budget_usage tables (DDL handles this)
133
+ registry.register(Migration(version=4, description="Add cost_events, budget_limits, budget_usage, daemon_jobs tables", upgrade=_noop))
134
+
135
+ # v5: Add session_events.annotation column
136
+ def _upgrade_v5(conn: sqlite3.Connection) -> None:
137
+ try:
138
+ conn.execute("ALTER TABLE session_events ADD COLUMN annotation TEXT")
139
+ except Exception:
140
+ pass
141
+
142
+ registry.register(Migration(version=5, description="Add session_events.annotation column", upgrade=_upgrade_v5))
143
+
144
+ # v6: Add memory.turn_index (with backfill), unique index, sessions.status column
145
+ def _upgrade_v6(conn: sqlite3.Connection) -> None:
146
+ try:
147
+ conn.execute("ALTER TABLE memory ADD COLUMN turn_index INTEGER NOT NULL DEFAULT 0")
148
+ except Exception:
149
+ pass
150
+ try:
151
+ conn.execute(
152
+ "UPDATE memory SET turn_index = ("
153
+ " SELECT COUNT(*) FROM memory m2"
154
+ " WHERE m2.session_id = memory.session_id AND m2.id < memory.id"
155
+ ")"
156
+ )
157
+ except Exception:
158
+ pass
159
+ try:
160
+ conn.execute(
161
+ "CREATE UNIQUE INDEX IF NOT EXISTS idx_memory_session_turn "
162
+ "ON memory(session_id, turn_index)"
163
+ )
164
+ except Exception:
165
+ pass
166
+ try:
167
+ conn.execute(
168
+ "ALTER TABLE sessions ADD COLUMN status TEXT NOT NULL DEFAULT 'active'"
169
+ )
170
+ except Exception:
171
+ pass
172
+
173
+ registry.register(Migration(version=6, description="Add memory.turn_index, UNIQUE index, sessions.status", upgrade=_upgrade_v6))
174
+
175
+
176
+ # v7: Add artifacts, artifact_versions, artifact_links, artifact_fts tables
177
+ def _upgrade_v7(conn: sqlite3.Connection) -> None:
178
+ conn.executescript("""
179
+ CREATE TABLE IF NOT EXISTS artifacts (
180
+ artifact_id TEXT PRIMARY KEY,
181
+ name TEXT NOT NULL,
182
+ type TEXT NOT NULL CHECK(type IN (
183
+ 'diagram','report','plan','coverage','diff','custom'
184
+ )),
185
+ description TEXT,
186
+ session_id TEXT REFERENCES sessions(session_id),
187
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
188
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
189
+ visibility TEXT NOT NULL DEFAULT 'private'
190
+ CHECK(visibility IN ('private','project','public')),
191
+ UNIQUE(name)
192
+ );
193
+ CREATE INDEX IF NOT EXISTS idx_artifacts_session ON artifacts(session_id);
194
+ CREATE INDEX IF NOT EXISTS idx_artifacts_type ON artifacts(type);
195
+
196
+ CREATE TABLE IF NOT EXISTS artifact_versions (
197
+ version_id TEXT PRIMARY KEY,
198
+ artifact_id TEXT NOT NULL REFERENCES artifacts(artifact_id) ON DELETE CASCADE,
199
+ version_num INTEGER NOT NULL,
200
+ content TEXT NOT NULL,
201
+ content_hash TEXT NOT NULL,
202
+ byte_size INTEGER NOT NULL DEFAULT 0,
203
+ event_id TEXT REFERENCES session_events(event_id),
204
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
205
+ UNIQUE(artifact_id, version_num)
206
+ );
207
+ CREATE INDEX IF NOT EXISTS idx_av_artifact ON artifact_versions(artifact_id, version_num DESC);
208
+
209
+ CREATE TABLE IF NOT EXISTS artifact_links (
210
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
211
+ artifact_id TEXT NOT NULL REFERENCES artifacts(artifact_id) ON DELETE CASCADE,
212
+ link_type TEXT NOT NULL CHECK(link_type IN (
213
+ 'session','turn','file','commit','pr','evidence_node'
214
+ )),
215
+ ref_value TEXT NOT NULL,
216
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
217
+ );
218
+ CREATE INDEX IF NOT EXISTS idx_artifact_links ON artifact_links(artifact_id, link_type);
219
+
220
+ CREATE VIRTUAL TABLE IF NOT EXISTS artifact_fts USING fts5(
221
+ name, description, content
222
+ );
223
+
224
+ CREATE TRIGGER IF NOT EXISTS artifact_fts_insert
225
+ AFTER INSERT ON artifact_versions BEGIN
226
+ INSERT INTO artifact_fts(rowid, name, description, content)
227
+ SELECT new.rowid,
228
+ (SELECT name FROM artifacts WHERE artifact_id = new.artifact_id),
229
+ (SELECT description FROM artifacts WHERE artifact_id = new.artifact_id),
230
+ new.content;
231
+ END;
232
+
233
+ CREATE TRIGGER IF NOT EXISTS artifact_fts_delete
234
+ AFTER DELETE ON artifact_versions BEGIN
235
+ DELETE FROM artifact_fts WHERE rowid = old.rowid;
236
+ END;
237
+ """)
238
+
239
+
240
+ registry.register(Migration(version=7, description="Add artifact tables (artifacts, artifact_versions, artifact_links, artifact_fts)", upgrade=_upgrade_v7))
241
+
242
+
243
+ # v8: Add checkpoints.turn_id column for per-turn rollback journal
244
+ def _upgrade_v8(conn: sqlite3.Connection) -> None:
245
+ try:
246
+ conn.execute("ALTER TABLE checkpoints ADD COLUMN turn_id TEXT")
247
+ except Exception:
248
+ pass # Already exists on fresh DBs
249
+
250
+
251
+ registry.register(Migration(version=8, description="Add checkpoints.turn_id for per-turn rollback journal", upgrade=_upgrade_v8))
252
+
253
+
254
+
255
+ # v9: Add edit_nodes, evidence_nodes, graph_edges for VerificationGraph
256
+ def _upgrade_v9(conn):
257
+ conn.executescript("""
258
+ CREATE TABLE IF NOT EXISTS edit_nodes (
259
+ node_id TEXT PRIMARY KEY,
260
+ session_id TEXT NOT NULL,
261
+ turn_index INTEGER NOT NULL,
262
+ file_path TEXT NOT NULL,
263
+ patch_ref TEXT,
264
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
265
+ UNIQUE(session_id, turn_index, file_path)
266
+ );
267
+ CREATE INDEX IF NOT EXISTS idx_edit_nodes_session ON edit_nodes(session_id);
268
+ CREATE INDEX IF NOT EXISTS idx_edit_nodes_file ON edit_nodes(file_path);
269
+ CREATE TABLE IF NOT EXISTS evidence_nodes (
270
+ evidence_id TEXT PRIMARY KEY,
271
+ node_id TEXT NOT NULL REFERENCES edit_nodes(node_id) ON DELETE CASCADE,
272
+ kind TEXT NOT NULL CHECK(kind IN (
273
+ 'TEST','LINT','TYPE_CHECK','REVIEW','RUN','SECURITY','PERF','COVERAGE'
274
+ )),
275
+ verdict TEXT NOT NULL CHECK(verdict IN (
276
+ 'PASS','FAIL','WARNING','PENDING','SKIPPED'
277
+ )),
278
+ detail_json TEXT NOT NULL DEFAULT '{}',
279
+ tool TEXT,
280
+ duration_ms INTEGER,
281
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
282
+ );
283
+ CREATE INDEX IF NOT EXISTS idx_evidence_node ON evidence_nodes(node_id, kind);
284
+ CREATE TABLE IF NOT EXISTS graph_edges (
285
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
286
+ from_node_id TEXT NOT NULL REFERENCES edit_nodes(node_id) ON DELETE CASCADE,
287
+ to_node_id TEXT NOT NULL REFERENCES edit_nodes(node_id) ON DELETE CASCADE,
288
+ UNIQUE(from_node_id, to_node_id)
289
+ );
290
+ CREATE INDEX IF NOT EXISTS idx_graph_edges_from ON graph_edges(from_node_id);
291
+ CREATE INDEX IF NOT EXISTS idx_graph_edges_to ON graph_edges(to_node_id);
292
+ """)
293
+
294
+ registry.register(Migration(
295
+ version=9,
296
+ description="Add edit_nodes, evidence_nodes, graph_edges for VerificationGraph",
297
+ upgrade=_upgrade_v9,
298
+ ))
299
+
300
+
301
+ # v10: Add mtime column to code_index for incremental re-indexing
302
+ def _upgrade_v10(conn: sqlite3.Connection) -> None:
303
+ try:
304
+ conn.execute("ALTER TABLE code_index ADD COLUMN mtime REAL DEFAULT 0")
305
+ except Exception:
306
+ pass # Already exists on fresh DBs
307
+
308
+
309
+ registry.register(Migration(
310
+ version=10,
311
+ description="Add code_index.mtime for incremental re-indexing",
312
+ upgrade=_upgrade_v10,
313
+ ))
314
+
315
+
316
+ # v11: Add confidence_outcomes table for ide-003 calibration persistence
317
+ def _upgrade_v11(conn: sqlite3.Connection) -> None:
318
+ conn.executescript("""
319
+ CREATE TABLE IF NOT EXISTS confidence_outcomes (
320
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
321
+ session_id TEXT NOT NULL,
322
+ hunk_hash TEXT NOT NULL,
323
+ score INTEGER NOT NULL,
324
+ reasons_json TEXT NOT NULL DEFAULT '[]',
325
+ verified INTEGER NOT NULL DEFAULT 0,
326
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
327
+ );
328
+ CREATE INDEX IF NOT EXISTS idx_co_session ON confidence_outcomes(session_id);
329
+ CREATE INDEX IF NOT EXISTS idx_co_hunk ON confidence_outcomes(hunk_hash);
330
+ """)
331
+
332
+
333
+ registry.register(Migration(
334
+ version=11,
335
+ description="Add confidence_outcomes table for calibration persistence",
336
+ upgrade=_upgrade_v11,
337
+ ))
@@ -0,0 +1,3 @@
1
+ from src.enterprise.usage_analytics import UsageAnalytics, UsageEvent, SessionSummary, TeamRollup
2
+
3
+ __all__ = ["UsageAnalytics", "UsageEvent", "SessionSummary", "TeamRollup"]