diff --git a/.claude/skills/time/SKILL.md b/.claude/skills/time/SKILL.md index cb08d84..b158760 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 (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:` (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. diff --git a/.github/time/test_time.py b/.github/time/test_time.py index 548e0d3..5653ed0 100644 --- a/.github/time/test_time.py +++ b/.github/time/test_time.py @@ -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") diff --git a/.github/time/time.py b/.github/time/time.py index 6ae6910..c6b921d 100644 --- a/.github/time/time.py +++ b/.github/time/time.py @@ -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.+?)\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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..183a0c9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/doctor.yml b/.github/workflows/doctor.yml index c8336e4..908180b 100644 --- a/.github/workflows/doctor.yml +++ b/.github/workflows/doctor.yml @@ -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 }} diff --git a/.github/workflows/heartbeat.yml b/.github/workflows/heartbeat.yml index 2fef3df..c153bc6 100644 --- a/.github/workflows/heartbeat.yml +++ b/.github/workflows/heartbeat.yml @@ -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 }} diff --git a/.github/workflows/update.yml b/.github/workflows/update.yml new file mode 100644 index 0000000..7190a45 --- /dev/null +++ b/.github/workflows/update.yml @@ -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" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c18dd8d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/README.md b/README.md index 0b287a0..2efea02 100644 --- a/README.md +++ b/README.md @@ -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