Hands-off Time: spec planner, auto-merge on Approve + green checks, next card, update workflow

- spec issues (label spec) become queued cards, parsed deterministically, seat fallback
- TIME_AUTOMERGE=on: squash-merge when the latest trusted verdict is Approve and all checks pass
- Object, red or missing checks, or protected paths go to needs-human
- merged card frees the slot; the next unblocked queued card gets ready and dispatches in the same run
- update.yml pulls the engine from teahubxyz/time into project repos via PR
- ci.yml runs Time's own tests

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UpT7eWzeLhRwuYmsSQnbRb
This commit is contained in:
claude committed 2026-10-11 11:46:43 -07:00
1 parent 9108adf4c6
commit 889e93799e
9 files changed
+379 -14

No files matched your search

+31 -5
View File
@@ -1,6 +1,6 @@
---
name: time
description: Set up and run Time, the GitHub-native agent factory in this repo (issues as cards, a `ready` label, a DONE line, a Cursor cloud agent as the worker, @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 card, tag a seat, or switch it to live.
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.
---
# Time
@@ -11,6 +11,8 @@ You (Claude Code, Cursor, or any agent with `gh`) are the hands for setup and th
## You are here
```
chat ─writes─▶ spec issue ─label `spec`─▶ planner ─▶ cards (`queued`, one PR each, in order)
│ first unblocked card gets `ready`
chat ─writes─▶ card = issue (spec + DONE:) ─label `ready`─▶ heartbeat (on label + hourly)
│
dry-run ◀─────────── TIME_MODE ───────────▶ live ▼
@@ -18,17 +20,20 @@ You (Claude Code, Cursor, or any agent with `gh`) are the hands for setup and th
│ next heartbeat finds it
▼
review (TIME_REVIEW): Cursor model | Venice seat | off
│ TIME_AUTOMERGE=on
Approve + checks green ─▶ squash merge ─▶ next `queued` card gets `ready`
Object · protected path · red checks ─▶ `needs-human`
@seat [model] [effort] in any comment ─▶ mention.yml ─▶ seat replies in the thread (Venice)
```
Card states (Time's comments on the issue): `would-dispatch` → `dispatched` → `pr` · side states `needs-done`, `stuck`, `blocked` (a key is missing; resumes by itself once added), `error` (comment `/time retry`).
Card states (Time's comments on the issue): `would-dispatch` → `dispatched` → `pr` → `merged` · landing states `needs-human`, `no-checks`, `checks-failed` · side states `needs-done`, `stuck`, `blocked` (a key is missing; resumes by itself once added), `error` (comment `/time retry`).
Files: `.github/workflows/{heartbeat,mention,doctor}.yml`, `.github/time/time.py` (stdlib), `.github/time/seats.json`, `.github/time/test_time.py` (offline tests).
Files: `.github/workflows/{heartbeat,mention,doctor,update}.yml`, `.github/time/time.py` (stdlib), `.github/time/seats.json`, `.github/time/test_time.py` (offline tests).
## Setup (idempotent; do only what's missing)
1. `gh auth status` — logged in with admin on this repo. If not: ask the human to run `gh auth login`.
2. Label: `gh label create ready --color 0e8a16 --description "Time picks this up on the next heartbeat" || true`
2. Labels: `gh label create ready --color 0e8a16 --description "Time picks this up on the next heartbeat" || true`, and for hands-off mode `gh label create spec --color 5319e7 || true; gh label create queued --color c5def5 || true; gh label create planned --color ededed || true; gh label create needs-human --color d93f0b || true`
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
@@ -72,6 +77,27 @@ Label it `ready`. No `DONE:` line → Time asks for one instead of dispatching.
Not a Time seat: `@cursor`. If Cursor's GitHub app is installed, `@cursor` in a comment is Cursor's own integration and it answers directly.
## Hands-off: spec in, merged work out
1. CI: the project needs at least one check on pull requests (tests or lint). Without checks Time won't merge
(`TIME_REQUIRE_CHECKS=off` overrides, not recommended). Add `.github/workflows/ci.yml` for the project's stack.
2. A reviewer: `gh variable set TIME_REVIEW --body cursor:<model id>` (or `venice:kimi`).
3. `gh variable set TIME_AUTOMERGE --body on`. Time squash-merges a card's PR when the reviewer's latest verdict is
Approve and every check is green, then gives `ready` to the next card. Approve/Object counts from bots and from the
repo's owner, members and collaborators only.
4. Human gate: PRs touching `TIME_PROTECTED` paths (default `.github/,deploy,infra/,.env,secret`) are never merged by
Time; they get `needs-human`. So are PRs the reviewer objects to.
5. Specs: write an issue, label it `spec`. One card per line, `- title — DONE: what finished means`, or `### title`
sections that contain a `DONE:` line. Time creates `queued` cards that run one at a time (`TIME_PARALLEL`, default 1).
A spec that isn't card-shaped is split by a seat when `VENICE_API_KEY` is set (`TIME_PLANNER`, default kimi).
## Updating Time
The engine lives centrally in teahubxyz/time. `update.yml` (weekly, or `gh workflow run update.yml`) opens a PR with
the latest `time.py`, tests, seats and this skill from `TIME_UPSTREAM`@`TIME_CHANNEL` (default `main`; pin a tag
for stability). Actions can't edit workflow files, so the PR lists any that changed for you to copy. Improvements go
to teahubxyz/time as PRs so every project gets them. Needs "Allow GitHub Actions to create pull requests" on.
## Going live
Only when the human says so: `gh variable set TIME_MODE --body live`. The daily cap still applies. Back to `dry-run` the same way. Stop everything: `gh workflow disable heartbeat.yml`.
@@ -79,7 +105,7 @@ Only when the human says so: `gh variable set TIME_MODE --body live`. The daily
## Rules
- Never print, echo, commit or paste a secret. Secrets go in with `gh secret set`, values typed by the human.
- Ask before: going live, raising the daily cap, changing the heartbeat schedule, adding a self-hosted runner. A public repo must never get a self-hosted runner.
- Ask before: going live, turning on auto-merge, raising the daily cap, changing the heartbeat schedule, adding a self-hosted runner. A public repo must never get a self-hosted runner.
- Runner minutes: private repos have a monthly free allowance, each job rounds up to a minute; keep the heartbeat hourly unless asked. Cursor agent runs bill to the human's Cursor plan.
- Changed `time.py`? Run `python3 .github/time/test_time.py` before committing.
- Status: `gh issue list -l ready`, `gh run list -L 10`, and the latest Time comment on each card.
+92 -4
View File
@@ -12,9 +12,17 @@ iso = lambda h=0: (NOW - dt.timedelta(hours=h)).strftime("%Y-%m-%dT%H:%M:%SZ")
class World:
def __init__(self, cards, comments=None, prs=None):
def __init__(self, cards, comments=None, prs=None, issues=None, gh_pr=None):
self.cards, self.comments, self.prs = cards, comments or {}, prs or []
self.posted, self.launched = [], []
self.issues = issues or {} # extra issues by number: {"state", "labels", "body", ...}
self.gh_pr = gh_pr or {} # number -> {"pr", "reviews", "files", "checks"}
self.calls = []
for c in cards:
self.issues.setdefault(c["number"], {**c, "state": "open", "labels": c.get("labels", ["ready"])})
def labelled(self, name):
return [i for i in self.issues.values() if i["state"] == "open" and name in i["labels"]]
def http(self, method, url, token, body=None, timeout=0):
if url.startswith(tm.CURSOR):
@@ -24,15 +32,40 @@ class World:
if url.endswith("/v1/models"):
return {"models": [{"id": "composer-2.5"}, {"id": "claude-sonnet-5-5"}]}
path = url.replace(tm.GH, "")
self.calls.append((method, path, body))
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"]}
return {"number": n}
if path.startswith("/repos/o/r/issues/") and "/labels" in path:
n = int(path.split("/")[5]); self.issues.setdefault(n, {"number": n, "state": "open", "labels": []})
if method == "DELETE": self.issues[n]["labels"].remove(path.rsplit("/", 1)[1])
else: self.issues[n]["labels"] += body["labels"]
return {}
if method == "PATCH": self.issues[int(path.split("/")[-1])].update(body); return {}
if method == "PUT" and path.endswith("/merge"):
self.gh_pr[int(path.split("/")[-2])]["pr"]["merged_at"] = iso(); return {"merged": True}
if "/pulls/" in path:
n = int(path.split("/")[5]); d = self.gh_pr[n]
if path.endswith("/reviews?per_page=100"): return d.get("reviews", [])
if path.endswith("/files?per_page=100"): return [{"filename": f} for f in d.get("files", ["src/x.py"])]
return d["pr"]
if "/check-runs" in path:
return {"check_runs": next(d.get("checks", []) for d in self.gh_pr.values() if d["pr"]["head"]["sha"] in path)}
if "/issues?state=open" in path:
lab = path.split("labels=")[1].split("&")[0]
return [{"number": i["number"], "title": i.get("title", ""), "body": i.get("body", ""), "state": "open"}
for i in sorted(self.labelled(lab), key=lambda i: i["number"])]
if method == "GET" and path.startswith("/repos/o/r/issues/") and path.count("/") == 5:
return self.issues[int(path.split("/")[-1])]
if method == "POST" and path.endswith("/comments"):
n = int(path.split("/")[-2]); self.posted.append((n, body["body"]))
self.comments.setdefault(n, []).append({"created_at": iso(), "user": {"login": "github-actions[bot]", "type": "Bot"}, "body": body["body"]})
return {}
if "/issues?state=open" in path: return self.cards
if "/pulls?state=all" in path: return self.prs
if "/issues/" in path and path.endswith("comments?per_page=100"): return self.comments.get(int(path.split("/")[-2]), [])
if path == "/repos/o/r": return {"default_branch": "main"}
if "/labels" in path: return [{"name": "ready"}]
if path.endswith("/labels?per_page=100"): return [{"name": "ready"}]
raise AssertionError(f"unexpected {method} {path}")
@@ -41,7 +74,8 @@ def beat(state, h=0, **kw):
def run(world, env):
for k in ("TIME_MODE", "CURSOR_API_KEY", "TIME_REVIEW", "TIME_DAILY_CAP", "TIME_CURSOR_MODEL"):
for k in ("TIME_MODE", "CURSOR_API_KEY", "TIME_REVIEW", "TIME_DAILY_CAP", "TIME_CURSOR_MODEL", "TIME_AUTOMERGE",
"TIME_REQUIRE_CHECKS", "VENICE_API_KEY"):
os.environ.pop(k, None)
os.environ.update(env)
tm.http = world.http
@@ -117,3 +151,57 @@ print("ALL OK")
w = run(World([card(1, D)], {1: [beat("would-dispatch", 2), beat("blocked", 1)]}), {"TIME_MODE": "live", "CURSOR_API_KEY": "ck"})
assert len(w.launched) == 1, w.posted
print("ALL OK 2")
# ---------- planner, landing, next card ----------
K = {"TIME_MODE": "live", "CURSOR_API_KEY": "ck"}
# 12 spec lines -> queued cards with dependencies; the first one is promoted and dispatched in the same run
spec_body = "Build orbit v0\n\n- [ ] Add login page — DONE: PR open, tests green\n- Add logout | DONE: PR open\n- Docs -- DONE: README updated"
w = run(World([], issues={1: {"number": 1, "title": "spec", "body": spec_body, "state": "open", "labels": ["spec"]}}), K)
made = [n for n in w.issues if n != 1]
assert made == [2, 3, 4] and w.issues[1]["labels"] == ["planned"], w.issues
assert "Depends on #2" in w.issues[3]["body"] and "DONE: README updated" in w.issues[4]["body"]
assert w.issues[2]["labels"] == ["ready"] and w.issues[3]["labels"] == ["queued"] and len(w.launched) == 1
assert "Add login page" in w.launched[0]["prompt"]["text"]
# section-style spec also parses
assert [c["title"] for c in tm.parse_spec("intro\n### One\nchange a\nDONE: x\n### Two\nDONE: y")] == ["One", "Two"]
# unparseable spec without a key -> one helpful comment, spec label removed
w = run(World([], issues={1: {"number": 1, "title": "s", "body": "make it nice", "state": "open", "labels": ["spec"]}}), K)
assert "couldn't find cards" in w.posted[0][1] and w.issues[1]["labels"] == []
# 13 landing: Approve from a trusted reviewer + green checks -> squash merge, card closed, next card dispatched
def pr_world(verdict_body="**Approve** looks right", assoc="NONE", bot=True, checks=None, files=None, extra=None):
prd = {"number": 7, "state": "open", "merged_at": None, "head": {"sha": "abc"}, "html_url": "u"}
issues = {2: {"number": 2, "title": "next", "body": "Depends on #1\n\nDONE: d", "state": "open", "labels": ["queued"]}}
cm = {1: [beat("pr", 1, pr=7)],
7: [{"created_at": iso(), "author_association": assoc, "user": {"type": "Bot" if bot else "User"}, "body": verdict_body}]}
return World([card(1, D)], cm, [], issues, {7: {"pr": prd, "checks": checks if checks is not None else
[{"status": "completed", "conclusion": "success"}], "files": files or ["src/a.py"]}})
w = run(pr_world(), {**K, "TIME_AUTOMERGE": "on"})
assert ("PUT", "/repos/o/r/pulls/7/merge", {"merge_method": "squash"}) in w.calls
assert w.issues[1]["state"] == "closed" and w.issues[2]["labels"] == ["ready"] and len(w.launched) == 1
# automerge off (default) -> never merges
w = run(pr_world(), K); assert not any(c[0] == "PUT" for c in w.calls) and w.launched == []
# a stranger's "Approve" comment on a public repo does nothing
w = run(pr_world(bot=False, assoc="NONE"), {**K, "TIME_AUTOMERGE": "on"}); assert not any(c[0] == "PUT" for c in w.calls)
# a collaborator's comment counts
w = run(pr_world(bot=False, assoc="COLLABORATOR"), {**K, "TIME_AUTOMERGE": "on"}); assert any(c[0] == "PUT" for c in w.calls)
# Object -> needs-human label, no merge
w = run(pr_world("**Object** missing tests"), {**K, "TIME_AUTOMERGE": "on"})
assert not any(c[0] == "PUT" for c in w.calls) and "objected" in w.posted[0][1]
# protected paths -> human merges
w = run(pr_world(files=[".github/workflows/ci.yml"]), {**K, "TIME_AUTOMERGE": "on"})
assert not any(c[0] == "PUT" for c in w.calls) and "protected paths" in w.posted[0][1]
# failing or missing checks block the merge even when approved
w = run(pr_world(checks=[{"status": "completed", "conclusion": "failure"}]), {**K, "TIME_AUTOMERGE": "on"})
assert not any(c[0] == "PUT" for c in w.calls) and "checks are failing" in w.posted[0][1]
w = run(pr_world(checks=[]), {**K, "TIME_AUTOMERGE": "on"})
assert not any(c[0] == "PUT" for c in w.calls) and "no checks" in w.posted[0][1]
w = run(pr_world(checks=[]), {**K, "TIME_AUTOMERGE": "on", "TIME_REQUIRE_CHECKS": "off"}); assert any(c[0] == "PUT" for c in w.calls)
# pending checks -> silent wait
w = run(pr_world(checks=[{"status": "in_progress", "conclusion": None}]), {**K, "TIME_AUTOMERGE": "on"})
assert w.posted == [] and not any(c[0] == "PUT" for c in w.calls)
# a human merged it by hand -> card closed, next card goes
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")
+171 -2
View File
@@ -1,7 +1,7 @@
"""Time: the clock for a GitHub-native agent factory. stdlib only; runs on GitHub-hosted runners.
python3 .github/time/time.py mention # on issue/comment events: answer @seat [model] [effort]
python3 .github/time/time.py heartbeat # on schedule: look at `ready` issues and dispatch (dry-run by default)
python3 .github/time/time.py heartbeat # on schedule: plan specs, dispatch `ready` cards, merge approved PRs, pull the next card
Ledger = this GitHub repo. Brains = Venice (VENICE_API_KEY secret). Seats and aliases: .github/time/seats.json.
Loop guards: bots and Time's own comments never trigger replies; comments posted with GITHUB_TOKEN don't start
@@ -207,6 +207,157 @@ def cursor_launch(prompt: str, *, model: str | None, pr_url: str | None = None,
"status": (out.get("run") or {}).get("status") or agent.get("status")}
# ---------- planner: a `spec` issue -> queued cards ----------
# Cascade: parse the spec deterministically first; only ask a seat when it isn't already card-shaped.
CARD_LINE = re.compile(r"^\s*[-*]\s*(?:\[ \]\s*)?(?P<title>.+?)\s+(?:—|--|-|\|)\s*DONE:\s*(?P<done>.+)$", re.I)
PLAN_PROMPT = """Split this spec into small cards, one pull request each, in build order.
Reply with ONLY a JSON list: [{"title": "...", "body": "what to change, as a short list", "done": "what finished means"}]"""
def parse_spec(body: str) -> list[dict]:
"""`- title — DONE: x` lines, or `### title` sections that contain a `DONE:` line."""
cards = [{"title": m["title"].strip(), "body": "", "done": m["done"].strip()}
for m in map(CARD_LINE.match, body.splitlines()) if m]
if cards:
return cards
for sec in re.split(r"^#{2,3}\s+", body, flags=re.M)[1:]:
title, _, rest = sec.partition("\n")
m = re.search(r"^\s*DONE:\s*(.+)$", rest, re.M | re.I)
if m:
cards.append({"title": title.strip(), "body": rest[:m.start()].strip(), "done": m.group(1).strip()})
return cards
def plan_spec(spec: dict) -> None:
n = spec["number"]
cards = parse_spec(spec.get("body") or "")
if not cards and os.environ.get("VENICE_API_KEY"):
seat = (os.environ.get("TIME_PLANNER") or "kimi").split()[0]
raw = think(seat, None, None, PLAN_PROMPT, f"# {spec['title']}\n{(spec.get('body') or '')[:20000]}")
try:
cards = json.loads(raw[raw.index("["): raw.rindex("]") + 1])
except ValueError:
cards = []
if not cards:
comment(n, "⏱️ Time: I couldn't find cards in this spec. Write one line per card as `- title — DONE: what finished "
"means` (or `### title` sections with a `DONE:` line), or add VENICE_API_KEY so a seat can split it.",
{"beat": "plan-failed"})
gh("DELETE", f"/repos/{REPO}/issues/{n}/labels/spec")
return
made, prev = [], None
for i, c in enumerate(cards, 1):
body = (c.get("body") or "").strip() + f"\n\nPart of #{n} (step {i}/{len(cards)})." + \
(f" Depends on #{prev}." if prev else "") + f"\n\nDONE: {c['done']}"
prev = gh("POST", f"/repos/{REPO}/issues", {"title": c["title"], "body": body.strip(), "labels": ["queued"]})["number"]
made.append(prev)
comment(n, "⏱️ Time: planned " + ", ".join(f"#{m}" for m in made) + ". They run one at a time: each starts when "
"the one before it merges.", {"beat": "planned", "cards": made})
gh("DELETE", f"/repos/{REPO}/issues/{n}/labels/spec")
gh("POST", f"/repos/{REPO}/issues/{n}/labels", {"labels": ["planned"]})
def promote_next() -> int | None:
"""Label the oldest `queued` card whose dependencies are closed `ready`; at most TIME_PARALLEL cards in flight."""
ready = [i for i in gh("GET", f"/repos/{REPO}/issues?state=open&labels=ready&per_page=50") if "pull_request" not in i]
if len(ready) >= int(os.environ.get("TIME_PARALLEL") or 1):
return None
queued = [i for i in gh("GET", f"/repos/{REPO}/issues?state=open&labels=queued&per_page=50&sort=created&direction=asc")
if "pull_request" not in i]
for card in sorted(queued, key=lambda i: i["number"]):
deps = [int(d) for d in re.findall(r"Depends on #(\d+)", card.get("body") or "")]
if all(gh("GET", f"/repos/{REPO}/issues/{d}")["state"] == "closed" for d in deps):
gh("DELETE", f"/repos/{REPO}/issues/{card['number']}/labels/queued")
gh("POST", f"/repos/{REPO}/issues/{card['number']}/labels", {"labels": ["ready"]})
return card["number"]
return None
# ---------- landing: verdict -> merge (TIME_AUTOMERGE=on) ----------
TRUSTED = ("OWNER", "MEMBER", "COLLABORATOR")
VERDICT = re.compile(r"^[\s*_>#]*(approve|object)\b", re.I)
DEFAULT_PROTECTED = ".github/,deploy,infra/,.env,secret"
def verdict(pr: dict, since: str) -> str | None:
"""Latest reviewer verdict after `since`: a comment starting Approve/Object, or a GitHub review."""
found = []
for c in gh("GET", f"/repos/{REPO}/issues/{pr['number']}/comments?per_page=100"):
m = VERDICT.match(c.get("body") or "")
trusted = c["user"].get("type") == "Bot" or c.get("author_association") in TRUSTED # public repos: strangers can comment
if m and trusted and c["created_at"] > since:
found.append((c["created_at"], m.group(1).lower()))
for r in gh("GET", f"/repos/{REPO}/pulls/{pr['number']}/reviews?per_page=100"):
state = {"APPROVED": "approve", "CHANGES_REQUESTED": "object"}.get(r.get("state"))
if state and r.get("author_association") in TRUSTED and (r.get("submitted_at") or "") > since:
found.append((r["submitted_at"], state))
return max(found)[1] if found else None
def touches_protected(pr_number: int) -> list[str]:
paths = [p.strip() for p in (os.environ.get("TIME_PROTECTED") or DEFAULT_PROTECTED).split(",") if p.strip()]
files = [f["filename"] for f in gh("GET", f"/repos/{REPO}/pulls/{pr_number}/files?per_page=100")]
return [f for f in files if any(f.startswith(p) or p in f for p in paths)]
def checks(sha: str) -> str:
"""pass | pending | fail | none, over every check run on the PR head."""
runs = gh("GET", f"/repos/{REPO}/commits/{sha}/check-runs?per_page=100").get("check_runs", [])
if not runs:
return "none"
if any(r["status"] != "completed" for r in runs):
return "pending"
return "pass" if all(r["conclusion"] in ("success", "neutral", "skipped") for r in runs) else "fail"
def land(card: dict, last: dict) -> None:
"""Move a card whose PR is up toward merged. Silent while nothing has changed."""
n, pr = card["number"], gh("GET", f"/repos/{REPO}/pulls/{last['pr']}")
if pr.get("merged_at"):
if card.get("state", "open") == "open":
gh("PATCH", f"/repos/{REPO}/issues/{n}", {"state": "closed"})
comment(n, f"⏱️ Time: PR #{pr['number']} merged. Card done.", {"beat": "merged", "pr": pr["number"]})
return
if pr["state"] == "closed" or (os.environ.get("TIME_AUTOMERGE") or "off").strip() != "on":
return
v = verdict(pr, last["at"])
if v is None:
return
if v == "object":
if last["beat"] != "needs-human":
label(pr["number"], "needs-human")
comment(n, f"⏱️ Time: the reviewer objected on PR #{pr['number']}. Over to a human (or comment `/time retry` "
"to send it back to the worker).", {"beat": "needs-human", "pr": pr["number"]})
return
if (hit := touches_protected(pr["number"])):
if last["beat"] != "needs-human":
label(pr["number"], "needs-human")
comment(n, f"⏱️ Time: approved, but PR #{pr['number']} touches protected paths ({', '.join(hit[:5])}), so a "
"human merges this one.", {"beat": "needs-human", "pr": pr["number"]})
return
c = checks(pr["head"]["sha"])
need = (os.environ.get("TIME_REQUIRE_CHECKS") or "on").strip() != "off"
if c == "pending" or (c == "none" and need):
if c == "none" and last["beat"] != "no-checks":
comment(n, f"⏱️ Time: approved, but PR #{pr['number']} has no checks, and I only merge on green. Add a CI "
"workflow (or set `TIME_REQUIRE_CHECKS=off`).", {"beat": "no-checks", "pr": pr["number"]})
return
if c == "fail":
if last["beat"] != "checks-failed":
comment(n, f"⏱️ Time: approved, but checks are failing on PR #{pr['number']}. Waiting for a fix.",
{"beat": "checks-failed", "pr": pr["number"]})
return
gh("PUT", f"/repos/{REPO}/pulls/{pr['number']}/merge", {"merge_method": "squash"})
if gh("GET", f"/repos/{REPO}/issues/{n}")["state"] == "open":
gh("PATCH", f"/repos/{REPO}/issues/{n}", {"state": "closed"})
comment(n, f"⏱️ Time: approved and green, merged PR #{pr['number']}. Card done.", {"beat": "merged", "pr": pr["number"]})
def label(number: int, name: str) -> None:
gh("POST", f"/repos/{REPO}/issues/{number}/labels", {"labels": [name]})
# ---------- heartbeat ----------
def beats_of(comments: list[dict]) -> list[dict]:
@@ -269,6 +420,19 @@ def review(card: dict, pr: dict, done: str) -> dict:
def cmd_heartbeat() -> int:
for spec in [i for i in gh("GET", f"/repos/{REPO}/issues?state=open&labels=spec&per_page=10") if "pull_request" not in i]:
try:
plan_spec(spec)
except Exception as e: # noqa: BLE001
print(f"spec #{spec['number']}: {type(e).__name__}: {e}")
for _ in range(3): # a merge can free the slot for the next card; dispatch it in the same run
tick()
if promote_next() is None:
break
return 0
def tick() -> None:
mode = (os.environ.get("TIME_MODE") or "dry-run").strip()
cap = int(os.environ.get("TIME_DAILY_CAP") or 5)
stuck_h = float(os.environ.get("TIME_STUCK_HOURS") or 6)
@@ -317,6 +481,9 @@ def cmd_heartbeat() -> int:
comment(n, f"⏱️ Time: dispatched to a Cursor cloud agent{link}. It will open a PR that closes #{n}; "
"I'll link it here when it appears.", {"beat": "dispatched", "launched": True, **r, "model": worker_model})
continue
if state in ("pr", "needs-human", "no-checks", "checks-failed"):
land(card, last)
continue
if state in ("dispatched", "stuck"):
found = linked_prs(prs, n)
if found:
@@ -340,7 +507,6 @@ def cmd_heartbeat() -> int:
print(f"#{n}: {type(e).__name__}: {e}")
comment(n, f"⏱️ Time: the worker call failed: `{str(e)[:300]}`. Comment `/time retry` to try again.",
{"beat": "error"})
return 0
# ---------- doctor ----------
@@ -358,6 +524,9 @@ def cmd_doctor() -> int:
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_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"):
row(f"(optional) label `{name}`", name in labels, "exists" if name in labels else f"missing: `gh label create {name}`")
try:
ids = [m["id"] for m in http("GET", f"{VENICE}/models?type=text", os.environ["VENICE_API_KEY"])["data"]] \
if os.environ.get("VENICE_API_KEY") else None
+15
View File
@@ -0,0 +1,15 @@
name: ci
# Time's own tests. Also the check that lets Time auto-merge changes to itself.
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 3
steps:
- uses: actions/checkout@v5
- run: python3 .github/time/test_time.py
+1
View File
@@ -21,3 +21,4 @@ jobs:
TIME_MODE: ${{ vars.TIME_MODE }}
TIME_DAILY_CAP: ${{ vars.TIME_DAILY_CAP }}
TIME_REVIEW: ${{ vars.TIME_REVIEW }}
TIME_AUTOMERGE: ${{ vars.TIME_AUTOMERGE }}
+19 -3
View File
@@ -1,21 +1,32 @@
name: heartbeat
# Time's clock: hourly, and right away when a card gets the `ready` label (dry-run until TIME_MODE=live).
# Time's clock: hourly, right away when a card gets `ready` or a spec gets `spec`, and when a PR is reviewed
# (dry-run until TIME_MODE=live; merges only with TIME_AUTOMERGE=on).
on:
schedule:
- cron: "7 * * * *"
workflow_dispatch:
issues:
types: [labeled]
pull_request_review:
types: [submitted]
issue_comment:
types: [created]
permissions:
contents: read
contents: write # squash-merge approved, green PRs
issues: write
pull-requests: write
checks: read
concurrency:
group: heartbeat
cancel-in-progress: false
jobs:
tick:
if: github.event_name != 'issues' || github.event.label.name == 'ready'
# issues: only `ready`/`spec` labels. comments: only on PRs, only a reviewer verdict (Approve/Object).
if: >-
(github.event_name != 'issues' || contains(fromJSON('["ready","spec"]'), github.event.label.name)) &&
(github.event_name != 'issue_comment' || (github.event.issue.pull_request &&
(startsWith(github.event.comment.body, '**Approve') || startsWith(github.event.comment.body, 'Approve') ||
startsWith(github.event.comment.body, '**Object') || startsWith(github.event.comment.body, 'Object'))))
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
@@ -32,3 +43,8 @@ jobs:
TIME_CURSOR_MODEL: ${{ vars.TIME_CURSOR_MODEL }}
TIME_REVIEW: ${{ vars.TIME_REVIEW }}
TIME_STUCK_HOURS: ${{ vars.TIME_STUCK_HOURS }}
TIME_AUTOMERGE: ${{ vars.TIME_AUTOMERGE }}
TIME_REQUIRE_CHECKS: ${{ vars.TIME_REQUIRE_CHECKS }}
TIME_PROTECTED: ${{ vars.TIME_PROTECTED }}
TIME_PARALLEL: ${{ vars.TIME_PARALLEL }}
TIME_PLANNER: ${{ vars.TIME_PLANNER }}
+47
View File
@@ -0,0 +1,47 @@
name: update
# Pull a fresh Time engine from the central repo and open a PR with the diff (weekly, or run it by hand).
# Upstream: TIME_UPSTREAM (default teahubxyz/time) at TIME_CHANNEL (default main; pin a tag like v1 for stability).
# Workflow files can't be changed by Actions' own token, so if those differ the PR lists them for a human to copy.
on:
schedule:
- cron: "17 9 * * 1"
workflow_dispatch:
permissions:
contents: write
pull-requests: write
jobs:
update:
if: github.repository != (vars.TIME_UPSTREAM || 'teahubxyz/time')
runs-on: ubuntu-latest
timeout-minutes: 3
env:
UPSTREAM: ${{ vars.TIME_UPSTREAM || 'teahubxyz/time' }}
CHANNEL: ${{ vars.TIME_CHANNEL || 'main' }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@v5
- run: git clone -q --depth 1 --branch "$CHANNEL" "https://github.com/$UPSTREAM" /tmp/up
- name: copy engine, open PR if changed
run: |
set -e
for f in .github/time/time.py .github/time/test_time.py .github/time/seats.json .claude/skills/time/SKILL.md; do
mkdir -p "$(dirname "$f")"; cp "/tmp/up/$f" "$f"
done
python3 .github/time/test_time.py > /dev/null
wf=$(for f in /tmp/up/.github/workflows/*.yml; do b=.github/workflows/$(basename "$f"); cmp -s "$f" "$b" || echo "- \`$b\`"; done)
if git diff --quiet; then
echo "Time engine is up to date." | tee -a "$GITHUB_STEP_SUMMARY"
[ -n "$wf" ] && printf 'Workflow files differ upstream, copy by hand:\n%s\n' "$wf" | tee -a "$GITHUB_STEP_SUMMARY"
exit 0
fi
sha=$(git -C /tmp/up rev-parse --short HEAD)
branch="time-update-$sha"
git switch -c "$branch"
git -c user.name=time -c user.email=time@users.noreply.github.com commit -qam "Update Time to $UPSTREAM@$sha"
git push -q origin "$branch"
body="Fresh Time engine from \`$UPSTREAM@$CHANNEL\` ($sha). Offline tests pass."
[ -n "$wf" ] && body="$body
These workflow files differ upstream. Actions can't edit workflows, so copy them by hand from https://github.com/$UPSTREAM/tree/$CHANNEL/.github/workflows:
$wf"
gh pr create --title "Update Time ($sha)" --body "$body" --head "$branch"
+1
View File
@@ -0,0 +1 @@
__pycache__/
+2
View File
@@ -5,7 +5,9 @@ A GitHub-native agent factory. GitHub is the ledger, GitHub-hosted runners are t
- **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.
- **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.
- **Update** (`update.yml`, weekly or manual): opens a PR with the latest engine from teahubxyz/time, so every project stays on one Time instead of drifting copies.
## Setup