loopagent-gate 0.4.2__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.
- loopagent_gate-0.4.2.dist-info/METADATA +374 -0
- loopagent_gate-0.4.2.dist-info/RECORD +40 -0
- loopagent_gate-0.4.2.dist-info/WHEEL +5 -0
- loopagent_gate-0.4.2.dist-info/entry_points.txt +2 -0
- loopagent_gate-0.4.2.dist-info/licenses/LICENSE +21 -0
- loopagent_gate-0.4.2.dist-info/top_level.txt +1 -0
- loopgate/__init__.py +7 -0
- loopgate/__main__.py +8 -0
- loopgate/cli.py +147 -0
- loopgate/gates/anchor_witness.py +249 -0
- loopgate/gates/assert_dims.py +88 -0
- loopgate/gates/bootstrap_state.py +111 -0
- loopgate/gates/change_health.py +232 -0
- loopgate/gates/check_loop.py +1267 -0
- loopgate/gates/check_product.py +463 -0
- loopgate/gates/check_visual.py +224 -0
- loopgate/gates/doctor.py +130 -0
- loopgate/gates/estimate_task.py +137 -0
- loopgate/gates/export_public.py +289 -0
- loopgate/gates/guard_card.py +519 -0
- loopgate/gates/loop_watch.py +118 -0
- loopgate/gates/regressions.py +463 -0
- loopgate/gates/render_status_page.py +151 -0
- loopgate/gates/render_task_ledger.py +312 -0
- loopgate/gates/review_pr.py +370 -0
- loopgate/gates/sync_issues_to_kanban.py +593 -0
- loopgate/gates/tree_parity.py +307 -0
- loopgate/gates/worker_brief.py +396 -0
- loopgate/init.py +128 -0
- loopgate/paths.py +43 -0
- loopgate/py.typed +0 -0
- loopgate/templates/__init__.py +1 -0
- loopgate/templates/configs/anchor_witness.example.json +14 -0
- loopgate/templates/configs/fixtures/issues_sample.json +75 -0
- loopgate/templates/configs/fixtures/review_approve.json +5 -0
- loopgate/templates/configs/fixtures/review_changes.json +5 -0
- loopgate/templates/configs/fixtures/review_no_verdict.json +5 -0
- loopgate/templates/configs/repo_profiles.example.json +63 -0
- loopgate/templates/configs/tree_parity.example.json +16 -0
- loopgate/templates/skills/loop-engineering/SKILL.md +281 -0
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Write a card's history into the repository, where a clone can read it.
|
|
3
|
+
|
|
4
|
+
The queue lives in a SQLite file on one machine; `artifacts/` and `handoff/` are gitignored because they
|
|
5
|
+
are evidence of a run. The consequence is that "what did we try, and why did it stop" disappears with the
|
|
6
|
+
laptop. This script renders one card to `backlog/<task-id>.md` — spec, the evidence bundle the worker
|
|
7
|
+
owed, how each run ended, and the review trail — so the memory survives `git clone`.
|
|
8
|
+
|
|
9
|
+
Execution stays where it was: the board still dispatches, the ledger only records.
|
|
10
|
+
|
|
11
|
+
python3 -m loopgate.gates.render_task_ledger t_example
|
|
12
|
+
python3 -m loopgate.gates.render_task_ledger --all-done
|
|
13
|
+
python3 -m loopgate.gates.render_task_ledger t_example --dry-run
|
|
14
|
+
|
|
15
|
+
Verdict: TASK_LEDGER_PASS_0001 (exit 0) when every requested card was rendered;
|
|
16
|
+
TASK_LEDGER_STOP_0001 (exit 1) when a card is unknown or the ledger cannot be written.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from loopgate.paths import project_root, gate_file
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import datetime
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import sqlite3
|
|
28
|
+
import sys
|
|
29
|
+
|
|
30
|
+
ROOT = project_root()
|
|
31
|
+
PASS_MARKER = "TASK_LEDGER_PASS_0001"
|
|
32
|
+
STOP_MARKER = "TASK_LEDGER_STOP_0001"
|
|
33
|
+
KANBAN_DB = os.path.expanduser("~/.hermes/kanban.db")
|
|
34
|
+
OUT_DIR = "backlog"
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
from loopgate.gates import worker_brief # noqa: E402
|
|
38
|
+
except ImportError: # pragma: no cover - the ledger must still render without the brief module
|
|
39
|
+
worker_brief = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def connect(db: str) -> sqlite3.Connection:
|
|
43
|
+
conn = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
|
|
44
|
+
conn.row_factory = sqlite3.Row
|
|
45
|
+
return conn
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def fetch_task(conn: sqlite3.Connection, task_id: str) -> dict | None:
|
|
49
|
+
row = conn.execute("select * from tasks where id = ?", (task_id,)).fetchone()
|
|
50
|
+
return dict(row) if row else None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def fetch_runs(conn: sqlite3.Connection, task_id: str) -> list[dict]:
|
|
54
|
+
rows = conn.execute("select * from task_runs where task_id = ? order by id", (task_id,)).fetchall()
|
|
55
|
+
return [dict(r) for r in rows]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def fetch_comments(conn: sqlite3.Connection, task_id: str) -> list[dict]:
|
|
59
|
+
rows = conn.execute("select author, body, created_at from task_comments where task_id = ? "
|
|
60
|
+
"order by id", (task_id,)).fetchall()
|
|
61
|
+
return [dict(r) for r in rows]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def done_tasks(conn: sqlite3.Connection) -> list[str]:
|
|
65
|
+
rows = conn.execute("select id from tasks where status in ('done','blocked') order by rowid").fetchall()
|
|
66
|
+
return [r["id"] for r in rows]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def bundle_for(profile_name: str | None) -> list[str]:
|
|
70
|
+
"""The commands this card's profile owed, with the source that requires each one."""
|
|
71
|
+
if not profile_name or worker_brief is None:
|
|
72
|
+
return []
|
|
73
|
+
try:
|
|
74
|
+
profile = worker_brief.get_profile(ROOT, profile_name)
|
|
75
|
+
return [f"{item['command']} — {worker_brief_source(item)}"
|
|
76
|
+
for item in worker_brief.bundle_commands(profile)]
|
|
77
|
+
except (KeyError, SystemExit, OSError):
|
|
78
|
+
return []
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def worker_brief_source(item: dict) -> str:
|
|
82
|
+
source = (item.get("source") or "").strip().splitlines()
|
|
83
|
+
return source[0] if source else "profile"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def run_exit_code(metadata: str | None) -> str | None:
|
|
87
|
+
if not metadata:
|
|
88
|
+
return None
|
|
89
|
+
try:
|
|
90
|
+
data = json.loads(metadata)
|
|
91
|
+
except (TypeError, ValueError):
|
|
92
|
+
return None
|
|
93
|
+
for key in ("command_exit_code", "exit_code"):
|
|
94
|
+
if key in data and data[key] is not None:
|
|
95
|
+
return str(data[key])
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def iso(ts) -> str:
|
|
100
|
+
if not ts:
|
|
101
|
+
return "—"
|
|
102
|
+
try:
|
|
103
|
+
return datetime.datetime.fromtimestamp(float(ts)).astimezone().isoformat(timespec="seconds")
|
|
104
|
+
except (TypeError, ValueError, OSError):
|
|
105
|
+
return str(ts)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def pr_links(comments: list[dict], runs: list[dict]) -> list[str]:
|
|
109
|
+
found: list[str] = []
|
|
110
|
+
for blob in [c.get("body") or "" for c in comments] + [r.get("summary") or "" for r in runs] + \
|
|
111
|
+
[r.get("metadata") or "" for r in runs]:
|
|
112
|
+
for token in blob.replace("(", " ").replace(")", " ").split():
|
|
113
|
+
if "/pull/" in token or "/issues/" in token:
|
|
114
|
+
token = token.strip(".,;\"'`")
|
|
115
|
+
if token not in found:
|
|
116
|
+
found.append(token)
|
|
117
|
+
return found
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def spec_from_body(body: str) -> str:
|
|
121
|
+
"""The issue's own words, not the brief that quoted them.
|
|
122
|
+
|
|
123
|
+
The bridge stores the rendered brief as the card body, so copying it here would duplicate the bundle
|
|
124
|
+
and the constraints in every ledger file. The binding part is the issue text; the rest is regenerable
|
|
125
|
+
with `loopgate/gates/worker_brief.py build`.
|
|
126
|
+
"""
|
|
127
|
+
marker = "## Issue body (binding acceptance criteria)"
|
|
128
|
+
if marker in body:
|
|
129
|
+
tail = body.split(marker, 1)[1]
|
|
130
|
+
stop = tail.find("\n## ")
|
|
131
|
+
chunk = tail if stop == -1 else tail[:stop]
|
|
132
|
+
quoted = [line[2:] if line.startswith("> ") else ("" if line.strip() == ">" else line)
|
|
133
|
+
for line in chunk.strip().splitlines()]
|
|
134
|
+
text = "\n".join(quoted).strip()
|
|
135
|
+
if text:
|
|
136
|
+
return text
|
|
137
|
+
goal = body.find("## Goal")
|
|
138
|
+
if goal != -1 and "## " in body[goal + 3:]:
|
|
139
|
+
tail = body[goal + len("## Goal"):]
|
|
140
|
+
stop = tail.find("\n## ")
|
|
141
|
+
text = (tail if stop == -1 else tail[:stop]).strip()
|
|
142
|
+
if text:
|
|
143
|
+
return text
|
|
144
|
+
return body
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def render(task: dict, runs: list[dict], comments: list[dict]) -> str:
|
|
148
|
+
task_id = task["id"]
|
|
149
|
+
profile = next((r.get("profile") for r in runs if r.get("profile")), None)
|
|
150
|
+
lines: list[str] = []
|
|
151
|
+
add = lines.append
|
|
152
|
+
add(f"# {task.get('title') or task_id}")
|
|
153
|
+
add("")
|
|
154
|
+
add(f"- card: `{task_id}`")
|
|
155
|
+
add(f"- assignee: `{task.get('assignee')}`")
|
|
156
|
+
add(f"- status: **{task.get('status')}**")
|
|
157
|
+
add(f"- created: {iso(task.get('created_at'))}")
|
|
158
|
+
if task.get("completed_at"):
|
|
159
|
+
add(f"- closed: {iso(task.get('completed_at'))}")
|
|
160
|
+
if task.get("workspace_path"):
|
|
161
|
+
add(f"- workspace: `{os.path.basename(str(task['workspace_path']))}`")
|
|
162
|
+
if task.get("branch_name"):
|
|
163
|
+
add(f"- branch: `{task['branch_name']}`")
|
|
164
|
+
if task.get("idempotency_key"):
|
|
165
|
+
add(f"- idempotency key: `{task['idempotency_key']}`")
|
|
166
|
+
links = pr_links(comments, runs)
|
|
167
|
+
for link in links:
|
|
168
|
+
add(f"- link: {link}")
|
|
169
|
+
add("")
|
|
170
|
+
|
|
171
|
+
add("## Spec")
|
|
172
|
+
add("")
|
|
173
|
+
body = (task.get("body") or "").strip()
|
|
174
|
+
if not body:
|
|
175
|
+
add("_no body recorded on the card_")
|
|
176
|
+
else:
|
|
177
|
+
for raw in spec_from_body(body).splitlines():
|
|
178
|
+
add(raw)
|
|
179
|
+
add("")
|
|
180
|
+
|
|
181
|
+
bundle = bundle_for(profile)
|
|
182
|
+
add("## Evidence bundle the card owed")
|
|
183
|
+
add("")
|
|
184
|
+
if bundle:
|
|
185
|
+
add(f"Profile `{profile}` requires all of these; a missing command is a REJECT at review.")
|
|
186
|
+
add("")
|
|
187
|
+
for item in bundle:
|
|
188
|
+
add(f"- `{item}`")
|
|
189
|
+
else:
|
|
190
|
+
add("_no profile bundle recorded (the card was not dispatched with a repo profile)_")
|
|
191
|
+
add("")
|
|
192
|
+
|
|
193
|
+
add("## Runs")
|
|
194
|
+
add("")
|
|
195
|
+
if not runs:
|
|
196
|
+
add("_no run recorded_")
|
|
197
|
+
else:
|
|
198
|
+
add("| run | status | outcome | started | ended | exit |")
|
|
199
|
+
add("|---|---|---|---|---|---|")
|
|
200
|
+
for run in runs:
|
|
201
|
+
add(f"| {run.get('id')} | {run.get('status')} | {run.get('outcome') or '—'} | "
|
|
202
|
+
f"{iso(run.get('started_at'))} | {iso(run.get('ended_at'))} | "
|
|
203
|
+
f"{run_exit_code(run.get('metadata')) or '—'} |")
|
|
204
|
+
add("")
|
|
205
|
+
for run in runs:
|
|
206
|
+
summary = (run.get("summary") or "").strip()
|
|
207
|
+
error = (run.get("error") or "").strip()
|
|
208
|
+
if summary or error:
|
|
209
|
+
add(f"**run {run.get('id')}**")
|
|
210
|
+
add("")
|
|
211
|
+
for raw in (summary or error).splitlines():
|
|
212
|
+
add(f"> {raw}" if raw.strip() else ">")
|
|
213
|
+
add("")
|
|
214
|
+
|
|
215
|
+
add("## Review trail")
|
|
216
|
+
add("")
|
|
217
|
+
if not comments:
|
|
218
|
+
add("_no comments_")
|
|
219
|
+
else:
|
|
220
|
+
for comment in comments:
|
|
221
|
+
add(f"**{comment.get('author') or 'unknown'}** · {iso(comment.get('created_at'))}")
|
|
222
|
+
add("")
|
|
223
|
+
for raw in (comment.get("body") or "").splitlines():
|
|
224
|
+
add(f"> {raw}" if raw.strip() else ">")
|
|
225
|
+
add("")
|
|
226
|
+
|
|
227
|
+
add("## Verdict")
|
|
228
|
+
add("")
|
|
229
|
+
last = runs[-1] if runs else None
|
|
230
|
+
if not last:
|
|
231
|
+
add("No run has executed this card yet: the ledger records the intent, not a result.")
|
|
232
|
+
else:
|
|
233
|
+
exit_code = run_exit_code(last.get("metadata"))
|
|
234
|
+
add(f"Last run `{last.get('id')}` ended as `{last.get('status')}`"
|
|
235
|
+
+ (f" with exit `{exit_code}`" if exit_code is not None else "")
|
|
236
|
+
+ f" ({iso(last.get('ended_at'))}).")
|
|
237
|
+
if task.get("last_failure_error"):
|
|
238
|
+
add("")
|
|
239
|
+
add(f"Last recorded failure: `{task['last_failure_error']}`")
|
|
240
|
+
add("")
|
|
241
|
+
# No render timestamp on purpose: a clock in the artefact makes every re-render a diff, and a ledger
|
|
242
|
+
# that churns is a ledger nobody reads. The history lives in git, which already records when.
|
|
243
|
+
add("_Rendered from the board by `loopgate/gates/render_task_ledger.py`. "
|
|
244
|
+
"The board decides, the ledger remembers._")
|
|
245
|
+
add("")
|
|
246
|
+
return "\n".join(lines)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def write_ledger(root: str, task_id: str, content: str, dry_run: bool) -> tuple[str, bool]:
|
|
250
|
+
out_dir = os.path.join(root, OUT_DIR)
|
|
251
|
+
path = os.path.join(out_dir, f"{task_id}.md")
|
|
252
|
+
previous = None
|
|
253
|
+
if os.path.exists(path):
|
|
254
|
+
with open(path, encoding="utf-8") as fh:
|
|
255
|
+
previous = fh.read()
|
|
256
|
+
if previous == content:
|
|
257
|
+
return path, False
|
|
258
|
+
if not dry_run:
|
|
259
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
260
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
261
|
+
fh.write(content)
|
|
262
|
+
return path, True
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def main(argv=None) -> int:
|
|
266
|
+
ap = argparse.ArgumentParser(description="Render board cards into backlog/<task-id>.md.")
|
|
267
|
+
ap.add_argument("task_ids", nargs="*", help="card ids, e.g. t_example")
|
|
268
|
+
ap.add_argument("--all-done", action="store_true", help="every card that is done or blocked")
|
|
269
|
+
ap.add_argument("--db", default=KANBAN_DB)
|
|
270
|
+
ap.add_argument("--root", default=ROOT)
|
|
271
|
+
ap.add_argument("--dry-run", action="store_true")
|
|
272
|
+
args = ap.parse_args(argv)
|
|
273
|
+
|
|
274
|
+
if not os.path.exists(args.db):
|
|
275
|
+
print(f"board not found: {args.db}", file=sys.stderr)
|
|
276
|
+
print(STOP_MARKER)
|
|
277
|
+
return 1
|
|
278
|
+
conn = connect(args.db)
|
|
279
|
+
ids = list(args.task_ids)
|
|
280
|
+
if args.all_done:
|
|
281
|
+
ids.extend(i for i in done_tasks(conn) if i not in ids)
|
|
282
|
+
if not ids:
|
|
283
|
+
print("nothing to render: pass card ids or --all-done", file=sys.stderr)
|
|
284
|
+
print(STOP_MARKER)
|
|
285
|
+
return 1
|
|
286
|
+
|
|
287
|
+
failed = 0
|
|
288
|
+
for task_id in ids:
|
|
289
|
+
task = fetch_task(conn, task_id)
|
|
290
|
+
if not task:
|
|
291
|
+
print(f" {task_id} [MISSING] no such card on the board", file=sys.stderr)
|
|
292
|
+
failed += 1
|
|
293
|
+
continue
|
|
294
|
+
runs = fetch_runs(conn, task_id)
|
|
295
|
+
comments = fetch_comments(conn, task_id)
|
|
296
|
+
path, changed = write_ledger(args.root, task_id, render(task, runs, comments), args.dry_run)
|
|
297
|
+
rel = os.path.relpath(path, args.root)
|
|
298
|
+
state = "written" if changed else "unchanged"
|
|
299
|
+
if args.dry_run:
|
|
300
|
+
state = "would write" if changed else "unchanged"
|
|
301
|
+
print(f" {task_id} [{state}] {rel} — {len(runs)} run(s), {len(comments)} comment(s)")
|
|
302
|
+
conn.close()
|
|
303
|
+
if failed:
|
|
304
|
+
print(f"cards not rendered: {failed}", file=sys.stderr)
|
|
305
|
+
print(STOP_MARKER)
|
|
306
|
+
return 1
|
|
307
|
+
print(PASS_MARKER)
|
|
308
|
+
return 0
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
if __name__ == "__main__":
|
|
312
|
+
sys.exit(main())
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Second-level review: the whole pull request, read by an engine that did not write it.
|
|
3
|
+
|
|
4
|
+
`loopgate/gates/worker_brief.py verify` proves the worker ran the bundle. It says nothing about whether the change
|
|
5
|
+
is a good idea: a worker can prove every gate and still ship the wrong design. This script sends the entire
|
|
6
|
+
PR diff to an independent model through `ywcrew` (the only door to the CLI agents, per AGENTS.md), asks for
|
|
7
|
+
a verdict, and either prints it or posts it as a PR review.
|
|
8
|
+
|
|
9
|
+
python3 -m loopgate.gates.review_pr --profile sample-service --pr 3 --dry-run # build the brief, dispatch nothing
|
|
10
|
+
python3 -m loopgate.gates.review_pr --profile sample-service --pr 3 # dispatch, print the verdict
|
|
11
|
+
python3 -m loopgate.gates.review_pr --profile sample-service --pr 3 --post # also post it to the PR
|
|
12
|
+
|
|
13
|
+
Verdict: PR_REVIEW_PASS_0001 (exit 0) when the reviewer accepts the change; PR_REVIEW_STOP_0001 (exit 1)
|
|
14
|
+
when it asks for changes, rejects it, or the dispatch cannot be made. A reviewer that cannot be reached is
|
|
15
|
+
not an approval.
|
|
16
|
+
|
|
17
|
+
Cost note: `--backend` and `--model` default to `agy:gemini-3.8-flash` on purpose. The panel (`--panel`) is
|
|
18
|
+
available and costs one dispatch per member; it is not the default because a review is not improved by
|
|
19
|
+
paying three times for the same first look.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from loopgate.paths import TEMPLATES_DIR, project_root, gate_file
|
|
25
|
+
|
|
26
|
+
import argparse
|
|
27
|
+
import json
|
|
28
|
+
import os
|
|
29
|
+
import re
|
|
30
|
+
import shutil
|
|
31
|
+
import subprocess
|
|
32
|
+
import sys
|
|
33
|
+
import tempfile
|
|
34
|
+
|
|
35
|
+
ROOT = project_root()
|
|
36
|
+
PASS_MARKER = "PR_REVIEW_PASS_0001"
|
|
37
|
+
STOP_MARKER = "PR_REVIEW_STOP_0001"
|
|
38
|
+
VERDICT_RE = re.compile(r"VERDICT\s*:\s*(APPROVE|CHANGES|REJECT)", re.I)
|
|
39
|
+
MAX_DIFF_CHARS = 60_000
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
from loopgate.gates import worker_brief # noqa: E402
|
|
43
|
+
except ImportError: # pragma: no cover
|
|
44
|
+
worker_brief = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def run(cmd: list[str], stdin: str | None = None, timeout: int = 900) -> subprocess.CompletedProcess:
|
|
48
|
+
return subprocess.run(cmd, input=stdin, capture_output=True, text=True, timeout=timeout)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def profile_or_die(name: str) -> dict:
|
|
52
|
+
if worker_brief is None:
|
|
53
|
+
sys.exit("worker_brief.py is not importable: cannot resolve a repo profile")
|
|
54
|
+
try:
|
|
55
|
+
return worker_brief.get_profile(ROOT, name)
|
|
56
|
+
except (KeyError, SystemExit):
|
|
57
|
+
print(f"unknown profile {name!r}; try one of: "
|
|
58
|
+
f"{', '.join(sorted(worker_brief.load_profiles(ROOT)['profiles']))}", file=sys.stderr)
|
|
59
|
+
raise SystemExit(1)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def pr_meta(repo: str, pr: int) -> dict:
|
|
63
|
+
proc = run(["gh", "pr", "view", str(pr), "-R", repo, "--json",
|
|
64
|
+
"title,body,headRefName,baseRefName,headRefOid,files,additions,deletions"])
|
|
65
|
+
if proc.returncode != 0:
|
|
66
|
+
raise RuntimeError(f"gh pr view failed: {proc.stderr.strip()[:300]}")
|
|
67
|
+
return json.loads(proc.stdout)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def pr_diff(repo: str, pr: int) -> str:
|
|
71
|
+
proc = run(["gh", "pr", "diff", str(pr), "-R", repo])
|
|
72
|
+
if proc.returncode != 0:
|
|
73
|
+
raise RuntimeError(f"gh pr diff failed: {proc.stderr.strip()[:300]}")
|
|
74
|
+
return proc.stdout
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def build_task(profile: dict, repo: str, pr: int, meta: dict, diff: str, bundle: list[str]) -> dict:
|
|
78
|
+
"""A five-part task for the dispatcher. English by policy: the payload is not a chat message."""
|
|
79
|
+
files = [f.get("path") for f in (meta.get("files") or []) if f.get("path")]
|
|
80
|
+
truncated = diff[:MAX_DIFF_CHARS]
|
|
81
|
+
cut = "" if len(diff) <= MAX_DIFF_CHARS else (
|
|
82
|
+
f"\n\n[diff truncated: showing the first {MAX_DIFF_CHARS} of {len(diff)} characters]")
|
|
83
|
+
rules = "; ".join((profile.get("pr_rules") or [])[:5])
|
|
84
|
+
task = {
|
|
85
|
+
"objective": (
|
|
86
|
+
f"Review pull request #{pr} in {repo} — '{meta.get('title', '')}' — as the second level of review. "
|
|
87
|
+
f"The worker already proved the evidence bundle, so do not re-litigate whether the commands ran: "
|
|
88
|
+
f"judge the change itself. Merge base {meta.get('baseRefName')} <- head {meta.get('headRefName')} "
|
|
89
|
+
f"({meta.get('additions', '?')} added, {meta.get('deletions', '?')} removed, {len(files)} files). "
|
|
90
|
+
"Say plainly whether this should be merged."),
|
|
91
|
+
"briefing": (
|
|
92
|
+
"This repository accepts work only inside a loop with evidence: one task = one PR, the PR body "
|
|
93
|
+
"carries the mandatory commands verbatim with exit codes, and a reviewer re-runs the bundle in a "
|
|
94
|
+
"separate worktree of the head commit. Your review is the second, independent level: design, "
|
|
95
|
+
"correctness, failure modes and whether the change does what the issue asked — not the plumbing."
|
|
96
|
+
+ (f" Repository PR rules: {rules}." if rules else "")
|
|
97
|
+
+ (f" The pull request body:\n\n{(meta.get('body') or '')[:4000]}" if meta.get("body") else "")),
|
|
98
|
+
"locations": ("The full diff is below and it is the whole change. Files touched: "
|
|
99
|
+
+ (", ".join(files[:40]) if files else "(none reported)")
|
|
100
|
+
+ ". The repository's own gates live in loopgate/gates/ (check_loop.py, assert_dims.py, "
|
|
101
|
+
"regressions.py) and configs/repo_profiles.json."),
|
|
102
|
+
"constraints": (
|
|
103
|
+
"Read-only: propose changes, never rewrite the code. Do not comment on formatting that the "
|
|
104
|
+
"project's own linter accepts. Do not invent files or APIs you cannot see in the diff. If the "
|
|
105
|
+
"diff is truncated, say which part you could not see instead of guessing. Do not discuss the "
|
|
106
|
+
"evidence bundle's completeness — another gate covers that."),
|
|
107
|
+
"output_contract": (
|
|
108
|
+
"Markdown, at most 400 words. Sections: 'Findings' (each with file:line, severity "
|
|
109
|
+
"blocker/major/minor, and the consequence for a user or operator), 'Missing tests or evidence', "
|
|
110
|
+
"'Questions for the author'. The last line of your reply must be exactly "
|
|
111
|
+
"'VERDICT: APPROVE' or 'VERDICT: CHANGES' or 'VERDICT: REJECT' — nothing after it."),
|
|
112
|
+
}
|
|
113
|
+
payload = "\n\n".join([f"## Objective\n{task['objective']}", f"## Diff\n```diff\n{truncated}\n```{cut}"])
|
|
114
|
+
# The dispatcher can hand the reviewer the real files, but it resolves globs against the dispatcher's
|
|
115
|
+
# own cwd — and the reviewed repository is usually a different checkout. A path that does not exist
|
|
116
|
+
# there aborts the whole dispatch, so only files this tree actually has are offered; the diff in the
|
|
117
|
+
# prompt is what the review is based on either way.
|
|
118
|
+
local = [f for f in (files or []) if os.path.exists(os.path.join(ROOT, f))]
|
|
119
|
+
return {"task": task, "diff": payload, "files": local or None}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def dispatch(spec: dict, backend: str, model: str | None, panel: bool) -> tuple[str, str]:
|
|
123
|
+
"""Send the task through ywcrew and return (raw_result_text, run_reference)."""
|
|
124
|
+
body = {"mode": "read-only", "label": spec["task"]["objective"][:60],
|
|
125
|
+
"task": dict(spec["task"]), "files": spec.get("files") or []}
|
|
126
|
+
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False, encoding="utf-8") as fh:
|
|
127
|
+
json.dump(body, fh, ensure_ascii=False)
|
|
128
|
+
task_path = fh.name
|
|
129
|
+
try:
|
|
130
|
+
if panel:
|
|
131
|
+
cmd = ["ywcrew", "panel", "--task-file", task_path]
|
|
132
|
+
else:
|
|
133
|
+
body["backend"] = backend
|
|
134
|
+
if model:
|
|
135
|
+
body["model"] = model
|
|
136
|
+
with open(task_path, "w", encoding="utf-8") as fh:
|
|
137
|
+
json.dump(body, fh, ensure_ascii=False)
|
|
138
|
+
cmd = ["ywcrew", "run", "--task-file", task_path]
|
|
139
|
+
proc = run(cmd)
|
|
140
|
+
if proc.returncode != 0:
|
|
141
|
+
raise RuntimeError(f"{' '.join(cmd[:2])} failed: {(proc.stderr or proc.stdout).strip()[:300]}")
|
|
142
|
+
out = proc.stdout.strip()
|
|
143
|
+
run_id = None
|
|
144
|
+
try:
|
|
145
|
+
data = json.loads(out)
|
|
146
|
+
run_id = data.get("runId") or data.get("run_id") or (data.get("run") or {}).get("id")
|
|
147
|
+
except json.JSONDecodeError:
|
|
148
|
+
match = re.search(r"\b(run_[A-Za-z0-9_-]+|[0-9a-f]{8,})\b", out)
|
|
149
|
+
run_id = match.group(1) if match else None
|
|
150
|
+
if not run_id:
|
|
151
|
+
raise RuntimeError(f"could not read a run id from ywcrew output: {out[:300]}")
|
|
152
|
+
# `ywcrew result` prints JSON by default; the flag that would ask for it does not exist and the
|
|
153
|
+
# earlier version failed on it after a review had already been paid for.
|
|
154
|
+
result = run(["ywcrew", "result", run_id, "--wait", "--timeout", "1200"], timeout=1800)
|
|
155
|
+
text = result.stdout or result.stderr
|
|
156
|
+
if result.returncode != 0:
|
|
157
|
+
raise RuntimeError(f"ywcrew result failed for {run_id}: {text.strip()[:300]}")
|
|
158
|
+
return text, run_id
|
|
159
|
+
finally:
|
|
160
|
+
os.unlink(task_path)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def extract_text(raw: str) -> str:
|
|
164
|
+
"""The reviewer's prose, whichever shape the dispatcher used to wrap it."""
|
|
165
|
+
try:
|
|
166
|
+
data = json.loads(raw)
|
|
167
|
+
except json.JSONDecodeError:
|
|
168
|
+
return raw
|
|
169
|
+
chunks: list[str] = []
|
|
170
|
+
|
|
171
|
+
def walk(node):
|
|
172
|
+
if isinstance(node, dict):
|
|
173
|
+
for key in ("output", "text", "content", "result", "summary"):
|
|
174
|
+
value = node.get(key)
|
|
175
|
+
if isinstance(value, str) and len(value) > 40:
|
|
176
|
+
chunks.append(value)
|
|
177
|
+
for value in node.values():
|
|
178
|
+
if isinstance(value, (dict, list)):
|
|
179
|
+
walk(value)
|
|
180
|
+
elif isinstance(node, list):
|
|
181
|
+
for item in node:
|
|
182
|
+
walk(item)
|
|
183
|
+
|
|
184
|
+
walk(data)
|
|
185
|
+
return "\n\n".join(chunks) if chunks else raw
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def verdict_of(text: str) -> str | None:
|
|
189
|
+
found = VERDICT_RE.findall(text)
|
|
190
|
+
return found[-1].upper() if found else None
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def post_review(repo: str, pr: int, body: str, verdict: str) -> str:
|
|
194
|
+
with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False, encoding="utf-8") as fh:
|
|
195
|
+
fh.write(body)
|
|
196
|
+
path = fh.name
|
|
197
|
+
try:
|
|
198
|
+
flag = {"APPROVE": "--approve", "CHANGES": "--request-changes"}.get(verdict, "--comment")
|
|
199
|
+
proc = run(["gh", "pr", "review", str(pr), "-R", repo, flag, "--body-file", path])
|
|
200
|
+
if proc.returncode == 0:
|
|
201
|
+
return f"posted as {flag}"
|
|
202
|
+
message = (proc.stderr or proc.stdout).strip()
|
|
203
|
+
# GitHub refuses a formal approval on your own pull request. That is a rule about the author, not a
|
|
204
|
+
# failure of the review, so the verdict still lands — as a comment, with the reason kept.
|
|
205
|
+
if "your own pull request" in message:
|
|
206
|
+
proc = run(["gh", "pr", "review", str(pr), "-R", repo, "--comment", "--body-file", path])
|
|
207
|
+
if proc.returncode == 0:
|
|
208
|
+
return (f"posted as --comment (GitHub refuses {flag} on your own pull request; the verdict "
|
|
209
|
+
f"is unchanged: {verdict})")
|
|
210
|
+
return f"failed: {message[:200]}"
|
|
211
|
+
finally:
|
|
212
|
+
os.unlink(path)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def selftest() -> int:
|
|
216
|
+
"""Offline unit test: assert parser verdict mapping and verify that missing verdict never posts."""
|
|
217
|
+
# The selftest must pass in any tree: the published one ships `sample-service`, the working one ships
|
|
218
|
+
# the real profiles. Hardcoding a profile name made the test pass only where that name happened to exist.
|
|
219
|
+
profiles = sorted((worker_brief.load_profiles(ROOT) if worker_brief else {}) or {})
|
|
220
|
+
if not profiles:
|
|
221
|
+
print("selftest: no repository profile to parse the fixtures with", file=sys.stderr)
|
|
222
|
+
return 1
|
|
223
|
+
profile_name = profiles[0]
|
|
224
|
+
fixtures = [
|
|
225
|
+
("configs/fixtures/review_approve.json", "APPROVE", PASS_MARKER, 0),
|
|
226
|
+
("configs/fixtures/review_changes.json", "CHANGES", STOP_MARKER, 1),
|
|
227
|
+
("configs/fixtures/review_no_verdict.json", None, STOP_MARKER, 1),
|
|
228
|
+
]
|
|
229
|
+
for path, expected_verdict, expected_marker, expected_rc in fixtures:
|
|
230
|
+
full_path = os.path.join(TEMPLATES_DIR, path)
|
|
231
|
+
if not os.path.exists(full_path):
|
|
232
|
+
print(f"selftest: missing fixture {path}", file=sys.stderr)
|
|
233
|
+
return 1
|
|
234
|
+
raw = open(full_path, encoding="utf-8").read()
|
|
235
|
+
text = extract_text(raw)
|
|
236
|
+
verdict = verdict_of(text)
|
|
237
|
+
if verdict != expected_verdict:
|
|
238
|
+
print(f"selftest failed for {path}: expected verdict {expected_verdict!r}, got {verdict!r}", file=sys.stderr)
|
|
239
|
+
return 1
|
|
240
|
+
# Run main() on all three fixtures to assert full end-to-end exit code and output
|
|
241
|
+
for path, expected_verdict, expected_marker, expected_rc in fixtures:
|
|
242
|
+
full_path = os.path.join(TEMPLATES_DIR, path)
|
|
243
|
+
proc = subprocess.run(
|
|
244
|
+
[sys.executable, "-m", "loopgate.gates.review_pr", "--profile", profile_name, "--pr", "0", "--result-file", full_path],
|
|
245
|
+
capture_output=True, text=True
|
|
246
|
+
)
|
|
247
|
+
if proc.returncode != expected_rc:
|
|
248
|
+
print(f"selftest failed for {path}: expected rc {expected_rc}, got {proc.returncode}. Output:\n{proc.stdout}\n{proc.stderr}", file=sys.stderr)
|
|
249
|
+
return 1
|
|
250
|
+
if expected_marker not in proc.stdout and expected_marker not in proc.stderr:
|
|
251
|
+
print(f"selftest failed for {path}: marker {expected_marker} not found in output", file=sys.stderr)
|
|
252
|
+
return 1
|
|
253
|
+
if expected_verdict is None:
|
|
254
|
+
if "the reviewer did not return a VERDICT line" not in proc.stderr:
|
|
255
|
+
print(f"selftest failed for {path}: did not name missing verdict line in stderr", file=sys.stderr)
|
|
256
|
+
return 1
|
|
257
|
+
|
|
258
|
+
# Assert that --post is NOT attempted when the verdict is missing
|
|
259
|
+
proc_post = subprocess.run(
|
|
260
|
+
[sys.executable, "-m", "loopgate.gates.review_pr", "--profile", profile_name, "--pr", "0",
|
|
261
|
+
"--result-file", os.path.join(TEMPLATES_DIR, "configs", "fixtures", "review_no_verdict.json"), "--post"],
|
|
262
|
+
capture_output=True, text=True
|
|
263
|
+
)
|
|
264
|
+
if proc_post.returncode != 1:
|
|
265
|
+
print("selftest failed: --post with missing verdict must exit 1", file=sys.stderr)
|
|
266
|
+
return 1
|
|
267
|
+
if "post:" in proc_post.stdout or "post:" in proc_post.stderr:
|
|
268
|
+
print("selftest failed: --post attempted despite missing verdict", file=sys.stderr)
|
|
269
|
+
return 1
|
|
270
|
+
|
|
271
|
+
print("selftest: all fixture verdicts and stop conditions verified")
|
|
272
|
+
print(PASS_MARKER)
|
|
273
|
+
return 0
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def main(argv=None) -> int:
|
|
277
|
+
ap = argparse.ArgumentParser(description="Second-level review of a pull request via ywcrew.")
|
|
278
|
+
# Required in practice, but validated after the --selftest branch: a bare `--selftest` must be able to
|
|
279
|
+
# run without inventing a repository, while every other invocation still fails fast instead of
|
|
280
|
+
# defaulting to some arbitrary profile and pull request.
|
|
281
|
+
ap.add_argument("--profile", help="repo profile that names the repository (required unless --selftest)")
|
|
282
|
+
ap.add_argument("--pr", type=int, help="pull request number to review (required unless --selftest)")
|
|
283
|
+
ap.add_argument("--backend", default="agy")
|
|
284
|
+
ap.add_argument("--model", default="gemini-3.8-flash")
|
|
285
|
+
ap.add_argument("--panel", action="store_true", help="use ywcrew panel instead of one backend")
|
|
286
|
+
ap.add_argument("--post", action="store_true", help="post the verdict to the PR as a review")
|
|
287
|
+
ap.add_argument("--dry-run", action="store_true",
|
|
288
|
+
help="print the brief, dispatch nothing (ignored with --result-file: nothing is dispatched)")
|
|
289
|
+
ap.add_argument("--diff-file", help="review a diff from a file instead of asking gh (offline)")
|
|
290
|
+
ap.add_argument("--result-file", help="parse an existing `ywcrew result` JSON instead of dispatching")
|
|
291
|
+
ap.add_argument("--selftest", action="store_true", help="run offline fixture assertions, then exit")
|
|
292
|
+
args = ap.parse_args(argv)
|
|
293
|
+
|
|
294
|
+
if args.selftest:
|
|
295
|
+
return selftest()
|
|
296
|
+
|
|
297
|
+
if not args.profile or args.pr is None:
|
|
298
|
+
ap.error("--profile and --pr are required unless --selftest is given")
|
|
299
|
+
|
|
300
|
+
profile = profile_or_die(args.profile)
|
|
301
|
+
repo = profile["repo"]
|
|
302
|
+
|
|
303
|
+
if args.result_file:
|
|
304
|
+
try:
|
|
305
|
+
raw = open(args.result_file, encoding="utf-8").read()
|
|
306
|
+
except OSError as exc:
|
|
307
|
+
print(f"cannot read the result file: {exc}", file=sys.stderr)
|
|
308
|
+
print(STOP_MARKER)
|
|
309
|
+
return 1
|
|
310
|
+
run_id = "from --result-file"
|
|
311
|
+
else:
|
|
312
|
+
if not shutil.which("gh") and not args.diff_file:
|
|
313
|
+
print("gh is not installed: pass --diff-file or install the GitHub CLI", file=sys.stderr)
|
|
314
|
+
print(STOP_MARKER)
|
|
315
|
+
return 1
|
|
316
|
+
try:
|
|
317
|
+
if args.diff_file:
|
|
318
|
+
diff = open(args.diff_file, encoding="utf-8", errors="replace").read()
|
|
319
|
+
meta = {"title": os.path.basename(args.diff_file), "files": [], "baseRefName": "?",
|
|
320
|
+
"headRefName": "?"}
|
|
321
|
+
else:
|
|
322
|
+
meta = pr_meta(repo, args.pr)
|
|
323
|
+
diff = pr_diff(repo, args.pr)
|
|
324
|
+
except (RuntimeError, OSError) as exc:
|
|
325
|
+
print(f"cannot read the pull request: {exc}", file=sys.stderr)
|
|
326
|
+
print(STOP_MARKER)
|
|
327
|
+
return 1
|
|
328
|
+
|
|
329
|
+
bundle = [i["command"] for i in (worker_brief.bundle_commands(profile) if worker_brief else [])]
|
|
330
|
+
spec = build_task(profile, repo, args.pr, meta, diff, bundle)
|
|
331
|
+
|
|
332
|
+
if args.dry_run:
|
|
333
|
+
print(spec["diff"])
|
|
334
|
+
print()
|
|
335
|
+
print(f"--- not dispatched. backend={args.backend}:{args.model} panel={args.panel} ---")
|
|
336
|
+
print(PASS_MARKER)
|
|
337
|
+
return 0
|
|
338
|
+
|
|
339
|
+
try:
|
|
340
|
+
raw, run_id = dispatch(spec, args.backend, args.model, args.panel)
|
|
341
|
+
except (RuntimeError, OSError, subprocess.SubprocessError) as exc:
|
|
342
|
+
print(f"dispatch failed: {exc}", file=sys.stderr)
|
|
343
|
+
print(STOP_MARKER)
|
|
344
|
+
return 1
|
|
345
|
+
|
|
346
|
+
text = extract_text(raw)
|
|
347
|
+
verdict = verdict_of(text)
|
|
348
|
+
print(text.strip()[:6000])
|
|
349
|
+
print()
|
|
350
|
+
print(f"review run: {run_id}")
|
|
351
|
+
if not verdict:
|
|
352
|
+
print("the reviewer did not return a VERDICT line: an unreadable review is not an approval",
|
|
353
|
+
file=sys.stderr)
|
|
354
|
+
print(STOP_MARKER)
|
|
355
|
+
return 1
|
|
356
|
+
print(f"verdict: {verdict}")
|
|
357
|
+
if args.post:
|
|
358
|
+
print("post: " + post_review(repo, args.pr,
|
|
359
|
+
f"Second-level review ({args.backend}"
|
|
360
|
+
f"{':' + args.model if args.model else ''} via ywcrew)\n\n"
|
|
361
|
+
+ text.strip(), verdict))
|
|
362
|
+
if verdict == "APPROVE":
|
|
363
|
+
print(PASS_MARKER)
|
|
364
|
+
return 0
|
|
365
|
+
print(STOP_MARKER)
|
|
366
|
+
return 1
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
if __name__ == "__main__":
|
|
370
|
+
sys.exit(main())
|