From 750d9a9fad26c53d507776c470d8dcb2f0887aed Mon Sep 17 00:00:00 2001 From: claude Date: Sun, 11 Oct 2026 12:38:00 -0700 Subject: [PATCH] Venice seats can be the worker: TIME_WORKER=venice: A seat gets hands through the repo's own Actions token: list/read/write files on branch time/card-N via the contents API, run shell on a checkout, open the PR. Same contract as the Cursor worker (PR body says Closes #N), so review, landing and next-card are unchanged. Workflow files are refused to the seat. Needs only VENICE_API_KEY; no Cursor key. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01UpT7eWzeLhRwuYmsSQnbRb --- .claude/skills/time/SKILL.md | 7 +- .github/time/test_time.py | 44 ++++++++++ .github/time/time.py | 144 +++++++++++++++++++++++++++++++- .github/workflows/doctor.yml | 1 + .github/workflows/heartbeat.yml | 2 + README.md | 4 +- 6 files changed, 196 insertions(+), 6 deletions(-) diff --git a/.claude/skills/time/SKILL.md b/.claude/skills/time/SKILL.md index b158760..024aad8 100644 --- a/.claude/skills/time/SKILL.md +++ b/.claude/skills/time/SKILL.md @@ -1,6 +1,6 @@ --- name: time -description: Set up and run Time, the GitHub-native agent factory in this repo (a spec becomes cards, a Cursor cloud agent builds each one, a reviewer approves, Time merges on green and starts the next card; @seat mentions; an hourly heartbeat in GitHub Actions). Use when asked to set up Time or the factory, connect Cursor, check its status, write a spec or a card, tag a seat, switch it to live or hands-off, or update Time. +description: Set up and run Time, the GitHub-native agent factory in this repo (a spec becomes cards, a worker (a Venice seat with hands, or a Cursor cloud agent) builds each one, a reviewer approves, Time merges on green and starts the next card; @seat mentions; an hourly heartbeat in GitHub Actions). Use when asked to set up Time or the factory, connect Cursor, check its status, write a spec or a card, tag a seat, switch it to live or hands-off, or update Time. --- # Time @@ -37,8 +37,9 @@ Files: `.github/workflows/{heartbeat,mention,doctor,update}.yml`, `.github/time/ 3. Settings (`gh variable list` first; set only missing ones): - `gh variable set TIME_MODE --body dry-run` - `gh variable set TIME_DAILY_CAP --body 5` — max agent launches per day - - optional `TIME_CURSOR_MODEL` (worker model id; unset = Cursor's default), `TIME_REVIEW`, `TIME_STUCK_HOURS` (default 6) -4. Cursor (the worker): + - `TIME_WORKER`: `cursor` (default) or `venice: [model] [effort]`, e.g. `venice:kimi code`. A Venice seat needs only `VENICE_API_KEY`: it reads and writes the repo on branch `time/card-N` through the GitHub API, runs tests on the runner, and opens the PR itself. Workflow files are refused to it. + - optional `TIME_CURSOR_MODEL` (Cursor worker model id), `TIME_REVIEW`, `TIME_STUCK_HOURS` (default 6), `TIME_WORKER_STEPS` (default 40 tool calls per card) +4. Cursor (the worker, only when `TIME_WORKER` is `cursor`): - The human installs Cursor's GitHub app on this repo (Cursor dashboard → Integrations → GitHub). Cloud agents can't clone or open PRs without it. You can't check this from `gh`; ask them to confirm. - The human creates an API key (Cursor dashboard → API Keys) and stores it: `gh secret set CURSOR_API_KEY` (they paste the value at the prompt). 5. Optional, seats on Venice: `gh secret set VENICE_API_KEY`. Without it, `@seat` mentions reply that the key is missing. diff --git a/.github/time/test_time.py b/.github/time/test_time.py index 5653ed0..e0c00c6 100644 --- a/.github/time/test_time.py +++ b/.github/time/test_time.py @@ -21,6 +21,21 @@ class World: for c in cards: self.issues.setdefault(c["number"], {**c, "state": "open", "labels": c.get("labels", ["ready"])}) + self.files, self.writes, self.opened, self.turn = {"README.md": "hi", "app.py": "x = 1\n"}, [], [], 0 + + def venice(self, body): + """Scripted seat: list, read, write, open_pr.""" + self.turn += 1 + tc = lambda name, args: {"id": f"c{self.turn}", "type": "function", "function": {"name": name, "arguments": json.dumps(args)}} + script = [tc("list_files", {}), tc("read_file", {"path": "app.py"}), + tc("write_file", {"path": "app.py", "content": "x = 2\n"}), + tc("write_file", {"path": ".github/workflows/evil.yml", "content": "nope"}), + tc("open_pr", {"title": "Set x to 2", "summary": "Changed app.py; ran nothing."})] + assert body["tools"] and body["messages"][0]["role"] == "system" + if self.turn <= len(script): + return {"choices": [{"message": {"role": "assistant", "content": None, "tool_calls": [script[self.turn - 1]]}}]} + return {"choices": [{"message": {"role": "assistant", "content": "done"}}]} + def labelled(self, name): return [i for i in self.issues.values() if i["state"] == "open" and name in i["labels"]] @@ -31,8 +46,21 @@ class World: self.launched.append(body); return {"agent": {"id": f"a{len(self.launched)}", "url": "https://cursor.com/agents/x"}, "run": {"status": "RUNNING"}} if url.endswith("/v1/models"): return {"models": [{"id": "composer-2.5"}, {"id": "claude-sonnet-5-5"}]} + if url.startswith(tm.VENICE): + return self.venice(body) path = url.replace(tm.GH, "") self.calls.append((method, path, body)) + if "/git/ref/heads/" in path: return {"object": {"sha": "base"}} + if path.endswith("/git/refs") and method == "POST": self.branches = getattr(self, "branches", []) + [body["ref"]]; return {} + if "/git/trees/" in path: return {"tree": [{"path": k, "type": "blob"} for k in self.files]} + if "/contents/" in path: + fp = path.split("/contents/")[1].split("?")[0] + if method == "GET": + if fp not in self.files: raise RuntimeError(f"GET {path} -> 404: not found") + import base64; return {"sha": "s", "content": base64.b64encode(self.files[fp].encode()).decode()} + import base64; self.files[fp] = base64.b64decode(body["content"]).decode(); self.writes.append((fp, body.get("sha"))); return {} + if path == "/repos/o/r/pulls" and method == "POST": + self.opened.append(body); return {"number": 42} if method == "POST" and path == "/repos/o/r/issues": n = max([0, *self.issues]) + 1 self.issues[n] = {"number": n, "title": body["title"], "body": body["body"], "state": "open", "labels": body["labels"]} @@ -205,3 +233,19 @@ assert w.posted == [] and not any(c[0] == "PUT" for c in w.calls) wm = pr_world(); wm.gh_pr[7]["pr"]["merged_at"] = iso() w = run(wm, K); assert w.issues[1]["state"] == "closed" and w.issues[2]["labels"] == ["ready"] print("ALL OK 3") + +# 14 TIME_WORKER=venice:kimi -> the seat edits on its branch, is refused on workflows, opens a PR that closes the card +w = run(World([card(1, D)], {1: [beat("would-dispatch")]}), {**K, "TIME_WORKER": "venice:kimi code", "VENICE_API_KEY": "vk"}) +assert w.launched == [], "no Cursor launch" +assert w.branches == ["refs/heads/time/card-1"] and w.files["app.py"] == "x = 2\n" and w.writes == [("app.py", "s")] +assert ".github/workflows/evil.yml" not in w.files +assert len(w.opened) == 1 and w.opened[0]["head"] == "time/card-1" and "Closes #1" in w.opened[0]["body"] +assert "opened PR #42" in w.posted[-1][1] and "kimi-k2-7-code" in w.posted[-1][1] +# then the normal path links the PR and reviews it +w2 = run(World([card(1, D)], {1: [beat("dispatched", launched=True, worker="venice", pr=42)]}, + [{"number": 42, "title": "Set x to 2", "body": "Closes #1", "html_url": "u"}]), K) +assert "PR #42" in w2.posted[0][1] +# missing key -> blocked, no crash +w3 = run(World([card(1, D)], {1: [beat("would-dispatch")]}), {"TIME_MODE": "live", "TIME_WORKER": "venice:kimi"}) +assert "VENICE_API_KEY" in w3.posted[-1][1] +print("ALL OK 4") diff --git a/.github/time/time.py b/.github/time/time.py index c6b921d..3cc9ede 100644 --- a/.github/time/time.py +++ b/.github/time/time.py @@ -358,6 +358,136 @@ def label(number: int, name: str) -> None: gh("POST", f"/repos/{REPO}/issues/{number}/labels", {"labels": [name]}) +# ---------- worker: a Venice seat with hands ---------- +# TIME_WORKER=venice: [model] [effort]. The seat edits the repo on its own branch through the GitHub contents +# API (the Actions token already has contents: write), runs shell steps on the runner's checkout, and opens the PR +# itself. Same contract as the Cursor worker: a PR whose body says "Closes #N". + +import base64 +import subprocess +import tempfile + +WORKER_TOOLS = [ + {"type": "function", "function": {"name": "list_files", "description": "List repository files (paths), optionally under a directory.", + "parameters": {"type": "object", "properties": {"dir": {"type": "string"}}, "required": []}}}, + {"type": "function", "function": {"name": "read_file", "description": "Read one file from the branch.", + "parameters": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}}}, + {"type": "function", "function": {"name": "write_file", "description": "Create or replace one file on the branch with the full new content (commits it).", + "parameters": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}, + "message": {"type": "string"}}, "required": ["path", "content"]}}}, + {"type": "function", "function": {"name": "run", "description": "Run a shell command on a checkout of the branch (tests, lint). Returns exit code and output.", + "parameters": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}}}, + {"type": "function", "function": {"name": "open_pr", "description": "Open the pull request when the work is done and checked. Call this exactly once, last.", + "parameters": {"type": "object", "properties": {"title": {"type": "string"}, "summary": {"type": "string", + "description": "What changed and how you checked it."}}, "required": ["title", "summary"]}}}, +] + +WORKER_RULES = """You are the worker for card #{n} in {repo}: "{title}". + +{body} + +You have hands: list_files, read_file, write_file (one file per call, full content), run (shell on a checkout of your +branch), open_pr. Work on the branch `{branch}`; everything you write lands there. Keep the change small and focused. +Read before you write. Run the project's tests or checks if any exist. When it is done and checked, call open_pr once +with a short summary; the PR must close the card, and Time adds that line for you. Do not touch .github/workflows. +The card's finish line is: DONE: {done}""" + + +class Hands: + def __init__(self, n: int, branch: str): + self.n, self.branch, self.pr, self.steps = n, branch, None, 0 + base = gh("GET", f"/repos/{REPO}/git/ref/heads/{default_branch()}")["object"]["sha"] + try: + gh("POST", f"/repos/{REPO}/git/refs", {"ref": f"refs/heads/{branch}", "sha": base}) + except RuntimeError as e: + if "422" not in str(e): + raise + self.checkout = None + + def _checkout(self) -> str: + d = tempfile.mkdtemp(prefix="time-worker-") + url = f"https://x-access-token:{os.environ['GITHUB_TOKEN']}@github.com/{REPO}" + subprocess.run(["git", "clone", "-q", "--depth", "1", "--branch", self.branch, url, d], check=True, capture_output=True) + return d + + def call(self, name: str, a: dict) -> str: + self.steps += 1 + if name == "list_files": + tree = gh("GET", f"/repos/{REPO}/git/trees/{self.branch}?recursive=1").get("tree", []) + d = (a.get("dir") or "").strip("/") + paths = [t["path"] for t in tree if t["type"] == "blob" and (not d or t["path"].startswith(d + "/"))] + return "\n".join(paths[:400]) or "(empty)" + if name == "read_file": + try: + f = gh("GET", f"/repos/{REPO}/contents/{a['path']}?ref={self.branch}") + except RuntimeError as e: + return f"error: {e}" + return base64.b64decode(f["content"]).decode(errors="replace")[:60000] + if name == "write_file": + if a["path"].startswith(".github/workflows"): + return "refused: workflow files are a human's to change" + body = {"message": a.get("message") or f"card #{self.n}: update {a['path']}", "branch": self.branch, + "content": base64.b64encode(a["content"].encode()).decode()} + try: + body["sha"] = gh("GET", f"/repos/{REPO}/contents/{a['path']}?ref={self.branch}")["sha"] + except RuntimeError: + pass + gh("PUT", f"/repos/{REPO}/contents/{a['path']}", body) + self.checkout = None + return f"wrote {a['path']}" + if name == "run": + self.checkout = self.checkout or self._checkout() + p = subprocess.run(["bash", "-lc", a["command"]], cwd=self.checkout, capture_output=True, text=True, timeout=600) + return f"exit {p.returncode}\n{(p.stdout + p.stderr)[-6000:]}" + if name == "open_pr": + pr = gh("POST", f"/repos/{REPO}/pulls", {"title": a["title"][:200], "head": self.branch, "base": default_branch(), + "body": f"{a['summary']}\n\nCloses #{self.n}\n\nworker: Venice seat via Time"}) + self.pr = pr["number"] + return f"opened PR #{self.pr}" + return f"unknown tool {name}" + + +def venice_work(card: dict, done: str, spec: str) -> dict: + """Run one card with a Venice seat as the worker. Returns {pr, steps, seat, model}.""" + words = spec.split() + seat = words[0] if words and words[0] in SEATS else "kimi" + model, effort = mentions(f"@{spec}").get(seat, (None, None)) if seat in SEATS else (None, None) + model = model or SEATS[seat].get("aliases", {}).get("code") or SEATS[seat]["default"] + key = os.environ.get("VENICE_API_KEY") + if not key: + raise LookupError("no VENICE_API_KEY secret on this repo yet") + hands = Hands(card["number"], f"time/card-{card['number']}") + msgs = [{"role": "system", "content": WORKER_RULES.format(n=card["number"], repo=REPO, title=card["title"], + body=(card.get("body") or "")[:20000], branch=hands.branch, done=done)}, + {"role": "user", "content": "Begin. Look at the repository first."}] + for _ in range(int(os.environ.get("TIME_WORKER_STEPS") or 40)): + body = {"model": model, "temperature": 0.2, "max_tokens": 6000, "messages": msgs, "tools": WORKER_TOOLS, + "venice_parameters": {"include_venice_system_prompt": False}} + if effort: + body["reasoning_effort"] = effort + msg = http("POST", f"{VENICE}/chat/completions", key, body, timeout=300)["choices"][0]["message"] + msgs.append({k: v for k, v in msg.items() if k in ("role", "content", "tool_calls")}) + calls = msg.get("tool_calls") or [] + if not calls: + if hands.pr: + break + msgs.append({"role": "user", "content": "You have not opened the PR. Finish the work and call open_pr, or explain what blocks you."}) + continue + for c in calls: + try: + args = json.loads(c["function"].get("arguments") or "{}") + except ValueError: + args = {} + out = hands.call(c["function"]["name"], args) + msgs.append({"role": "tool", "tool_call_id": c["id"], "content": out}) + if hands.pr: + break + if not hands.pr: + last = next((m.get("content") for m in reversed(msgs) if m.get("role") == "assistant" and m.get("content")), "") + raise RuntimeError(f"{seat} stopped without a PR after {hands.steps} steps: {(last or '')[:200]}") + return {"pr": hands.pr, "steps": hands.steps, "seat": seat, "model": model} + + # ---------- heartbeat ---------- def beats_of(comments: list[dict]) -> list[dict]: @@ -437,6 +567,9 @@ def tick() -> None: cap = int(os.environ.get("TIME_DAILY_CAP") or 5) stuck_h = float(os.environ.get("TIME_STUCK_HOURS") or 6) worker_model = (os.environ.get("TIME_CURSOR_MODEL") or "").strip() or None + worker = (os.environ.get("TIME_WORKER") or "cursor").strip() + worker_name = f"Venice seat `{worker.split(':', 1)[1].split()[0]}`" if worker.startswith("venice:") else \ + f"Cursor cloud agent{f' ({worker_model})' if worker_model else ''}" now = dt.datetime.now(dt.timezone.utc) today = now.date().isoformat() cards = [i for i in gh("GET", f"/repos/{REPO}/issues?state=open&labels=ready&per_page=50") if "pull_request" not in i] @@ -467,13 +600,21 @@ def tick() -> None: continue if state in (None, "needs-done") and mode != "live": comment(n, f"⏱️ Time (dry run): would dispatch this to the worker now.\n\n- DONE: {done}\n" - f"- Worker: Cursor cloud agent{f' ({worker_model})' if worker_model else ''}, opens a PR that closes #{n}\n" + f"- Worker: {worker_name}, opens a PR that closes #{n}\n" "- Goes for real once `TIME_MODE=live`", {"beat": "would-dispatch", "mode": mode}) continue if state in (None, "needs-done", "would-dispatch") and mode == "live": if launches_today >= cap: print(f"#{n}: daily cap reached") continue + if worker.startswith("venice:"): + comment(n, f"⏱️ Time: {worker_name} is working on this now, on branch `time/card-{n}`.", + {"beat": "dispatched", "launched": True, "worker": "venice"}) + launches_today += 1 + r = venice_work(card, done, worker.split(":", 1)[1]) + comment(n, f"⏱️ Time: `{r['seat']}` opened PR #{r['pr']} ({r['steps']} steps, {r['model']}). " + "Review and landing follow on the next beat.", {"beat": "dispatched", "worker": "venice", **r}) + continue r = cursor_launch(BUILD_PROMPT.format(n=n, repo=REPO, title=card["title"], body=(card.get("body") or "")[:20000], base=default_branch(), done=done), model=worker_model) launches_today += 1 @@ -523,6 +664,7 @@ def cmd_doctor() -> int: row("label `ready`", "ready" in labels, "exists" if "ready" in labels else "missing: `gh label create ready`") row("TIME_MODE", True, os.environ.get("TIME_MODE") or "dry-run (default)") row("TIME_DAILY_CAP", True, os.environ.get("TIME_DAILY_CAP") or "5 (default)") + row("TIME_WORKER", True, os.environ.get("TIME_WORKER") or "cursor (default); or venice: for a seat with hands") row("TIME_REVIEW", True, os.environ.get("TIME_REVIEW") or "off (default)") row("TIME_AUTOMERGE", True, (os.environ.get("TIME_AUTOMERGE") or "off (default)") + "; merges on Approve + green checks") for name in ("queued", "spec", "needs-human"): diff --git a/.github/workflows/doctor.yml b/.github/workflows/doctor.yml index 908180b..f9d6f87 100644 --- a/.github/workflows/doctor.yml +++ b/.github/workflows/doctor.yml @@ -21,4 +21,5 @@ jobs: TIME_MODE: ${{ vars.TIME_MODE }} TIME_DAILY_CAP: ${{ vars.TIME_DAILY_CAP }} TIME_REVIEW: ${{ vars.TIME_REVIEW }} + TIME_WORKER: ${{ vars.TIME_WORKER }} TIME_AUTOMERGE: ${{ vars.TIME_AUTOMERGE }} diff --git a/.github/workflows/heartbeat.yml b/.github/workflows/heartbeat.yml index c153bc6..3d4cd55 100644 --- a/.github/workflows/heartbeat.yml +++ b/.github/workflows/heartbeat.yml @@ -48,3 +48,5 @@ jobs: TIME_PROTECTED: ${{ vars.TIME_PROTECTED }} TIME_PARALLEL: ${{ vars.TIME_PARALLEL }} TIME_PLANNER: ${{ vars.TIME_PLANNER }} + TIME_WORKER: ${{ vars.TIME_WORKER }} + TIME_WORKER_STEPS: ${{ vars.TIME_WORKER_STEPS }} diff --git a/README.md b/README.md index 2efea02..67c4f50 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ # time -A GitHub-native agent factory. GitHub is the ledger, GitHub-hosted runners are the clock (Time), a Cursor cloud agent is the worker, and chat is where you plan. +A GitHub-native agent factory. GitHub is the ledger, GitHub-hosted runners are the clock (Time), the worker is a Venice seat with hands or a Cursor cloud agent, and chat is where you plan. - **Cards**: an issue with a `DONE:` line, labelled `ready`. -- **Heartbeat** (`heartbeat.yml`, hourly and whenever a card is labelled `ready`): moves cards forward. In `dry-run` (default) it only says what it would do; with `TIME_MODE=live` it dispatches each card to a Cursor cloud agent, which opens a PR that closes the card. An optional reviewer (`TIME_REVIEW`) checks the PR. At most `TIME_DAILY_CAP` launches a day. +- **Heartbeat** (`heartbeat.yml`, hourly and whenever a card is labelled `ready`): moves cards forward. In `dry-run` (default) it only says what it would do; with `TIME_MODE=live` it dispatches each card to the worker (`TIME_WORKER`: a Venice seat such as `venice:kimi`, which edits the repo and opens the PR itself, or a Cursor cloud agent), which opens a PR that closes the card. An optional reviewer (`TIME_REVIEW`) checks the PR. At most `TIME_DAILY_CAP` launches a day. - **Mentions** (`mention.yml`): `@kimi`, `@grok 4.7`, `@qwen max high` in any issue, PR or comment gets that seat's reply (Venice). - **Hands-off** (`TIME_AUTOMERGE=on`): label an issue `spec` and Time splits it into cards that run one at a time. A card's PR merges itself once the reviewer approves and checks are green, then the next card starts. Objections, red checks and protected paths (workflows, deploy, secrets) go to a human. - **Doctor** (`doctor.yml`, manual): runs the tests and checks label, settings and keys.