pi-t3-bridge 0.2.0 → 0.4.0

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 (56) hide show
  1. package/README.md +5 -1
  2. package/overlay/overlay.json +118 -0
  3. package/package.json +15 -6
  4. package/pstack/skills/architect/SKILL.md +1 -1
  5. package/pstack/skills/arena/SKILL.md +1 -1
  6. package/pstack/skills/how/SKILL.md +1 -1
  7. package/pstack/skills/interrogate/SKILL.md +1 -1
  8. package/pstack/skills/poteto-help/SKILL.md +1 -1
  9. package/pstack/skills/poteto-mode/SKILL.md +4 -4
  10. package/pstack/skills/poteto-mode/brief.md +4 -4
  11. package/pstack/skills/poteto-mode/playbooks/autopilot-full.md +1 -1
  12. package/pstack/skills/poteto-mode/playbooks/orchestrate.md +1 -1
  13. package/pstack/skills/reflect/SKILL.md +1 -1
  14. package/pstack/skills/setup-pstack/SKILL.md +3 -3
  15. package/pstack/skills/statechart/scripts/run.ts +1 -1
  16. package/pstack/skills/swarm/SKILL.md +1 -1
  17. package/pstack/skills/why/SKILL.md +1 -1
  18. package/src/bridge/accounts.ts +2 -2
  19. package/src/bridge/ask.ts +2 -2
  20. package/src/bridge/bash-timeout.ts +1 -1
  21. package/src/bridge/config.ts +10 -2
  22. package/src/bridge/plan.ts +1 -1
  23. package/src/bridge/resume.ts +2 -2
  24. package/src/bridge/state.ts +2 -2
  25. package/src/bridge/steer-judge.ts +2 -2
  26. package/src/bridge/steer-transcript.ts +1 -1
  27. package/src/bridge/steer.ts +1 -1
  28. package/src/bridge/t3-json.ts +2 -2
  29. package/src/bridge/tasks.ts +2 -2
  30. package/src/bridge/thread.ts +1 -1
  31. package/src/bridge/transcript.ts +2 -2
  32. package/src/pstack/brief.ts +1 -1
  33. package/src/pstack/child.ts +50 -75
  34. package/src/pstack/models.ts +2 -2
  35. package/src/pstack/overlay.ts +251 -0
  36. package/src/pstack/resources.ts +16 -2
  37. package/src/pstack/results.ts +1 -1
  38. package/src/pstack/sandbox.ts +253 -0
  39. package/src/pstack/session.ts +4 -24
  40. package/tstack/LICENSE +21 -0
  41. package/tstack/README.md +53 -0
  42. package/tstack/skills/babysit-pr/SKILL.md +117 -0
  43. package/tstack/skills/babysit-pr/scripts/pr-wait +556 -0
  44. package/tstack/skills/council/SKILL.md +56 -0
  45. package/tstack/skills/file-pr/SKILL.md +50 -0
  46. package/tstack/skills/full-send/SKILL.md +123 -0
  47. package/tstack/skills/full-send/agents/openai.yaml +4 -0
  48. package/tstack/skills/full-stop/SKILL.md +47 -0
  49. package/tstack/skills/full-stop/agents/openai.yaml +4 -0
  50. package/tstack/skills/leaving-pr-comments/SKILL.md +44 -0
  51. package/tstack/skills/leaving-pr-comments/agents/openai.yaml +4 -0
  52. package/tstack/skills/modernize/SKILL.md +102 -0
  53. package/tstack/skills/modernize/agents/openai.yaml +4 -0
  54. package/tstack/skills/sol-loop/SKILL.md +119 -0
  55. package/tstack/skills/takeover/SKILL.md +42 -0
  56. package/tstack.ts +21 -0
@@ -0,0 +1,556 @@
1
+ #!/usr/bin/env python3
2
+ """pr-wait: watch one GitHub pull request and print one line per real event.
3
+
4
+ pr-wait <pr> [--repo owner/name] [--until green|ready|any-change] [--interval 20]
5
+
6
+ <pr> is a PR number or PR URL. Without --repo, the repo comes from the URL or
7
+ from the current checkout. Stdout carries only events, one line each, so a
8
+ monitor (Claude Code's Monitor tool) wakes the agent once per real event.
9
+ Progress and errors that do not end the run go to stderr.
10
+
11
+ --until
12
+ green required checks passed on the current head (every CI check when
13
+ the base branch requires none)
14
+ ready green, Greptile or Macroscope reviewed the current head, and no
15
+ review thread is unresolved (default)
16
+ any-change stop after the first poll that prints an event
17
+
18
+ Exit codes
19
+ 0 the --until condition is met, or the PR merged
20
+ 1 the condition cannot be met without action: a required check failed, the
21
+ branch conflicts with its base, the PR closed, `ready` is blocked by
22
+ unresolved threads or a BEHIND branch, or GitHub stayed unreachable
23
+ 2 bad arguments, unknown PR, or another pr-wait already watches this PR
24
+
25
+ Events
26
+ - a required check failed (name and link)
27
+ - all checks finished on the head (one summary line)
28
+ - a new comment, review, thread, or thread reply from someone other than the
29
+ PR author or the gh user
30
+ - Greptile, Macroscope, or CodeRabbit finished on the head, or a Greptile or
31
+ Macroscope summary changed after that
32
+ - the base branch moved and the new commits touch files in this PR
33
+ - merge state became BEHIND or DIRTY
34
+ - the PR merged or closed
35
+
36
+ CodeRabbit is advisory: pr-wait reports it but never waits for it. Comment
37
+ edits are never events, and top-level comments from CI and deploy bots
38
+ (github-actions, Vercel) are skipped, because checks already carry that news.
39
+
40
+ State lives in ~/.local/state/pr-wait/. A lock makes a second watcher for the
41
+ same PR exit at once. A state file lets a restarted watcher report what arrived
42
+ while no watcher ran. The first run for a PR records what already exists and
43
+ reports only what comes after.
44
+ """
45
+
46
+ from __future__ import annotations
47
+
48
+ import argparse
49
+ import fcntl
50
+ import hashlib
51
+ import html
52
+ import json
53
+ import os
54
+ import re
55
+ import subprocess
56
+ import sys
57
+ import time
58
+ from collections.abc import Sequence
59
+ from pathlib import Path
60
+ from typing import Any, NoReturn
61
+
62
+ GATING_BOTS = ("greptile", "macroscope")
63
+ ADVISORY_BOTS = ("coderabbit",)
64
+ # CI and deploy bots report through checks. Their PR comments are status reports.
65
+ STATUS_BOTS = ("github-actions", "vercel", "netlify", "railway", "cloudflare")
66
+ STATE_DIR = Path.home() / ".local" / "state" / "pr-wait"
67
+ # A state file older than this describes an old session. Start a fresh baseline
68
+ # instead of replaying hours of comments as new events.
69
+ STALE_STATE_SECONDS = 6 * 3600
70
+ MAX_FAILED_POLLS = 10
71
+ TRANSIENT = re.compile(
72
+ r"HTTP (5\d\d|429)|rate limit|timed? ?out|connection (reset|refused)|EOF|"
73
+ r"temporar|could not resolve host|TLS handshake|Something went wrong",
74
+ re.IGNORECASE,
75
+ )
76
+ FAILED = ("fail", "cancel")
77
+ # gh pr checks buckets, worst first. Duplicate names (push and pull_request runs
78
+ # of one job) collapse to the worst bucket.
79
+ RANK = {"fail": 4, "cancel": 3, "pending": 2, "pass": 1, "skipping": 0}
80
+
81
+ QUERY = """
82
+ query($owner: String!, $name: String!, $number: Int!) {
83
+ viewer { login }
84
+ repository(owner: $owner, name: $name) {
85
+ pullRequest(number: $number) {
86
+ url state headRefOid baseRefName mergeStateStatus
87
+ author { login }
88
+ baseRef { target { oid } }
89
+ comments(last: 100) { nodes { id url body author { login __typename } } }
90
+ reviews(last: 100) {
91
+ nodes { id url state body commit { oid } author { login __typename } }
92
+ }
93
+ reviewThreads(last: 100) {
94
+ nodes {
95
+ isResolved path line
96
+ comments(first: 100) { nodes { id url body author { login __typename } } }
97
+ }
98
+ }
99
+ }
100
+ }
101
+ }
102
+ """
103
+
104
+ Json = dict[str, Any]
105
+
106
+
107
+ class GhError(Exception):
108
+ def __init__(self, message: str, transient: bool) -> None:
109
+ super().__init__(message)
110
+ self.transient = transient
111
+
112
+
113
+ def log(message: str) -> None:
114
+ print(f"pr-wait: {message}", file=sys.stderr, flush=True)
115
+
116
+
117
+ def fail(message: str, code: int = 2) -> NoReturn:
118
+ # Fatal messages go to stdout so a monitor shows the agent why it stopped.
119
+ print(f"pr-wait: {message}", flush=True)
120
+ sys.exit(code)
121
+
122
+
123
+ def gh(args: list[str], allow: tuple[int, ...] = (0,)) -> subprocess.CompletedProcess[str]:
124
+ """Run gh. Retries transient failures (HTTP 5xx or 429, network) with backoff."""
125
+ delay = 2
126
+ for attempt in range(5):
127
+ proc = subprocess.run(["gh", *args], capture_output=True, text=True)
128
+ transient = proc.returncode != 0 and bool(TRANSIENT.search(proc.stderr))
129
+ if not transient and proc.returncode in allow:
130
+ return proc
131
+ lines = proc.stderr.strip().splitlines()
132
+ message = lines[-1] if lines else f"gh exited {proc.returncode}"
133
+ if not transient or attempt == 4:
134
+ raise GhError(message, transient)
135
+ log(f"gh {' '.join(args[:2])}: {message}; retry in {delay}s")
136
+ time.sleep(delay)
137
+ delay *= 2
138
+ raise AssertionError("unreachable")
139
+
140
+
141
+ def gh_lines(args: list[str]) -> list[str]:
142
+ return [line for line in gh(args).stdout.splitlines() if line]
143
+
144
+
145
+ def bot_name(text: str | None) -> str | None:
146
+ """Which review bot a login or check name belongs to, if any."""
147
+ lowered = (text or "").lower()
148
+ return next((bot for bot in GATING_BOTS + ADVISORY_BOTS if bot in lowered), None)
149
+
150
+
151
+ def who(node: Json) -> tuple[str, bool]:
152
+ author = node.get("author") or {}
153
+ return author.get("login") or "ghost", author.get("__typename") == "Bot"
154
+
155
+
156
+ def plain(body: str) -> str:
157
+ body = re.sub(r"<!--.*?-->", " ", body or "", flags=re.DOTALL)
158
+ return html.unescape(re.sub(r"<[^>]+>", " ", body))
159
+
160
+
161
+ def snippet(body: str, limit: int = 100) -> str:
162
+ text = re.sub(r"\s+", " ", plain(body)).strip()
163
+ return text if len(text) <= limit else text[: limit - 3] + "..."
164
+
165
+
166
+ def verdict(body: str) -> str:
167
+ """The verdict line of a bot summary (Macroscope, Greptile), if it has one."""
168
+ for line in plain(body).splitlines():
169
+ if re.search(r"verdict|confidence score", line, re.IGNORECASE):
170
+ return snippet(line.replace("*", ""), 80)
171
+ return ""
172
+
173
+
174
+ def digest(body: str) -> str:
175
+ return hashlib.sha1(re.sub(r"\s+", " ", plain(body)).encode()).hexdigest()[:12]
176
+
177
+
178
+ def parse_target(raw: str | None, repo: str | None) -> tuple[str, int]:
179
+ if raw is None or not raw.strip():
180
+ fail("missing PR number. Did a variable expand to nothing? Usage: pr-wait <pr> [--repo owner/name]")
181
+ url = re.fullmatch(r"https?://github\.com/([^/\s]+/[^/\s]+)/pull/(\d+)(?:[/?#]\S*)?", raw.strip())
182
+ number = re.fullmatch(r"#?(\d+)", raw.strip())
183
+ if url:
184
+ repo = repo or url.group(1)
185
+ pr = int(url.group(2))
186
+ elif number:
187
+ pr = int(number.group(1))
188
+ else:
189
+ fail(f"'{raw}' is not a PR number or PR URL")
190
+ if pr <= 0:
191
+ fail(f"'{raw}' is not a valid PR number")
192
+ if not repo:
193
+ try:
194
+ repo = gh(["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"]).stdout.strip()
195
+ except GhError as error:
196
+ fail(f"no --repo given and the current directory has no GitHub repo ({error})")
197
+ if not re.fullmatch(r"[\w.-]+/[\w.-]+", repo):
198
+ fail(f"'{repo}' is not owner/name")
199
+ try:
200
+ gh(["pr", "view", str(pr), "-R", repo, "--json", "number"])
201
+ except GhError as error:
202
+ if not error.transient: # a transient failure is retried by the poll loop
203
+ fail(f"cannot read {repo}#{pr}: {error}")
204
+ return repo, pr
205
+
206
+
207
+ def lock(repo: str, pr: int) -> Any:
208
+ """Hold a per-PR lock for the life of the process. The OS drops it on exit."""
209
+ STATE_DIR.mkdir(parents=True, exist_ok=True)
210
+ handle = open(STATE_DIR / f"{repo.replace('/', '__')}__{pr}.lock", "a+")
211
+ try:
212
+ fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
213
+ except BlockingIOError:
214
+ handle.seek(0)
215
+ pid = handle.read().strip() or "unknown"
216
+ fail(
217
+ f"pid {pid} already watches {repo}#{pr}, so this watcher exits to avoid "
218
+ f"duplicate events. If no events reach you from it, run `kill {pid}` and start again."
219
+ )
220
+ handle.seek(0)
221
+ handle.truncate()
222
+ handle.write(str(os.getpid()))
223
+ handle.flush()
224
+ return handle
225
+
226
+
227
+ def required_checks(repo: str, base: str) -> set[str]:
228
+ """Required check names for the base branch, from rulesets and classic branch
229
+ protection. Knowing them up front means a required check that has not started
230
+ yet counts as pending, instead of green by omission."""
231
+ sources = (
232
+ (
233
+ f"repos/{repo}/rules/branches/{base}",
234
+ '.[] | select(.type == "required_status_checks") | .parameters.required_status_checks[].context',
235
+ ),
236
+ (f"repos/{repo}/branches/{base}/protection/required_status_checks", ".contexts[]?, .checks[]?.context"),
237
+ )
238
+ names: set[str] = set()
239
+ for path, jq in sources:
240
+ try:
241
+ names.update(gh_lines(["api", path, "--jq", jq]))
242
+ except GhError as error:
243
+ if "HTTP 404" not in str(error): # 404 means no protection, which is normal
244
+ log(f"could not read required checks from {path}: {error}")
245
+ return names
246
+
247
+
248
+ class Watcher:
249
+ def __init__(self, repo: str, pr: int, until: str, interval: int) -> None:
250
+ self.repo, self.pr, self.until, self.interval = repo, pr, until, interval
251
+ self.owner, self.name = repo.split("/")
252
+ self.path = STATE_DIR / f"{repo.replace('/', '__')}__{pr}.json"
253
+ state = self.load()
254
+ # First run for this PR: record what exists and report nothing.
255
+ self.baseline = state is None
256
+ self.state: Json = state or {"reported": [], "summaries": {}, "base_oid": None, "merge_state": None}
257
+ self.reported: set[str] = set(self.state["reported"])
258
+ self.printed: set[str] = set()
259
+ self.events = 0
260
+ self.required: set[str] | None = None
261
+ self.prev_snapshot: list[tuple[str, str]] | None = None
262
+ self.pr_files: dict[str, set[str]] = {}
263
+
264
+ def load(self) -> Json | None:
265
+ try:
266
+ if time.time() - self.path.stat().st_mtime > STALE_STATE_SECONDS:
267
+ return None
268
+ return json.loads(self.path.read_text())
269
+ except (OSError, ValueError):
270
+ return None
271
+
272
+ def save(self) -> None:
273
+ self.state["reported"] = sorted(self.reported)
274
+ tmp = self.path.with_suffix(".tmp")
275
+ tmp.write_text(json.dumps(self.state))
276
+ tmp.replace(self.path)
277
+
278
+ def say(self, line: str) -> None:
279
+ if line not in self.printed:
280
+ self.printed.add(line)
281
+ print(f"#{self.pr} {line}", flush=True)
282
+
283
+ def emit(self, key: str, line: str) -> None:
284
+ """Print an event once per PR, across restarts."""
285
+ if key in self.reported:
286
+ return
287
+ self.reported.add(key)
288
+ if not self.baseline:
289
+ self.events += 1
290
+ self.say(line)
291
+
292
+ def finish(self, code: int, lines: Sequence[str] = ()) -> NoReturn:
293
+ for line in lines:
294
+ self.say(line)
295
+ self.save()
296
+ sys.exit(code)
297
+
298
+ def fetch_pr(self) -> Json:
299
+ proc = gh([
300
+ "api", "graphql", "-f", f"query={QUERY}",
301
+ "-f", f"owner={self.owner}", "-f", f"name={self.name}", "-F", f"number={self.pr}",
302
+ ])
303
+ data = json.loads(proc.stdout)["data"]
304
+ pr = data["repository"]["pullRequest"]
305
+ pr["viewer"] = data["viewer"]["login"]
306
+ return pr
307
+
308
+ def fetch_checks(self) -> dict[str, Json]:
309
+ # gh pr checks exits 8 while checks are pending and 1 when one failed.
310
+ # Those are states, not errors: read the JSON and ignore the exit code.
311
+ proc = gh(["pr", "checks", str(self.pr), "-R", self.repo, "--json", "name,bucket,link,description"], allow=(0, 1, 8))
312
+ if not proc.stdout.strip():
313
+ if "no checks reported" in proc.stderr:
314
+ return {}
315
+ raise GhError(proc.stderr.strip() or f"gh pr checks exited {proc.returncode}", False)
316
+ checks: dict[str, Json] = {}
317
+ for row in json.loads(proc.stdout):
318
+ current = checks.get(row["name"])
319
+ if current is None or RANK.get(row["bucket"], 2) > RANK.get(current["bucket"], 2):
320
+ checks[row["name"]] = row
321
+ return checks
322
+
323
+ def base_overlap(self, head: str, old: str, new: str) -> list[str]:
324
+ """Files this PR touches that the new base commits also touch."""
325
+ try:
326
+ if head not in self.pr_files:
327
+ self.pr_files[head] = set(gh_lines(["pr", "diff", str(self.pr), "-R", self.repo, "--name-only"]))
328
+ moved = gh_lines(["api", f"repos/{self.repo}/compare/{old}...{new}", "--jq", ".files[].filename"])
329
+ except GhError as error:
330
+ log(f"could not compare base commits: {error}")
331
+ return []
332
+ return sorted(self.pr_files[head].intersection(moved))
333
+
334
+ def behind(self, base: str, base_oid: str | None, head: str) -> str:
335
+ """How far the head is behind its base, for the green and ready lines."""
336
+ if not base_oid:
337
+ return ""
338
+ try:
339
+ count = gh(["api", f"repos/{self.repo}/compare/{base_oid}...{head}", "--jq", ".behind_by"]).stdout.strip()
340
+ except GhError:
341
+ return ""
342
+ return f"; {count} commit{'' if count == '1' else 's'} behind {base}"
343
+
344
+ def poll(self) -> None:
345
+ pr = self.fetch_pr()
346
+ if pr["state"] == "MERGED":
347
+ self.finish(0, [f"merged: {pr['url']}"])
348
+ if pr["state"] == "CLOSED":
349
+ self.finish(1, [f"closed without merging: {pr['url']}"])
350
+
351
+ base = pr["baseRefName"]
352
+ if self.required is None:
353
+ self.required = required_checks(self.repo, base)
354
+ gate = ", ".join(sorted(self.required)) or "none set, so every CI check gates"
355
+ log(f"watching {self.repo}#{self.pr} until {self.until}; required checks: {gate}")
356
+ checks = self.fetch_checks()
357
+ head = pr["headRefOid"]
358
+ short = head[:7]
359
+ mine = {who(pr)[0], pr["viewer"]}
360
+ comments = pr["comments"]["nodes"]
361
+ reviews = pr["reviews"]["nodes"]
362
+ threads = pr["reviewThreads"]["nodes"]
363
+ self.events = 0
364
+
365
+ # Required checks. With no required checks configured, every CI check
366
+ # gates except the review bots' own checks.
367
+ gate = self.required or {name for name in checks if not bot_name(name)}
368
+ label = "required check" if self.required else "check"
369
+
370
+ def bucket(name: str) -> str:
371
+ return checks[name]["bucket"] if name in checks else "pending"
372
+
373
+ fail_lines: list[str] = []
374
+ for name in sorted(gate):
375
+ if bucket(name) in FAILED:
376
+ link = checks[name]["link"]
377
+ fail_lines.append(f"{label} failed: {name} {link}")
378
+ self.emit(f"fail:{head}:{name}:{link}", fail_lines[-1])
379
+ watched = {name: c for name, c in checks.items() if bot_name(name) not in ADVISORY_BOTS}
380
+ gate_done = bool(gate) and all(bucket(n) != "pending" for n in gate)
381
+ all_done = gate_done and all(c["bucket"] != "pending" for c in watched.values())
382
+ # Checks appear a few seconds after a push. Two identical polls in a row
383
+ # guard against calling a half-registered set of checks finished.
384
+ snapshot = sorted((name, c["bucket"]) for name, c in watched.items())
385
+ stable = snapshot == self.prev_snapshot
386
+ self.prev_snapshot = snapshot
387
+ green = gate_done and stable and not fail_lines
388
+
389
+ # People and bots talking. Edits are never events.
390
+ for comment in comments:
391
+ login, is_bot = who(comment)
392
+ if login in mine or bot_name(login) or (is_bot and any(b in login for b in STATUS_BOTS)):
393
+ continue
394
+ self.emit(f"c:{comment['id']}", f"comment from {login}: {snippet(comment['body'])} {comment['url']}")
395
+ for review in reviews:
396
+ login, is_bot = who(review)
397
+ if login in mine or is_bot:
398
+ continue
399
+ if review["state"] in ("APPROVED", "CHANGES_REQUESTED") or review["body"].strip():
400
+ state = review["state"].lower().replace("_", " ")
401
+ self.emit(f"c:{review['id']}", f"review from {login} ({state}): {snippet(review['body'])} {review['url']}")
402
+ unresolved: list[str] = []
403
+ for thread in threads:
404
+ nodes = thread["comments"]["nodes"]
405
+ if not thread["isResolved"] and nodes:
406
+ unresolved.append(nodes[0]["url"])
407
+ where = f"{thread['path']}:{thread['line']}" if thread["line"] else thread["path"]
408
+ for index, comment in enumerate(nodes):
409
+ login, _ = who(comment)
410
+ if login in mine:
411
+ continue
412
+ kind = "new thread" if index == 0 else "thread reply"
413
+ self.emit(f"c:{comment['id']}", f"{kind} from {login} on {where}: {snippet(comment['body'])} {comment['url']}")
414
+
415
+ # Review bots on the current head. A bot with checks is done when they
416
+ # finish. Otherwise it is done once it reviews the head commit or its
417
+ # summary names the head.
418
+ bot_status: dict[str, str] = {}
419
+ reviewed: list[str] = []
420
+ for bot in GATING_BOTS + ADVISORY_BOTS:
421
+ own_checks = {name: c for name, c in checks.items() if bot_name(name) == bot}
422
+ summaries = [c for c in comments if bot_name(who(c)[0]) == bot]
423
+ own_reviews = [r for r in reviews if bot_name(who(r)[0]) == bot]
424
+ on_head = any((r.get("commit") or {}).get("oid") == head for r in own_reviews)
425
+ if own_checks:
426
+ done = all(c["bucket"] != "pending" for c in own_checks.values())
427
+ else:
428
+ done = on_head or any(short in s["body"] for s in summaries)
429
+ seen = bool(own_checks or summaries or own_reviews)
430
+ if bot in ADVISORY_BOTS and own_checks:
431
+ desc = next(iter(own_checks.values()))
432
+ bot_status[bot] = f"{bot} {desc['description'] or desc['bucket']} (advisory)"
433
+ elif seen:
434
+ bot_status[bot] = f"{bot} {'done' if done else 'pending'}{' (advisory)' if bot in ADVISORY_BOTS else ''}"
435
+ key = f"bot:{bot}:{head}"
436
+ was_done = key in self.reported
437
+ if done:
438
+ if bot in GATING_BOTS:
439
+ reviewed.append(bot)
440
+ failed = [name for name, c in own_checks.items() if c["bucket"] in FAILED]
441
+ detail = "; ".join(filter(None, [
442
+ next((v for v in map(verdict, (s["body"] for s in summaries)) if v), ""),
443
+ f"failed: {', '.join(failed)}" if failed else "",
444
+ ]))
445
+ tag = " (advisory, not waited on)" if bot in ADVISORY_BOTS else ""
446
+ self.emit(key, f"{bot} finished on {short}{': ' + detail if detail else ''}{tag}")
447
+ if bot in GATING_BOTS:
448
+ for s in summaries:
449
+ new, old = digest(s["body"]), self.state["summaries"].get(s["id"])
450
+ self.state["summaries"][s["id"]] = new
451
+ if was_done and old and old != new:
452
+ text = verdict(s["body"]) or snippet(s["body"])
453
+ self.emit(f"summary:{s['id']}:{new}", f"{bot} summary changed on {short}: {text} {s['url']}")
454
+
455
+ # Base branch and merge state.
456
+ base_oid = ((pr.get("baseRef") or {}).get("target") or {}).get("oid")
457
+ old_base = self.state["base_oid"]
458
+ if base_oid and old_base and base_oid != old_base and not self.baseline:
459
+ touched = self.base_overlap(head, old_base, base_oid)
460
+ if touched:
461
+ more = f" and {len(touched) - 5} more" if len(touched) > 5 else ""
462
+ self.emit(
463
+ f"base:{base_oid}",
464
+ f"{base} moved to {base_oid[:7]} and touches files in this PR: {', '.join(touched[:5])}{more}",
465
+ )
466
+ if base_oid:
467
+ self.state["base_oid"] = base_oid
468
+ merge_state = pr["mergeStateStatus"]
469
+ dirty_line = f"merge state is DIRTY: the branch conflicts with {base}, rebase it"
470
+ behind_line = f"merge state is BEHIND: update the branch from {base} before merging"
471
+ if merge_state != "UNKNOWN":
472
+ if merge_state in ("BEHIND", "DIRTY") and self.state["merge_state"] != merge_state:
473
+ line = dirty_line if merge_state == "DIRTY" else behind_line
474
+ self.emit(f"merge:{merge_state}:{head}:{base_oid}", line)
475
+ self.state["merge_state"] = merge_state
476
+
477
+ # One summary line per head once all checks finish.
478
+ counts: dict[str, int] = {}
479
+ for c in watched.values():
480
+ counts[c["bucket"]] = counts.get(c["bucket"], 0) + 1
481
+ failed_names = sorted(name for name, c in watched.items() if c["bucket"] in FAILED)
482
+ parts = [
483
+ ", ".join(f"{n} {b}" for b, n in sorted(counts.items()))
484
+ + (f" ({', '.join(failed_names)})" if failed_names else ""),
485
+ f"required {sum(bucket(n) in ('pass', 'skipping') for n in self.required)}/{len(self.required)} green"
486
+ if self.required
487
+ else "",
488
+ ", ".join(bot_status[b] for b in GATING_BOTS + ADVISORY_BOTS if b in bot_status)
489
+ or "no Greptile or Macroscope yet",
490
+ f"{len(unresolved)} unresolved threads",
491
+ f"merge state {merge_state}",
492
+ ]
493
+ summary = "; ".join(p for p in parts if p)
494
+ checks_key = f"checks:{head}"
495
+
496
+ # Decide whether to stop. A green or ready line carries the same summary
497
+ # as the checks line, so it marks that event as reported.
498
+ if self.until != "any-change":
499
+ if merge_state == "DIRTY":
500
+ self.finish(1, [dirty_line])
501
+ if fail_lines:
502
+ self.finish(1, fail_lines)
503
+ if green and self.until == "green":
504
+ self.reported.add(checks_key)
505
+ self.finish(0, [f"green on {short}: {summary}{self.behind(base, base_oid, head)}"])
506
+ # ready waits for Greptile or Macroscope, never for CodeRabbit.
507
+ if green and reviewed:
508
+ if unresolved:
509
+ self.finish(1, [f"blocked by {len(unresolved)} unresolved threads on {short}: {' '.join(unresolved)}"])
510
+ if merge_state == "BEHIND":
511
+ self.finish(1, [behind_line])
512
+ self.reported.add(checks_key)
513
+ self.finish(0, [f"ready on {short}: {summary}{self.behind(base, base_oid, head)}"])
514
+ if all_done and (stable or self.baseline):
515
+ self.emit(checks_key, f"checks finished on {short}: {summary}")
516
+ if self.until == "any-change" and self.events:
517
+ self.finish(0)
518
+
519
+ def run(self) -> NoReturn:
520
+ failures = 0
521
+ while True:
522
+ try:
523
+ self.poll()
524
+ failures = 0
525
+ except GhError as error:
526
+ if not error.transient:
527
+ self.finish(1, [f"pr-wait stopped: {error}"])
528
+ failures += 1
529
+ log(f"poll failed ({failures}/{MAX_FAILED_POLLS}): {error}")
530
+ if failures >= MAX_FAILED_POLLS:
531
+ self.finish(1, [f"pr-wait stopped: GitHub unreachable for {failures} polls: {error}"])
532
+ else:
533
+ self.save()
534
+ if self.baseline:
535
+ log(f"baseline recorded for {self.repo}#{self.pr}; reporting only new events")
536
+ self.baseline = False
537
+ time.sleep(self.interval)
538
+
539
+
540
+ def main() -> None:
541
+ parser = argparse.ArgumentParser(prog="pr-wait", description="Watch a GitHub PR and print one line per real event.")
542
+ parser.add_argument("pr", nargs="?", help="PR number or URL")
543
+ parser.add_argument("--repo", help="owner/name (default: from the URL or the current checkout)")
544
+ parser.add_argument("--until", choices=("green", "ready", "any-change"), default="ready")
545
+ parser.add_argument("--interval", type=int, default=20, help="seconds between polls (default 20, min 5)")
546
+ args = parser.parse_args()
547
+ repo, pr = parse_target(args.pr, args.repo)
548
+ _lock = lock(repo, pr)
549
+ Watcher(repo, pr, args.until, max(args.interval, 5)).run()
550
+
551
+
552
+ if __name__ == "__main__":
553
+ try:
554
+ main()
555
+ except KeyboardInterrupt:
556
+ sys.exit(130)
@@ -0,0 +1,56 @@
1
+ ---
2
+ name: council
3
+ description: Trigger this if the user says 'council'.
4
+ ---
5
+
6
+ # Council
7
+
8
+ Before an audit, address outstanding PR comments, fix known issues, and run
9
+ relevant checks. Give council the updated implementation; do not defer known
10
+ fixes until reviews return. If the user wants to discard the implementation
11
+ and explore new ideas, skip fixes to code being replaced.
12
+
13
+ Bring in three delegates to help with the user's request, usually for review
14
+ or feedback. Use one strong model from each of three different providers, at
15
+ high reasoning effort. If the user's instructions name council members, use
16
+ those. Otherwise pick the latest flagship model from each provider, for
17
+ example Claude Opus, GPT Sol, and Gemini Pro.
18
+
19
+ In T3 Code, use `orchestrator_capabilities` to resolve the models, then
20
+ `delegate_task` to start one delegate per model in parallel. Elsewhere, use
21
+ the harness's subagent tools. If you cannot reach three providers, or a named
22
+ member is unavailable, report it instead of substituting.
23
+
24
+ Give each delegate the request and relevant context. For review or feedback,
25
+ keep their work read-only. Collect their responses and summarize the useful
26
+ findings, disagreements, and recommended next steps.
27
+
28
+ For a code review, ask each delegate for a score out of 10.
29
+ Count each full review pass as one round.
30
+ A fresh review after fixes starts a new round.
31
+
32
+ If the work ends in a pull request, put the results in the PR description.
33
+ Put this section before the model and harness line at the end:
34
+
35
+ ```markdown
36
+ ## Council review
37
+
38
+ Rounds: 2
39
+
40
+ | Delegate | Round 1 | Round 2 |
41
+ | --- | --- | --- |
42
+ | Claude Opus | 7/10 | 9/10 |
43
+ | GPT Sol | 8/10 | 10/10 |
44
+ | Gemini Pro | 7/10 | 9/10 |
45
+ ```
46
+
47
+ If a delegate gives no score, write `no score`. Do not invent one.
48
+ If the PR is open, edit the description. Do not post a comment for this.
49
+ If there is no PR, put the same results in the final reply only.
50
+
51
+ If you agree with their recommendations, make the changes and request a fresh
52
+ review after. Continue this process until all delegates are satisfied with the
53
+ outcome.
54
+
55
+ Do not let repeated reviews result in scope creep. It is your job to fight to
56
+ keep scope limited.
@@ -0,0 +1,50 @@
1
+ ---
2
+ name: file-pr
3
+ description: File a concise pull request. Use when the user asks to file, open, or create a PR.
4
+ ---
5
+
6
+ # File PR
7
+
8
+ Before filing, check whether a PR for this branch already exists. Review the
9
+ diff locally against the base branch (usually `origin/main`) to make sure its
10
+ contents match the goal.
11
+
12
+ PR titles usually become commit messages, so follow the repository's title
13
+ conventions. Look at recently merged PRs and Git history for examples. Prefer a
14
+ concise, human-readable title that explains why the change matters:
15
+
16
+ BAD
17
+
18
+ > ❌ perf(server): negotiate permessage-deflate on the websocket
19
+
20
+ GOOD
21
+
22
+ > ✅ perf(server): cut websocket frame size by 70%+ with gzipping
23
+
24
+ Open the description with a simple explanation of the problem based on the
25
+ user's original prompt, then briefly explain the solution. Do not lead with an
26
+ implementation inventory:
27
+
28
+ BAD
29
+
30
+ > ❌ Removed implicit workspace carry-over from every "new thread" entry point (cmd+n / cmd+shift+o, sidebar v1/v2 buttons, command palette). New threads inherit only the project from context; branch, worktree, and env mode always come from the configured defaults. Deleted buildContextualThreadOptions, startNewThreadInProjectFromContext, and the v1 sidebar's seed-context machinery.
31
+
32
+ GOOD
33
+
34
+ > ✅ My "new worktree" default was ignored when starting new threads on existing worktrees. Super unintuitive. Now your preferences always apply.
35
+
36
+ End the description with the public model name and harness that made the
37
+ changes, for example `Created with <model> in <harness>.` Include subagent
38
+ models that wrote part of the change. Use the model name the harness reports.
39
+ If model identity is unavailable, name the harness without guessing a model.
40
+
41
+ Before submission, check the title, description, quotes, and attachments for
42
+ secrets and private details.
43
+
44
+ Open a real PR rather than a draft so review bots run. If `gh pr create` fails
45
+ because the GraphQL rate limit is used up, open it through REST with
46
+ `gh api repos/<owner>/<repo>/pulls -f title=... -f head=... -f base=... -F body=@<file>`. If the user also asked
47
+ to babysit it, continue with the `babysit-pr` skill.
48
+
49
+ Do not post a follow-up PR comment unless it serves the user's request. When a
50
+ comment is needed, use the `leaving-pr-comments` skill.