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
whiletrue/cli/app.py ADDED
@@ -0,0 +1,550 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import os
6
+ import signal as signal_module
7
+ import sys
8
+ from collections.abc import Awaitable, Callable
9
+ from dataclasses import dataclass
10
+ from datetime import UTC, datetime
11
+ from pathlib import Path
12
+
13
+ from whiletrue.abort import AbortSignal
14
+ from whiletrue.cli import gates, ops
15
+ from whiletrue.cli.common import EXIT, Io
16
+ from whiletrue.cli.registry import RegistryOptions, create_default_registry, load_plugins
17
+ from whiletrue.cli.templates import RALPH_AGENT, RALPH_PIPELINE
18
+ from whiletrue.core import (
19
+ LOCK_FILE,
20
+ ConfigError,
21
+ LockError,
22
+ PipelineConfig,
23
+ PipelineNode,
24
+ Registry,
25
+ ReplayError,
26
+ check_lock,
27
+ format_diagnostic,
28
+ has_errors,
29
+ hash_pipeline,
30
+ load_pipeline,
31
+ load_recording,
32
+ read_lock,
33
+ run_pipeline,
34
+ sha256_file,
35
+ validate_pipeline,
36
+ write_lock,
37
+ )
38
+ from whiletrue.store_sqlite import SqliteStore
39
+ from whiletrue.tmux import TmuxController, session_name
40
+
41
+ USAGE = """whiletrue — run configurable loops around coding agents
42
+
43
+ Usage: whiletrue <command> [options]
44
+
45
+ Commands:
46
+ init Scaffold whiletrue.yaml and agents/ralph.md
47
+ validate Check config, wiring and safety rules (no tokens spent)
48
+ plan Print the expanded pipeline tree (no tokens spent)
49
+ run Run the pipeline
50
+ status [run] Show the latest (or given) run
51
+ replay [run] Re-run a recorded run's logic without calling agents or tools
52
+ lock Pin the plugin versions in use to whiletrue.lock
53
+ attach Attach to the project's tmux session (live agent panes)
54
+ panel The control panel (tmux window 0): items, state, who they wait for
55
+ resume [run] Continue a parked run (a decided gate lets its item through)
56
+ approve <item> Approve the gate an item is parked at [--note, --step]
57
+ reject <item> Reject it with a reason --note "what to change"
58
+ comment <item> "text" Leave a comment the pipeline must answer
59
+ rerun <item> Run one item again as a new run [--from <step>]
60
+ cancel <item> Cancel an item (a running one stops before its next step)
61
+ logs <item> The item's event log [--follow]
62
+ cost [run] Spend by item, step and runner
63
+ history <item> The item's timeline: parks, decisions, comments, replies, steers
64
+ learnings [approve|reject <id>] Review lessons proposed by history.distill
65
+
66
+ Options:
67
+ -f, --file <path> Pipeline file (default: whiletrue.yaml)
68
+ --force init: overwrite existing files
69
+ -m, --note <text> approve/reject: the reason
70
+ --step <id> approve/reject: which gate, when an item waits at several
71
+ -h, --help Show this help
72
+ """
73
+
74
+
75
+ @dataclass
76
+ class _Loaded:
77
+ config: PipelineConfig
78
+ registry: Registry
79
+ cwd: str
80
+
81
+
82
+ def _load(io: Io, file: str, *, check_pins: bool = True) -> _Loaded | int:
83
+ path = (Path(io.cwd) / file).resolve()
84
+ try:
85
+ config = load_pipeline(path)
86
+ except ConfigError as error:
87
+ io.err(f"{error}\n")
88
+ return EXIT.failed
89
+ except OSError as error:
90
+ io.err(f"Cannot load {file}: {error}\n")
91
+ return EXIT.failed
92
+ registry = create_default_registry(
93
+ RegistryOptions(project_dir=str(path.parent), pane_host=io.pane_host, github=io.github)
94
+ )
95
+ try:
96
+ load_plugins(registry, config.uses, str(path.parent))
97
+ except Exception as error:
98
+ io.err(f"{error}\n")
99
+ return EXIT.failed
100
+ cwd = str(path.parent)
101
+ if not check_pins:
102
+ return _Loaded(config, registry, cwd)
103
+ try:
104
+ pinned = read_lock(Path(cwd) / LOCK_FILE)
105
+ except LockError as error:
106
+ io.err(f"{error}\n")
107
+ return EXIT.failed
108
+ if pinned is not None:
109
+ problems = check_lock(pinned, registry.plugin_versions())
110
+ if problems:
111
+ io.err("".join(f"{p}\n" for p in problems) + "Run `whiletrue lock` to update it.\n")
112
+ return EXIT.failed
113
+ return _Loaded(config, registry, cwd)
114
+
115
+
116
+ def _validated(io: Io, loaded: _Loaded) -> bool:
117
+ diagnostics = validate_pipeline(loaded.config, loaded.registry, cwd=loaded.cwd)
118
+ for d in diagnostics:
119
+ io.err(f"{format_diagnostic(d)}\n")
120
+ return not has_errors(diagnostics)
121
+
122
+
123
+ def _render_tree(
124
+ node: PipelineNode, prefix: str = "", last: bool = True, root: bool = True
125
+ ) -> list[str]:
126
+ summary = f" {json.dumps(node.config, separators=(',', ':'))}" if node.config else ""
127
+ connector = "" if root else prefix + ("└─ " if last else "├─ ")
128
+ line = f"{connector}{node.use}{summary} [{node.id}]"
129
+ child_prefix = "" if root else prefix + (" " if last else "│ ")
130
+ lines = [line]
131
+ for i, child in enumerate(node.children):
132
+ lines += _render_tree(child, child_prefix, i == len(node.children) - 1, False)
133
+ return lines
134
+
135
+
136
+ def _init(io: Io, force: bool) -> int:
137
+ files = [("whiletrue.yaml", RALPH_PIPELINE), ("agents/ralph.md", RALPH_AGENT)]
138
+ existing = [name for name, _ in files if (Path(io.cwd) / name).exists()]
139
+ if existing and not force:
140
+ io.err(f"Refusing to overwrite {', '.join(existing)} (use --force)\n")
141
+ return EXIT.failed
142
+ for name, content in files:
143
+ target = Path(io.cwd) / name
144
+ target.parent.mkdir(parents=True, exist_ok=True)
145
+ target.write_text(content, encoding="utf-8")
146
+ io.out(f"created {name}\n")
147
+ io.out("Next: whiletrue validate && whiletrue plan && whiletrue run\n")
148
+ return EXIT.ok
149
+
150
+
151
+ async def _run_loaded(io: Io, loaded: _Loaded) -> int:
152
+ store = SqliteStore(Path(loaded.cwd) / ".whiletrue" / "state.db")
153
+
154
+ def log(level: str, message: str, data: object = None) -> None:
155
+ if level != "debug":
156
+ io.out(f"[{level}] {message}\n")
157
+
158
+ try:
159
+ try:
160
+ items = await ops.source_items(loaded)
161
+ except Exception as error:
162
+ io.err(f"Cannot fetch items: {error}\n")
163
+ return EXIT.failed
164
+ problem = ops.cycle_error(items)
165
+ if problem is not None:
166
+ io.err(f"{problem}\n")
167
+ return EXIT.failed
168
+ result = await run_pipeline(
169
+ registry=loaded.registry,
170
+ config=loaded.config,
171
+ store=store,
172
+ cwd=loaded.cwd,
173
+ signal=io.signal,
174
+ log=log,
175
+ lockfile=_lock_hash(loaded.cwd),
176
+ items=items,
177
+ )
178
+ io.out(f"run {result.run_id}: {result.status}\n")
179
+ for item in result.items:
180
+ io.out(ops.item_line(item))
181
+ return {
182
+ "done": EXIT.ok,
183
+ "parked": EXIT.parked,
184
+ "cancelled": EXIT.cancelled,
185
+ }.get(result.status, EXIT.failed)
186
+ finally:
187
+ store.close()
188
+
189
+
190
+ async def _replay(io: Io, file: str, run_id: str | None) -> int:
191
+ db_path = Path(io.cwd) / ".whiletrue" / "state.db"
192
+ if not db_path.exists():
193
+ io.err("No runs yet (.whiletrue/state.db not found)\n")
194
+ return EXIT.failed
195
+ loaded = _load(io, file)
196
+ if isinstance(loaded, int):
197
+ return loaded
198
+ if not _validated(io, loaded):
199
+ return EXIT.failed
200
+ store = SqliteStore(db_path)
201
+ try:
202
+ if run_id is None:
203
+ latest = store.latest_run()
204
+ run_id = latest.id if latest else ""
205
+ try:
206
+ recording = load_recording(store, run_id)
207
+ except ReplayError as error:
208
+ io.err(f"{error}\n")
209
+ return EXIT.failed
210
+ if recording.pipeline_hash != hash_pipeline(loaded.config):
211
+ io.out("note: the pipeline changed since the recorded run\n")
212
+ result = await run_pipeline(
213
+ registry=loaded.registry,
214
+ config=loaded.config,
215
+ store=store,
216
+ cwd=loaded.cwd,
217
+ signal=io.signal,
218
+ items=recording.items,
219
+ replay=recording.steps,
220
+ )
221
+ io.out(f"replay of {recording.run_id}: run {result.run_id}: {result.status}\n")
222
+ differences = [
223
+ f" {o.item_id}: recorded {recording.item_states.get(o.item_id)}, replayed {o.state}"
224
+ for o in result.items
225
+ if recording.item_states.get(o.item_id) != o.state
226
+ ]
227
+ if result.status != recording.status or differences:
228
+ io.err(
229
+ f"replay differs: recorded {recording.status}, replayed {result.status}\n"
230
+ + "".join(f"{d}\n" for d in differences)
231
+ )
232
+ return EXIT.failed
233
+ io.out(f"replay matches the recorded run ({recording.status})\n")
234
+ return EXIT.ok
235
+ finally:
236
+ store.close()
237
+
238
+
239
+ async def _attach(io: Io) -> int:
240
+ tmux = io.tmux or TmuxController(
241
+ session_name(io.cwd), binary=os.environ.get("WHILETRUE_TMUX_BIN", "tmux")
242
+ )
243
+ if not await tmux.has_session():
244
+ io.err(
245
+ f"No tmux session {tmux.session!r}: it exists while a run uses an interactive agent\n"
246
+ )
247
+ return EXIT.failed
248
+ argv = tmux.attach_argv()
249
+ if io.attach is not None:
250
+ io.attach(argv)
251
+ else: # pragma: no cover (replaces this process)
252
+ os.execvp(argv[0], argv)
253
+ return EXIT.ok
254
+
255
+
256
+ async def _locked(io: Io, loaded: _Loaded, action: Callable[[], Awaitable[int]]) -> int:
257
+ """Runs an action that changes a run while holding the project's run lock."""
258
+ lock = ops.RunLock(loaded.cwd)
259
+ if not lock.acquire():
260
+ io.err(f"Another whiletrue run is active in this project (pid {lock.holder()})\n")
261
+ return EXIT.failed
262
+ try:
263
+ return await action()
264
+ finally:
265
+ lock.release()
266
+
267
+
268
+ async def _plan_items(io: Io, loaded: _Loaded) -> int:
269
+ """With a source, `plan` also lists the items and rejects dependency cycles."""
270
+ if "source" not in loaded.config.extra:
271
+ return EXIT.ok
272
+ try:
273
+ items = await ops.source_items(loaded)
274
+ except Exception as error:
275
+ io.err(f"Cannot fetch items: {error}\n")
276
+ return EXIT.failed
277
+ items = items or []
278
+ io.out(f"items ({len(items)}, up to {loaded.config.parallel} at once):\n")
279
+ for item in items:
280
+ waits = f" after {', '.join(item.depends_on)}" if item.depends_on else ""
281
+ io.out(f" {item.id}{waits}\n")
282
+ problem = ops.cycle_error(items)
283
+ if problem is not None:
284
+ io.err(f"{problem}\n")
285
+ return EXIT.failed
286
+ return EXIT.ok
287
+
288
+
289
+ async def _with_loaded(io: Io, file: str, action: Callable[[_Loaded], Awaitable[int]]) -> int:
290
+ loaded = _load(io, file)
291
+ if isinstance(loaded, int):
292
+ return loaded
293
+ if not _validated(io, loaded):
294
+ return EXIT.failed
295
+ return await action(loaded)
296
+
297
+
298
+ async def _run_locked(io: Io, loaded: _Loaded) -> int:
299
+ return await _locked(io, loaded, lambda: _run_loaded(io, loaded))
300
+
301
+
302
+ async def _panel(io: Io) -> int: # pragma: no cover (interactive terminal loop)
303
+ from whiletrue.cli.panel import run_panel # noqa: PLC0415
304
+
305
+ store = gates.open_store(io)
306
+ if store is None:
307
+ return EXIT.failed
308
+ tmux = io.tmux or TmuxController(
309
+ session_name(io.cwd), binary=os.environ.get("WHILETRUE_TMUX_BIN", "tmux")
310
+ )
311
+ try:
312
+ return await run_panel(io, store, tmux)
313
+ finally:
314
+ store.close()
315
+
316
+
317
+ def _lock_hash(cwd: str) -> str | None:
318
+ path = Path(cwd) / LOCK_FILE
319
+ return sha256_file(path) if path.is_file() else None
320
+
321
+
322
+ def _lock(io: Io, file: str) -> int:
323
+ loaded = _load(io, file, check_pins=False)
324
+ if isinstance(loaded, int):
325
+ return loaded
326
+ write_lock(Path(loaded.cwd) / LOCK_FILE, loaded.registry.plugin_versions())
327
+ io.out(f"wrote {LOCK_FILE}\n")
328
+ return EXIT.ok
329
+
330
+
331
+ def _iso(ms: int) -> str:
332
+ return (
333
+ datetime.fromtimestamp(ms / 1000, UTC)
334
+ .isoformat(timespec="milliseconds")
335
+ .replace("+00:00", "Z")
336
+ )
337
+
338
+
339
+ def _status(io: Io, run_id: str | None) -> int:
340
+ db_path = Path(io.cwd) / ".whiletrue" / "state.db"
341
+ if not db_path.exists():
342
+ io.err("No runs yet (.whiletrue/state.db not found)\n")
343
+ return EXIT.failed
344
+ store = SqliteStore(db_path)
345
+ try:
346
+ run = store.get_run(run_id) if run_id else store.latest_run()
347
+ if run is None:
348
+ io.err(f'No run "{run_id}"\n' if run_id else "No runs yet\n")
349
+ return EXIT.failed
350
+ io.out(f"run {run.id} {run.status} started {_iso(run.started_at)}\n")
351
+ steps = store.list_steps(run.id)
352
+ for item in store.list_items(run.id):
353
+ item_steps = [s for s in steps if s.item_id == item.id]
354
+ cost = sum(s.cost_usd for s in item_steps)
355
+ blocked = ""
356
+ if item.state == "pending":
357
+ states = store.list_events(run.id, item_id=item.id, type="item.state")
358
+ by = states[-1].data.get("blocked_by") if states else None
359
+ blocked = f" — blocked by {', '.join(by)}" if by else ""
360
+ io.out(
361
+ f" {item.id} {item.state} steps={len(item_steps)} cost=${cost:.4f}{blocked}\n"
362
+ )
363
+ for s in item_steps:
364
+ port = f" {s.port}" if s.port else ""
365
+ error = f" — {s.error}" if s.error else ""
366
+ io.out(f" {s.node_path}#{s.attempt} {s.status}{port}{error}\n")
367
+ waiting = store.list_parks(undecided_only=True)
368
+ if waiting:
369
+ io.out("waiting for a person:\n")
370
+ for park in waiting:
371
+ io.out(
372
+ f" {park.item_id} {park.what} at {park.node_path} "
373
+ f"(whiletrue approve {park.item_id} | reject {park.item_id} --note ...)\n"
374
+ )
375
+ return EXIT.ok
376
+ finally:
377
+ store.close()
378
+
379
+
380
+ @dataclass
381
+ class _Args:
382
+ positionals: list[str]
383
+ file: str = "whiletrue.yaml"
384
+ force: bool = False
385
+ help: bool = False
386
+ note: str | None = None
387
+ step: str | None = None
388
+ from_step: str | None = None
389
+ follow: bool = False
390
+
391
+
392
+ def _parse_args(argv: list[str]) -> _Args:
393
+ args = _Args(positionals=[])
394
+ it = iter(argv)
395
+ for arg in it:
396
+ if arg in ("-f", "--file"):
397
+ value = next(it, None)
398
+ if value is None:
399
+ raise ValueError(f"Option '{arg}' argument missing")
400
+ args.file = value
401
+ elif arg.startswith("--file="):
402
+ args.file = arg.split("=", 1)[1]
403
+ elif arg == "--follow":
404
+ args.follow = True
405
+ elif arg == "--from":
406
+ value = next(it, None)
407
+ if value is None:
408
+ raise ValueError(f"Option '{arg}' argument missing")
409
+ args.from_step = value
410
+ elif arg.startswith("--from="):
411
+ args.from_step = arg.split("=", 1)[1]
412
+ elif arg in ("-m", "--note", "--step"):
413
+ value = next(it, None)
414
+ if value is None:
415
+ raise ValueError(f"Option '{arg}' argument missing")
416
+ if arg == "--step":
417
+ args.step = value
418
+ else:
419
+ args.note = value
420
+ elif arg.startswith(("--note=", "--step=")):
421
+ name, _, value = arg.partition("=")
422
+ if name == "--step":
423
+ args.step = value
424
+ else:
425
+ args.note = value
426
+ elif arg == "--force":
427
+ args.force = True
428
+ elif arg in ("-h", "--help"):
429
+ args.help = True
430
+ elif arg.startswith("-") and arg != "-":
431
+ raise ValueError(f"Unknown option '{arg}'")
432
+ else:
433
+ args.positionals.append(arg)
434
+ return args
435
+
436
+
437
+ async def main(argv: list[str], io: Io) -> int:
438
+ try:
439
+ args = _parse_args(argv)
440
+ except ValueError as error:
441
+ io.err(f"{error}\n{USAGE}")
442
+ return EXIT.usage
443
+ if args.help:
444
+ io.out(USAGE)
445
+ return EXIT.ok
446
+ if not args.positionals:
447
+ io.err(USAGE)
448
+ return EXIT.usage
449
+ command, *rest = args.positionals
450
+
451
+ match command:
452
+ case "init":
453
+ return _init(io, args.force)
454
+ case "validate":
455
+ loaded = _load(io, args.file)
456
+ if isinstance(loaded, int):
457
+ return loaded
458
+ if not _validated(io, loaded):
459
+ return EXIT.failed
460
+ io.out(f"{loaded.config.file}: ok\n")
461
+ return EXIT.ok
462
+ case "plan":
463
+ loaded = _load(io, args.file)
464
+ if isinstance(loaded, int):
465
+ return loaded
466
+ if not _validated(io, loaded):
467
+ return EXIT.failed
468
+ io.out(f"{loaded.config.name}\n{chr(10).join(_render_tree(loaded.config.root))}\n")
469
+ return await _plan_items(io, loaded)
470
+ case "run":
471
+ return await _with_loaded(io, args.file, lambda loaded: _run_locked(io, loaded))
472
+ case "replay":
473
+ return await _replay(io, args.file, rest[0] if rest else None)
474
+ case "attach":
475
+ return await _attach(io)
476
+ case "panel":
477
+ return await _panel(io)
478
+ case "resume":
479
+ return await _with_loaded(
480
+ io,
481
+ args.file,
482
+ lambda loaded: _locked(
483
+ io,
484
+ loaded,
485
+ lambda: ops.resume(
486
+ io, loaded, rest[0] if rest else None, _lock_hash(loaded.cwd)
487
+ ),
488
+ ),
489
+ )
490
+ case "rerun":
491
+ if not rest:
492
+ io.err("Usage: whiletrue rerun <item> [--from <step>]\n")
493
+ return EXIT.usage
494
+ return await _with_loaded(
495
+ io,
496
+ args.file,
497
+ lambda loaded: _locked(
498
+ io,
499
+ loaded,
500
+ lambda: ops.rerun(io, loaded, rest[0], args.from_step, _lock_hash(loaded.cwd)),
501
+ ),
502
+ )
503
+ case "cancel":
504
+ return ops.cancel(io, rest[0] if rest else None)
505
+ case "logs":
506
+ return await ops.logs(io, rest[0] if rest else None, args.follow)
507
+ case "cost":
508
+ return ops.cost(io, rest[0] if rest else None)
509
+ case "approve":
510
+ return gates.decide(io, "approved", rest[0] if rest else None, args.note, args.step)
511
+ case "reject":
512
+ return gates.decide(io, "rejected", rest[0] if rest else None, args.note, args.step)
513
+ case "comment":
514
+ return gates.comment(
515
+ io, rest[0] if rest else None, " ".join(rest[1:]) if len(rest) > 1 else None
516
+ )
517
+ case "history":
518
+ return gates.history(io, rest[0] if rest else None)
519
+ case "learnings":
520
+ return gates.learnings(
521
+ io, rest[0] if rest else None, rest[1] if len(rest) > 1 else None
522
+ )
523
+ case "lock":
524
+ return _lock(io, args.file)
525
+ case "status":
526
+ return _status(io, rest[0] if rest else None)
527
+ case _:
528
+ io.err(f'Unknown command "{command}"\n{USAGE}')
529
+ return EXIT.usage
530
+
531
+
532
+ async def _amain(argv: list[str]) -> int:
533
+ abort = AbortSignal()
534
+ loop = asyncio.get_running_loop()
535
+ for sig in (signal_module.SIGINT, signal_module.SIGTERM):
536
+ loop.add_signal_handler(sig, abort.abort)
537
+ return await main(
538
+ argv,
539
+ Io(
540
+ cwd=str(Path.cwd()),
541
+ out=sys.stdout.write,
542
+ err=sys.stderr.write,
543
+ signal=abort,
544
+ ),
545
+ )
546
+
547
+
548
+ def entrypoint() -> None:
549
+ """Console-script entry point (`whiletrue`)."""
550
+ sys.exit(asyncio.run(_amain(sys.argv[1:])))
@@ -0,0 +1,36 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from dataclasses import dataclass
5
+
6
+ from whiletrue.abort import AbortSignal
7
+ from whiletrue.claude_code import PaneHost
8
+ from whiletrue.github import GitHubClient
9
+ from whiletrue.tmux import TmuxController
10
+
11
+
12
+ @dataclass
13
+ class Io:
14
+ cwd: str
15
+ out: Callable[[str], object]
16
+ err: Callable[[str], object]
17
+ signal: AbortSignal | None = None
18
+ #: The project's tmux controller; defaults to the real one (tests inject a fake).
19
+ tmux: TmuxController | None = None
20
+ #: Replaces this process with the given argv (tmux attach); defaults to `os.execvp`.
21
+ attach: Callable[[list[str]], object] | None = None
22
+ #: Where interactive agents run; defaults to tmux panes (tests inject a plain-process host).
23
+ pane_host: PaneHost | None = None
24
+ #: GitHub access; defaults to the `gh` CLI (tests inject an in-memory fake).
25
+ github: GitHubClient | None = None
26
+
27
+
28
+ class Exit:
29
+ ok = 0
30
+ failed = 1
31
+ parked = 2
32
+ usage = 64
33
+ cancelled = 130
34
+
35
+
36
+ EXIT = Exit()