whiletrue 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 (98) hide show
  1. whiletrue/__init__.py +0 -0
  2. whiletrue/__main__.py +3 -0
  3. whiletrue/abort.py +79 -0
  4. whiletrue/basics/__init__.py +99 -0
  5. whiletrue/basics/agent.py +249 -0
  6. whiletrue/basics/comments.py +113 -0
  7. whiletrue/basics/diff_size.py +80 -0
  8. whiletrue/basics/exec_.py +84 -0
  9. whiletrue/basics/history.py +117 -0
  10. whiletrue/basics/human.py +201 -0
  11. whiletrue/basics/json_source.py +35 -0
  12. whiletrue/basics/markdown_source.py +55 -0
  13. whiletrue/basics/process.py +99 -0
  14. whiletrue/basics/sessions.py +62 -0
  15. whiletrue/basics/set_.py +29 -0
  16. whiletrue/basics/shell.py +98 -0
  17. whiletrue/basics/wake.py +27 -0
  18. whiletrue/claude_code/__init__.py +58 -0
  19. whiletrue/claude_code/endpoint.py +143 -0
  20. whiletrue/claude_code/headless.py +181 -0
  21. whiletrue/claude_code/interactive.py +344 -0
  22. whiletrue/claude_code/learnings.py +32 -0
  23. whiletrue/claude_code/panes.py +164 -0
  24. whiletrue/claude_code/plugin/.claude-plugin/plugin.json +5 -0
  25. whiletrue/claude_code/plugin/hook.py +44 -0
  26. whiletrue/claude_code/plugin/hooks/hooks.json +81 -0
  27. whiletrue/claude_code/plugin/mcp_server.py +98 -0
  28. whiletrue/claude_code/session.py +345 -0
  29. whiletrue/claude_code/transcript.py +123 -0
  30. whiletrue/cli/__init__.py +15 -0
  31. whiletrue/cli/__main__.py +3 -0
  32. whiletrue/cli/app.py +550 -0
  33. whiletrue/cli/common.py +36 -0
  34. whiletrue/cli/gates.py +156 -0
  35. whiletrue/cli/ops.py +339 -0
  36. whiletrue/cli/panel.py +198 -0
  37. whiletrue/cli/registry.py +143 -0
  38. whiletrue/cli/templates.py +24 -0
  39. whiletrue/codex/__init__.py +14 -0
  40. whiletrue/codex/runner.py +243 -0
  41. whiletrue/control/__init__.py +38 -0
  42. whiletrue/control/chain.py +61 -0
  43. whiletrue/control/loop.py +139 -0
  44. whiletrue/control/map_.py +101 -0
  45. whiletrue/control/parallel.py +32 -0
  46. whiletrue/control/retry.py +100 -0
  47. whiletrue/control/sequence.py +40 -0
  48. whiletrue/control/switch.py +67 -0
  49. whiletrue/core/__init__.py +260 -0
  50. whiletrue/core/caps.py +139 -0
  51. whiletrue/core/comments.py +80 -0
  52. whiletrue/core/composite.py +140 -0
  53. whiletrue/core/config.py +461 -0
  54. whiletrue/core/context.py +177 -0
  55. whiletrue/core/duration.py +19 -0
  56. whiletrue/core/events.py +94 -0
  57. whiletrue/core/expr.py +85 -0
  58. whiletrue/core/history.py +83 -0
  59. whiletrue/core/lock.py +86 -0
  60. whiletrue/core/memory_store.py +352 -0
  61. whiletrue/core/module.py +146 -0
  62. whiletrue/core/outcomes.py +82 -0
  63. whiletrue/core/registry.py +129 -0
  64. whiletrue/core/replay.py +58 -0
  65. whiletrue/core/runner.py +98 -0
  66. whiletrue/core/runtime.py +538 -0
  67. whiletrue/core/source.py +45 -0
  68. whiletrue/core/store.py +207 -0
  69. whiletrue/core/template.py +60 -0
  70. whiletrue/core/validate.py +142 -0
  71. whiletrue/core/wake.py +117 -0
  72. whiletrue/core/workspace.py +55 -0
  73. whiletrue/git/__init__.py +31 -0
  74. whiletrue/git/modules.py +128 -0
  75. whiletrue/git/workspaces.py +77 -0
  76. whiletrue/github/__init__.py +30 -0
  77. whiletrue/github/client.py +269 -0
  78. whiletrue/github/filebacked.py +87 -0
  79. whiletrue/github/memory.py +96 -0
  80. whiletrue/github/plugin.py +343 -0
  81. whiletrue/github/specs.py +39 -0
  82. whiletrue/opencode/__init__.py +25 -0
  83. whiletrue/opencode/runner.py +186 -0
  84. whiletrue/py.typed +0 -0
  85. whiletrue/recipes/issue-loop.yaml +59 -0
  86. whiletrue/recipes/ralph.yaml +14 -0
  87. whiletrue/recipes/review-pass.yaml +27 -0
  88. whiletrue/recipes/review-respond.yaml +22 -0
  89. whiletrue/store_sqlite/__init__.py +6 -0
  90. whiletrue/store_sqlite/migrations.py +132 -0
  91. whiletrue/store_sqlite/sqlite_store.py +537 -0
  92. whiletrue/tmux/__init__.py +11 -0
  93. whiletrue/tmux/controller.py +197 -0
  94. whiletrue-0.1.0.dist-info/METADATA +48 -0
  95. whiletrue-0.1.0.dist-info/RECORD +98 -0
  96. whiletrue-0.1.0.dist-info/WHEEL +4 -0
  97. whiletrue-0.1.0.dist-info/entry_points.txt +2 -0
  98. whiletrue-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,537 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sqlite3
5
+ from pathlib import Path
6
+ from typing import Any, Literal
7
+
8
+ from whiletrue.core.events import WtEvent
9
+ from whiletrue.core.history import (
10
+ CommentRecord,
11
+ Decision,
12
+ EffectRecord,
13
+ LearningRecord,
14
+ LearningStatus,
15
+ ParkRecord,
16
+ SteerRecord,
17
+ )
18
+ from whiletrue.core.outcomes import ItemState
19
+ from whiletrue.core.store import ItemRecord, RunRecord, StepRecord
20
+ from whiletrue.store_sqlite.migrations import MIGRATIONS
21
+
22
+
23
+ def _dump(value: Any) -> str | None:
24
+ return None if value is None else json.dumps(value)
25
+
26
+
27
+ def _load(text: str | None) -> Any:
28
+ return None if text is None else json.loads(text)
29
+
30
+
31
+ class SqliteStore:
32
+ """`StateStore` backed by SQLite (stdlib `sqlite3`)."""
33
+
34
+ def __init__(self, path: str | Path) -> None:
35
+ """`path` may be ":memory:". Parent directories are created for file databases."""
36
+ if str(path) != ":memory:":
37
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
38
+ # isolation_level=None: autocommit, so migrations manage their own transactions.
39
+ self._db = sqlite3.connect(str(path), isolation_level=None)
40
+ self._db.row_factory = sqlite3.Row
41
+ self._db.executescript("PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;")
42
+ self._migrate()
43
+
44
+ def _migrate(self) -> None:
45
+ self._db.execute(
46
+ "CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER PRIMARY KEY)"
47
+ )
48
+ current = self.schema_version()
49
+ for index, sql in enumerate(MIGRATIONS):
50
+ version = index + 1
51
+ if version <= current:
52
+ continue
53
+ try:
54
+ self._db.execute("BEGIN")
55
+ for statement in _split_statements(sql):
56
+ self._db.execute(statement)
57
+ self._db.execute("INSERT INTO schema_migrations (version) VALUES (?)", (version,))
58
+ self._db.execute("COMMIT")
59
+ except BaseException:
60
+ self._db.execute("ROLLBACK")
61
+ raise
62
+
63
+ def schema_version(self) -> int:
64
+ row = self._db.execute("SELECT MAX(version) AS v FROM schema_migrations").fetchone()
65
+ return int(row["v"] or 0)
66
+
67
+ def tables(self) -> list[str]:
68
+ rows = self._db.execute(
69
+ "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' "
70
+ "ORDER BY name"
71
+ ).fetchall()
72
+ return [r["name"] for r in rows]
73
+
74
+ # ---- runs ----
75
+
76
+ def create_run(
77
+ self, *, id: str, pipeline_hash: str, started_at: int, lockfile: str | None = None
78
+ ) -> None:
79
+ self._db.execute(
80
+ "INSERT INTO runs (id, pipeline_hash, lockfile, status, started_at) "
81
+ "VALUES (?, ?, ?, 'running', ?)",
82
+ (id, pipeline_hash, lockfile, started_at),
83
+ )
84
+
85
+ def finish_run(
86
+ self, id: str, status: Literal["done", "failed", "parked", "cancelled"], ended_at: int
87
+ ) -> None:
88
+ self._db.execute(
89
+ "UPDATE runs SET status = ?, ended_at = ? WHERE id = ?", (status, ended_at, id)
90
+ )
91
+
92
+ @staticmethod
93
+ def _run(row: sqlite3.Row | None) -> RunRecord | None:
94
+ if row is None:
95
+ return None
96
+ return RunRecord(
97
+ id=row["id"],
98
+ pipeline_hash=row["pipeline_hash"],
99
+ lockfile=row["lockfile"],
100
+ status=row["status"],
101
+ started_at=row["started_at"],
102
+ ended_at=row["ended_at"],
103
+ )
104
+
105
+ def interrupt_run(self, id: str, ended_at: int) -> None:
106
+ self._db.execute(
107
+ "UPDATE steps SET status = 'errored', error = 'interrupted', ended_at = ? "
108
+ "WHERE run_id = ? AND status = 'running'",
109
+ (ended_at, id),
110
+ )
111
+ self._db.execute(
112
+ "UPDATE items SET state = 'parked' WHERE run_id = ? AND state = 'running'", (id,)
113
+ )
114
+ self.finish_run(id, "parked", ended_at)
115
+
116
+ def get_run(self, id: str) -> RunRecord | None:
117
+ return self._run(self._db.execute("SELECT * FROM runs WHERE id = ?", (id,)).fetchone())
118
+
119
+ def list_runs(self) -> list[RunRecord]:
120
+ rows = self._db.execute("SELECT * FROM runs ORDER BY started_at, rowid").fetchall()
121
+ return [run for run in (self._run(r) for r in rows) if run is not None]
122
+
123
+ def latest_run(self) -> RunRecord | None:
124
+ return self._run(
125
+ self._db.execute(
126
+ "SELECT * FROM runs ORDER BY started_at DESC, rowid DESC LIMIT 1"
127
+ ).fetchone()
128
+ )
129
+
130
+ # ---- items ----
131
+
132
+ def upsert_item(self, item: ItemRecord) -> None:
133
+ self._db.execute(
134
+ """INSERT INTO items (run_id, id, source_key, state, payload, depends_on)
135
+ VALUES (?, ?, ?, ?, ?, ?)
136
+ ON CONFLICT (run_id, id) DO UPDATE SET
137
+ source_key = excluded.source_key, state = excluded.state,
138
+ payload = excluded.payload, depends_on = excluded.depends_on""",
139
+ (
140
+ item.run_id,
141
+ item.id,
142
+ item.source_key,
143
+ item.state,
144
+ _dump(item.payload),
145
+ json.dumps(item.depends_on),
146
+ ),
147
+ )
148
+
149
+ def set_item_state(self, run_id: str, item_id: str, state: ItemState) -> None:
150
+ cursor = self._db.execute(
151
+ "UPDATE items SET state = ? WHERE run_id = ? AND id = ?", (state, run_id, item_id)
152
+ )
153
+ if cursor.rowcount == 0:
154
+ raise KeyError(f'No item "{item_id}" in run "{run_id}"')
155
+
156
+ def get_item(self, run_id: str, item_id: str) -> ItemRecord | None:
157
+ return next((i for i in self.list_items(run_id) if i.id == item_id), None)
158
+
159
+ def list_items(self, run_id: str) -> list[ItemRecord]:
160
+ rows = self._db.execute(
161
+ "SELECT * FROM items WHERE run_id = ? ORDER BY rowid", (run_id,)
162
+ ).fetchall()
163
+ return [
164
+ ItemRecord(
165
+ id=r["id"],
166
+ run_id=r["run_id"],
167
+ source_key=r["source_key"],
168
+ state=r["state"],
169
+ payload=_load(r["payload"]),
170
+ depends_on=json.loads(r["depends_on"]),
171
+ )
172
+ for r in rows
173
+ ]
174
+
175
+ # ---- steps ----
176
+
177
+ def start_step(
178
+ self,
179
+ *,
180
+ run_id: str,
181
+ item_id: str,
182
+ node_path: str,
183
+ attempt: int,
184
+ input: Any,
185
+ started_at: int,
186
+ ) -> int:
187
+ cursor = self._db.execute(
188
+ """INSERT INTO steps (run_id, item_id, node_path, attempt, status, input, started_at)
189
+ VALUES (?, ?, ?, ?, 'running', ?, ?)""",
190
+ (run_id, item_id, node_path, attempt, _dump(input), started_at),
191
+ )
192
+ assert cursor.lastrowid is not None
193
+ return cursor.lastrowid
194
+
195
+ def finish_step(
196
+ self,
197
+ step_id: int,
198
+ *,
199
+ status: Literal["completed", "errored"],
200
+ ended_at: int,
201
+ port: str | None = None,
202
+ output: Any = None,
203
+ cost_usd: float = 0,
204
+ error: str | None = None,
205
+ ) -> None:
206
+ cursor = self._db.execute(
207
+ """UPDATE steps SET status = ?, port = ?, output = ?, cost_usd = ?, error = ?,
208
+ ended_at = ? WHERE id = ?""",
209
+ (status, port, _dump(output), cost_usd, error, ended_at, step_id),
210
+ )
211
+ if cursor.rowcount == 0:
212
+ raise KeyError(f"No step {step_id}")
213
+
214
+ def list_steps(self, run_id: str, item_id: str | None = None) -> list[StepRecord]:
215
+ if item_id is None:
216
+ rows = self._db.execute(
217
+ "SELECT * FROM steps WHERE run_id = ? ORDER BY id", (run_id,)
218
+ ).fetchall()
219
+ else:
220
+ rows = self._db.execute(
221
+ "SELECT * FROM steps WHERE run_id = ? AND item_id = ? ORDER BY id",
222
+ (run_id, item_id),
223
+ ).fetchall()
224
+ return [
225
+ StepRecord(
226
+ id=r["id"],
227
+ run_id=r["run_id"],
228
+ item_id=r["item_id"],
229
+ node_path=r["node_path"],
230
+ attempt=r["attempt"],
231
+ status=r["status"],
232
+ port=r["port"],
233
+ input=_load(r["input"]),
234
+ output=_load(r["output"]),
235
+ cost_usd=r["cost_usd"],
236
+ error=r["error"],
237
+ started_at=r["started_at"],
238
+ ended_at=r["ended_at"],
239
+ )
240
+ for r in rows
241
+ ]
242
+
243
+ # ---- events ----
244
+
245
+ def append_event(self, event: WtEvent) -> None:
246
+ self._db.execute(
247
+ "INSERT INTO events (run_id, item_id, step_id, type, data, ts) "
248
+ "VALUES (?, ?, ?, ?, ?, ?)",
249
+ (
250
+ event.run_id,
251
+ event.item_id,
252
+ event.step_id,
253
+ event.type,
254
+ json.dumps(event.data),
255
+ event.ts,
256
+ ),
257
+ )
258
+
259
+ def list_events(
260
+ self, run_id: str, *, item_id: str | None = None, type: str | None = None
261
+ ) -> list[WtEvent]:
262
+ where = ["run_id = ?"]
263
+ args: list[Any] = [run_id]
264
+ if item_id is not None:
265
+ where.append("item_id = ?")
266
+ args.append(item_id)
267
+ if type is not None:
268
+ where.append("type = ?")
269
+ args.append(type)
270
+ rows = self._db.execute(
271
+ f"SELECT * FROM events WHERE {' AND '.join(where)} ORDER BY id", args
272
+ ).fetchall()
273
+ return [
274
+ WtEvent(
275
+ type=r["type"],
276
+ run_id=r["run_id"],
277
+ item_id=r["item_id"],
278
+ step_id=r["step_id"],
279
+ ts=r["ts"],
280
+ data=json.loads(r["data"]),
281
+ )
282
+ for r in rows
283
+ ]
284
+
285
+ def cache_get(self, key: str) -> Any | None:
286
+ row = self._db.execute("SELECT value FROM cache WHERE key = ?", (key,)).fetchone()
287
+ return None if row is None else json.loads(row["value"])
288
+
289
+ def cache_put(self, key: str, value: Any, created_at: int) -> None:
290
+ self._db.execute(
291
+ "INSERT INTO cache (key, value, created_at) VALUES (?, ?, ?) "
292
+ "ON CONFLICT(key) DO UPDATE SET value = excluded.value, "
293
+ "created_at = excluded.created_at",
294
+ (key, json.dumps(value), created_at),
295
+ )
296
+
297
+ # ---- human input and side effects ----
298
+
299
+ @staticmethod
300
+ def _park_row(r: sqlite3.Row) -> ParkRecord:
301
+ return ParkRecord(
302
+ id=r["id"],
303
+ run_id=r["run_id"],
304
+ item_id=r["item_id"],
305
+ node_path=r["node_path"],
306
+ what=r["what"],
307
+ wake=_load(r["wake"]) or {},
308
+ reason=r["reason"] or "",
309
+ created_at=r["created_at"],
310
+ decision=r["decision"],
311
+ notes=r["notes"],
312
+ decided_by=r["decided_by"],
313
+ decided_at=r["decided_at"],
314
+ )
315
+
316
+ def create_park(
317
+ self,
318
+ *,
319
+ run_id: str,
320
+ item_id: str,
321
+ node_path: str,
322
+ what: str,
323
+ wake: dict[str, Any],
324
+ reason: str,
325
+ created_at: int,
326
+ ) -> ParkRecord:
327
+ cursor = self._db.execute(
328
+ """INSERT INTO parks (run_id, item_id, node_path, what, wake, reason, created_at)
329
+ VALUES (?, ?, ?, ?, ?, ?, ?)""",
330
+ (run_id, item_id, node_path, what, json.dumps(wake), reason, created_at),
331
+ )
332
+ return self._park_by_id(cursor.lastrowid or 0)
333
+
334
+ def _park_by_id(self, park_id: int) -> ParkRecord:
335
+ row = self._db.execute("SELECT * FROM parks WHERE id = ?", (park_id,)).fetchone()
336
+ if row is None:
337
+ raise KeyError(f"No park {park_id}")
338
+ return self._park_row(row)
339
+
340
+ def decide_park(
341
+ self,
342
+ park_id: int,
343
+ decision: Decision,
344
+ *,
345
+ decided_by: str,
346
+ decided_at: int,
347
+ notes: str | None,
348
+ ) -> None:
349
+ cursor = self._db.execute(
350
+ "UPDATE parks SET decision = ?, decided_by = ?, decided_at = ?, notes = ? WHERE id = ?",
351
+ (decision, decided_by, decided_at, notes, park_id),
352
+ )
353
+ if cursor.rowcount == 0:
354
+ raise KeyError(f"No park {park_id}")
355
+
356
+ def list_parks(
357
+ self,
358
+ item_id: str | None = None,
359
+ *,
360
+ node_path: str | None = None,
361
+ run_id: str | None = None,
362
+ undecided_only: bool = False,
363
+ ) -> list[ParkRecord]:
364
+ where: list[str] = []
365
+ args: list[Any] = []
366
+ for column, value in (("item_id", item_id), ("node_path", node_path), ("run_id", run_id)):
367
+ if value is not None:
368
+ where.append(f"{column} = ?")
369
+ args.append(value)
370
+ if undecided_only:
371
+ where.append("decision IS NULL")
372
+ clause = f"WHERE {' AND '.join(where)}" if where else ""
373
+ rows = self._db.execute(f"SELECT * FROM parks {clause} ORDER BY id", args).fetchall()
374
+ return [self._park_row(r) for r in rows]
375
+
376
+ @staticmethod
377
+ def _comment_row(r: sqlite3.Row) -> CommentRecord:
378
+ return CommentRecord(
379
+ id=r["id"],
380
+ item_id=r["item_id"],
381
+ source=r["source"],
382
+ external_id=r["external_id"],
383
+ author=r["author"],
384
+ body=r["body"],
385
+ created_at=r["created_at"],
386
+ thread=r["thread"],
387
+ url=r["url"],
388
+ kind=r["kind"],
389
+ answered=bool(r["answered"]),
390
+ reply=r["reply"],
391
+ )
392
+
393
+ def add_comment(
394
+ self,
395
+ *,
396
+ item_id: str,
397
+ source: str,
398
+ external_id: str,
399
+ author: str,
400
+ body: str,
401
+ created_at: int,
402
+ thread: str | None = None,
403
+ url: str | None = None,
404
+ kind: Literal["human", "reviewer"] = "human",
405
+ ) -> CommentRecord | None:
406
+ try:
407
+ cursor = self._db.execute(
408
+ """INSERT INTO comments
409
+ (item_id, source, external_id, thread, author, body, url, kind, created_at)
410
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
411
+ (item_id, source, external_id, thread, author, body, url, kind, created_at),
412
+ )
413
+ except sqlite3.IntegrityError:
414
+ return None
415
+ row = self._db.execute(
416
+ "SELECT * FROM comments WHERE id = ?", (cursor.lastrowid,)
417
+ ).fetchone()
418
+ return self._comment_row(row)
419
+
420
+ def list_comments(self, item_id: str, *, unanswered_only: bool = False) -> list[CommentRecord]:
421
+ extra = " AND answered = 0" if unanswered_only else ""
422
+ rows = self._db.execute(
423
+ f"SELECT * FROM comments WHERE item_id = ?{extra} ORDER BY id", (item_id,)
424
+ ).fetchall()
425
+ return [self._comment_row(r) for r in rows]
426
+
427
+ def answer_comment(self, comment_id: int, reply: str) -> None:
428
+ cursor = self._db.execute(
429
+ "UPDATE comments SET answered = 1, reply = ? WHERE id = ?", (reply, comment_id)
430
+ )
431
+ if cursor.rowcount == 0:
432
+ raise KeyError(f"No comment {comment_id}")
433
+
434
+ def add_steer(
435
+ self, *, run_id: str, item_id: str, step: str, prompt: str, created_at: int
436
+ ) -> SteerRecord:
437
+ cursor = self._db.execute(
438
+ "INSERT INTO steers (run_id, item_id, step, prompt, created_at) VALUES (?, ?, ?, ?, ?)",
439
+ (run_id, item_id, step, prompt, created_at),
440
+ )
441
+ return SteerRecord(cursor.lastrowid or 0, run_id, item_id, step, prompt, created_at)
442
+
443
+ def list_steers(self, item_id: str) -> list[SteerRecord]:
444
+ rows = self._db.execute(
445
+ "SELECT * FROM steers WHERE item_id = ? ORDER BY id", (item_id,)
446
+ ).fetchall()
447
+ return [
448
+ SteerRecord(r["id"], r["run_id"], r["item_id"], r["step"], r["prompt"], r["created_at"])
449
+ for r in rows
450
+ ]
451
+
452
+ def effect_get(self, key: str) -> EffectRecord | None:
453
+ row = self._db.execute("SELECT * FROM effects WHERE key = ?", (key,)).fetchone()
454
+ if row is None:
455
+ return None
456
+ return EffectRecord(
457
+ row["key"], row["status"], row["run_id"], row["created_at"], _load(row["external_ref"])
458
+ )
459
+
460
+ def effect_put(
461
+ self,
462
+ key: str,
463
+ *,
464
+ run_id: str,
465
+ status: Literal["started", "done"],
466
+ ref: Any,
467
+ created_at: int,
468
+ ) -> None:
469
+ self._db.execute(
470
+ """INSERT INTO effects (key, run_id, status, external_ref, created_at)
471
+ VALUES (?, ?, ?, ?, ?)
472
+ ON CONFLICT(key) DO UPDATE SET run_id = excluded.run_id, status = excluded.status,
473
+ external_ref = excluded.external_ref""",
474
+ (key, run_id, status, _dump(ref), created_at),
475
+ )
476
+
477
+ @staticmethod
478
+ def _learning_row(r: sqlite3.Row) -> LearningRecord:
479
+ return LearningRecord(
480
+ id=r["id"],
481
+ scope=r["scope"],
482
+ note=r["note"],
483
+ status=r["status"],
484
+ created_at=r["created_at"],
485
+ run_id=r["run_id"],
486
+ provenance=_load(r["provenance"]) or {},
487
+ )
488
+
489
+ def add_learning(
490
+ self,
491
+ *,
492
+ scope: str,
493
+ note: str,
494
+ status: LearningStatus,
495
+ created_at: int,
496
+ run_id: str | None = None,
497
+ provenance: dict[str, Any] | None = None,
498
+ ) -> LearningRecord:
499
+ cursor = self._db.execute(
500
+ """INSERT INTO learnings (run_id, scope, note, provenance, created_at, status)
501
+ VALUES (?, ?, ?, ?, ?, ?)""",
502
+ (run_id, scope, note, _dump(provenance or {}), created_at, status),
503
+ )
504
+ row = self._db.execute(
505
+ "SELECT * FROM learnings WHERE id = ?", (cursor.lastrowid,)
506
+ ).fetchone()
507
+ return self._learning_row(row)
508
+
509
+ def list_learnings(
510
+ self, *, scope: str | None = None, status: LearningStatus | None = None
511
+ ) -> list[LearningRecord]:
512
+ where: list[str] = []
513
+ args: list[Any] = []
514
+ if scope is not None:
515
+ where.append("scope = ?")
516
+ args.append(scope)
517
+ if status is not None:
518
+ where.append("status = ?")
519
+ args.append(status)
520
+ clause = f"WHERE {' AND '.join(where)}" if where else ""
521
+ rows = self._db.execute(f"SELECT * FROM learnings {clause} ORDER BY id", args).fetchall()
522
+ return [self._learning_row(r) for r in rows]
523
+
524
+ def set_learning_status(self, learning_id: int, status: LearningStatus) -> None:
525
+ cursor = self._db.execute(
526
+ "UPDATE learnings SET status = ? WHERE id = ?", (status, learning_id)
527
+ )
528
+ if cursor.rowcount == 0:
529
+ raise KeyError(f"No learning {learning_id}")
530
+
531
+ def close(self) -> None:
532
+ self._db.close()
533
+
534
+
535
+ def _split_statements(sql: str) -> list[str]:
536
+ """Splits a migration script into statements (our migrations contain no `;` in literals)."""
537
+ return [s.strip() for s in sql.split(";") if s.strip()]
@@ -0,0 +1,11 @@
1
+ """tmux session, window and pane management (the TUI's foundation)."""
2
+
3
+ from whiletrue.tmux.controller import (
4
+ ExecResult,
5
+ PaneRef,
6
+ TmuxController,
7
+ TmuxError,
8
+ session_name,
9
+ )
10
+
11
+ __all__ = ["ExecResult", "PaneRef", "TmuxController", "TmuxError", "session_name"]