Venice seats can be the worker: TIME_WORKER=venice:<seat>
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UpT7eWzeLhRwuYmsSQnbRb
This commit is contained in:
1 parent
889e93799e
commit
750d9a9fad
6 files changed
+196
-6
No files matched your search
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: time
|
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
|
# 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):
|
3. Settings (`gh variable list` first; set only missing ones):
|
||||||
- `gh variable set TIME_MODE --body dry-run`
|
- `gh variable set TIME_MODE --body dry-run`
|
||||||
- `gh variable set TIME_DAILY_CAP --body 5` — max agent launches per day
|
- `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)
|
- `TIME_WORKER`: `cursor` (default) or `venice:<seat> [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.
|
||||||
4. Cursor (the worker):
|
- 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 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).
|
- 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.
|
5. Optional, seats on Venice: `gh secret set VENICE_API_KEY`. Without it, `@seat` mentions reply that the key is missing.
|
||||||
|
|||||||
@@ -21,6 +21,21 @@ class World:
|
|||||||
for c in cards:
|
for c in cards:
|
||||||
self.issues.setdefault(c["number"], {**c, "state": "open", "labels": c.get("labels", ["ready"])})
|
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):
|
def labelled(self, name):
|
||||||
return [i for i in self.issues.values() if i["state"] == "open" and name in i["labels"]]
|
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"}}
|
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"):
|
if url.endswith("/v1/models"):
|
||||||
return {"models": [{"id": "composer-2.5"}, {"id": "claude-sonnet-5-5"}]}
|
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, "")
|
path = url.replace(tm.GH, "")
|
||||||
self.calls.append((method, path, body))
|
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":
|
if method == "POST" and path == "/repos/o/r/issues":
|
||||||
n = max([0, *self.issues]) + 1
|
n = max([0, *self.issues]) + 1
|
||||||
self.issues[n] = {"number": n, "title": body["title"], "body": body["body"], "state": "open", "labels": body["labels"]}
|
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()
|
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"]
|
w = run(wm, K); assert w.issues[1]["state"] == "closed" and w.issues[2]["labels"] == ["ready"]
|
||||||
print("ALL OK 3")
|
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")
|
||||||
+143
-1
@@ -358,6 +358,136 @@ def label(number: int, name: str) -> None:
|
|||||||
gh("POST", f"/repos/{REPO}/issues/{number}/labels", {"labels": [name]})
|
gh("POST", f"/repos/{REPO}/issues/{number}/labels", {"labels": [name]})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- worker: a Venice seat with hands ----------
|
||||||
|
# TIME_WORKER=venice:<seat> [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\n<sub>worker: Venice seat via Time</sub>"})
|
||||||
|
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 ----------
|
# ---------- heartbeat ----------
|
||||||
|
|
||||||
def beats_of(comments: list[dict]) -> list[dict]:
|
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)
|
cap = int(os.environ.get("TIME_DAILY_CAP") or 5)
|
||||||
stuck_h = float(os.environ.get("TIME_STUCK_HOURS") or 6)
|
stuck_h = float(os.environ.get("TIME_STUCK_HOURS") or 6)
|
||||||
worker_model = (os.environ.get("TIME_CURSOR_MODEL") or "").strip() or None
|
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)
|
now = dt.datetime.now(dt.timezone.utc)
|
||||||
today = now.date().isoformat()
|
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]
|
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
|
continue
|
||||||
if state in (None, "needs-done") and mode != "live":
|
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"
|
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})
|
"- Goes for real once `TIME_MODE=live`", {"beat": "would-dispatch", "mode": mode})
|
||||||
continue
|
continue
|
||||||
if state in (None, "needs-done", "would-dispatch") and mode == "live":
|
if state in (None, "needs-done", "would-dispatch") and mode == "live":
|
||||||
if launches_today >= cap:
|
if launches_today >= cap:
|
||||||
print(f"#{n}: daily cap reached")
|
print(f"#{n}: daily cap reached")
|
||||||
continue
|
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],
|
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)
|
base=default_branch(), done=done), model=worker_model)
|
||||||
launches_today += 1
|
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("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_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_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:<seat> for a seat with hands")
|
||||||
row("TIME_REVIEW", True, os.environ.get("TIME_REVIEW") or "off (default)")
|
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")
|
row("TIME_AUTOMERGE", True, (os.environ.get("TIME_AUTOMERGE") or "off (default)") + "; merges on Approve + green checks")
|
||||||
for name in ("queued", "spec", "needs-human"):
|
for name in ("queued", "spec", "needs-human"):
|
||||||
|
|||||||
@@ -21,4 +21,5 @@ jobs:
|
|||||||
TIME_MODE: ${{ vars.TIME_MODE }}
|
TIME_MODE: ${{ vars.TIME_MODE }}
|
||||||
TIME_DAILY_CAP: ${{ vars.TIME_DAILY_CAP }}
|
TIME_DAILY_CAP: ${{ vars.TIME_DAILY_CAP }}
|
||||||
TIME_REVIEW: ${{ vars.TIME_REVIEW }}
|
TIME_REVIEW: ${{ vars.TIME_REVIEW }}
|
||||||
|
TIME_WORKER: ${{ vars.TIME_WORKER }}
|
||||||
TIME_AUTOMERGE: ${{ vars.TIME_AUTOMERGE }}
|
TIME_AUTOMERGE: ${{ vars.TIME_AUTOMERGE }}
|
||||||
@@ -48,3 +48,5 @@ jobs:
|
|||||||
TIME_PROTECTED: ${{ vars.TIME_PROTECTED }}
|
TIME_PROTECTED: ${{ vars.TIME_PROTECTED }}
|
||||||
TIME_PARALLEL: ${{ vars.TIME_PARALLEL }}
|
TIME_PARALLEL: ${{ vars.TIME_PARALLEL }}
|
||||||
TIME_PLANNER: ${{ vars.TIME_PLANNER }}
|
TIME_PLANNER: ${{ vars.TIME_PLANNER }}
|
||||||
|
TIME_WORKER: ${{ vars.TIME_WORKER }}
|
||||||
|
TIME_WORKER_STEPS: ${{ vars.TIME_WORKER_STEPS }}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
# time
|
# 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`.
|
- **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).
|
- **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.
|
- **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.
|
- **Doctor** (`doctor.yml`, manual): runs the tests and checks label, settings and keys.
|
||||||
|
|||||||
Reference in new issue
Block a user