forgex-cli 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 (115) hide show
  1. forgex/__init__.py +3 -0
  2. forgex/__main__.py +6 -0
  3. forgex/agents/__init__.py +36 -0
  4. forgex/agents/architect.py +233 -0
  5. forgex/agents/base.py +385 -0
  6. forgex/agents/builder.py +269 -0
  7. forgex/agents/ensemble.py +334 -0
  8. forgex/agents/expert_coder.py +283 -0
  9. forgex/agents/supervisor.py +303 -0
  10. forgex/cli/__init__.py +1 -0
  11. forgex/cli/app.py +232 -0
  12. forgex/cli/build_views.py +94 -0
  13. forgex/cli/commands/__init__.py +1 -0
  14. forgex/cli/commands/build.py +63 -0
  15. forgex/cli/commands/checkpoint.py +41 -0
  16. forgex/cli/commands/config.py +225 -0
  17. forgex/cli/commands/context.py +94 -0
  18. forgex/cli/commands/diff.py +32 -0
  19. forgex/cli/commands/do_cmd.py +50 -0
  20. forgex/cli/commands/doctor.py +175 -0
  21. forgex/cli/commands/fix.py +46 -0
  22. forgex/cli/commands/init.py +41 -0
  23. forgex/cli/commands/logs.py +102 -0
  24. forgex/cli/commands/models.py +144 -0
  25. forgex/cli/commands/plan.py +103 -0
  26. forgex/cli/commands/review.py +76 -0
  27. forgex/cli/commands/rollback.py +65 -0
  28. forgex/cli/commands/run.py +75 -0
  29. forgex/cli/commands/status.py +141 -0
  30. forgex/cli/commands/supervise.py +61 -0
  31. forgex/cli/commands/task.py +136 -0
  32. forgex/cli/commands/tools.py +116 -0
  33. forgex/cli/commands/verify.py +65 -0
  34. forgex/cli/context_memory.py +62 -0
  35. forgex/cli/intent.py +209 -0
  36. forgex/cli/progress.py +101 -0
  37. forgex/cli/repl.py +377 -0
  38. forgex/cli/runtime.py +233 -0
  39. forgex/cli/session.py +95 -0
  40. forgex/cli/ui.py +113 -0
  41. forgex/config/__init__.py +53 -0
  42. forgex/config/loader.py +189 -0
  43. forgex/config/schema.py +145 -0
  44. forgex/context/__init__.py +6 -0
  45. forgex/context/chunker.py +102 -0
  46. forgex/context/dependency_graph.py +110 -0
  47. forgex/context/indexer.py +161 -0
  48. forgex/context/manager.py +174 -0
  49. forgex/context/retriever.py +99 -0
  50. forgex/models/__init__.py +25 -0
  51. forgex/models/base.py +112 -0
  52. forgex/models/capabilities.py +21 -0
  53. forgex/models/factory.py +66 -0
  54. forgex/models/groq.py +13 -0
  55. forgex/models/local.py +10 -0
  56. forgex/models/openai_compatible.py +233 -0
  57. forgex/models/registry.py +81 -0
  58. forgex/models/router.py +332 -0
  59. forgex/orchestration/__init__.py +36 -0
  60. forgex/orchestration/checkpoints.py +117 -0
  61. forgex/orchestration/engine.py +573 -0
  62. forgex/orchestration/events.py +101 -0
  63. forgex/orchestration/lockfile.py +82 -0
  64. forgex/orchestration/retry.py +65 -0
  65. forgex/orchestration/state.py +89 -0
  66. forgex/orchestration/stores.py +90 -0
  67. forgex/orchestration/workflow.py +103 -0
  68. forgex/project.py +209 -0
  69. forgex/prompts/__init__.py +27 -0
  70. forgex/prompts/architect.md +18 -0
  71. forgex/prompts/builder.md +20 -0
  72. forgex/prompts/expert_coder.md +25 -0
  73. forgex/prompts/reviewer.md +11 -0
  74. forgex/prompts/supervisor.md +26 -0
  75. forgex/research/__init__.py +15 -0
  76. forgex/research/citations.py +16 -0
  77. forgex/research/engine.py +83 -0
  78. forgex/research/sources.py +154 -0
  79. forgex/sandbox/__init__.py +37 -0
  80. forgex/sandbox/executor.py +418 -0
  81. forgex/sandbox/permissions.py +67 -0
  82. forgex/sandbox/policies.py +172 -0
  83. forgex/schemas/__init__.py +101 -0
  84. forgex/schemas/agent.py +109 -0
  85. forgex/schemas/checkpoint.py +19 -0
  86. forgex/schemas/error.py +125 -0
  87. forgex/schemas/event.py +76 -0
  88. forgex/schemas/plan.py +84 -0
  89. forgex/schemas/project.py +92 -0
  90. forgex/schemas/research.py +25 -0
  91. forgex/schemas/result.py +68 -0
  92. forgex/schemas/task.py +118 -0
  93. forgex/storage/__init__.py +25 -0
  94. forgex/storage/database.py +40 -0
  95. forgex/storage/migrations.py +130 -0
  96. forgex/storage/repositories.py +492 -0
  97. forgex/tools/__init__.py +73 -0
  98. forgex/tools/base.py +212 -0
  99. forgex/tools/dependencies.py +137 -0
  100. forgex/tools/filesystem.py +322 -0
  101. forgex/tools/git.py +239 -0
  102. forgex/tools/project.py +39 -0
  103. forgex/tools/search.py +205 -0
  104. forgex/tools/terminal.py +81 -0
  105. forgex/tools/testing.py +100 -0
  106. forgex/tools/verification.py +174 -0
  107. forgex/utils/__init__.py +15 -0
  108. forgex/utils/logging.py +28 -0
  109. forgex/utils/paths.py +48 -0
  110. forgex/utils/redact.py +44 -0
  111. forgex_cli-0.1.0.dist-info/METADATA +189 -0
  112. forgex_cli-0.1.0.dist-info/RECORD +115 -0
  113. forgex_cli-0.1.0.dist-info/WHEEL +4 -0
  114. forgex_cli-0.1.0.dist-info/entry_points.txt +2 -0
  115. forgex_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,492 @@
1
+ """SQLite repositories (spec section 25).
2
+
3
+ Pure persistence: parameterized SQL, no business logic, no JSON-file
4
+ knowledge. Every sqlite error is wrapped in StorageError so callers only
5
+ ever see controlled FORGEX failures (spec section 28).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import sqlite3
12
+ from datetime import UTC, datetime
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from forgex.schemas.checkpoint import CheckpointRecord
17
+ from forgex.schemas.error import ErrorRecord, StorageError
18
+ from forgex.schemas.event import Event, EventType
19
+ from forgex.schemas.project import ProjectState, RunState, RunStatus, WorkflowStatus
20
+ from forgex.schemas.result import ModelUsageRecord
21
+ from forgex.schemas.task import Task, TaskPriority, TaskStatus
22
+ from forgex.storage.database import Database
23
+
24
+
25
+ def _iso(value: datetime) -> str:
26
+ return value.isoformat()
27
+
28
+
29
+ def _dt(value: str | None) -> datetime | None:
30
+ return datetime.fromisoformat(value) if value else None
31
+
32
+
33
+ class ProjectRepository:
34
+ def __init__(self, db: Database) -> None:
35
+ self._db = db
36
+
37
+ def upsert(self, state: ProjectState) -> None:
38
+ try:
39
+ self._db.conn.execute(
40
+ """
41
+ INSERT INTO projects (project_id, path, name, status, created_at, updated_at)
42
+ VALUES (?, ?, ?, ?, ?, ?)
43
+ ON CONFLICT(project_id) DO UPDATE SET
44
+ path = excluded.path,
45
+ name = excluded.name,
46
+ status = excluded.status,
47
+ updated_at = excluded.updated_at
48
+ """,
49
+ (
50
+ state.project_id,
51
+ state.project_path,
52
+ Path(state.project_path).name,
53
+ state.status.value,
54
+ _iso(state.created_at),
55
+ _iso(state.updated_at),
56
+ ),
57
+ )
58
+ self._db.conn.commit()
59
+ except sqlite3.Error as exc:
60
+ raise StorageError(f"Failed to upsert project: {exc}") from exc
61
+
62
+ def get(self, project_id: str) -> ProjectState | None:
63
+ try:
64
+ row = self._db.conn.execute(
65
+ "SELECT * FROM projects WHERE project_id = ?", (project_id,)
66
+ ).fetchone()
67
+ if not row:
68
+ return None
69
+ data = dict(row)
70
+ return ProjectState(
71
+ project_id=data["project_id"],
72
+ project_path=data.get("path") or "",
73
+ status=WorkflowStatus(data["status"]),
74
+ created_at=_dt(data.get("created_at")) or datetime.now(UTC),
75
+ updated_at=_dt(data.get("updated_at")) or datetime.now(UTC),
76
+ )
77
+ except sqlite3.Error as exc:
78
+ raise StorageError(f"Failed to get project: {exc}") from exc
79
+
80
+
81
+ class RunRepository:
82
+ def __init__(self, db: Database) -> None:
83
+ self._db = db
84
+
85
+ def create(self, run: RunState) -> None:
86
+ try:
87
+ self._db.conn.execute(
88
+ """
89
+ INSERT INTO runs (run_id, project_id, request, status, phase, iteration, started_at, finished_at, summary, error)
90
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
91
+ """,
92
+ (
93
+ run.run_id,
94
+ run.project_id,
95
+ run.request,
96
+ run.status.value,
97
+ run.phase.value,
98
+ run.iteration,
99
+ _iso(run.started_at),
100
+ _iso(run.finished_at) if run.finished_at else None,
101
+ run.summary,
102
+ run.error,
103
+ ),
104
+ )
105
+ self._db.conn.commit()
106
+ except sqlite3.Error as exc:
107
+ raise StorageError(f"Failed to create run: {exc}") from exc
108
+
109
+ def update(self, run: RunState) -> None:
110
+ try:
111
+ self._db.conn.execute(
112
+ """
113
+ UPDATE runs
114
+ SET status = ?, phase = ?, iteration = ?, finished_at = ?, summary = ?, error = ?
115
+ WHERE run_id = ?
116
+ """,
117
+ (
118
+ run.status.value,
119
+ run.phase.value,
120
+ run.iteration,
121
+ _iso(run.finished_at) if run.finished_at else None,
122
+ run.summary,
123
+ run.error,
124
+ run.run_id,
125
+ ),
126
+ )
127
+ self._db.conn.commit()
128
+ except sqlite3.Error as exc:
129
+ raise StorageError(f"Failed to update run: {exc}") from exc
130
+
131
+ def get(self, run_id: str) -> RunState | None:
132
+ try:
133
+ row = self._db.conn.execute("SELECT * FROM runs WHERE run_id = ?", (run_id,)).fetchone()
134
+ if not row:
135
+ return None
136
+ return self._to_run(row)
137
+ except sqlite3.Error as exc:
138
+ raise StorageError(f"Failed to get run: {exc}") from exc
139
+
140
+ def list_recent(self, project_id: str | None = None, limit: int = 50) -> list[RunState]:
141
+ try:
142
+ if project_id:
143
+ rows = self._db.conn.execute(
144
+ "SELECT * FROM runs WHERE project_id = ? ORDER BY started_at DESC LIMIT ?",
145
+ (project_id, limit),
146
+ ).fetchall()
147
+ else:
148
+ rows = self._db.conn.execute(
149
+ "SELECT * FROM runs ORDER BY started_at DESC LIMIT ?", (limit,)
150
+ ).fetchall()
151
+ return [self._to_run(r) for r in rows]
152
+ except sqlite3.Error as exc:
153
+ raise StorageError(f"Failed to list runs: {exc}") from exc
154
+
155
+ @staticmethod
156
+ def _to_run(row: sqlite3.Row) -> RunState:
157
+ return RunState(
158
+ run_id=row["run_id"],
159
+ project_id=row["project_id"],
160
+ request=row["request"],
161
+ status=RunStatus(row["status"]),
162
+ phase=WorkflowStatus(row["phase"]),
163
+ iteration=row["iteration"],
164
+ started_at=_dt(row["started_at"]) or datetime.now(UTC),
165
+ finished_at=_dt(row["finished_at"]),
166
+ summary=row["summary"],
167
+ error=row["error"],
168
+ )
169
+
170
+
171
+ class TaskRepository:
172
+ def __init__(self, db: Database) -> None:
173
+ self._db = db
174
+
175
+ def upsert(self, task: Task, project_id: str) -> None:
176
+ try:
177
+ self._db.conn.execute(
178
+ """
179
+ INSERT INTO tasks (id, project_id, description, priority, status, depends_on, files, acceptance_criteria, attempts, created_at, updated_at, result)
180
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
181
+ ON CONFLICT(id, project_id) DO UPDATE SET
182
+ description = excluded.description,
183
+ priority = excluded.priority,
184
+ status = excluded.status,
185
+ depends_on = excluded.depends_on,
186
+ files = excluded.files,
187
+ acceptance_criteria = excluded.acceptance_criteria,
188
+ attempts = excluded.attempts,
189
+ updated_at = excluded.updated_at,
190
+ result = excluded.result
191
+ """,
192
+ (
193
+ task.id,
194
+ project_id,
195
+ task.description,
196
+ task.priority.value,
197
+ task.status.value,
198
+ json.dumps(task.depends_on),
199
+ json.dumps(task.files),
200
+ json.dumps(task.acceptance_criteria),
201
+ task.attempts,
202
+ _iso(task.created_at),
203
+ _iso(task.updated_at),
204
+ task.result,
205
+ ),
206
+ )
207
+ self._db.conn.commit()
208
+ except sqlite3.Error as exc:
209
+ raise StorageError(f"Failed to upsert task: {exc}") from exc
210
+
211
+ def list(self, project_id: str) -> list[Task]:
212
+ try:
213
+ rows = self._db.conn.execute(
214
+ "SELECT * FROM tasks WHERE project_id = ? ORDER BY rowid ASC", (project_id,)
215
+ ).fetchall()
216
+ return [self._to_task(r) for r in rows]
217
+ except sqlite3.Error as exc:
218
+ raise StorageError(f"Failed to list tasks: {exc}") from exc
219
+
220
+ def get(self, task_id: str, project_id: str) -> Task | None:
221
+ try:
222
+ row = self._db.conn.execute(
223
+ "SELECT * FROM tasks WHERE id = ? AND project_id = ?", (task_id, project_id)
224
+ ).fetchone()
225
+ return self._to_task(row) if row else None
226
+ except sqlite3.Error as exc:
227
+ raise StorageError(f"Failed to get task: {exc}") from exc
228
+
229
+ @staticmethod
230
+ def _to_task(row: sqlite3.Row) -> Task:
231
+ return Task(
232
+ id=row["id"],
233
+ description=row["description"],
234
+ priority=TaskPriority(row["priority"]),
235
+ status=TaskStatus(row["status"]),
236
+ depends_on=json.loads(row["depends_on"]),
237
+ files=json.loads(row["files"]),
238
+ acceptance_criteria=json.loads(row["acceptance_criteria"]),
239
+ attempts=row["attempts"],
240
+ created_at=_dt(row["created_at"]) or datetime.now(UTC),
241
+ updated_at=_dt(row["updated_at"]) or datetime.now(UTC),
242
+ result=row["result"],
243
+ )
244
+
245
+
246
+ class EventRepository:
247
+ def __init__(self, db: Database) -> None:
248
+ self._db = db
249
+
250
+ def insert(self, event: Event) -> None:
251
+ try:
252
+ self._db.conn.execute(
253
+ "INSERT INTO events (ts, type, run_id, data) VALUES (?, ?, ?, ?)",
254
+ (event.ts.isoformat(), event.type.value, event.run_id, json.dumps(event.data, default=str)),
255
+ )
256
+ self._db.conn.commit()
257
+ except sqlite3.Error as exc:
258
+ raise StorageError(f"Failed to insert event: {exc}") from exc
259
+
260
+ def count(self) -> int:
261
+ try:
262
+ row = self._db.conn.execute("SELECT COUNT(*) FROM events").fetchone()
263
+ return row[0] if row else 0
264
+ except sqlite3.Error as exc:
265
+ raise StorageError(f"Failed to count events: {exc}") from exc
266
+
267
+ def recent(self, limit: int = 50) -> list[Event]:
268
+ try:
269
+ rows = self._db.conn.execute(
270
+ "SELECT * FROM events ORDER BY id DESC LIMIT ?", (limit,)
271
+ ).fetchall()
272
+ return [
273
+ Event(
274
+ ts=_dt(r["ts"]) or datetime.now(UTC),
275
+ type=EventType(r["type"]),
276
+ run_id=r["run_id"],
277
+ data=json.loads(r["data"]),
278
+ )
279
+ for r in rows
280
+ ]
281
+ except sqlite3.Error as exc:
282
+ raise StorageError(f"Failed to get recent events: {exc}") from exc
283
+
284
+
285
+ class ErrorRepository:
286
+ def __init__(self, db: Database) -> None:
287
+ self._db = db
288
+
289
+ def insert(self, error: ErrorRecord, project_id: str) -> None:
290
+ try:
291
+ self._db.conn.execute(
292
+ """
293
+ INSERT INTO errors (project_id, run_id, ts, source, kind, message, detail)
294
+ VALUES (?, ?, ?, ?, ?, ?, ?)
295
+ """,
296
+ (
297
+ project_id,
298
+ error.run_id,
299
+ _iso(error.ts),
300
+ error.source,
301
+ error.kind,
302
+ error.message,
303
+ json.dumps(error.detail, default=str) if error.detail else None,
304
+ ),
305
+ )
306
+ self._db.conn.commit()
307
+ except sqlite3.Error as exc:
308
+ raise StorageError(f"Failed to insert error: {exc}") from exc
309
+
310
+ def list(self, project_id: str, limit: int = 50) -> list[ErrorRecord]:
311
+ try:
312
+ rows = self._db.conn.execute(
313
+ "SELECT * FROM errors WHERE project_id = ? ORDER BY id DESC LIMIT ?",
314
+ (project_id, limit),
315
+ ).fetchall()
316
+ return [
317
+ ErrorRecord(
318
+ ts=_dt(r["ts"]) or datetime.now(UTC),
319
+ source=r["source"],
320
+ kind=r["kind"],
321
+ message=r["message"],
322
+ run_id=r["run_id"],
323
+ detail=json.loads(r["detail"]) if r["detail"] else None,
324
+ )
325
+ for r in rows
326
+ ]
327
+ except sqlite3.Error as exc:
328
+ raise StorageError(f"Failed to list errors: {exc}") from exc
329
+
330
+
331
+ class ModelUsageRepository:
332
+ """Persists per-call model usage (spec sections 40, 41)."""
333
+
334
+ def __init__(self, db: Database) -> None:
335
+ self._db = db
336
+
337
+ def insert(self, record: ModelUsageRecord) -> None:
338
+ try:
339
+ self._db.conn.execute(
340
+ """
341
+ INSERT INTO model_usage (ts, run_id, agent, provider, model,
342
+ input_tokens, output_tokens, total_tokens,
343
+ latency_ms, attempts, success, error)
344
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
345
+ """,
346
+ (
347
+ _iso(record.ts),
348
+ record.run_id,
349
+ record.agent,
350
+ record.provider,
351
+ record.model,
352
+ record.input_tokens,
353
+ record.output_tokens,
354
+ record.total_tokens,
355
+ record.latency_ms,
356
+ record.attempts,
357
+ int(record.success),
358
+ record.error,
359
+ ),
360
+ )
361
+ self._db.conn.commit()
362
+ except sqlite3.Error as exc:
363
+ raise StorageError(f"Failed to record model usage: {exc}") from exc
364
+
365
+ def recent(self, run_id: str | None = None, limit: int = 50) -> list[ModelUsageRecord]:
366
+ sql = "SELECT * FROM model_usage"
367
+ params: list[Any] = []
368
+ if run_id is not None:
369
+ sql += " WHERE run_id = ?"
370
+ params.append(run_id)
371
+ sql += " ORDER BY id DESC LIMIT ?"
372
+ params.append(limit)
373
+ try:
374
+ rows = self._db.conn.execute(sql, params).fetchall()
375
+ return [self._to_record(row) for row in rows]
376
+ except sqlite3.Error as exc:
377
+ raise StorageError(f"Failed to read model usage: {exc}") from exc
378
+
379
+ def totals(self) -> dict[str, Any]:
380
+ try:
381
+ row = self._db.conn.execute(
382
+ """
383
+ SELECT COUNT(*) as calls,
384
+ SUM(input_tokens) as input_tokens,
385
+ SUM(output_tokens) as output_tokens,
386
+ SUM(total_tokens) as total_tokens
387
+ FROM model_usage
388
+ """
389
+ ).fetchone()
390
+ if not row:
391
+ return {"calls": 0, "input_tokens": 0, "output_tokens": 0, "total_tokens": 0}
392
+ return {
393
+ "calls": row["calls"] or 0,
394
+ "input_tokens": row["input_tokens"],
395
+ "output_tokens": row["output_tokens"],
396
+ "total_tokens": row["total_tokens"],
397
+ }
398
+ except sqlite3.Error as exc:
399
+ raise StorageError(f"Failed to calculate usage totals: {exc}") from exc
400
+
401
+ @staticmethod
402
+ def _to_record(row: sqlite3.Row) -> ModelUsageRecord:
403
+ return ModelUsageRecord(
404
+ ts=_dt(row["ts"]) or datetime.now(UTC),
405
+ run_id=row["run_id"],
406
+ agent=row["agent"],
407
+ provider=row["provider"],
408
+ model=row["model"],
409
+ input_tokens=row["input_tokens"],
410
+ output_tokens=row["output_tokens"],
411
+ total_tokens=row["total_tokens"],
412
+ latency_ms=row["latency_ms"],
413
+ attempts=row["attempts"],
414
+ success=bool(row["success"]),
415
+ error=row["error"],
416
+ )
417
+
418
+
419
+ class CheckpointRepository:
420
+ """Persists checkpoint metadata (spec section 21)."""
421
+
422
+ def __init__(self, db: Database) -> None:
423
+ self._db = db
424
+
425
+ def insert(self, record: CheckpointRecord) -> None:
426
+ try:
427
+ self._db.conn.execute(
428
+ """
429
+ INSERT INTO checkpoints (id, project_id, label, git_commit,
430
+ description, created_at, run_id)
431
+ VALUES (?, ?, ?, ?, ?, ?, ?)
432
+ """,
433
+ (
434
+ record.id,
435
+ record.project_id,
436
+ record.label,
437
+ record.git_commit,
438
+ record.description,
439
+ _iso(record.created_at),
440
+ record.run_id,
441
+ ),
442
+ )
443
+ self._db.conn.commit()
444
+ except sqlite3.Error as exc:
445
+ raise StorageError(f"Failed to persist checkpoint: {exc}") from exc
446
+
447
+ def get(self, checkpoint_id: str) -> CheckpointRecord | None:
448
+ try:
449
+ row = self._db.conn.execute(
450
+ "SELECT * FROM checkpoints WHERE id = ?", (checkpoint_id,)
451
+ ).fetchone()
452
+ return self._to_record(row) if row else None
453
+ except sqlite3.Error as exc:
454
+ raise StorageError(f"Failed to read checkpoint: {exc}") from exc
455
+
456
+ def latest(self, project_id: str) -> CheckpointRecord | None:
457
+ try:
458
+ row = self._db.conn.execute(
459
+ """
460
+ SELECT * FROM checkpoints WHERE project_id = ?
461
+ ORDER BY created_at DESC, rowid DESC LIMIT 1
462
+ """,
463
+ (project_id,),
464
+ ).fetchone()
465
+ return self._to_record(row) if row else None
466
+ except sqlite3.Error as exc:
467
+ raise StorageError(f"Failed to read latest checkpoint: {exc}") from exc
468
+
469
+ def list(self, project_id: str, limit: int = 20) -> list[CheckpointRecord]:
470
+ try:
471
+ rows = self._db.conn.execute(
472
+ """
473
+ SELECT * FROM checkpoints WHERE project_id = ?
474
+ ORDER BY created_at DESC, rowid DESC LIMIT ?
475
+ """,
476
+ (project_id, limit),
477
+ ).fetchall()
478
+ return [self._to_record(r) for r in rows]
479
+ except sqlite3.Error as exc:
480
+ raise StorageError(f"Failed to list checkpoints: {exc}") from exc
481
+
482
+ @staticmethod
483
+ def _to_record(row: sqlite3.Row) -> CheckpointRecord:
484
+ return CheckpointRecord(
485
+ id=row["id"],
486
+ project_id=row["project_id"],
487
+ label=row["label"],
488
+ git_commit=row["git_commit"],
489
+ description=row["description"],
490
+ created_at=_dt(row["created_at"]) or datetime.now(UTC),
491
+ run_id=row["run_id"],
492
+ )
@@ -0,0 +1,73 @@
1
+ """Tool system (spec sections 10, 18, 19, 32)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from forgex.config.schema import ForgexConfig
10
+ from forgex.orchestration.events import EventRecorder
11
+ from forgex.sandbox.executor import build_executor
12
+ from forgex.sandbox.permissions import Permission
13
+ from forgex.tools import dependencies as _dependencies
14
+ from forgex.tools import filesystem as _filesystem
15
+ from forgex.tools import git as _git
16
+ from forgex.tools import project as _project
17
+ from forgex.tools import search as _search
18
+ from forgex.tools import terminal as _terminal
19
+ from forgex.tools import testing as _testing
20
+ from forgex.tools.base import (
21
+ SimpleTool,
22
+ Tool,
23
+ ToolCall,
24
+ ToolContext,
25
+ ToolExecutor,
26
+ ToolRegistry,
27
+ ToolResult,
28
+ )
29
+
30
+ __all__ = [
31
+ "Permission",
32
+ "SimpleTool",
33
+ "Tool",
34
+ "ToolCall",
35
+ "ToolContext",
36
+ "ToolExecutor",
37
+ "ToolRegistry",
38
+ "ToolResult",
39
+ "build_tool_registry",
40
+ "create_context",
41
+ ]
42
+
43
+
44
+ def build_tool_registry() -> ToolRegistry:
45
+ registry = ToolRegistry()
46
+ for module in (_filesystem, _search, _terminal, _git, _testing, _dependencies, _project):
47
+ for tool in module.create_tools():
48
+ registry.register(tool)
49
+ return registry
50
+
51
+
52
+ def create_context(
53
+ workspace: Path | None = None,
54
+ config: ForgexConfig | None = None,
55
+ *,
56
+ events: EventRecorder | None = None,
57
+ approval: Callable[[str], bool] | None = None,
58
+ run_id: str | None = None,
59
+ agent=None,
60
+ **kwargs: Any,
61
+ ) -> ToolContext:
62
+ ws = Path(workspace or kwargs.get("workspace") or Path.cwd()).resolve()
63
+ cfg = config or kwargs.get("config") or ForgexConfig()
64
+ executor = build_executor(workspace=ws, config=cfg, approval=approval)
65
+ return ToolContext(
66
+ workspace=ws,
67
+ config=cfg,
68
+ executor=executor,
69
+ events=events,
70
+ approval=approval,
71
+ run_id=run_id,
72
+ agent=agent,
73
+ )