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,343 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, cast
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+ from whiletrue.basics.process import run_command, tail
8
+ from whiletrue.core import (
9
+ ActionModule,
10
+ CommentRecord,
11
+ CommentsContext,
12
+ Emit,
13
+ IncomingComment,
14
+ Ports,
15
+ PortSpec,
16
+ RunItem,
17
+ SimplePlugin,
18
+ StepContext,
19
+ WakeContext,
20
+ WakeResult,
21
+ define_action,
22
+ )
23
+ from whiletrue.core.module import PortValues
24
+ from whiletrue.github.client import (
25
+ GhCliClient,
26
+ GhComment,
27
+ GitHubClient,
28
+ GitHubError,
29
+ repo_from_remote,
30
+ )
31
+ from whiletrue.github.specs import parse_spec_refs
32
+
33
+
34
+ async def _detect_repo(cwd: str) -> str:
35
+ result = await run_command("git", ["remote", "get-url", "origin"], cwd=cwd)
36
+ repo = repo_from_remote(result.stdout) if result.code == 0 else None
37
+ if repo is None:
38
+ raise GitHubError("Cannot tell which repository this is: set `repo: owner/name`")
39
+ return repo
40
+
41
+
42
+ async def _repo(configured: str | None, cwd: str) -> str:
43
+ return configured or await _detect_repo(cwd)
44
+
45
+
46
+ def _pr_ref(input: Any, item: dict[str, Any] | None) -> tuple[str | None, int | None]:
47
+ """The (repo, PR number) a step or a wake condition is about: the PR the previous step made."""
48
+ source = cast(dict[str, Any], input) if isinstance(input, dict) else {}
49
+ number = source.get("number") or (item or {}).get("pr")
50
+ repo = source.get("repo") or (item or {}).get("repo")
51
+ return (str(repo) if repo else None), (int(number) if number else None)
52
+
53
+
54
+ def _encode_thread(repo: str, number: int, comment: GhComment) -> str:
55
+ return f"{repo}#{number}:{comment.id}:{comment.thread_root or ''}"
56
+
57
+
58
+ def _decode_thread(thread: str | None) -> tuple[str, int, GhComment] | None:
59
+ if not thread or "#" not in thread:
60
+ return None
61
+ head, _, rest = thread.partition("#")
62
+ number, _, tail_ = rest.partition(":")
63
+ comment_id, _, root = tail_.rpartition(":")
64
+ kind = "inline" if comment_id.startswith("inline:") else "issue"
65
+ comment = GhComment(comment_id, kind, "", "", 0, thread_root=root or None) # type: ignore[arg-type]
66
+ return head, int(number), comment
67
+
68
+
69
+ class GithubIssuesSource:
70
+ def __init__(self, client: GitHubClient) -> None:
71
+ self._client = client
72
+
73
+ async def fetch(self, config: dict[str, Any], cwd: str) -> list[RunItem]:
74
+ repo = await _repo(config.get("repo"), cwd)
75
+ issues = await self._client.list_issues(
76
+ repo,
77
+ milestone=config.get("milestone"),
78
+ state=str(config.get("state", "open")),
79
+ labels=list(config.get("labels") or []),
80
+ )
81
+ numbers = {i.number for i in issues}
82
+ use_blocked_by = config.get("depends_on", "blocked_by") == "blocked_by"
83
+ return [
84
+ RunItem(
85
+ str(i.number),
86
+ {
87
+ "number": i.number,
88
+ "title": i.title,
89
+ "body": i.body,
90
+ "labels": i.labels,
91
+ "url": i.url,
92
+ "repo": repo,
93
+ "spec_refs": parse_spec_refs(i.body),
94
+ },
95
+ [str(b) for b in i.blocked_by if b in numbers] if use_blocked_by else [],
96
+ )
97
+ for i in issues
98
+ ]
99
+
100
+
101
+ class GithubPrComments:
102
+ """Review threads and conversation comments on the PR the item opened."""
103
+
104
+ def __init__(self, client: GitHubClient) -> None:
105
+ self._client = client
106
+
107
+ async def fetch(self, ctx: CommentsContext) -> list[IncomingComment]:
108
+ repo, number = _pr_ref(ctx.input, ctx.item)
109
+ if number is None:
110
+ return []
111
+ repo = await _repo(repo, ctx.cwd)
112
+ found = await self._client.list_comments(repo, number)
113
+ return [
114
+ IncomingComment(
115
+ external_id=c.id,
116
+ author=c.author,
117
+ body=c.body,
118
+ created_at=c.created_at,
119
+ thread=_encode_thread(repo, number, c),
120
+ url=c.url,
121
+ kind="reviewer" if c.author.endswith("[bot]") else "human",
122
+ )
123
+ for c in found
124
+ if not c.body.lstrip().startswith("[whiletrue]")
125
+ ]
126
+
127
+ async def reply(self, comment: CommentRecord, body: str, ctx: CommentsContext) -> str | None:
128
+ decoded = _decode_thread(comment.thread)
129
+ if decoded is None:
130
+ return None
131
+ repo, number, target = decoded
132
+ return await self._client.reply(repo, number, target, body)
133
+
134
+
135
+ class GithubReviewWake:
136
+ """`github.pr_review`: `approved`, `changes_requested`, or omitted for either."""
137
+
138
+ def __init__(self, client: GitHubClient) -> None:
139
+ self._client = client
140
+
141
+ async def check(self, arg: Any, ctx: WakeContext) -> WakeResult | None:
142
+ repo, number = _pr_ref(ctx.input, ctx.item)
143
+ if number is None:
144
+ return None
145
+ pr = await self._client.get_pr(await _repo(repo, ctx.cwd), number)
146
+ wanted = {
147
+ None: {"approved", "changes_requested"},
148
+ "any": {"approved", "changes_requested"},
149
+ }.get(arg, {str(arg)})
150
+ if pr.review == "approved" and "approved" in wanted:
151
+ return WakeResult("approved", "approved on GitHub", by="github")
152
+ if pr.review == "changes_requested" and "changes_requested" in wanted:
153
+ note = "\n\n".join(pr.review_notes) or "changes requested on GitHub"
154
+ return WakeResult("rejected", note, by="github")
155
+ return None
156
+
157
+
158
+ class GithubMergedWake:
159
+ """`github.pr_merged`: someone merged the PR outside whiletrue."""
160
+
161
+ def __init__(self, client: GitHubClient) -> None:
162
+ self._client = client
163
+
164
+ async def check(self, arg: Any, ctx: WakeContext) -> WakeResult | None:
165
+ repo, number = _pr_ref(ctx.input, ctx.item)
166
+ if number is None:
167
+ return None
168
+ pr = await self._client.get_pr(await _repo(repo, ctx.cwd), number)
169
+ return (
170
+ WakeResult("approved", "merged on GitHub", by="github")
171
+ if pr.state == "merged"
172
+ else None
173
+ )
174
+
175
+
176
+ class OpenPrConfig(BaseModel):
177
+ repo: str | None = None
178
+ #: The branch to open the PR from (templates apply, e.g. `wt/{{item.number}}`).
179
+ head: str = Field(min_length=1)
180
+ base: str = "main"
181
+ title: str | None = None
182
+ body: str = ""
183
+ #: Issue number the PR closes; appended to the body as `Closes #N`.
184
+ closes: int | str | None = None
185
+ #: Push `head` to origin first.
186
+ push: bool = True
187
+
188
+
189
+ class SyncBaseConfig(BaseModel):
190
+ base: str = "main"
191
+ strategy: str = Field(default="rebase", pattern="^rebase$")
192
+ push: bool = True
193
+
194
+
195
+ class MergeConfig(BaseModel):
196
+ repo: str | None = None
197
+ number: int | None = None
198
+ method: str = Field(default="squash", pattern="^(squash|merge|rebase)$")
199
+ #: Hold this named lock while merging, so only one item merges at a time.
200
+ lock: str | None = None
201
+
202
+
203
+ def _pr_value(pr: Any, repo: str) -> dict[str, Any]:
204
+ return {
205
+ "number": pr.number,
206
+ "url": pr.url,
207
+ "branch": pr.branch,
208
+ "base": pr.base,
209
+ "title": pr.title,
210
+ "repo": repo,
211
+ }
212
+
213
+
214
+ def github_plugin(client: GitHubClient | None = None) -> SimplePlugin:
215
+ """Registers the GitHub source, modules, comments provider and wake conditions."""
216
+ gh: GitHubClient = client if client is not None else GhCliClient()
217
+
218
+ async def open_pr(input: PortValues, ctx: StepContext[OpenPrConfig]) -> Emit:
219
+ cfg = ctx.config
220
+ try:
221
+ repo = await _repo(cfg.repo, ctx.cwd)
222
+ if cfg.push:
223
+ pushed = await run_command(
224
+ "git", ["push", "-u", "origin", cfg.head], cwd=ctx.cwd, signal=ctx.signal
225
+ )
226
+ if pushed.code != 0:
227
+ return Emit("fail", {"error": f"git push failed: {tail(pushed.stderr)}"})
228
+ existing = await gh.find_pr(repo, cfg.head)
229
+ if existing is not None:
230
+ return Emit("ok", _pr_value(existing, repo))
231
+ item: dict[str, Any] = ctx.scope.get("item") or {}
232
+ title = cfg.title or str(item.get("title") or cfg.head)
233
+ body = cfg.body + (f"\n\nCloses #{cfg.closes}" if cfg.closes else "")
234
+ pr = await gh.create_pr(repo, head=cfg.head, base=cfg.base, title=title, body=body)
235
+ return Emit("ok", _pr_value(pr, repo))
236
+ except GitHubError as error:
237
+ return Emit("fail", {"error": str(error)})
238
+
239
+ async def sync_base(input: PortValues, ctx: StepContext[SyncBaseConfig]) -> Emit:
240
+ cfg = ctx.config
241
+
242
+ async def git(*args: str) -> Any:
243
+ return await run_command("git", list(args), cwd=ctx.cwd, signal=ctx.signal)
244
+
245
+ before = (await git("rev-parse", "HEAD")).stdout.strip()
246
+ fetched = await git("fetch", "origin", cfg.base)
247
+ if fetched.code != 0:
248
+ return Emit("fail", {"error": f"git fetch failed: {tail(fetched.stderr)}"})
249
+ rebased = await git("rebase", f"origin/{cfg.base}")
250
+ if rebased.code != 0:
251
+ conflicts = (await git("diff", "--name-only", "--diff-filter=U")).stdout.split()
252
+ await git("rebase", "--abort")
253
+ return Emit(
254
+ "needs_human",
255
+ {"reason": f"rebase onto {cfg.base} has conflicts", "files": conflicts},
256
+ )
257
+ after = (await git("rev-parse", "HEAD")).stdout.strip()
258
+ if cfg.push and after != before:
259
+ pushed = await git("push", "--force-with-lease", "origin", "HEAD")
260
+ if pushed.code != 0:
261
+ return Emit("fail", {"error": f"git push failed: {tail(pushed.stderr)}"})
262
+ return Emit("ok", input.get("in"))
263
+
264
+ async def merge(input: PortValues, ctx: StepContext[MergeConfig]) -> Emit:
265
+ cfg = ctx.config
266
+ in_repo, in_number = _pr_ref(input.get("in"), ctx.scope.get("item"))
267
+ number = cfg.number or in_number
268
+ if number is None:
269
+ return Emit("fail", {"error": "github.merge needs a PR number (config or input)"})
270
+ try:
271
+ repo = await _repo(cfg.repo or in_repo, ctx.cwd)
272
+
273
+ async def do() -> Emit:
274
+ pr = await gh.get_pr(repo, number)
275
+ if pr.state == "closed":
276
+ return Emit("fail", {"error": f"PR #{number} is closed"})
277
+ if pr.state != "merged":
278
+ await gh.merge_pr(repo, number, cfg.method)
279
+ pr = await gh.get_pr(repo, number)
280
+ return Emit("ok", {**_pr_value(pr, repo), "state": pr.state})
281
+
282
+ if cfg.lock:
283
+ async with ctx.locks.hold(cfg.lock):
284
+ return await do()
285
+ return await do()
286
+ except GitHubError as error:
287
+ return Emit("fail", {"error": str(error)})
288
+
289
+ pr_ports = Ports(inputs=[PortSpec("in", "Any")], outputs=[PortSpec("out", "PullRequest")])
290
+
291
+ def register(api: Any) -> None:
292
+ api.register_provider("source", "github_issues", GithubIssuesSource(gh))
293
+ api.register_provider("comments", "github_pr", GithubPrComments(gh))
294
+ api.register_provider("wake", "github.pr_review", GithubReviewWake(gh))
295
+ api.register_provider("wake", "github.pr_merged", GithubMergedWake(gh))
296
+ api.register_action(
297
+ define_action(
298
+ ActionModule(
299
+ id="github.open_pr",
300
+ config=OpenPrConfig,
301
+ ports=pr_ports,
302
+ effects="side-effect",
303
+ cacheable=False,
304
+ journal=True,
305
+ idempotency_key=lambda _i, ctx: (
306
+ f"github.open_pr:{ctx.config.repo or 'origin'}:{ctx.config.head}"
307
+ ),
308
+ run=open_pr,
309
+ )
310
+ )
311
+ )
312
+ api.register_action(
313
+ define_action(
314
+ ActionModule(
315
+ id="github.sync_base",
316
+ config=SyncBaseConfig,
317
+ ports=Ports(inputs=[PortSpec("in", "Any")], outputs=[PortSpec("out", "Any")]),
318
+ effects="side-effect",
319
+ cacheable=False,
320
+ idempotency_key=lambda _i, ctx: f"github.sync_base:{ctx.step_key}",
321
+ run=sync_base,
322
+ )
323
+ )
324
+ )
325
+ api.register_action(
326
+ define_action(
327
+ ActionModule(
328
+ id="github.merge",
329
+ config=MergeConfig,
330
+ ports=pr_ports,
331
+ effects="side-effect",
332
+ cacheable=False,
333
+ journal=True,
334
+ idempotency_key=lambda i, ctx: (
335
+ f"github.merge:{ctx.config.repo or 'origin'}:"
336
+ f"{ctx.config.number or _pr_ref(i.get('in'), None)[1]}"
337
+ ),
338
+ run=merge,
339
+ )
340
+ )
341
+ )
342
+
343
+ return SimplePlugin(name="whiletrue.github", register=register)
@@ -0,0 +1,39 @@
1
+ """Spec links in issue bodies (OQ-5).
2
+
3
+ An issue points at the spec text an agent should read with a line of the form
4
+
5
+ spec: specs/m3/api.md#L40-L88
6
+
7
+ (also `specs:`, case-insensitive; several links separated by commas or spaces). A bare path means
8
+ the whole file; `#L40` is one line and `#L40-L88` a range. Links are returned as written, so the
9
+ knowledge provider can resolve them later.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+
16
+ _LINE = re.compile(r"^[ \t>*-]*specs?[ \t]*:[ \t]*(.+?)[ \t]*$", re.IGNORECASE | re.MULTILINE)
17
+ _REF = re.compile(r"^[^\s#]+(?:#L\d+(?:-L\d+)?)?$")
18
+
19
+
20
+ def parse_spec_refs(body: str | None) -> list[str]:
21
+ refs: list[str] = []
22
+ for match in _LINE.finditer(body or ""):
23
+ for raw in re.split(r"[,\s]+", match.group(1)):
24
+ ref = raw.strip("`<>()[]")
25
+ if ref and _REF.match(ref) and ref not in refs:
26
+ refs.append(ref)
27
+ return refs
28
+
29
+
30
+ def blocked_by_in_body(body: str | None) -> list[int]:
31
+ """`Blocked by #12` lines: the fallback when the API has no dependency data."""
32
+ found: list[int] = []
33
+ for match in re.finditer(
34
+ r"blocked[ \t]+by[ \t:]*((?:#\d+[ \t,]*(?:and[ \t]*)?)+)", body or "", re.I
35
+ ):
36
+ for number in re.findall(r"#(\d+)", match.group(1)):
37
+ if int(number) not in found:
38
+ found.append(int(number))
39
+ return found
@@ -0,0 +1,25 @@
1
+ """The opencode runner (second opinions and review passes)."""
2
+
3
+ from whiletrue.core import PluginApi, SimplePlugin
4
+ from whiletrue.opencode.runner import (
5
+ OpencodeOptions,
6
+ OpencodeRunner,
7
+ build_opencode_args,
8
+ extract_json,
9
+ )
10
+
11
+
12
+ def opencode_plugin(options: OpencodeOptions | None = None) -> SimplePlugin:
13
+ def register(api: PluginApi) -> None:
14
+ api.register_runner(OpencodeRunner(options))
15
+
16
+ return SimplePlugin(name="whiletrue.opencode", register=register)
17
+
18
+
19
+ __all__ = [
20
+ "OpencodeOptions",
21
+ "OpencodeRunner",
22
+ "build_opencode_args",
23
+ "extract_json",
24
+ "opencode_plugin",
25
+ ]
@@ -0,0 +1,186 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import re
6
+ from dataclasses import dataclass, field
7
+ from typing import Any, cast
8
+
9
+ from whiletrue.basics.process import kill_process_group
10
+ from whiletrue.claude_code.panes import PaneHost, StreamMirror
11
+ from whiletrue.core import AgentRequest, AgentResult, RunContext, RunnerCapabilities
12
+
13
+ _LINE_LIMIT = 64 * 1024 * 1024
14
+
15
+
16
+ @dataclass
17
+ class OpencodeOptions:
18
+ #: The opencode binary. Defaults to `opencode` on PATH.
19
+ command: str = "opencode"
20
+ #: Where to show the live stream (a read-only pane); None runs without one.
21
+ host: PaneHost | None = None
22
+ extra_args: list[str] = field(default_factory=lambda: [])
23
+
24
+
25
+ _JSON_INSTRUCTION = (
26
+ "\n\nWhen you are finished, reply with ONLY a JSON value that matches this JSON Schema, "
27
+ "with no commentary and no code fence:\n{schema}"
28
+ )
29
+
30
+
31
+ def build_opencode_args(req: AgentRequest, extra_args: list[str] | None = None) -> list[str]:
32
+ """argv after the binary. The prompt is the last positional, after `--`."""
33
+ args = ["run", "--format", "json", "--dir", req.cwd]
34
+ if req.model:
35
+ args += ["--model", req.model]
36
+ if req.resume:
37
+ args += ["--session", req.resume]
38
+ if req.fork:
39
+ args += ["--fork"]
40
+ prompt = req.prompt
41
+ if req.output_schema:
42
+ prompt += _JSON_INSTRUCTION.format(schema=json.dumps(req.output_schema))
43
+ return [*args, *(extra_args or []), "--", prompt]
44
+
45
+
46
+ def extract_json(text: str) -> Any:
47
+ """The last JSON object or array in a model's reply (raw, or inside a code fence)."""
48
+ candidates = re.findall(r"```(?:json)?\s*(.*?)```", text, re.DOTALL)
49
+ candidates.append(text)
50
+ for chunk in reversed(candidates):
51
+ decoder = json.JSONDecoder()
52
+ found: Any = None
53
+ for start in (m.start() for m in re.finditer(r"[{\[]", chunk)):
54
+ try:
55
+ value, _ = decoder.raw_decode(chunk[start:])
56
+ except ValueError:
57
+ continue
58
+ found = value
59
+ break
60
+ if found is not None:
61
+ return found
62
+ return None
63
+
64
+
65
+ class OpencodeRunner:
66
+ """Runs a step with `opencode run --format json` (review passes, cheap second opinions).
67
+
68
+ There is no interactive mode: with a pane host, the step's live stream is shown in a
69
+ read-only pane. Structured output is requested in the prompt and checked by the agent module.
70
+ """
71
+
72
+ id = "opencode"
73
+ capabilities = RunnerCapabilities(
74
+ interactive=False, structured_output=True, resume=True, fork=True, budget=False
75
+ )
76
+
77
+ def __init__(self, options: OpencodeOptions | None = None) -> None:
78
+ self._options = options or OpencodeOptions()
79
+
80
+ async def run(self, request: AgentRequest, ctx: RunContext) -> AgentResult:
81
+ if ctx.signal.aborted:
82
+ return AgentResult(ok=False, text="", error="aborted")
83
+ mirror = StreamMirror(self._options.host)
84
+ await mirror.open(request.cwd, request.step_key, request.label or "opencode")
85
+ try:
86
+ return await self._run(request, ctx, mirror)
87
+ finally:
88
+ await mirror.close(keep=request.on_done == "keep", title=f"{request.label} (done)")
89
+
90
+ async def _run(
91
+ self, request: AgentRequest, ctx: RunContext, mirror: StreamMirror
92
+ ) -> AgentResult:
93
+ opts = self._options
94
+ try:
95
+ proc = await asyncio.create_subprocess_exec(
96
+ opts.command,
97
+ *build_opencode_args(request, opts.extra_args),
98
+ cwd=request.cwd,
99
+ stdin=asyncio.subprocess.DEVNULL,
100
+ stdout=asyncio.subprocess.PIPE,
101
+ stderr=asyncio.subprocess.PIPE,
102
+ start_new_session=True,
103
+ limit=_LINE_LIMIT,
104
+ )
105
+ except OSError as error:
106
+ return AgentResult(ok=False, text="", error=f"Failed to start {opts.command}: {error}")
107
+
108
+ texts: list[str] = []
109
+ state: dict[str, Any] = {"cost": 0.0, "session": None, "turns": 0, "error": None}
110
+
111
+ def emit(event: dict[str, Any]) -> None:
112
+ if ctx.on_event is not None:
113
+ ctx.on_event(event)
114
+
115
+ note = mirror.note
116
+
117
+ async def drive() -> None:
118
+ assert proc.stdout is not None
119
+ while raw := await proc.stdout.readline():
120
+ try:
121
+ event = json.loads(raw)
122
+ except ValueError:
123
+ continue
124
+ if not isinstance(event, dict):
125
+ continue
126
+ fields = cast(dict[str, Any], event)
127
+ part = cast(dict[str, Any], fields.get("part") or {})
128
+ state["session"] = fields.get("sessionID") or state["session"]
129
+ kind = fields.get("type")
130
+ if kind == "text":
131
+ text = str(part.get("text", ""))
132
+ texts.append(text)
133
+ note(text)
134
+ emit({"kind": "text"})
135
+ elif kind == "step_finish":
136
+ state["turns"] += 1
137
+ state["cost"] += float(part.get("cost") or 0)
138
+ elif kind in ("tool_use", "tool"):
139
+ name = str(part.get("tool") or part.get("name") or "")
140
+ note(f"[tool] {name}")
141
+ emit({"kind": "tool_use", "name": name})
142
+ elif kind == "error":
143
+ state["error"] = json.dumps(fields.get("error", fields))[:400]
144
+ note(f"[error] {state['error']}")
145
+
146
+ drive_task = asyncio.ensure_future(drive())
147
+ stderr_task = asyncio.ensure_future(proc.stderr.read() if proc.stderr else asyncio.sleep(0))
148
+ abort_task = asyncio.ensure_future(ctx.signal.wait())
149
+ wall_ms = request.limits.wall_ms if request.limits is not None else None
150
+ try:
151
+ done, _ = await asyncio.wait(
152
+ {drive_task, abort_task},
153
+ timeout=None if wall_ms is None else wall_ms / 1000,
154
+ return_when=asyncio.FIRST_COMPLETED,
155
+ )
156
+ if drive_task not in done:
157
+ kill_process_group(proc.pid)
158
+ drive_task.cancel()
159
+ await asyncio.gather(drive_task, return_exceptions=True)
160
+ await proc.wait()
161
+ return AgentResult(
162
+ ok=False,
163
+ text="".join(texts),
164
+ error="aborted" if abort_task in done else "wall-time limit exceeded",
165
+ )
166
+ code = await proc.wait()
167
+ stderr = (await stderr_task or b"").decode(errors="replace").strip() # type: ignore[union-attr]
168
+ finally:
169
+ abort_task.cancel()
170
+ stderr_task.cancel()
171
+ text = "".join(texts).strip()
172
+ common: dict[str, Any] = {
173
+ "text": text,
174
+ "session_id": state["session"],
175
+ "cost_usd": state["cost"],
176
+ "turns": state["turns"],
177
+ }
178
+ if code != 0 or state["error"]:
179
+ detail = state["error"] or stderr[-400:]
180
+ return AgentResult(
181
+ ok=False, error=f"{opts.command} exited with code {code}: {detail}", **common
182
+ )
183
+ output = extract_json(text) if request.output_schema else None
184
+ if request.output_schema and output is None:
185
+ return AgentResult(ok=False, error="the reply contained no JSON value", **common)
186
+ return AgentResult(ok=True, output=output, **common)
whiletrue/py.typed ADDED
File without changes
@@ -0,0 +1,59 @@
1
+ # One issue, open to merged: implement on a branch, open a PR, let a person review it while every
2
+ # comment gets an answer, fix what they reject, then merge after a final approval.
3
+ #
4
+ # source: { github_issues: { milestone: M3 } } # items are issues; blockers come first
5
+ # pipeline:
6
+ # composite: { uses: "recipe:issue-loop", params: { implementer: ./agents/implement.md, ... } }
7
+ composite: issue-loop
8
+ params:
9
+ implementer: { required: true } # agent file: implements the issue and commits
10
+ fixer: { required: true } # agent file: fixes what a reviewer rejected ({{input}} has the note)
11
+ responder: { required: true } # agent file that answers comments (see recipe:review-respond)
12
+ gate: { default: pytest } # must pass before a PR opens, after fixes and after replies
13
+ base: { default: main }
14
+ repo: { default: "" } # owner/name; empty means "read it from the origin remote"
15
+ merge_method: { default: squash }
16
+ lock: { default: "repo:main" }
17
+ ports: { in: Any, out: Any }
18
+ pipeline:
19
+ - shell: { run: "git checkout -B wt/{{item.id}}" }
20
+ - id: impl
21
+ retry: { max: 3, feedback: true }
22
+ do:
23
+ - agent: { uses: "{{params.implementer}}" }
24
+ - gate.shell: { run: "{{params.gate}}" }
25
+ - id: pr
26
+ github.open_pr:
27
+ repo: "{{params.repo}}"
28
+ head: "wt/{{item.id}}"
29
+ base: "{{params.base}}"
30
+ closes: "{{item.number}}"
31
+ - id: review
32
+ human.approve:
33
+ what: pr
34
+ wake: { any: [cli, { github.pr_review: any }] }
35
+ do:
36
+ - composite:
37
+ uses: "recipe:review-respond"
38
+ params: { responder: "{{params.responder}}", gate: "{{params.gate}}" }
39
+ wires: { rejected: fix }
40
+ - id: sync
41
+ github.sync_base: { base: "{{params.base}}" }
42
+ - id: merge_ok
43
+ human.approve:
44
+ what: merge
45
+ wake: { any: [cli, github.pr_merged] }
46
+ do:
47
+ - composite:
48
+ uses: "recipe:review-respond"
49
+ params: { responder: "{{params.responder}}", gate: "{{params.gate}}" }
50
+ - id: merge
51
+ github.merge: { repo: "{{params.repo}}", method: "{{params.merge_method}}", lock: "{{params.lock}}" }
52
+ # Reached only by a rejection: fix it, push, and ask for the review again.
53
+ - id: fix
54
+ tap:
55
+ do:
56
+ - agent: { uses: "{{params.fixer}}" }
57
+ - gate.shell: { run: "{{params.gate}}", pass_input: true }
58
+ - shell: { run: "git push --force-with-lease origin HEAD" }
59
+ wires: { ok: review }
@@ -0,0 +1,14 @@
1
+ # Ralph loop: run an agent with a fresh context, check with a deterministic gate, repeat until
2
+ # green. The agent sees the previous failure as {{iter.previous}}.
3
+ composite: ralph
4
+ params:
5
+ agent: { required: true } # path to the agent file, e.g. ./agents/ralph.md
6
+ gate: { default: pytest } # shell command; exit 0 means done
7
+ max: { default: 5 } # iterations before the item is parked
8
+ ports: { in: Any, out: Any }
9
+ pipeline:
10
+ loop:
11
+ max: "{{params.max}}"
12
+ do:
13
+ - agent: { uses: "{{params.agent}}" }
14
+ - gate.shell: { run: "{{params.gate}}" }