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,207 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any, Literal, Protocol
5
+
6
+ from whiletrue.core.events import WtEvent
7
+ from whiletrue.core.history import (
8
+ CommentRecord,
9
+ Decision,
10
+ EffectRecord,
11
+ LearningRecord,
12
+ LearningStatus,
13
+ ParkRecord,
14
+ SteerRecord,
15
+ )
16
+ from whiletrue.core.outcomes import ItemState
17
+
18
+ RunStatus = Literal["running", "done", "failed", "parked", "cancelled"]
19
+ StepStatus = Literal["running", "completed", "errored"]
20
+
21
+
22
+ @dataclass
23
+ class RunRecord:
24
+ id: str
25
+ pipeline_hash: str
26
+ lockfile: str | None
27
+ status: RunStatus
28
+ started_at: int
29
+ ended_at: int | None
30
+
31
+
32
+ @dataclass
33
+ class ItemRecord:
34
+ id: str
35
+ run_id: str
36
+ source_key: str
37
+ state: ItemState
38
+ payload: Any = None
39
+ depends_on: list[str] = field(default_factory=lambda: [])
40
+
41
+
42
+ @dataclass
43
+ class StepRecord:
44
+ id: int
45
+ run_id: str
46
+ item_id: str
47
+ node_path: str
48
+ attempt: int
49
+ status: StepStatus
50
+ #: Port the step fired (ok, fail, ...), once completed.
51
+ port: str | None
52
+ input: Any
53
+ output: Any
54
+ cost_usd: float
55
+ error: str | None
56
+ started_at: int
57
+ ended_at: int | None
58
+
59
+
60
+ class StateStore(Protocol):
61
+ """Runtime source of truth for pipeline progress.
62
+
63
+ SQLite is the only v1 backend; everything else (GitHub, git, ...) is an optional integration
64
+ elsewhere.
65
+ """
66
+
67
+ def create_run(
68
+ self, *, id: str, pipeline_hash: str, started_at: int, lockfile: str | None = None
69
+ ) -> None: ...
70
+ def finish_run(
71
+ self, id: str, status: Literal["done", "failed", "parked", "cancelled"], ended_at: int
72
+ ) -> None: ...
73
+ def get_run(self, id: str) -> RunRecord | None: ...
74
+ def interrupt_run(self, id: str, ended_at: int) -> None:
75
+ """A run whose process died: its running steps errored, running items parked, run parked."""
76
+ ...
77
+
78
+ def latest_run(self) -> RunRecord | None: ...
79
+ def list_runs(self) -> list[RunRecord]:
80
+ """Every run, oldest first."""
81
+ ...
82
+
83
+ def upsert_item(self, item: ItemRecord) -> None: ...
84
+ def set_item_state(self, run_id: str, item_id: str, state: ItemState) -> None: ...
85
+ def list_items(self, run_id: str) -> list[ItemRecord]: ...
86
+ def get_item(self, run_id: str, item_id: str) -> ItemRecord | None: ...
87
+
88
+ def start_step(
89
+ self,
90
+ *,
91
+ run_id: str,
92
+ item_id: str,
93
+ node_path: str,
94
+ attempt: int,
95
+ input: Any,
96
+ started_at: int,
97
+ ) -> int: ...
98
+ def finish_step(
99
+ self,
100
+ step_id: int,
101
+ *,
102
+ status: Literal["completed", "errored"],
103
+ ended_at: int,
104
+ port: str | None = None,
105
+ output: Any = None,
106
+ cost_usd: float = 0,
107
+ error: str | None = None,
108
+ ) -> None: ...
109
+ def list_steps(self, run_id: str, item_id: str | None = None) -> list[StepRecord]: ...
110
+
111
+ def append_event(self, event: WtEvent) -> None: ...
112
+ def list_events(
113
+ self, run_id: str, *, item_id: str | None = None, type: str | None = None
114
+ ) -> list[WtEvent]: ...
115
+
116
+ def cache_get(self, key: str) -> Any | None:
117
+ """The value stored under `key`, or None on a miss (callers wrap values so None is free)."""
118
+ ...
119
+
120
+ def cache_put(self, key: str, value: Any, created_at: int) -> None: ...
121
+
122
+ # ---- human input and side effects (keyed by item, not run) ----
123
+
124
+ def create_park(
125
+ self,
126
+ *,
127
+ run_id: str,
128
+ item_id: str,
129
+ node_path: str,
130
+ what: str,
131
+ wake: dict[str, Any],
132
+ reason: str,
133
+ created_at: int,
134
+ ) -> ParkRecord: ...
135
+ def decide_park(
136
+ self,
137
+ park_id: int,
138
+ decision: Decision,
139
+ *,
140
+ decided_by: str,
141
+ decided_at: int,
142
+ notes: str | None,
143
+ ) -> None: ...
144
+ def list_parks(
145
+ self,
146
+ item_id: str | None = None,
147
+ *,
148
+ node_path: str | None = None,
149
+ run_id: str | None = None,
150
+ undecided_only: bool = False,
151
+ ) -> list[ParkRecord]:
152
+ """Parks in creation order. The Nth execution of a gate uses the Nth of its parks."""
153
+ ...
154
+
155
+ def add_comment(
156
+ self,
157
+ *,
158
+ item_id: str,
159
+ source: str,
160
+ external_id: str,
161
+ author: str,
162
+ body: str,
163
+ created_at: int,
164
+ thread: str | None = None,
165
+ url: str | None = None,
166
+ kind: Literal["human", "reviewer"] = "human",
167
+ ) -> CommentRecord | None:
168
+ """Stores a comment; None when (item, source, external id) was already recorded."""
169
+ ...
170
+
171
+ def list_comments(
172
+ self, item_id: str, *, unanswered_only: bool = False
173
+ ) -> list[CommentRecord]: ...
174
+ def answer_comment(self, comment_id: int, reply: str) -> None: ...
175
+
176
+ def add_steer(
177
+ self, *, run_id: str, item_id: str, step: str, prompt: str, created_at: int
178
+ ) -> SteerRecord: ...
179
+ def list_steers(self, item_id: str) -> list[SteerRecord]: ...
180
+
181
+ def effect_get(self, key: str) -> EffectRecord | None: ...
182
+ def effect_put(
183
+ self,
184
+ key: str,
185
+ *,
186
+ run_id: str,
187
+ status: Literal["started", "done"],
188
+ ref: Any,
189
+ created_at: int,
190
+ ) -> None: ...
191
+
192
+ def add_learning(
193
+ self,
194
+ *,
195
+ scope: str,
196
+ note: str,
197
+ status: LearningStatus,
198
+ created_at: int,
199
+ run_id: str | None = None,
200
+ provenance: dict[str, Any] | None = None,
201
+ ) -> LearningRecord: ...
202
+ def list_learnings(
203
+ self, *, scope: str | None = None, status: LearningStatus | None = None
204
+ ) -> list[LearningRecord]: ...
205
+ def set_learning_status(self, learning_id: int, status: LearningStatus) -> None: ...
206
+
207
+ def close(self) -> None: ...
@@ -0,0 +1,60 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from collections.abc import Mapping
6
+ from typing import Any, cast
7
+
8
+
9
+ class TemplateError(Exception):
10
+ pass
11
+
12
+
13
+ _TOKEN = re.compile(r"\{\{\s*([^{}]*?)\s*\}\}")
14
+ _PATH = re.compile(r"^[A-Za-z_][A-Za-z0-9_-]*(\.[A-Za-z0-9_-]+)*$")
15
+
16
+
17
+ def _lookup(scope: Any, path: str) -> Any:
18
+ current: Any = scope
19
+ for key in path.split("."):
20
+ if isinstance(current, Mapping) and key in current:
21
+ current = cast(Mapping[str, Any], current)[key]
22
+ elif (
23
+ isinstance(current, list) and key.isdigit() and int(key) < len(cast(list[Any], current))
24
+ ):
25
+ current = cast(list[Any], current)[int(key)]
26
+ else:
27
+ raise TemplateError(f'Template variable "{path}" is not defined')
28
+ return current
29
+
30
+
31
+ def _stringify(value: Any) -> str:
32
+ if isinstance(value, str):
33
+ return value
34
+ return json.dumps(value, separators=(",", ":"), ensure_ascii=False)
35
+
36
+
37
+ def render_template(template: str, scope: Any) -> str:
38
+ """Replaces `{{dotted.path}}` with values from `scope`. Strict: a missing variable raises.
39
+
40
+ Objects are JSON-encoded. (Full expressions arrive with the expression-language item.)
41
+ """
42
+
43
+ def substitute(match: re.Match[str]) -> str:
44
+ expr = match.group(1)
45
+ if not _PATH.match(expr):
46
+ raise TemplateError(f'Unsupported template expression "{expr}"')
47
+ return _stringify(_lookup(scope, expr))
48
+
49
+ return _TOKEN.sub(substitute, template)
50
+
51
+
52
+ def render_deep(value: Any, scope: Any) -> Any:
53
+ """Renders every string leaf of a JSON-like value."""
54
+ if isinstance(value, str):
55
+ return render_template(value, scope)
56
+ if isinstance(value, list):
57
+ return [render_deep(v, scope) for v in value] # pyright: ignore[reportUnknownVariableType]
58
+ if isinstance(value, dict):
59
+ return {k: render_deep(v, scope) for k, v in value.items()} # pyright: ignore[reportUnknownVariableType]
60
+ return value
@@ -0,0 +1,142 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass
5
+ from typing import Literal
6
+
7
+ from pydantic import ValidationError
8
+
9
+ from whiletrue.core.config import PipelineConfig, PipelineNode, SourceLoc
10
+ from whiletrue.core.module import ConfigIssue, PortSpec, ValidateContext
11
+ from whiletrue.core.registry import Registry
12
+ from whiletrue.core.source import SourceError, source_spec
13
+
14
+ Severity = Literal["error", "warning"]
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class Diagnostic:
19
+ severity: Severity
20
+ message: str
21
+ node_id: str
22
+ loc: SourceLoc
23
+
24
+
25
+ def has_errors(diagnostics: list[Diagnostic]) -> bool:
26
+ return any(d.severity == "error" for d in diagnostics)
27
+
28
+
29
+ def format_diagnostic(d: Diagnostic) -> str:
30
+ return f"{d.loc.file}:{d.loc.line}:{d.loc.col}: {d.severity}: {d.message} [{d.node_id}]"
31
+
32
+
33
+ def _first_port(ports: list[PortSpec], preferred: str) -> PortSpec | None:
34
+ return next((p for p in ports if p.name == preferred), ports[0] if ports else None)
35
+
36
+
37
+ def validate_pipeline(
38
+ config: PipelineConfig, registry: Registry, *, cwd: str | None = None
39
+ ) -> list[Diagnostic]:
40
+ """Static validation: everything that can be known before any tokens are spent.
41
+
42
+ Resolves modules, checks config schemas, wiring caps and the safety rules.
43
+ """
44
+ vctx = ValidateContext(cwd=cwd if cwd is not None else os.getcwd(), registry=registry)
45
+ out: list[Diagnostic] = []
46
+ try:
47
+ source = source_spec(config.extra)
48
+ except SourceError as error:
49
+ out.append(Diagnostic("error", str(error), config.root.id, config.root.loc))
50
+ else:
51
+ if source is not None and registry.get_provider("source", source[0]) is None:
52
+ out.append(
53
+ Diagnostic(
54
+ "error", f'Unknown source "{source[0]}"', config.root.id, config.root.loc
55
+ )
56
+ )
57
+
58
+ def push(node: PipelineNode, severity: Severity, message: str) -> None:
59
+ out.append(Diagnostic(severity, message, node.id, node.loc))
60
+
61
+ def push_issues(node: PipelineNode, issues: list[ConfigIssue]) -> None:
62
+ for issue in issues:
63
+ push(node, issue.severity, issue.message)
64
+
65
+ def declared(node: PipelineNode, direction: Literal["in", "out"]) -> PortSpec | None:
66
+ cap = node.ports.get(direction) if node.ports else None
67
+ return PortSpec(direction, cap) if cap is not None and registry.caps.has(cap) else None
68
+
69
+ def in_port(node: PipelineNode) -> PortSpec | None:
70
+ resolved = registry.resolve(node.use)
71
+ if node.ports is not None:
72
+ return declared(node, "in")
73
+ if resolved is None or resolved.kind != "action":
74
+ return None
75
+ return _first_port(resolved.module.ports.inputs, "in")
76
+
77
+ def out_port(node: PipelineNode) -> PortSpec | None:
78
+ resolved = registry.resolve(node.use)
79
+ if node.ports is not None:
80
+ return declared(node, "out")
81
+ if resolved is None or resolved.kind != "action":
82
+ return None
83
+ return _first_port(resolved.module.ports.outputs, "out")
84
+
85
+ def visit(node: PipelineNode) -> None:
86
+ resolved = registry.resolve(node.use)
87
+ if resolved is None:
88
+ known = registry.ids()
89
+ suffix = f" (known: {', '.join(known)})" if known else ""
90
+ push(node, "error", f'Unknown module "{node.use}"{suffix}')
91
+ else:
92
+ module = resolved.module
93
+ try:
94
+ parsed = module.config.model_validate(node.config)
95
+ except ValidationError as error:
96
+ for issue in error.errors():
97
+ loc = ".".join(str(p) for p in issue["loc"])
98
+ at = f' at "{loc}"' if loc else ""
99
+ push(node, "error", f'Invalid config for "{node.use}": {issue["msg"]}{at}')
100
+ else:
101
+ if module.validate is not None:
102
+ push_issues(node, module.validate(parsed, vctx))
103
+ if resolved.kind == "strategy" and module.validate_node is not None:
104
+ push_issues(node, module.validate_node(parsed, node, vctx))
105
+
106
+ if resolved.kind == "action":
107
+ if node.children:
108
+ push(
109
+ node, "error", f'Action module "{node.use}" cannot have child steps (`do`)'
110
+ )
111
+ if module.id.startswith("gate.") and module.effects != "deterministic":
112
+ push(node, "error", f'Gate "{module.id}" must be deterministic')
113
+ if module.effects == "side-effect" and module.idempotency_key is None:
114
+ push(
115
+ node,
116
+ "error",
117
+ f'Side-effect module "{module.id}" must provide an idempotencyKey',
118
+ )
119
+ elif module.chain:
120
+ for prev, nxt in zip(node.children, node.children[1:], strict=False):
121
+ source = out_port(prev)
122
+ target = in_port(nxt)
123
+ if (
124
+ source is not None
125
+ and target is not None
126
+ and not registry.caps.compatible(source.cap, target.cap)
127
+ ):
128
+ push(
129
+ nxt,
130
+ "error",
131
+ f'Cannot wire "{prev.id}".{source.name} ({source.cap}) '
132
+ f'into "{nxt.id}".{target.name} ({target.cap})',
133
+ )
134
+ if node.ports is not None:
135
+ for name, cap in node.ports.items():
136
+ if not registry.caps.has(cap):
137
+ push(node, "error", f'Composite port "{name}" declares unknown cap "{cap}"')
138
+ for child in node.children:
139
+ visit(child)
140
+
141
+ visit(config.root)
142
+ return out
whiletrue/core/wake.py ADDED
@@ -0,0 +1,117 @@
1
+ """Wake conditions: what ends a human gate's wait.
2
+
3
+ A person can always decide through the CLI (`cli`). Other conditions are checked by providers
4
+ registered under kind `wake` (a file appearing, a timer, a GitHub review, a plugin's own check).
5
+ A checker looks at the world and returns a decision, or None to keep waiting.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from typing import TYPE_CHECKING, Any, Literal, Protocol, cast
12
+
13
+ from whiletrue.core.history import Decision, ParkRecord
14
+
15
+ if TYPE_CHECKING:
16
+ from collections.abc import Callable
17
+
18
+ from whiletrue.core.registry import Registry
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class WakeCondition:
23
+ name: str
24
+ #: The condition's argument (`approved`, a path, `10m`, ...), or None.
25
+ arg: Any = None
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class WakeSpec:
30
+ mode: Literal["any", "all"]
31
+ #: Conditions that are checked automatically (everything except `cli`).
32
+ conditions: tuple[WakeCondition, ...]
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class WakeResult:
37
+ decision: Decision
38
+ note: str | None = None
39
+ #: Who or what decided: `github`, `file_exists`, ...
40
+ by: str = "wake"
41
+
42
+
43
+ @dataclass
44
+ class WakeContext:
45
+ cwd: str
46
+ now: Callable[[], int]
47
+ item_id: str
48
+ park: ParkRecord
49
+ registry: Registry
50
+ #: The item's payload (issue number, PR url once known, ...).
51
+ item: dict[str, Any]
52
+ #: Whatever the step before the gate produced.
53
+ input: Any = None
54
+
55
+
56
+ class WakeChecker(Protocol):
57
+ async def check(self, arg: Any, ctx: WakeContext) -> WakeResult | None: ...
58
+
59
+
60
+ def parse_wake(raw: Any) -> WakeSpec:
61
+ """`cli`, `[cli, ...]`, `{any: [...]}` or `{all: [...]}`; each entry is a name or `{name: arg}`.
62
+
63
+ Raises ValueError with a message fit for a config error.
64
+ """
65
+ mode: Literal["any", "all"] = "any"
66
+ entries: Any = raw
67
+ if isinstance(raw, dict):
68
+ keys = list(cast(dict[str, Any], raw))
69
+ if len(keys) != 1 or keys[0] not in ("any", "all"):
70
+ raise ValueError("`wake` must be a list, or a mapping with a single `any` or `all` key")
71
+ mode = keys[0]
72
+ entries = cast(dict[str, Any], raw)[mode]
73
+ if isinstance(entries, str):
74
+ entries = [entries]
75
+ if not isinstance(entries, list) or len(cast(list[Any], entries)) == 0:
76
+ raise ValueError("`wake` needs at least one condition")
77
+ conditions: list[WakeCondition] = []
78
+ for entry in entries: # pyright: ignore[reportUnknownVariableType]
79
+ if isinstance(entry, str):
80
+ name, arg = entry, None
81
+ elif isinstance(entry, dict) and len(entry) == 1: # pyright: ignore[reportUnknownArgumentType]
82
+ ((name, arg),) = entry.items() # pyright: ignore[reportUnknownVariableType, reportUnknownMemberType]
83
+ else:
84
+ raise ValueError("A wake condition is a name or a single `name: argument` mapping")
85
+ if name != "cli":
86
+ conditions.append(WakeCondition(str(name), arg)) # pyright: ignore[reportUnknownArgumentType]
87
+ return WakeSpec(mode, tuple(conditions))
88
+
89
+
90
+ def wake_to_dict(spec: WakeSpec) -> dict[str, Any]:
91
+ return {
92
+ "mode": spec.mode,
93
+ "conditions": [{"name": c.name, "arg": c.arg} for c in spec.conditions],
94
+ }
95
+
96
+
97
+ async def evaluate_wake(spec: WakeSpec, ctx: WakeContext) -> WakeResult | None:
98
+ """The automatic decision, if the conditions are met (`cli` decisions are the gate's job)."""
99
+ if not spec.conditions:
100
+ return None
101
+ results: list[WakeResult] = []
102
+ for condition in spec.conditions:
103
+ checker: WakeChecker | None = ctx.registry.get_provider("wake", condition.name)
104
+ if checker is None:
105
+ continue
106
+ result = await checker.check(condition.arg, ctx)
107
+ if result is None:
108
+ if spec.mode == "all":
109
+ return None
110
+ continue
111
+ if spec.mode == "any":
112
+ return result
113
+ results.append(result)
114
+ if spec.mode == "all" and results:
115
+ rejected = next((r for r in results if r.decision == "rejected"), None)
116
+ return rejected or results[0]
117
+ return None
@@ -0,0 +1,55 @@
1
+ """Workspaces give each item its own working directory (a git worktree, a copy, or the project)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Awaitable, Callable
6
+ from dataclasses import dataclass
7
+ from typing import TYPE_CHECKING, Any, Protocol
8
+
9
+ if TYPE_CHECKING:
10
+ from whiletrue.core.outcomes import ItemState
11
+ from whiletrue.core.registry import Registry
12
+
13
+
14
+ class WorkspaceError(Exception):
15
+ pass
16
+
17
+
18
+ @dataclass
19
+ class Workspace:
20
+ #: The directory the item's steps run in.
21
+ path: str
22
+ #: Called once when the item reaches a final state for this run (`done` may clean up; a
23
+ #: parked item keeps its workspace so a resume continues in it).
24
+ release: Callable[[ItemState], Awaitable[None]]
25
+
26
+
27
+ class WorkspaceProvider(Protocol):
28
+ async def create(self, config: dict[str, Any], item: dict[str, Any], cwd: str) -> Workspace:
29
+ """Creates the workspace, or reuses it if it already exists (resume)."""
30
+ ...
31
+
32
+
33
+ def workspace_spec(extra: dict[str, Any]) -> tuple[str, dict[str, Any]] | None:
34
+ """`workspace: {name: {...config}}` -> (name, config). None means the project directory."""
35
+ raw = extra.get("workspace")
36
+ if raw is None:
37
+ return None
38
+ if isinstance(raw, str):
39
+ return raw, {}
40
+ if not isinstance(raw, dict) or len(raw) != 1: # pyright: ignore[reportUnknownArgumentType]
41
+ raise WorkspaceError("`workspace` must be a name or a mapping with one provider")
42
+ ((name, config),) = raw.items() # pyright: ignore[reportUnknownVariableType, reportUnknownMemberType]
43
+ if config is not None and not isinstance(config, dict):
44
+ raise WorkspaceError(f"`workspace.{name}` must be a mapping")
45
+ return str(name), dict(config or {}) # pyright: ignore[reportUnknownArgumentType]
46
+
47
+
48
+ def resolve_workspace(registry: Registry, extra: dict[str, Any]) -> WorkspaceProvider | None:
49
+ spec = workspace_spec(extra)
50
+ if spec is None:
51
+ return None
52
+ provider: WorkspaceProvider | None = registry.get_provider("workspace", spec[0])
53
+ if provider is None:
54
+ raise WorkspaceError(f'Unknown workspace "{spec[0]}"')
55
+ return provider
@@ -0,0 +1,31 @@
1
+ """Git workspaces (worktree per item) and commit/push/diff modules."""
2
+
3
+ from whiletrue.core import PluginApi, SimplePlugin
4
+ from whiletrue.git.modules import (
5
+ git_commit_module,
6
+ git_commit_push_module,
7
+ git_diff_module,
8
+ )
9
+ from whiletrue.git.workspaces import DirCopy, GitWorktree, NoWorkspace
10
+
11
+
12
+ def _register(api: PluginApi) -> None:
13
+ api.register_provider("workspace", "git-worktree", GitWorktree())
14
+ api.register_provider("workspace", "dir-copy", DirCopy())
15
+ api.register_provider("workspace", "none", NoWorkspace())
16
+ api.register_action(git_commit_module)
17
+ api.register_action(git_commit_push_module)
18
+ api.register_action(git_diff_module)
19
+
20
+
21
+ git_plugin = SimplePlugin(name="whiletrue.git", register=_register)
22
+
23
+ __all__ = [
24
+ "DirCopy",
25
+ "GitWorktree",
26
+ "NoWorkspace",
27
+ "git_commit_module",
28
+ "git_commit_push_module",
29
+ "git_diff_module",
30
+ "git_plugin",
31
+ ]