diff --git a/.github/time/test_time.py b/.github/time/test_time.py index 4a1dbe3..b167ea8 100644 --- a/.github/time/test_time.py +++ b/.github/time/test_time.py @@ -262,3 +262,20 @@ w = run(w, {**K, "TIME_AUTOMERGE": "on"}); assert not any(c[0] == "PUT" for c in w = pr_world(); w.comments[1] = [beat("pr", 0, pr=7)] w = run(w, {**K, "TIME_AUTOMERGE": "on"}); assert any(c[0] == "PUT" for c in w.calls) print("ALL OK 5") + +# ---------- venture loop (offline: scripted seats) ---------- +vspec = importlib.util.spec_from_file_location("vt", Path(__file__).resolve().parent / "venture.py") +vt = importlib.util.module_from_spec(vspec); vspec.loader.exec_module(vt) +repos = [{"name": "a/b", "stars": 900, "desc": "a tiny thing", "lang": "Rust", "topics": ["cli"], "url": "u"}] +assert "a/b (900★, Rust): a tiny thing [cli]" in vt.radar(repos) +answers = {"qwen": '{"slug": "Star Checker!", "title": "Star checker", "one_liner": "x", "why_now": "a/b", "page": "p"}', + "kimi": '{"slug": "other", "title": "Other", "one_liner": "y", "why_now": "a/b", "page": "q"}'} +vt.tm.think = lambda seat, model, effort, system, user: answers.get(seat, "not json") +ps = vt.pitch(repos, ["qwen", "kimi", "grok"]) +assert [p["slug"] for p in ps] == ["star-checker", "other"] and ps[0]["by"] == "qwen" +votes = {"grok": '{"ranking": ["other", "star-checker"], "reason": "r"}', "kimi": '{"ranking": ["star-checker"], "reason": "r"}', + "qwen": '{"ranking": ["other"], "reason": "r"}'} +vt.tm.think = lambda seat, model, effort, system, user: votes[seat] +w, meta = vt.board(ps, ["grok", "kimi", "qwen"]) +assert w == "other" and meta["score"] == {"star-checker": 5, "other": 6}, meta +print("ALL OK 6") diff --git a/.github/time/venture.py b/.github/time/venture.py new file mode 100644 index 0000000..8be4ca9 --- /dev/null +++ b/.github/time/venture.py @@ -0,0 +1,231 @@ +"""Venture loop on GitHub: scout trending repos -> seats pitch ideas -> board votes -> a seat builds -> Pages ships. + + python3 .github/time/venture.py tick # advance today's venture card one stage (cron, or on demand) + +One card per venture (label `venture`), stages as Time beats on it: scout -> pitch -> board -> build -> ship -> done. +Scout: GitHub search for repos that gathered the most stars recently (no notplanned, no scraping). +Pitch: each pitching seat proposes one small, shippable product idea per repo cluster. +Board: each voting seat ranks the pitches; Borda 3/2/1 picks. Build: the Venice worker writes +ventures//index.html on branch gh-pages (served by GitHub Pages). Nothing leaves the repo. +""" +from __future__ import annotations + +import datetime as dt +import importlib.util +import json +import os +import re +import sys +import urllib.parse +from pathlib import Path + +HERE = Path(__file__).resolve().parent +_spec = importlib.util.spec_from_file_location("tm", HERE / "time.py") +tm = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(tm) +REPO = tm.REPO +PAGES_BRANCH = "gh-pages" + + +def env(name: str, default: str) -> str: + return (os.environ.get(name) or default).strip() + + +# ---------- scout: trending repos ---------- + +def scout(days: int = 7, n: int = 12) -> list[dict]: + since = (dt.datetime.now(dt.timezone.utc) - dt.timedelta(days=days)).date().isoformat() + q = urllib.parse.quote(f"created:>{since} stars:>50") + out = tm.gh("GET", f"/search/repositories?q={q}&sort=stars&order=desc&per_page={n}") + return [{"name": r["full_name"], "stars": r["stargazers_count"], "desc": (r.get("description") or "")[:200], + "lang": r.get("language") or "", "topics": r.get("topics", [])[:6], "url": r["html_url"]} + for r in out.get("items", [])] + + +def radar(repos: list[dict]) -> str: + return "\n".join(f"- {r['name']} ({r['stars']}★, {r['lang']}): {r['desc']}" + (f" [{', '.join(r['topics'])}]" if r["topics"] else "") + for r in repos) + + +# ---------- pitch and board ---------- + +PITCH = """You are `{seat}` on the board of a tiny software venture studio run by agents. Below are the repos that gained the +most stars on GitHub this week. They tell you what builders care about right now. + +{radar} + +Pitch ONE small product the studio could ship as a single static web page today: a tool, calculator, explainer, +directory, checker, or playground that rides the same wave. It must be useful on its own, need no backend, and be +honest (no fake data, no fake testimonials). Reply with ONLY JSON: +{{"slug": "kebab-case", "title": "...", "one_liner": "...", "why_now": "which repo(s) and trend this rides", "page": "what the page does, concretely, in 3-5 sentences"}}""" + +VOTE = """You are `{seat}`, voting on the board of an agent-run venture studio. Pitches: + +{pitches} + +Rank your top three by: would a real person use this page this week, can one agent build it as a static page in an +hour, is it honest. Reply with ONLY JSON: {{"ranking": ["slug1", "slug2", "slug3"], "reason": "one sentence"}}""" + + +def ask_json(seat: str, system: str, user: str) -> dict | None: + text = tm.think(seat, None, None, system, user) + try: + return json.loads(text[text.index("{"): text.rindex("}") + 1]) + except ValueError: + return None + + +def pitch(repos: list[dict], seats: list[str]) -> list[dict]: + out = [] + for s in seats: + p = ask_json(s, f"You are `{s}`.", PITCH.format(seat=s, radar=radar(repos))) + if p and p.get("slug") and p.get("title"): + p["slug"] = re.sub(r"[^a-z0-9-]", "-", p["slug"].lower())[:40].strip("-") + p["by"] = s + out.append(p) + return out + + +def board(pitches: list[dict], seats: list[str]) -> tuple[str | None, dict]: + text = "\n\n".join(f"### {p['slug']} (by {p['by']})\n{p['title']}: {p['one_liner']}\nWhy now: {p['why_now']}\n{p['page']}" for p in pitches) + score, votes = {p["slug"]: 0 for p in pitches}, {} + for s in seats: + v = ask_json(s, f"You are `{s}`.", VOTE.format(seat=s, pitches=text)) + if not v: + continue + votes[s] = v + for pts, slug in zip((3, 2, 1), v.get("ranking", [])): + if slug in score: + score[slug] += pts + if not any(score.values()): + return None, votes + return max(score, key=lambda k: (score[k], -[p["slug"] for p in pitches].index(k))), {"votes": votes, "score": score} + + +# ---------- build and ship ---------- + +BUILD_BODY = """Build the venture **{title}** as one static page. + +{page} + +Why now: {why_now} + +Write `ventures/{slug}/index.html`: a complete, self-contained page (inline CSS and JS, no build step, no external +calls except optional public CDNs). Make it genuinely usable, mobile-friendly, and honest: no invented numbers, +reviews or logos. Add a one-line footer "built by the Time venture loop" linking to the repo. Also add a row for it to +`ventures/index.html` (create that index if missing: a plain list of ventures with title, one-liner and link). + +DONE: ventures/{slug}/index.html renders and ventures/index.html links to it""" + + +def ensure_pages() -> str: + """gh-pages branch exists and Pages serves it. Returns the site URL.""" + try: + tm.gh("GET", f"/repos/{REPO}/git/ref/heads/{PAGES_BRANCH}") + except RuntimeError: + base = tm.gh("GET", f"/repos/{REPO}/git/ref/heads/{tm.default_branch()}")["object"]["sha"] + tm.gh("POST", f"/repos/{REPO}/git/refs", {"ref": f"refs/heads/{PAGES_BRANCH}", "sha": base}) + try: + p = tm.gh("GET", f"/repos/{REPO}/pages") + except RuntimeError: + p = tm.gh("POST", f"/repos/{REPO}/pages", {"source": {"branch": PAGES_BRANCH, "path": "/"}}) + return p.get("html_url") or f"https://{REPO.split('/')[0]}.github.io/{REPO.split('/')[1]}/" + + +# ---------- the card ---------- + +def venture_cards() -> list[dict]: + return [i for i in tm.gh("GET", f"/repos/{REPO}/issues?state=open&labels=venture&per_page=10") if "pull_request" not in i] + + +def new_card(today: str) -> dict: + body = ("The venture loop runs by itself: scout trending repos → seats pitch → board votes → a seat builds a static page → " + "GitHub Pages ships it. Progress is posted here.\n\nDONE: a shipped page linked from this card, or a digest saying where it stuck.") + return tm.gh("POST", f"/repos/{REPO}/issues", {"title": f"Venture {today}", "body": body, "labels": ["venture"]}) + + +def state_of(card: dict) -> dict: + beats = tm.beats_of(tm.gh("GET", f"/repos/{REPO}/issues/{card['number']}/comments?per_page=100")) + return beats[-1] if beats else {"beat": "new"} + + +def tick() -> int: + today = dt.datetime.now(dt.timezone.utc).date().isoformat() + pitchers = env("TIME_VENTURE_PITCH", "qwen,kimi,grok").split(",") + voters = env("TIME_VENTURE_BOARD", "grok,kimi,qwen").split(",") + cards = venture_cards() + card = cards[0] if cards else (new_card(today) if env("TIME_VENTURE", "off") == "on" else None) + if not card: + print("venture loop off (TIME_VENTURE=on to start)") + return 0 + n, st = card["number"], state_of(card) + stage = st["beat"] + print(f"venture #{n} stage={stage}") + say = lambda text, meta: tm.comment(n, text, {"venture": True, **meta}) + try: + if stage == "new": + repos = scout() + say("🔭 Scout: the repos that gained the most stars this week.\n\n" + radar(repos), {"beat": "scouted", "repos": repos}) + elif stage == "scouted": + pitches = pitch(st["repos"], pitchers) + if not pitches: + raise RuntimeError("no seat produced a pitch") + text = "\n\n".join(f"**{p['slug']}** by `{p['by']}`: {p['title']}. {p['one_liner']}\n_{p['why_now']}_" for p in pitches) + say("💡 Pitches:\n\n" + text, {"beat": "pitched", "pitches": pitches}) + elif stage == "pitched": + winner, meta = board(st["pitches"], voters) + if not winner: + raise RuntimeError("the board returned no usable votes") + w = next(p for p in st["pitches"] if p["slug"] == winner) + reasons = "\n".join(f"- `{s}`: {v.get('ranking')} ({v.get('reason', '')})" for s, v in meta["votes"].items()) + say(f"🗳️ Board picks **{w['title']}** (`{winner}`) with Borda {meta['score']}.\n\n{reasons}", {"beat": "picked", "winner": w}) + elif stage == "picked": + w = st["winner"] + site = ensure_pages() + spec = env("TIME_WORKER", "venice:qwen coder") + if not spec.startswith("venice:"): + spec = "venice:qwen coder" + fake = {"number": n, "title": w["title"], "body": BUILD_BODY.format(**w)} + # build straight onto gh-pages: the worker's branch is the Pages branch for ventures + r = _build_on_pages(fake, spec) + url = f"{site.rstrip('/')}/ventures/{w['slug']}/" + say(f"🔨 `{r['seat']}` built it in {r['steps']} steps ({r['model']}). Shipping to {url}", {"beat": "built", "winner": w, "url": url, **r}) + elif stage == "built": + url = st["url"] + say(f"🚀 Shipped: {url}\n\nPages can take a minute to publish. This card is done; tomorrow's loop starts a new one.", {"beat": "done", "url": url}) + tm.gh("PATCH", f"/repos/{REPO}/issues/{n}", {"state": "closed"}) + except Exception as e: # noqa: BLE001 + errs = int(st.get("errors", 0)) + 1 + if errs >= 3: + say(f"🧭 Digest: stuck at `{stage}` after 3 tries: {str(e)[:300]}. Closing; tomorrow starts fresh.", {"beat": "done", "stuck": stage}) + tm.gh("PATCH", f"/repos/{REPO}/issues/{n}", {"state": "closed"}) + else: + say(f"⚠️ {stage} failed ({errs}/3): `{str(e)[:300]}`. Retrying next tick.", {**st, "errors": errs}) + return 0 + + +def _build_on_pages(card: dict, spec: str) -> dict: + """venice_work, but the hands work on gh-pages and never open a PR: ventures ship by landing on the Pages branch.""" + hands_cls = tm.Hands + orig_init = hands_cls.__init__ + + def init(self, n, branch): + orig_init(self, n, PAGES_BRANCH) + hands_cls.__init__ = init + orig_call = hands_cls.call + + def call(self, name, a): + if name == "open_pr": + self.pr = -1 # truthy sentinel: nothing to open, the page is live on the branch + return "done: the page is on the Pages branch, no PR needed" + return orig_call(self, name, a) + hands_cls.call = call + try: + r = tm.venice_work(card, "the page is on gh-pages", spec) + finally: + hands_cls.__init__, hands_cls.call = orig_init, orig_call + return r + + +if __name__ == "__main__": + sys.exit({"tick": tick}[sys.argv[1] if len(sys.argv) > 1 else "tick"]()) diff --git a/.github/workflows/update.yml b/.github/workflows/update.yml index 7190a45..ddf82ec 100644 --- a/.github/workflows/update.yml +++ b/.github/workflows/update.yml @@ -24,7 +24,7 @@ jobs: - 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 + for f in .github/time/time.py .github/time/venture.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 diff --git a/.github/workflows/venture.yml b/.github/workflows/venture.yml new file mode 100644 index 0000000..6f8ab2c --- /dev/null +++ b/.github/workflows/venture.yml @@ -0,0 +1,31 @@ +name: venture +# The venture loop: one stage per tick. Daily kickoff plus a nudge every 20 minutes while a card is open +# (cheap: a tick with no open card and TIME_VENTURE!=on exits at once). Off until TIME_VENTURE=on. +on: + schedule: + - cron: "*/20 * * * *" + workflow_dispatch: +permissions: + contents: write + issues: write + pages: write +concurrency: + group: venture + cancel-in-progress: false +jobs: + tick: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v5 + with: + sparse-checkout: .github/time + - run: python3 .github/time/venture.py tick + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VENICE_API_KEY: ${{ secrets.VENICE_API_KEY }} + TIME_VENTURE: ${{ vars.TIME_VENTURE }} + TIME_VENTURE_PITCH: ${{ vars.TIME_VENTURE_PITCH }} + TIME_VENTURE_BOARD: ${{ vars.TIME_VENTURE_BOARD }} + TIME_WORKER: ${{ vars.TIME_WORKER }} + TIME_WORKER_STEPS: ${{ vars.TIME_WORKER_STEPS }} diff --git a/README.md b/README.md index 67c4f50..a1e2bd4 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ A GitHub-native agent factory. GitHub is the ledger, GitHub-hosted runners are t - **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. +- **Venture loop** (`venture.yml`, every 20 min once `TIME_VENTURE=on`): scouts the repos that gained the most stars this week, seats pitch one small static-page product each, the board votes (Borda), a seat builds it on `gh-pages`, and GitHub Pages ships it at `/ventures//`. One card per venture (label `venture`), progress as comments. - **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.