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,461 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+ from typing import Any, Literal, NoReturn, cast
7
+
8
+ import yaml
9
+ from pydantic import BaseModel, ConfigDict, Field, PositiveFloat, PositiveInt, ValidationError
10
+
11
+ from whiletrue.core.duration import parse_duration
12
+ from whiletrue.core.outcomes import OnFail
13
+ from whiletrue.core.runner import AgentLimits
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class SourceLoc:
18
+ file: str
19
+ line: int
20
+ col: int
21
+
22
+
23
+ class ConfigError(Exception):
24
+ def __init__(self, loc: SourceLoc, message: str) -> None:
25
+ super().__init__(f"{loc.file}:{loc.line}:{loc.col}: {message}")
26
+ self.file = loc.file
27
+ self.line = loc.line
28
+ self.col = loc.col
29
+
30
+
31
+ @dataclass
32
+ class PipelineNode:
33
+ id: str
34
+ #: Module or strategy id, e.g. "shell" or "loop".
35
+ use: str
36
+ config: dict[str, Any]
37
+ children: list[PipelineNode]
38
+ #: Outcome/data port -> sibling node id.
39
+ wires: dict[str, str]
40
+ loc: SourceLoc
41
+ #: Declared port caps (`in`/`out`), set on expanded composites so wiring can be checked.
42
+ ports: dict[str, str] | None = None
43
+
44
+
45
+ @dataclass
46
+ class PipelineConfig:
47
+ file: str
48
+ name: str
49
+ uses: list[str]
50
+ on_idle: Literal["exit", "watch"]
51
+ on_fail: OnFail
52
+ #: Cache results of pure/deterministic modules by input hash.
53
+ cache: bool
54
+ #: How many items run at once.
55
+ parallel: int
56
+ #: Cap on live agent sessions across the whole run (None: unlimited).
57
+ max_agents: int | None
58
+ #: Passed through for providers (source, comments, ...).
59
+ extra: dict[str, Any]
60
+ root: PipelineNode
61
+ #: Composite files that were expanded into `root` (resolved paths), for hashing.
62
+ sources: list[str] = field(default_factory=lambda: [])
63
+
64
+
65
+ Path_ = list[str | int]
66
+
67
+
68
+ class _Loader(yaml.SafeLoader):
69
+ """YAML 1.2-ish scalars: only true/false are booleans and timestamps stay strings."""
70
+
71
+
72
+ _Loader.yaml_implicit_resolvers = {
73
+ first: [
74
+ (tag, regexp)
75
+ for tag, regexp in resolvers
76
+ if tag not in ("tag:yaml.org,2002:bool", "tag:yaml.org,2002:timestamp")
77
+ ]
78
+ for first, resolvers in yaml.SafeLoader.yaml_implicit_resolvers.items()
79
+ }
80
+ _Loader.add_implicit_resolver( # pyright: ignore[reportUnknownMemberType]
81
+ "tag:yaml.org,2002:bool",
82
+ re.compile(r"^(?:true|True|TRUE|false|False|FALSE)$"),
83
+ list("tTfF"),
84
+ )
85
+
86
+
87
+ class _Located:
88
+ """A parsed YAML document that can answer "where is this path?" for error messages."""
89
+
90
+ def __init__(self, text: str, file: str, line_offset: int = 0) -> None:
91
+ self.file = file
92
+ self.line_offset = line_offset
93
+ loader = _Loader(text)
94
+ try:
95
+ self.root: yaml.Node | None = loader.get_single_node()
96
+ self.value: Any = (
97
+ loader.construct_document(self.root) # pyright: ignore[reportUnknownMemberType]
98
+ if self.root
99
+ else None
100
+ )
101
+ except yaml.YAMLError as error:
102
+ mark = getattr(error, "problem_mark", None)
103
+ problem = getattr(error, "problem", None) or str(error)
104
+ line = (mark.line if mark else 0) + 1 + line_offset
105
+ col = (mark.column if mark else 0) + 1
106
+ raise ConfigError(SourceLoc(file, line, col), str(problem).split("\n")[0]) from error
107
+ finally:
108
+ loader.dispose()
109
+
110
+ def _node_at(self, path: Path_) -> yaml.Node | None:
111
+ node = self.root
112
+ for key in path:
113
+ if isinstance(node, yaml.MappingNode):
114
+ node = next(
115
+ (v for k, v in node.value if isinstance(k, yaml.ScalarNode) and k.value == key),
116
+ None,
117
+ )
118
+ elif isinstance(node, yaml.SequenceNode) and isinstance(key, int):
119
+ node = node.value[key] if 0 <= key < len(node.value) else None
120
+ else:
121
+ node = None
122
+ if node is None:
123
+ return None
124
+ return node
125
+
126
+ def loc(self, path: Path_) -> SourceLoc:
127
+ # Fall back to the nearest existing ancestor.
128
+ for end in range(len(path), 0, -1):
129
+ node = self._node_at(path[:end])
130
+ if node is not None:
131
+ return self._loc_of(node)
132
+ return SourceLoc(self.file, 1 + self.line_offset, 1)
133
+
134
+ def _loc_of(self, node: yaml.Node) -> SourceLoc:
135
+ mark = node.start_mark
136
+ return SourceLoc(self.file, mark.line + 1 + self.line_offset, mark.column + 1)
137
+
138
+ def fail(self, path: Path_, message: str) -> NoReturn:
139
+ raise ConfigError(self.loc(path), message)
140
+
141
+ def validate[T: BaseModel](self, model: type[T], value: Any, base: Path_ | None = None) -> T:
142
+ try:
143
+ return model.model_validate(value)
144
+ except ValidationError as error:
145
+ issue = error.errors()[0]
146
+ loc = list(issue["loc"])
147
+ where = f' at "{".".join(str(p) for p in loc)}"' if loc else ""
148
+ self.fail([*(base or []), *loc], f"{issue['msg']}{where}")
149
+
150
+
151
+ _RESERVED = {"id", "wires", "do"}
152
+
153
+
154
+ class _TopLevel(BaseModel):
155
+ model_config = ConfigDict(extra="allow")
156
+
157
+ name: str | None = Field(default=None, min_length=1)
158
+ uses: list[str] = Field(default_factory=lambda: [])
159
+ on_idle: Literal["exit", "watch"] = "exit"
160
+ on_fail: Literal["park", "drop", "propagate"] = "park"
161
+ cache: bool = True
162
+ parallel: int = Field(default=1, ge=1)
163
+ max_agents: int | None = Field(default=None, ge=1)
164
+
165
+
166
+ def _build_node(
167
+ located: _Located, value: Any, path: Path_, default_id: str, seen: dict[str, SourceLoc]
168
+ ) -> PipelineNode:
169
+ if not isinstance(value, dict):
170
+ located.fail(path, "A pipeline step must be a mapping")
171
+ step: dict[str, Any] = value # pyright: ignore[reportUnknownVariableType]
172
+ keys = [k for k in step if k not in _RESERVED]
173
+ if len(keys) != 1:
174
+ located.fail(
175
+ path,
176
+ "A step needs a module key (e.g. `shell: {...}`)"
177
+ if not keys
178
+ else f"A step must have exactly one module key, found: {', '.join(map(str, keys))}",
179
+ )
180
+ use = str(keys[0])
181
+ raw_config = step[keys[0]]
182
+ if raw_config is not None and not isinstance(raw_config, dict):
183
+ located.fail([*path, use], f'Config for "{use}" must be a mapping')
184
+ config: dict[str, Any] = dict(raw_config) if raw_config else {} # pyright: ignore[reportUnknownArgumentType]
185
+
186
+ # `do` may sit beside the module key or inside its config (strategies); hoist the latter.
187
+ do_path: Path_ = [*path, "do"]
188
+ raw_do = step.get("do")
189
+ if raw_do is None and "do" in config:
190
+ raw_do = config.pop("do")
191
+ do_path = [*path, use, "do"]
192
+ if raw_do is not None and not isinstance(raw_do, list):
193
+ located.fail(do_path, "`do` must be a list of steps")
194
+
195
+ explicit_id = step.get("id")
196
+ if "id" in step and (not isinstance(explicit_id, str) or explicit_id == ""):
197
+ located.fail([*path, "id"], "`id` must be a non-empty string")
198
+ node_id: str = explicit_id if isinstance(explicit_id, str) else default_id
199
+ loc = located.loc(path)
200
+ if node_id in seen:
201
+ located.fail(
202
+ path, f'Duplicate step id "{node_id}" (first defined at line {seen[node_id].line})'
203
+ )
204
+ seen[node_id] = loc
205
+
206
+ raw_wires = step.get("wires")
207
+ if raw_wires is not None and not (
208
+ isinstance(raw_wires, dict) and all(isinstance(t, str) for t in raw_wires.values()) # pyright: ignore[reportUnknownVariableType, reportUnknownMemberType]
209
+ ):
210
+ located.fail([*path, "wires"], "`wires` must map port names to step ids")
211
+
212
+ children = [
213
+ _build_node(located, child, [*do_path, i], f"{node_id}.{i}", seen)
214
+ for i, child in enumerate(raw_do or []) # pyright: ignore[reportUnknownVariableType, reportUnknownArgumentType]
215
+ ]
216
+ return PipelineNode(
217
+ id=node_id,
218
+ use=use,
219
+ config=config,
220
+ children=children,
221
+ wires=dict(raw_wires) if raw_wires else {}, # pyright: ignore[reportUnknownArgumentType]
222
+ loc=loc,
223
+ )
224
+
225
+
226
+ def _check_wires(node: PipelineNode) -> None:
227
+ """Wire targets must be siblings."""
228
+ siblings = {c.id for c in node.children}
229
+ for child in node.children:
230
+ for port, target in child.wires.items():
231
+ if target not in siblings:
232
+ raise ConfigError(
233
+ child.loc, f'Wire "{port}" targets unknown sibling step "{target}"'
234
+ )
235
+ _check_wires(child)
236
+
237
+
238
+ def build_root(located: _Located, pipeline: Any, path: Path_) -> PipelineNode:
239
+ """Builds the node tree for a `pipeline:` value (a single step or an implicit sequence)."""
240
+ seen: dict[str, SourceLoc] = {}
241
+ if isinstance(pipeline, list):
242
+ seen["root"] = located.loc(path)
243
+ children = [
244
+ _build_node(located, child, [*path, i], f"root.{i}", seen)
245
+ for i, child in enumerate(pipeline) # pyright: ignore[reportUnknownVariableType, reportUnknownArgumentType]
246
+ ]
247
+ root = PipelineNode(
248
+ id="root", use="sequence", config={}, children=children, wires={}, loc=located.loc(path)
249
+ )
250
+ else:
251
+ root = _build_node(located, pipeline, path, "root", seen)
252
+ _check_wires(root)
253
+ return root
254
+
255
+
256
+ def parse_pipeline(
257
+ text: str, file: str = "whiletrue.yaml", *, base_dir: Path | None = None
258
+ ) -> PipelineConfig:
259
+ """Parses a pipeline. With `base_dir`, `composite:` steps are loaded and expanded in place."""
260
+ located = _Located(text, file)
261
+ raw = located.value
262
+ if not isinstance(raw, dict):
263
+ located.fail([], "whiletrue.yaml must be a mapping")
264
+ top = located.validate(_TopLevel, raw)
265
+ if "pipeline" not in raw:
266
+ located.fail([], "Missing required key `pipeline`")
267
+
268
+ extra: dict[str, Any] = {k: v for k, v in (top.model_extra or {}).items() if k != "pipeline"}
269
+ root = build_root(located, cast(dict[str, Any], raw)["pipeline"], ["pipeline"])
270
+ sources: list[str] = []
271
+ if base_dir is not None:
272
+ from whiletrue.core.composite import expand_composites # noqa: PLC0415 (import cycle)
273
+
274
+ root = expand_composites(root, base_dir, sources)
275
+
276
+ return PipelineConfig(
277
+ file=file,
278
+ name=top.name or Path(file).resolve().parent.name,
279
+ uses=top.uses,
280
+ on_idle=top.on_idle,
281
+ on_fail=top.on_fail,
282
+ cache=top.cache,
283
+ parallel=top.parallel,
284
+ max_agents=top.max_agents,
285
+ extra=extra,
286
+ root=root,
287
+ sources=sources,
288
+ )
289
+
290
+
291
+ def load_pipeline(path: str | Path) -> PipelineConfig:
292
+ return parse_pipeline(
293
+ Path(path).read_text(encoding="utf-8"), str(path), base_dir=Path(path).resolve().parent
294
+ )
295
+
296
+
297
+ # ---- Composites (reusable sub-pipelines) -------------------------------------------------
298
+
299
+
300
+ class _ParamSpec(BaseModel):
301
+ model_config = ConfigDict(extra="forbid")
302
+
303
+ default: Any = None
304
+ required: bool = False
305
+
306
+
307
+ class _CompositeTop(BaseModel):
308
+ model_config = ConfigDict(extra="forbid")
309
+
310
+ composite: str = Field(min_length=1)
311
+ params: dict[str, _ParamSpec | None] = Field(default_factory=lambda: {})
312
+ ports: dict[str, str] = Field(default_factory=lambda: {})
313
+ pipeline: Any
314
+
315
+
316
+ @dataclass
317
+ class ParamSpec:
318
+ default: Any = None
319
+ required: bool = False
320
+
321
+
322
+ @dataclass
323
+ class CompositeDefinition:
324
+ file: str
325
+ name: str
326
+ params: dict[str, ParamSpec]
327
+ #: Declared port caps, keyed `in` / `out`.
328
+ ports: dict[str, str]
329
+ root: PipelineNode
330
+
331
+
332
+ def parse_composite(text: str, file: str) -> CompositeDefinition:
333
+ located = _Located(text, file)
334
+ raw: Any = located.value
335
+ if not isinstance(raw, dict):
336
+ located.fail([], "A composite file must be a mapping")
337
+ if "pipeline" not in raw:
338
+ located.fail([], "Missing required key `pipeline`")
339
+ top = located.validate(_CompositeTop, raw)
340
+ unknown = [k for k in top.ports if k not in ("in", "out")]
341
+ if unknown:
342
+ located.fail(["ports", unknown[0]], 'Composite ports must be named "in" or "out"')
343
+ return CompositeDefinition(
344
+ file=file,
345
+ name=top.composite,
346
+ params={
347
+ name: ParamSpec(spec.default, spec.required) if spec else ParamSpec()
348
+ for name, spec in top.params.items()
349
+ },
350
+ ports=top.ports,
351
+ root=build_root(located, top.pipeline, ["pipeline"]),
352
+ )
353
+
354
+
355
+ def load_composite(path: str | Path) -> CompositeDefinition:
356
+ return parse_composite(Path(path).read_text(encoding="utf-8"), str(path))
357
+
358
+
359
+ # ---- Agent modules (markdown with frontmatter) -------------------------------------------
360
+
361
+
362
+ class _LimitsFrontmatter(BaseModel):
363
+ budget_usd: PositiveFloat | None = None
364
+ turns: PositiveInt | None = None
365
+ wall: str | int | float | None = None
366
+
367
+
368
+ class _AgentFrontmatter(BaseModel):
369
+ agent: str = Field(min_length=1)
370
+ runner: str = "claude-code-headless"
371
+ model: str | None = None
372
+ session: str = "fresh"
373
+ tools: list[str] | None = None
374
+ permission_mode: str | None = None
375
+ inputs: dict[str, str] = Field(default_factory=lambda: {})
376
+ outputs: dict[str, str] = Field(default_factory=lambda: {})
377
+ context: dict[str, Any] = Field(default_factory=lambda: {})
378
+ limits: _LimitsFrontmatter | None = None
379
+ fork: bool = False
380
+ on_done: Literal["close", "keep"] = "close"
381
+ add_dirs: list[str] | None = None
382
+ append_system_prompt_file: str | None = None
383
+ mcp_config: list[str] | None = None
384
+ bare: bool = False
385
+
386
+
387
+ @dataclass
388
+ class AgentDefinition:
389
+ file: str
390
+ name: str
391
+ prompt: str
392
+ runner: str = "claude-code-headless"
393
+ model: str | None = None
394
+ session: str = "fresh"
395
+ tools: list[str] | None = None
396
+ permission_mode: str | None = None
397
+ inputs: dict[str, str] = field(default_factory=lambda: {})
398
+ outputs: dict[str, str] = field(default_factory=lambda: {})
399
+ context: dict[str, Any] = field(default_factory=lambda: {})
400
+ limits: AgentLimits | None = None
401
+ #: With a shared `session`: branch from it without changing it.
402
+ fork: bool = False
403
+ #: Interactive runners: close the pane when the step ends, or keep it on screen.
404
+ on_done: Literal["close", "keep"] = "close"
405
+ add_dirs: list[str] | None = None
406
+ append_system_prompt_file: str | None = None
407
+ mcp_config: list[str] | None = None
408
+ bare: bool = False
409
+
410
+
411
+ _FRONTMATTER = re.compile(r"\A---\r?\n(.*?)\r?\n---[ \t]*(?:\r?\n|\Z)(.*)\Z", re.DOTALL)
412
+
413
+
414
+ def parse_agent_definition(text: str, file: str) -> AgentDefinition:
415
+ match = _FRONTMATTER.match(text)
416
+ if match is None:
417
+ raise ConfigError(
418
+ SourceLoc(file, 1, 1),
419
+ "Agent file must start with a YAML frontmatter block delimited by ---",
420
+ )
421
+ located = _Located(match.group(1), file, line_offset=1)
422
+ frontmatter: Any = located.value
423
+ if not isinstance(frontmatter, dict):
424
+ located.fail([], "Agent frontmatter must be a mapping")
425
+ fm = located.validate(_AgentFrontmatter, cast(dict[str, Any], frontmatter))
426
+
427
+ wall_ms: int | None = None
428
+ if fm.limits is not None and fm.limits.wall is not None:
429
+ try:
430
+ wall_ms = parse_duration(fm.limits.wall)
431
+ except ValueError as error:
432
+ located.fail(["limits", "wall"], str(error))
433
+ limits = (
434
+ AgentLimits(budget_usd=fm.limits.budget_usd, turns=fm.limits.turns, wall_ms=wall_ms)
435
+ if fm.limits is not None
436
+ else None
437
+ )
438
+ return AgentDefinition(
439
+ file=file,
440
+ name=fm.agent,
441
+ runner=fm.runner,
442
+ model=fm.model,
443
+ session=fm.session,
444
+ tools=fm.tools,
445
+ permission_mode=fm.permission_mode,
446
+ inputs=fm.inputs,
447
+ outputs=fm.outputs,
448
+ context=fm.context,
449
+ limits=limits,
450
+ fork=fm.fork,
451
+ on_done=fm.on_done,
452
+ add_dirs=fm.add_dirs,
453
+ append_system_prompt_file=fm.append_system_prompt_file,
454
+ mcp_config=fm.mcp_config,
455
+ bare=fm.bare,
456
+ prompt=match.group(2).strip(),
457
+ )
458
+
459
+
460
+ def load_agent_definition(path: str | Path) -> AgentDefinition:
461
+ return parse_agent_definition(Path(path).read_text(encoding="utf-8"), str(path))
@@ -0,0 +1,177 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import inspect
5
+ import os
6
+ import time
7
+ from collections.abc import Awaitable, Callable
8
+ from dataclasses import dataclass, field
9
+ from typing import TYPE_CHECKING, Any, Protocol
10
+
11
+ from whiletrue.abort import AbortSignal
12
+ from whiletrue.core.events import EventBus
13
+ from whiletrue.core.registry import Registry
14
+
15
+ if TYPE_CHECKING:
16
+ from whiletrue.core.config import PipelineNode
17
+ from whiletrue.core.module import Emit, PortValues
18
+ from whiletrue.core.outcomes import OutcomePolicy
19
+ from whiletrue.core.store import StateStore
20
+
21
+
22
+ class Locks:
23
+ """Named locks shared by a run's steps (`repo:main` so only one item merges at a time)."""
24
+
25
+ def __init__(self) -> None:
26
+ self._locks: dict[str, asyncio.Lock] = {}
27
+
28
+ def hold(self, name: str) -> asyncio.Lock:
29
+ """Use as `async with locks.hold("repo:main"):`."""
30
+ return self._locks.setdefault(name, asyncio.Lock())
31
+
32
+
33
+ @dataclass
34
+ class Resource:
35
+ name: str
36
+ dispose: Callable[[], Awaitable[None] | None]
37
+
38
+
39
+ class Scope:
40
+ """Key-value context inherited by children, plus owned resources (workspaces, sessions, panes).
41
+
42
+ Resources are released when the scope exits, which makes "one worktree per issue" a matter of
43
+ where you declare it.
44
+ """
45
+
46
+ def __init__(self, parent: Scope | None = None) -> None:
47
+ self.parent = parent
48
+ self._values: dict[str, Any] = {}
49
+ self._resources: list[Resource] = []
50
+ self._disposed = False
51
+
52
+ def get(self, key: str) -> Any:
53
+ if key in self._values:
54
+ return self._values[key]
55
+ return self.parent.get(key) if self.parent is not None else None
56
+
57
+ def has(self, key: str) -> bool:
58
+ return key in self._values or (self.parent.has(key) if self.parent is not None else False)
59
+
60
+ def set(self, key: str, value: Any) -> None:
61
+ self._values[key] = value
62
+
63
+ def snapshot(self) -> dict[str, Any]:
64
+ """All visible values; children shadow parents. Used as the template scope."""
65
+ inherited = self.parent.snapshot() if self.parent is not None else {}
66
+ return {**inherited, **self._values}
67
+
68
+ def child(self) -> Scope:
69
+ return Scope(self)
70
+
71
+ def add_resource(self, resource: Resource) -> None:
72
+ if self._disposed:
73
+ raise RuntimeError(f'Scope already disposed; cannot add "{resource.name}"')
74
+ self._resources.append(resource)
75
+
76
+ @property
77
+ def resource_names(self) -> list[str]:
78
+ return [r.name for r in self._resources]
79
+
80
+ async def dispose(self) -> None:
81
+ """Releases resources in reverse order. Every disposer runs; the first failure is raised."""
82
+ if self._disposed:
83
+ return
84
+ self._disposed = True
85
+ first: Exception | None = None
86
+ for resource in reversed(self._resources):
87
+ try:
88
+ result = resource.dispose()
89
+ if inspect.isawaitable(result):
90
+ await result
91
+ except Exception as error:
92
+ if first is None:
93
+ first = error
94
+ if first is not None:
95
+ raise first
96
+
97
+
98
+ class Logger(Protocol):
99
+ def __call__(self, level: str, message: str, data: dict[str, Any] | None = None) -> None: ...
100
+
101
+
102
+ def _noop_log(level: str, message: str, data: dict[str, Any] | None = None) -> None:
103
+ return None
104
+
105
+
106
+ def _noop_cost(usd: float) -> None:
107
+ return None
108
+
109
+
110
+ @dataclass(kw_only=True)
111
+ class StepContext[Cfg]:
112
+ """Everything an action can see."""
113
+
114
+ config: Cfg
115
+ run_id: str
116
+ item_id: str
117
+ #: The step's id in the pipeline tree.
118
+ node_id: str
119
+ attempt: int
120
+ #: Working directory for this step (project root or a workspace).
121
+ cwd: str
122
+ scope: Scope
123
+ registry: Registry
124
+ bus: EventBus
125
+ log: Logger
126
+ signal: AbortSignal
127
+ #: Adds spend (USD) to this step's recorded cost.
128
+ record_cost: Callable[[float], None]
129
+ #: Stable per-execution key, e.g. for idempotency: `<run>:<item>:<node>`.
130
+ step_key: str
131
+ #: The run's state store (human gates, comments and the effect journal live here).
132
+ store: StateStore | None = None
133
+ #: Epoch milliseconds; injectable so tests control time.
134
+ now: Callable[[], int] = field(default=lambda: int(time.time() * 1000))
135
+ #: `on_idle: watch`: gates wait for a decision instead of parking the item.
136
+ watch: bool = False
137
+ #: Parks created by this chain of resumed runs belong to this id.
138
+ lineage: str = ""
139
+ locks: Locks = field(default_factory=Locks)
140
+ #: Caps live agent sessions across the run; agents acquire it around their runner call.
141
+ agent_slots: asyncio.Semaphore | None = None
142
+
143
+
144
+ class RunChild(Protocol):
145
+ """Runs a child step; `scope` overrides the scope it runs in (a per-pass scope for `map`)."""
146
+
147
+ def __call__(
148
+ self, node: PipelineNode, input: PortValues, scope: Scope | None = None, /
149
+ ) -> Awaitable[Emit]: ...
150
+
151
+
152
+ @dataclass(kw_only=True)
153
+ class CompositeContext[Cfg](StepContext[Cfg]):
154
+ """Everything a strategy can see. Children run only through `run`, so each is recorded."""
155
+
156
+ policy: OutcomePolicy
157
+ run: RunChild = field(init=True)
158
+
159
+
160
+ def create_step_context[Cfg](config: Cfg, **overrides: Any) -> StepContext[Cfg]:
161
+ """Builds a context with sensible defaults; used by the scheduler and by tests."""
162
+ run_id: str = overrides.pop("run_id", "run")
163
+ item_id: str = overrides.pop("item_id", "item")
164
+ node_id: str = overrides.pop("node_id", "node")
165
+ values: dict[str, Any] = {
166
+ "attempt": 1,
167
+ "cwd": os.getcwd(),
168
+ "scope": Scope(),
169
+ "registry": Registry(),
170
+ "bus": EventBus(),
171
+ "log": _noop_log,
172
+ "record_cost": _noop_cost,
173
+ "signal": AbortSignal(),
174
+ "step_key": f"{run_id}:{item_id}:{node_id}",
175
+ }
176
+ values.update(overrides)
177
+ return StepContext(config=config, run_id=run_id, item_id=item_id, node_id=node_id, **values)
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ import re
5
+
6
+ _UNITS = {"ms": 1, "s": 1000, "m": 60_000, "h": 3_600_000}
7
+ _PATTERN = re.compile(r"^(\d+(?:\.\d+)?)(ms|s|m|h)$")
8
+
9
+
10
+ def parse_duration(value: str | int | float) -> int:
11
+ """Parses "90m", "30s", "1h", "250ms" (or a bare number of milliseconds) into milliseconds."""
12
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
13
+ if not math.isfinite(value) or value < 0:
14
+ raise ValueError(f"Invalid duration: {value}")
15
+ return int(value)
16
+ match = _PATTERN.match(str(value).strip())
17
+ if not match:
18
+ raise ValueError(f'Invalid duration "{value}" (use e.g. 30s, 90m, 1h)')
19
+ return round(float(match.group(1)) * _UNITS[match.group(2)])