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.
- whiletrue/__init__.py +0 -0
- whiletrue/__main__.py +3 -0
- whiletrue/abort.py +79 -0
- whiletrue/basics/__init__.py +99 -0
- whiletrue/basics/agent.py +249 -0
- whiletrue/basics/comments.py +113 -0
- whiletrue/basics/diff_size.py +80 -0
- whiletrue/basics/exec_.py +84 -0
- whiletrue/basics/history.py +117 -0
- whiletrue/basics/human.py +201 -0
- whiletrue/basics/json_source.py +35 -0
- whiletrue/basics/markdown_source.py +55 -0
- whiletrue/basics/process.py +99 -0
- whiletrue/basics/sessions.py +62 -0
- whiletrue/basics/set_.py +29 -0
- whiletrue/basics/shell.py +98 -0
- whiletrue/basics/wake.py +27 -0
- whiletrue/claude_code/__init__.py +58 -0
- whiletrue/claude_code/endpoint.py +143 -0
- whiletrue/claude_code/headless.py +181 -0
- whiletrue/claude_code/interactive.py +344 -0
- whiletrue/claude_code/learnings.py +32 -0
- whiletrue/claude_code/panes.py +164 -0
- whiletrue/claude_code/plugin/.claude-plugin/plugin.json +5 -0
- whiletrue/claude_code/plugin/hook.py +44 -0
- whiletrue/claude_code/plugin/hooks/hooks.json +81 -0
- whiletrue/claude_code/plugin/mcp_server.py +98 -0
- whiletrue/claude_code/session.py +345 -0
- whiletrue/claude_code/transcript.py +123 -0
- whiletrue/cli/__init__.py +15 -0
- whiletrue/cli/__main__.py +3 -0
- whiletrue/cli/app.py +550 -0
- whiletrue/cli/common.py +36 -0
- whiletrue/cli/gates.py +156 -0
- whiletrue/cli/ops.py +339 -0
- whiletrue/cli/panel.py +198 -0
- whiletrue/cli/registry.py +143 -0
- whiletrue/cli/templates.py +24 -0
- whiletrue/codex/__init__.py +14 -0
- whiletrue/codex/runner.py +243 -0
- whiletrue/control/__init__.py +38 -0
- whiletrue/control/chain.py +61 -0
- whiletrue/control/loop.py +139 -0
- whiletrue/control/map_.py +101 -0
- whiletrue/control/parallel.py +32 -0
- whiletrue/control/retry.py +100 -0
- whiletrue/control/sequence.py +40 -0
- whiletrue/control/switch.py +67 -0
- whiletrue/core/__init__.py +260 -0
- whiletrue/core/caps.py +139 -0
- whiletrue/core/comments.py +80 -0
- whiletrue/core/composite.py +140 -0
- whiletrue/core/config.py +461 -0
- whiletrue/core/context.py +177 -0
- whiletrue/core/duration.py +19 -0
- whiletrue/core/events.py +94 -0
- whiletrue/core/expr.py +85 -0
- whiletrue/core/history.py +83 -0
- whiletrue/core/lock.py +86 -0
- whiletrue/core/memory_store.py +352 -0
- whiletrue/core/module.py +146 -0
- whiletrue/core/outcomes.py +82 -0
- whiletrue/core/registry.py +129 -0
- whiletrue/core/replay.py +58 -0
- whiletrue/core/runner.py +98 -0
- whiletrue/core/runtime.py +538 -0
- whiletrue/core/source.py +45 -0
- whiletrue/core/store.py +207 -0
- whiletrue/core/template.py +60 -0
- whiletrue/core/validate.py +142 -0
- whiletrue/core/wake.py +117 -0
- whiletrue/core/workspace.py +55 -0
- whiletrue/git/__init__.py +31 -0
- whiletrue/git/modules.py +128 -0
- whiletrue/git/workspaces.py +77 -0
- whiletrue/github/__init__.py +30 -0
- whiletrue/github/client.py +269 -0
- whiletrue/github/filebacked.py +87 -0
- whiletrue/github/memory.py +96 -0
- whiletrue/github/plugin.py +343 -0
- whiletrue/github/specs.py +39 -0
- whiletrue/opencode/__init__.py +25 -0
- whiletrue/opencode/runner.py +186 -0
- whiletrue/py.typed +0 -0
- whiletrue/recipes/issue-loop.yaml +59 -0
- whiletrue/recipes/ralph.yaml +14 -0
- whiletrue/recipes/review-pass.yaml +27 -0
- whiletrue/recipes/review-respond.yaml +22 -0
- whiletrue/store_sqlite/__init__.py +6 -0
- whiletrue/store_sqlite/migrations.py +132 -0
- whiletrue/store_sqlite/sqlite_store.py +537 -0
- whiletrue/tmux/__init__.py +11 -0
- whiletrue/tmux/controller.py +197 -0
- whiletrue-0.1.0.dist-info/METADATA +48 -0
- whiletrue-0.1.0.dist-info/RECORD +98 -0
- whiletrue-0.1.0.dist-info/WHEEL +4 -0
- whiletrue-0.1.0.dist-info/entry_points.txt +2 -0
- 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}}" }
|