devcouncil 0.1.1 → 0.3.0

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 (159) hide show
  1. package/README.md +201 -6
  2. package/package.json +9 -2
  3. package/pyproject.toml +34 -2
  4. package/src/devcouncil/app/config.py +348 -12
  5. package/src/devcouncil/app/orchestrator.py +10 -6
  6. package/src/devcouncil/app/state_machine.py +4 -0
  7. package/src/devcouncil/artifacts/graph.py +32 -5
  8. package/src/devcouncil/assets/__init__.py +1 -0
  9. package/src/devcouncil/assets/devcouncil-logo.svg +60 -0
  10. package/src/devcouncil/assets/devcouncil_logo_premium.png +0 -0
  11. package/src/devcouncil/cli/commands/agents.py +292 -0
  12. package/src/devcouncil/cli/commands/artifacts.py +6 -3
  13. package/src/devcouncil/cli/commands/check.py +220 -0
  14. package/src/devcouncil/cli/commands/config.py +43 -4
  15. package/src/devcouncil/cli/commands/cost.py +57 -0
  16. package/src/devcouncil/cli/commands/dashboard.py +6 -1
  17. package/src/devcouncil/cli/commands/design.py +186 -0
  18. package/src/devcouncil/cli/commands/doctor.py +379 -22
  19. package/src/devcouncil/cli/commands/evidence.py +48 -0
  20. package/src/devcouncil/cli/commands/go.py +532 -33
  21. package/src/devcouncil/cli/commands/handoff.py +69 -0
  22. package/src/devcouncil/cli/commands/hook.py +296 -15
  23. package/src/devcouncil/cli/commands/init.py +161 -20
  24. package/src/devcouncil/cli/commands/integrate.py +1371 -124
  25. package/src/devcouncil/cli/commands/logs.py +106 -0
  26. package/src/devcouncil/cli/commands/map.py +80 -10
  27. package/src/devcouncil/cli/commands/okf.py +245 -0
  28. package/src/devcouncil/cli/commands/plan.py +256 -55
  29. package/src/devcouncil/cli/commands/prompt.py +18 -7
  30. package/src/devcouncil/cli/commands/repair.py +50 -24
  31. package/src/devcouncil/cli/commands/report.py +8 -0
  32. package/src/devcouncil/cli/commands/reset_demo_state.py +4 -2
  33. package/src/devcouncil/cli/commands/rollback.py +27 -28
  34. package/src/devcouncil/cli/commands/run.py +195 -54
  35. package/src/devcouncil/cli/commands/runs.py +223 -0
  36. package/src/devcouncil/cli/commands/scaffold.py +32 -0
  37. package/src/devcouncil/cli/commands/semantic.py +47 -0
  38. package/src/devcouncil/cli/commands/setup.py +145 -6
  39. package/src/devcouncil/cli/commands/shell.py +73 -0
  40. package/src/devcouncil/cli/commands/skills.py +267 -0
  41. package/src/devcouncil/cli/commands/status.py +30 -15
  42. package/src/devcouncil/cli/commands/trace.py +47 -3
  43. package/src/devcouncil/cli/commands/verify.py +144 -3
  44. package/src/devcouncil/cli/commands/watch.py +32 -12
  45. package/src/devcouncil/cli/commands/watch_fs.py +40 -0
  46. package/src/devcouncil/cli/main.py +91 -7
  47. package/src/devcouncil/domain/evidence.py +29 -2
  48. package/src/devcouncil/domain/gap.py +27 -1
  49. package/src/devcouncil/domain/task.py +31 -2
  50. package/src/devcouncil/execution/checkpoints.py +256 -0
  51. package/src/devcouncil/execution/context_builder.py +1 -1
  52. package/src/devcouncil/execution/fs_watcher.py +205 -0
  53. package/src/devcouncil/execution/handoff.py +102 -0
  54. package/src/devcouncil/execution/hook_policy.py +162 -74
  55. package/src/devcouncil/execution/patch.py +65 -10
  56. package/src/devcouncil/execution/permissions.py +24 -24
  57. package/src/devcouncil/execution/policy_engine.py +350 -0
  58. package/src/devcouncil/execution/prompt_builder.py +751 -23
  59. package/src/devcouncil/execution/shell_session.py +231 -0
  60. package/src/devcouncil/execution/task_runner.py +24 -9
  61. package/src/devcouncil/executors/agent_registry.py +596 -0
  62. package/src/devcouncil/executors/coding_cli.py +791 -39
  63. package/src/devcouncil/executors/mini_swe.py +6 -0
  64. package/src/devcouncil/executors/native/agent.py +135 -19
  65. package/src/devcouncil/executors/openhands.py +6 -0
  66. package/src/devcouncil/gating/checks/clean_git.py +3 -1
  67. package/src/devcouncil/gating/checks/secret_scan_check.py +47 -21
  68. package/src/devcouncil/gating/policy.py +190 -11
  69. package/src/devcouncil/hardware.py +184 -0
  70. package/src/devcouncil/indexing/ast_matcher.py +17 -7
  71. package/src/devcouncil/indexing/lsp.py +45 -4
  72. package/src/devcouncil/indexing/repo_mapper.py +1284 -15
  73. package/src/devcouncil/indexing/semantic_index.py +221 -0
  74. package/src/devcouncil/integrations/actions.py +166 -0
  75. package/src/devcouncil/integrations/check.py +426 -0
  76. package/src/devcouncil/integrations/claude_assets.py +444 -0
  77. package/src/devcouncil/integrations/code_review_graph.py +13 -2
  78. package/src/devcouncil/integrations/github_intent.py +149 -0
  79. package/src/devcouncil/integrations/gitnexus.py +45 -2
  80. package/src/devcouncil/integrations/mcp/server.py +1944 -32
  81. package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +24 -0
  82. package/src/devcouncil/integrations/pr_comments.py +9 -0
  83. package/src/devcouncil/knowledge/__init__.py +23 -0
  84. package/src/devcouncil/knowledge/design.py +374 -0
  85. package/src/devcouncil/knowledge/design_conformance.py +317 -0
  86. package/src/devcouncil/knowledge/fetch.py +223 -0
  87. package/src/devcouncil/knowledge/frontmatter.py +51 -0
  88. package/src/devcouncil/knowledge/okf.py +202 -0
  89. package/src/devcouncil/knowledge/skill_bridge.py +96 -0
  90. package/src/devcouncil/knowledge/sources.py +239 -0
  91. package/src/devcouncil/live/cards.py +181 -25
  92. package/src/devcouncil/live/repair_prompt.py +29 -6
  93. package/src/devcouncil/live/reviewer.py +72 -13
  94. package/src/devcouncil/live/signals.py +2 -2
  95. package/src/devcouncil/live/summary.py +18 -8
  96. package/src/devcouncil/live/transcripts.py +47 -11
  97. package/src/devcouncil/llm/cache.py +20 -8
  98. package/src/devcouncil/llm/model_defaults.yaml +44 -0
  99. package/src/devcouncil/llm/provider.py +617 -49
  100. package/src/devcouncil/llm/router.py +337 -53
  101. package/src/devcouncil/optimization/__init__.py +1 -0
  102. package/src/devcouncil/optimization/gepa_agent.py +318 -0
  103. package/src/devcouncil/optimization/skillopt.py +673 -0
  104. package/src/devcouncil/planning/arbiter_service.py +10 -2
  105. package/src/devcouncil/planning/correction_manifest.py +346 -0
  106. package/src/devcouncil/planning/critique_service.py +16 -4
  107. package/src/devcouncil/planning/plan_service.py +86 -6
  108. package/src/devcouncil/planning/prompt_enhancer_service.py +206 -1
  109. package/src/devcouncil/planning/repair_service.py +8 -2
  110. package/src/devcouncil/planning/spec_service.py +37 -3
  111. package/src/devcouncil/repo/ci_scaffold.py +165 -0
  112. package/src/devcouncil/repo/gitignore.py +123 -0
  113. package/src/devcouncil/repo/sca.py +384 -0
  114. package/src/devcouncil/reporting/json_report.py +22 -1
  115. package/src/devcouncil/reporting/markdown_report.py +29 -1
  116. package/src/devcouncil/reporting/okf_bundle_writer.py +364 -0
  117. package/src/devcouncil/reporting/okf_html.py +323 -0
  118. package/src/devcouncil/reporting/report_builder.py +18 -1
  119. package/src/devcouncil/skills/__init__.py +19 -0
  120. package/src/devcouncil/skills/library/README.md +46 -0
  121. package/src/devcouncil/skills/library/ai-training.md +50 -0
  122. package/src/devcouncil/skills/library/android.md +50 -0
  123. package/src/devcouncil/skills/library/backend.md +52 -0
  124. package/src/devcouncil/skills/library/core-engineering.md +95 -0
  125. package/src/devcouncil/skills/library/data-engineering.md +47 -0
  126. package/src/devcouncil/skills/library/desktop.md +46 -0
  127. package/src/devcouncil/skills/library/devops.md +48 -0
  128. package/src/devcouncil/skills/library/game-dev.md +46 -0
  129. package/src/devcouncil/skills/library/ios.md +48 -0
  130. package/src/devcouncil/skills/library/mobile-cross-platform.md +46 -0
  131. package/src/devcouncil/skills/library/security.md +48 -0
  132. package/src/devcouncil/skills/library/systems.md +48 -0
  133. package/src/devcouncil/skills/library/web.md +47 -0
  134. package/src/devcouncil/skills/library/windows.md +47 -0
  135. package/src/devcouncil/skills/registry.py +408 -0
  136. package/src/devcouncil/storage/db.py +140 -3
  137. package/src/devcouncil/storage/models.py +125 -0
  138. package/src/devcouncil/storage/native.py +559 -0
  139. package/src/devcouncil/storage/repositories.py +157 -78
  140. package/src/devcouncil/telemetry/cost.py +123 -17
  141. package/src/devcouncil/telemetry/logging_setup.py +244 -0
  142. package/src/devcouncil/telemetry/model_pricing.yaml +48 -0
  143. package/src/devcouncil/telemetry/pricing.py +28 -0
  144. package/src/devcouncil/telemetry/stages.py +141 -0
  145. package/src/devcouncil/telemetry/traces.py +62 -7
  146. package/src/devcouncil/telemetry/tracker.py +24 -10
  147. package/src/devcouncil/ui/dashboard.py +393 -28
  148. package/src/devcouncil/utils/redaction.py +9 -3
  149. package/src/devcouncil/utils/subprocess_env.py +69 -0
  150. package/src/devcouncil/verification/acceptance_compiler.py +253 -0
  151. package/src/devcouncil/verification/ad_hoc_check.py +135 -0
  152. package/src/devcouncil/verification/diff_coverage.py +353 -0
  153. package/src/devcouncil/verification/implementation_reviewer.py +11 -2
  154. package/src/devcouncil/verification/next_actions.py +189 -0
  155. package/src/devcouncil/verification/sandbox.py +181 -0
  156. package/src/devcouncil/verification/test_resolver.py +91 -0
  157. package/src/devcouncil/verification/verifier.py +1549 -143
  158. package/uv.lock +205 -64
  159. package/src/devcouncil/indexing/symbol_index.py +0 -0
@@ -0,0 +1,559 @@
1
+ """Native control-plane storage repositories."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import secrets
7
+ import uuid
8
+ from datetime import datetime, timedelta, timezone
9
+ from typing import Optional
10
+
11
+ from pydantic import BaseModel
12
+ from sqlalchemy.exc import IntegrityError
13
+ from sqlmodel import Session, col, select
14
+
15
+ from devcouncil.storage.models import (
16
+ AgentHandoffModel,
17
+ CorrectionManifestModel,
18
+ FileChangeEventModel,
19
+ SemanticDiffModel,
20
+ ShellCommandEventModel,
21
+ ShellSessionModel,
22
+ TaskLeaseModel,
23
+ VerificationRunModel,
24
+ )
25
+
26
+
27
+ def _utc_now() -> str:
28
+ return datetime.now(timezone.utc).isoformat()
29
+
30
+
31
+ class TaskLeaseRecord(BaseModel):
32
+ id: str
33
+ task_id: str
34
+ owner: str
35
+ agent: Optional[str] = None
36
+ client_id: Optional[str] = None
37
+ run_id: Optional[str] = None
38
+ branch: Optional[str] = None
39
+ lease_token: str
40
+ status: str
41
+ created_at: str
42
+ expires_at: Optional[str] = None
43
+ released_at: Optional[str] = None
44
+
45
+
46
+ class ShellSessionRecord(BaseModel):
47
+ id: str
48
+ task_id: str
49
+ lease_id: Optional[str] = None
50
+ shell: str
51
+ cwd: str
52
+ status: str
53
+ started_at: str
54
+ ended_at: Optional[str] = None
55
+
56
+
57
+ class ShellCommandRecord(BaseModel):
58
+ id: int
59
+ task_id: str
60
+ session_id: Optional[str] = None
61
+ command: str
62
+ status: str
63
+ exit_code: Optional[int] = None
64
+ reason: str = ""
65
+ stdout_path: str = ""
66
+ stderr_path: str = ""
67
+ created_at: str
68
+
69
+
70
+ class FileChangeRecord(BaseModel):
71
+ id: int
72
+ task_id: Optional[str] = None
73
+ lease_id: Optional[str] = None
74
+ session_id: Optional[str] = None
75
+ path: str
76
+ operation: str
77
+ allowed: bool
78
+ reason: str = ""
79
+ created_at: str
80
+
81
+
82
+ class SemanticDiffRecord(BaseModel):
83
+ id: int
84
+ task_id: str
85
+ before_snapshot_path: str
86
+ after_snapshot_path: str
87
+ classifications: list[dict]
88
+ summary: str
89
+ created_at: str
90
+
91
+
92
+ class AgentHandoffRecord(BaseModel):
93
+ id: str
94
+ task_id: str
95
+ from_agent: str
96
+ to_agent: str
97
+ run_id: str
98
+ manifest_path: str
99
+ status: str
100
+ created_at: str
101
+
102
+
103
+ class CorrectionManifestRecord(BaseModel):
104
+ id: str
105
+ task_id: str
106
+ run_id: Optional[str] = None
107
+ manifest_path: str
108
+ retry_budget: int
109
+ attempt: int
110
+ status: str
111
+ created_at: str
112
+
113
+
114
+ class VerificationRunRecord(BaseModel):
115
+ id: str
116
+ task_id: str
117
+ sandbox: str
118
+ environment: dict
119
+ commands: list[dict]
120
+ status: str
121
+ started_at: str
122
+ finished_at: Optional[str] = None
123
+
124
+
125
+ def _lease_from_model(model: TaskLeaseModel) -> TaskLeaseRecord:
126
+ return TaskLeaseRecord.model_validate(model.model_dump())
127
+
128
+
129
+ class TaskLeaseRepository:
130
+ def __init__(self, session: Session):
131
+ self.session = session
132
+
133
+ def acquire(
134
+ self,
135
+ task_id: str,
136
+ owner: str,
137
+ *,
138
+ agent: Optional[str] = None,
139
+ client_id: Optional[str] = None,
140
+ run_id: Optional[str] = None,
141
+ branch: Optional[str] = None,
142
+ ttl_seconds: Optional[int] = None,
143
+ force: bool = False,
144
+ ) -> TaskLeaseRecord:
145
+ active = self._active_model_for_task(task_id)
146
+ if active is not None and not force:
147
+ raise ValueError(f"Active lease already exists for task {task_id}")
148
+ if active is not None and force:
149
+ active.status = "stale"
150
+ active.released_at = _utc_now()
151
+ self.session.add(active)
152
+ self.session.commit()
153
+
154
+ lease_id = str(uuid.uuid4())
155
+ token = secrets.token_urlsafe(32)
156
+ expires_at = None
157
+ if ttl_seconds is not None:
158
+ expires_at = (datetime.now(timezone.utc) + timedelta(seconds=ttl_seconds)).isoformat()
159
+
160
+ model = TaskLeaseModel(
161
+ id=lease_id,
162
+ task_id=task_id,
163
+ owner=owner,
164
+ agent=agent,
165
+ client_id=client_id,
166
+ run_id=run_id,
167
+ branch=branch,
168
+ lease_token=token,
169
+ status="active",
170
+ created_at=_utc_now(),
171
+ expires_at=expires_at,
172
+ )
173
+ self.session.add(model)
174
+ try:
175
+ self.session.commit()
176
+ except IntegrityError as exc:
177
+ # Lost a concurrent race: another writer inserted the active lease between
178
+ # our active_for_task() check and this commit. The partial unique index
179
+ # rejected the duplicate — surface it as the same conflict callers expect.
180
+ self.session.rollback()
181
+ raise ValueError(f"Active lease already exists for task {task_id}") from exc
182
+ self.session.refresh(model)
183
+ return _lease_from_model(model)
184
+
185
+ def release(self, task_id: str, lease_token: str, status: str = "released") -> bool:
186
+ model = self._active_model_for_task(task_id)
187
+ if model is None or model.lease_token != lease_token:
188
+ return False
189
+ model.status = status
190
+ model.released_at = _utc_now()
191
+ self.session.add(model)
192
+ self.session.commit()
193
+ return True
194
+
195
+ def _active_model_for_task(self, task_id: str) -> Optional[TaskLeaseModel]:
196
+ """Return the live ACTIVE lease *model* for a task (or None), lazily expiring a
197
+ lease that is past its TTL. Callers that need a Record wrap the result via
198
+ _lease_from_model — this avoids re-fetching the same row by primary key after a
199
+ Record-returning lookup."""
200
+ statement = (
201
+ select(TaskLeaseModel)
202
+ .where(TaskLeaseModel.task_id == task_id)
203
+ .where(TaskLeaseModel.status == "active")
204
+ )
205
+ model = self.session.exec(statement).first()
206
+ if model is None:
207
+ return None
208
+ if model.expires_at:
209
+ expires = datetime.fromisoformat(model.expires_at)
210
+ if expires.tzinfo is None:
211
+ expires = expires.replace(tzinfo=timezone.utc)
212
+ if datetime.now(timezone.utc) > expires:
213
+ model.status = "stale"
214
+ model.released_at = _utc_now()
215
+ self.session.add(model)
216
+ self.session.commit()
217
+ return None
218
+ return model
219
+
220
+ def active_for_task(self, task_id: str) -> TaskLeaseRecord | None:
221
+ model = self._active_model_for_task(task_id)
222
+ if model is None:
223
+ return None
224
+ return _lease_from_model(model)
225
+
226
+ def validate(self, task_id: str, lease_token: str) -> bool:
227
+ active = self.active_for_task(task_id)
228
+ return active is not None and active.lease_token == lease_token
229
+
230
+ def renew(self, task_id: str, lease_token: str, ttl_seconds: int) -> TaskLeaseRecord | None:
231
+ """Push the lease's expiry out by ``ttl_seconds`` from now. Returns the updated
232
+ record, or None when the token is invalid / the lease already expired."""
233
+ model = self._active_model_for_task(task_id)
234
+ if model is None or model.lease_token != lease_token:
235
+ return None
236
+ model.expires_at = (datetime.now(timezone.utc) + timedelta(seconds=ttl_seconds)).isoformat()
237
+ self.session.add(model)
238
+ self.session.commit()
239
+ self.session.refresh(model)
240
+ return _lease_from_model(model)
241
+
242
+ def list_leases(self, *, active_only: bool = True) -> list[tuple[TaskLeaseRecord, bool]]:
243
+ """Return ``(lease, expired)`` pairs for fleet supervision. ``expired`` means the
244
+ lease is still marked active but past its TTL (a crashed/disconnected agent)."""
245
+ statement = select(TaskLeaseModel)
246
+ if active_only:
247
+ statement = statement.where(TaskLeaseModel.status == "active")
248
+ statement = statement.order_by(col(TaskLeaseModel.created_at).desc())
249
+ now = datetime.now(timezone.utc)
250
+ results: list[tuple[TaskLeaseRecord, bool]] = []
251
+ for model in self.session.exec(statement).all():
252
+ expired = False
253
+ if model.status == "active" and model.expires_at:
254
+ expires = datetime.fromisoformat(model.expires_at)
255
+ if expires.tzinfo is None:
256
+ expires = expires.replace(tzinfo=timezone.utc)
257
+ expired = now > expires
258
+ results.append((_lease_from_model(model), expired))
259
+ return results
260
+
261
+
262
+ class ShellSessionRepository:
263
+ def __init__(self, session: Session):
264
+ self.session = session
265
+
266
+ def start(
267
+ self,
268
+ task_id: str,
269
+ shell: str,
270
+ cwd: str,
271
+ *,
272
+ lease_id: Optional[str] = None,
273
+ status: str = "active",
274
+ ) -> ShellSessionRecord:
275
+ session_id = str(uuid.uuid4())
276
+ model = ShellSessionModel(
277
+ id=session_id,
278
+ task_id=task_id,
279
+ lease_id=lease_id,
280
+ shell=shell,
281
+ cwd=cwd,
282
+ status=status,
283
+ started_at=_utc_now(),
284
+ )
285
+ self.session.add(model)
286
+ self.session.commit()
287
+ self.session.refresh(model)
288
+ return ShellSessionRecord.model_validate(model.model_dump())
289
+
290
+ def finish(self, session_id: str, status: str) -> bool:
291
+ model = self.session.get(ShellSessionModel, session_id)
292
+ if model is None:
293
+ return False
294
+ model.status = status
295
+ model.ended_at = _utc_now()
296
+ self.session.add(model)
297
+ self.session.commit()
298
+ return True
299
+
300
+
301
+ class ShellCommandRepository:
302
+ def __init__(self, session: Session):
303
+ self.session = session
304
+
305
+ def record(
306
+ self,
307
+ task_id: str,
308
+ command: str,
309
+ status: str,
310
+ *,
311
+ session_id: Optional[str] = None,
312
+ exit_code: Optional[int] = None,
313
+ reason: str = "",
314
+ stdout_path: str = "",
315
+ stderr_path: str = "",
316
+ ) -> ShellCommandRecord:
317
+ model = ShellCommandEventModel(
318
+ task_id=task_id,
319
+ session_id=session_id,
320
+ command=command,
321
+ status=status,
322
+ exit_code=exit_code,
323
+ reason=reason,
324
+ stdout_path=stdout_path,
325
+ stderr_path=stderr_path,
326
+ created_at=_utc_now(),
327
+ )
328
+ self.session.add(model)
329
+ self.session.commit()
330
+ self.session.refresh(model)
331
+ return ShellCommandRecord.model_validate(model.model_dump())
332
+
333
+
334
+ class FileChangeRepository:
335
+ def __init__(self, session: Session):
336
+ self.session = session
337
+
338
+ def record(
339
+ self,
340
+ path: str,
341
+ operation: str,
342
+ allowed: bool,
343
+ *,
344
+ task_id: Optional[str] = None,
345
+ lease_id: Optional[str] = None,
346
+ session_id: Optional[str] = None,
347
+ reason: str = "",
348
+ ) -> FileChangeRecord:
349
+ model = FileChangeEventModel(
350
+ task_id=task_id,
351
+ lease_id=lease_id,
352
+ session_id=session_id,
353
+ path=path,
354
+ operation=operation,
355
+ allowed=allowed,
356
+ reason=reason,
357
+ created_at=_utc_now(),
358
+ )
359
+ self.session.add(model)
360
+ self.session.commit()
361
+ self.session.refresh(model)
362
+ return FileChangeRecord.model_validate(model.model_dump())
363
+
364
+ def list_for_task(self, task_id: str) -> list[FileChangeRecord]:
365
+ statement = (
366
+ select(FileChangeEventModel)
367
+ .where(FileChangeEventModel.task_id == task_id)
368
+ .order_by(col(FileChangeEventModel.created_at))
369
+ )
370
+ return [FileChangeRecord.model_validate(m.model_dump()) for m in self.session.exec(statement).all()]
371
+
372
+
373
+ class SemanticDiffRepository:
374
+ def __init__(self, session: Session):
375
+ self.session = session
376
+
377
+ def save(
378
+ self,
379
+ task_id: str,
380
+ before_snapshot_path: str,
381
+ after_snapshot_path: str,
382
+ classifications: list[dict],
383
+ summary: str,
384
+ ) -> SemanticDiffRecord:
385
+ model = SemanticDiffModel(
386
+ task_id=task_id,
387
+ before_snapshot_path=before_snapshot_path,
388
+ after_snapshot_path=after_snapshot_path,
389
+ classifications_json=json.dumps(classifications),
390
+ summary=summary,
391
+ created_at=_utc_now(),
392
+ )
393
+ self.session.add(model)
394
+ self.session.commit()
395
+ self.session.refresh(model)
396
+ return SemanticDiffRecord(
397
+ id=model.id or 0,
398
+ task_id=model.task_id,
399
+ before_snapshot_path=model.before_snapshot_path,
400
+ after_snapshot_path=model.after_snapshot_path,
401
+ classifications=classifications,
402
+ summary=model.summary,
403
+ created_at=model.created_at,
404
+ )
405
+
406
+ def latest_for_task(self, task_id: str) -> SemanticDiffRecord | None:
407
+ statement = (
408
+ select(SemanticDiffModel)
409
+ .where(SemanticDiffModel.task_id == task_id)
410
+ .order_by(col(SemanticDiffModel.created_at).desc())
411
+ )
412
+ model = self.session.exec(statement).first()
413
+ if model is None:
414
+ return None
415
+ return SemanticDiffRecord(
416
+ id=model.id or 0,
417
+ task_id=model.task_id,
418
+ before_snapshot_path=model.before_snapshot_path,
419
+ after_snapshot_path=model.after_snapshot_path,
420
+ classifications=json.loads(model.classifications_json),
421
+ summary=model.summary,
422
+ created_at=model.created_at,
423
+ )
424
+
425
+
426
+ class AgentHandoffRepository:
427
+ def __init__(self, session: Session):
428
+ self.session = session
429
+
430
+ def save(
431
+ self,
432
+ task_id: str,
433
+ from_agent: str,
434
+ to_agent: str,
435
+ run_id: str,
436
+ manifest_path: str,
437
+ status: str,
438
+ *,
439
+ handoff_id: Optional[str] = None,
440
+ ) -> AgentHandoffRecord:
441
+ hid = handoff_id or str(uuid.uuid4())
442
+ model = AgentHandoffModel(
443
+ id=hid,
444
+ task_id=task_id,
445
+ from_agent=from_agent,
446
+ to_agent=to_agent,
447
+ run_id=run_id,
448
+ manifest_path=manifest_path,
449
+ status=status,
450
+ created_at=_utc_now(),
451
+ )
452
+ self.session.add(model)
453
+ self.session.commit()
454
+ self.session.refresh(model)
455
+ return AgentHandoffRecord.model_validate(model.model_dump())
456
+
457
+
458
+ class CorrectionManifestRepository:
459
+ def __init__(self, session: Session):
460
+ self.session = session
461
+
462
+ def save(
463
+ self,
464
+ task_id: str,
465
+ manifest_path: str,
466
+ status: str,
467
+ *,
468
+ run_id: Optional[str] = None,
469
+ retry_budget: int = 3,
470
+ attempt: int = 0,
471
+ manifest_id: Optional[str] = None,
472
+ ) -> CorrectionManifestRecord:
473
+ mid = manifest_id or str(uuid.uuid4())
474
+ model = CorrectionManifestModel(
475
+ id=mid,
476
+ task_id=task_id,
477
+ run_id=run_id,
478
+ manifest_path=manifest_path,
479
+ retry_budget=retry_budget,
480
+ attempt=attempt,
481
+ status=status,
482
+ created_at=_utc_now(),
483
+ )
484
+ self.session.add(model)
485
+ self.session.commit()
486
+ self.session.refresh(model)
487
+ return CorrectionManifestRecord.model_validate(model.model_dump())
488
+
489
+ def latest_for_task(self, task_id: str) -> CorrectionManifestRecord | None:
490
+ statement = (
491
+ select(CorrectionManifestModel)
492
+ .where(CorrectionManifestModel.task_id == task_id)
493
+ .order_by(col(CorrectionManifestModel.created_at).desc())
494
+ )
495
+ model = self.session.exec(statement).first()
496
+ if model is None:
497
+ return None
498
+ return CorrectionManifestRecord.model_validate(model.model_dump())
499
+
500
+
501
+ class VerificationRunRepository:
502
+ def __init__(self, session: Session):
503
+ self.session = session
504
+
505
+ def save(
506
+ self,
507
+ task_id: str,
508
+ sandbox: str,
509
+ environment: dict,
510
+ commands: list[dict],
511
+ status: str,
512
+ *,
513
+ run_id: Optional[str] = None,
514
+ finished_at: Optional[str] = None,
515
+ ) -> VerificationRunRecord:
516
+ rid = run_id or str(uuid.uuid4())
517
+ model = VerificationRunModel(
518
+ id=rid,
519
+ task_id=task_id,
520
+ sandbox=sandbox,
521
+ environment_json=json.dumps(environment),
522
+ commands_json=json.dumps(commands),
523
+ status=status,
524
+ started_at=_utc_now(),
525
+ finished_at=finished_at,
526
+ )
527
+ self.session.add(model)
528
+ self.session.commit()
529
+ self.session.refresh(model)
530
+ return VerificationRunRecord(
531
+ id=model.id,
532
+ task_id=model.task_id,
533
+ sandbox=model.sandbox,
534
+ environment=environment,
535
+ commands=commands,
536
+ status=model.status,
537
+ started_at=model.started_at,
538
+ finished_at=model.finished_at,
539
+ )
540
+
541
+ def list_for_task(self, task_id: str) -> list[VerificationRunRecord]:
542
+ statement = (
543
+ select(VerificationRunModel)
544
+ .where(VerificationRunModel.task_id == task_id)
545
+ .order_by(col(VerificationRunModel.started_at))
546
+ )
547
+ records: list[VerificationRunRecord] = []
548
+ for model in self.session.exec(statement).all():
549
+ records.append(VerificationRunRecord(
550
+ id=model.id,
551
+ task_id=model.task_id,
552
+ sandbox=model.sandbox,
553
+ environment=json.loads(model.environment_json),
554
+ commands=json.loads(model.commands_json),
555
+ status=model.status,
556
+ started_at=model.started_at,
557
+ finished_at=model.finished_at,
558
+ ))
559
+ return records