diff --git a/.github/time/astra.py b/.github/time/astra.py index 01e1202..dd64184 100644 --- a/.github/time/astra.py +++ b/.github/time/astra.py @@ -19,6 +19,9 @@ nothing; `live` queues from the top while the day's money left covers each card' in the pool that takes the worker's route, else the daily cap's remainder, never more than the month has left), creates the seat's new cards unqueued (they are candidates the next day; never a repeat of an open or lately closed card), and the heartbeat promotes queued cards in the ranked order. +Ventures (TIME_VENTURE_MODE=router, the loop's TIME_VENTURE): "start a new venture" is actionable, and every live venture +with signups is a "v2 of " candidate. A picked new venture dispatches venture.yml with start=true (never queued +as a card); a picked v2 posts the venture's open v2 card, else writes one (Base: gh-pages) and queues it. `off`, or no router.json, is the planner as before. """ from __future__ import annotations @@ -218,10 +221,46 @@ def repeat_of(title: str, known: list[dict]) -> int | None: return None +def venture_registry() -> dict: + """ventures/ventures.json on the Pages branch; unreadable, there are no ventures to iterate on.""" + try: + f = tm.gh("GET", f"/repos/{tm.REPO}/contents/ventures/ventures.json?ref=gh-pages") + return json.loads(base64.b64decode(f.get("content") or "").decode(errors="replace")) + except Exception as e: # noqa: BLE001 context, not a dependency + print(f"venture registry unreadable: {str(e)[:120]}") + return {} + + +def start_venture(r: dict, open_issues: list[dict]) -> str: + """A picked venture candidate. A new venture: venture.yml is dispatched with start=true (the loop in router mode then + starts one card; it is never queued as a card here). A v2: the venture's existing v2 card, else a new one on gh-pages + (no dispatch: that would start a different, new venture). Returns what happened, for the log.""" + if r["key"] == router.NEW_VENTURE["key"]: + try: + tm.gh("POST", f"/repos/{tm.REPO}/actions/workflows/venture.yml/dispatches", + {"ref": tm.default_branch(), "inputs": {"start": "true"}}) + except RuntimeError as e: + return f"new venture: dispatching venture.yml failed ({str(e)[:100]})" + return "new venture: dispatched venture.yml with start=true" + v = f"v{r['next_v']}" + for i in open_issues: + t = (i.get("title") or "").lower() + if re.search(rf"\b{v}\b", t) and (r["name"].lower() in t or r["slug"] in t): + return f"{r['name']} {v}: already on #{i['number']}, posted that card" + n = tm.new_issue(f"{r['name']} {v}: build the next version from what signups asked for", + f"Build {v} of **{r['name']}** in ventures/{r['slug']}/ from what its signups asked for (its signal issue " + f"and early-access issues). {r['body']}\n\nBase: gh-pages\n\nRead the folder first and keep what works.\n\n" + f"DONE: PR open against gh-pages with {v} of ventures/{r['slug']}/, checks green.\n\n" + "card written by Astra", [])["number"] + tm.label(n, "queued") + return f"{r['name']} {v}: no card yet, wrote and queued #{n}" + + def router_tick(cfg: dict, seat: str, log: int, context: str, thread: list[dict], max_ready: int, max_new: int) -> int: """The seat judges every candidate; router.py ranks them; live queues from the top, dry only shows the ranking.""" open_issues = [i for i in tm.issues("state=open") if i.get("title") != LOG_TITLE] - cands = router.candidates(open_issues) + [dict(router.NEW_VENTURE)] + mode = (os.environ.get("TIME_VENTURE_MODE") or "").strip().lower() # the loop's TIME_VENTURE, passed on by astra.yml + cands = router.candidates(open_issues) + [router.new_venture(mode)] + router.v2_candidates(venture_registry()) listing = "\n\n".join(f"### {c['key']}: {c['title']}\nlabels: {', '.join(c['labels']) or '-'}; opened " f"{c['created'][:10] or '-'}; cards waiting on it: {c['unblocks']}\n{c['body'][:400]}" for c in cands) today, day_cap, month, month_cap = tm.budget() @@ -246,10 +285,13 @@ def router_tick(cfg: dict, seat: str, log: int, context: str, thread: list[dict] n = repeat_of(str(c["title"]), open_issues + closed) (repeats.append((c, n)) if n else new.append(c)) new = new[:max_new] - live, created = cfg["mode"] == "live", [] + live, created, started = cfg["mode"] == "live", [], [] if live: for r in picks: - tm.label(r["n"], "queued") # the heartbeat promotes it, in ranked order + if r["n"] is None: + started.append(start_venture(r, open_issues)) # a venture: dispatched or written, never queued as itself + else: + tm.label(r["n"], "queued") # the heartbeat promotes it, in ranked order for c in new: body = str(c.get("body") or "") if not router.DONE.search(body): @@ -264,22 +306,24 @@ def router_tick(cfg: dict, seat: str, log: int, context: str, thread: list[dict] if unjudged: lines.append(f"_{unjudged} candidate(s) the seat didn't judge are scored at value 3, size M._") if live: - lines.append(("Queued: " + ", ".join(f"#{r['n']}" for r in picks)) if picks else "Queued nothing today.") + queued = [f"#{r['n']}" for r in picks if r["n"] is not None] + lines.append(("Queued: " + ", ".join(queued)) if queued else "Queued nothing today.") + lines += [f"Venture: {x}" for x in started] if created: lines.append("New cards (candidates from tomorrow): " + ", ".join(f"#{n}" for n in created)) else: lines.append("Dry run: nothing queued or created. Live, it would queue: " - + (", ".join(f"#{r['n']}" for r in picks) or "nothing") + ".") + + (", ".join(f"#{r['n']}" if r["n"] is not None else r["title"] for r in picks) or "nothing") + ".") if new: lines.append("It would write: " + "; ".join(router._txt(c["title"], 100) for c in new) + ".") if repeats: lines.append("Not written, repeats of existing cards: " + "; ".join(f"{router._txt(c['title'], 80)} (#{n})" for c, n in repeats) + ".") - meta = {"beat": "astra", "router": cfg["mode"], "queued": len(picks) if live else 0, "order": router.order(ranked), + meta = {"beat": "astra", "router": cfg["mode"], "queued": len(picks) - len(started) if live else 0, "started": started, "order": router.order(ranked), "picked": [{"n": r["n"], "kind": r["kind"]} for r in picks] if live else [], - "would": [r["n"] for r in picks] if not live else [], "new": created} + "would": [r["n"] for r in picks if r["n"] is not None] if not live else [], "new": created} tm.comment(log, "\n".join(lines), meta) - print(f"astra router {cfg['mode']}: picks={[r['n'] for r in picks]} new={created} ranked={len(ranked)}") + print(f"astra router {cfg['mode']}: picks={[r['n'] for r in picks]} new={created} ranked={len(ranked)}" + "".join(f" | {x}" for x in started)) return 0 diff --git a/.github/time/router.py b/.github/time/router.py index e48153c..090b481 100644 --- a/.github/time/router.py +++ b/.github/time/router.py @@ -38,7 +38,30 @@ DONE = re.compile(r"^\s*DONE:", re.M | re.I) NEW_VENTURE = {"key": "venture:new", "n": None, "title": "Start a new venture: research, pick, build and launch one page", "body": "The venture loop (venture.py): one venture, researched and picked by the lead, built and judged.", "labels": [], "lined_up": False, "actionable": False, "kind": "venture", "unblocks": 0, "created": "", - "why_not": "not wired to the router yet; the venture loop is paused (TIME_VENTURE=off)"} + "why_not": "the venture loop isn't in router mode (TIME_VENTURE=router, and Astra needs TIME_VENTURE_MODE=router)"} + + +def new_venture(mode: str) -> dict: + """"Start a new venture": actionable only when Astra is told the loop is in router mode (the loop then starts a card + only when asked). Its size and cost come from the seat like any card's.""" + return {**NEW_VENTURE, "actionable": True, "why_not": ""} if mode == "router" else dict(NEW_VENTURE) + + +def v2_candidates(reg: dict) -> list[dict]: + """A "v2 of " for every live venture with signups: the studio's next version of what people asked for.""" + out = [] + for v in reg.get("ventures") or []: + try: + signups = int(v.get("signups") or 0) + except (TypeError, ValueError): + continue + if v.get("status") != "live" or signups <= 0 or not v.get("slug"): + continue + name = _txt(v.get("name") or v["slug"], 60) + out.append({**NEW_VENTURE, "key": f"venture:v2:{v['slug']}", "title": f"v2 of {name} ({signups} signups)", + "body": _txt(f"{v.get('one_liner', '')} Next: {v.get('next', '')}", 400), "actionable": True, "why_not": "", + "slug": v["slug"], "name": name, "next_v": int(v.get("version") or 1) + 1}) + return out def _num(v, default: float, lo: float = 0.0, hi: float = 100.0) -> float: diff --git a/.github/time/test_time.py b/.github/time/test_time.py index 0f8f6d0..f0dff4b 100644 --- a/.github/time/test_time.py +++ b/.github/time/test_time.py @@ -1193,7 +1193,7 @@ beats80 = [{"beat": "astra", "router": "live", "at": "2026-10-17T13:13:00Z", "pi {"beat": "astra", "router": "live", "at": "2026-10-18T13:13:00Z", "picked": [{"n": 5, "kind": "feature"}, "junk"]}] assert rt.last_picked(beats80) == {"feature": "2026-10-18"}, "live picks only" t80 = rt.table(r80, rt.pick(r80, live80, 10.0, 2), "dry") -assert "| 1 | #2 Fix the docs |" in t80 and "lined up" in t80 and "**would queue**" in t80 and "split" in t80 and "not wired" in t80 +assert "| 1 | #2 Fix the docs |" in t80 and "lined up" in t80 and "**would queue**" in t80 and "split" in t80 and "router mode" in t80 assert "**queued today**" in rt.table(r80, rt.pick(r80, live80, 10.0, 2), "live") # Astra's router tick: dry shows the ranking and touches nothing; live queues the picks and writes the new cards unqueued aspec = importlib.util.spec_from_file_location("astra80", Path(__file__).resolve().parent / "astra.py") @@ -1264,6 +1264,72 @@ astra.tm.pools, astra.tm.allowance, astra.tm.pool_spent = real175[:3] if real175[4] is None: os.environ.pop("TIME_WORKER", None) else: os.environ["TIME_WORKER"] = real175[4] assert astra.parse("x {bad} y {\"a\": 1}") == {"a": 1} and astra.parse("") == {} +# 170 router mode: "start a new venture" is actionable only when Astra is told the loop is in router mode; v2 of a live +# venture with signups is a candidate; a picked venture dispatches venture.yml once (new) or writes/posts a v2 card, never queued +assert rt.new_venture("router")["actionable"] and not rt.new_venture("on")["actionable"] and "TIME_VENTURE_MODE" in rt.new_venture("")["why_not"] +reg170 = {"ventures": [{"slug": "loot-drop", "name": "Loot Drop", "status": "live", "signups": 3, "one_liner": "x", "next": "y"}, + {"slug": "nobody", "name": "Nobody", "status": "live", "signups": 0}, {"slug": "dead", "status": "killed", "signups": 9}, + {"slug": "bad", "status": "live", "signups": "many"}]} +v2s170 = rt.v2_candidates(reg170) +assert [c["title"] for c in v2s170] == ["v2 of Loot Drop (3 signups)"] and v2s170[0]["kind"] == "venture" and v2s170[0]["actionable"], v2s170 +assert rt.v2_candidates({}) == [] +real170 = (astra.tm.gh, astra.tm.default_branch, astra.venture_registry, os.environ.get("TIME_VENTURE_MODE")) +astra.tm.issues = lambda q: closed80 if q.startswith("state=closed") else open80 + [iss80(57, "Astra log", labels=["astra"])] +calls170 = [] +astra.tm.gh = lambda method, path, body=None, **k: calls170.append((method, path, body)) or {} +astra.tm.default_branch = lambda: "main" +astra.venture_registry = lambda: reg170 +live170 = rt.config('{"mode": "live"}') +def tick170(assess, mode="router"): + for lst in (said80, labelled80, made80, calls170): lst.clear() + if mode: os.environ["TIME_VENTURE_MODE"] = mode + else: os.environ.pop("TIME_VENTURE_MODE", None) + reply = {"note": "n", "assess": [dict(v, key=k) for k, v in assess.items()], "new": []} + astra.tm.think = lambda seat, a, b, system, user: json.dumps(reply) + assert astra.router_tick(live170, "opus", 57, "ctx", [], 1, 0) == 0 +new170 = {"venture:new": {"kind": "venture", "value": 5, "size": "S"}} +tick170(new170) +assert calls170 == [("POST", f"/repos/{astra.tm.REPO}/actions/workflows/venture.yml/dispatches", + {"ref": "main", "inputs": {"start": "true"}})], calls170 +assert not made80 and not labelled80, "a new venture is never queued as a card" +assert "Venture: new venture: dispatched venture.yml with start=true" in said80[-1][1] and said80[-1][2]["queued"] == 0, said80[-1] +assert said80[-1][2]["picked"] == [{"n": None, "kind": "venture"}] +tick170(new170, mode="on") +assert not calls170 and not made80, "not in router mode: not actionable, nothing dispatched" +assert "router mode" in said80[-1][1] +v2a170 = {"venture:new": {"kind": "venture", "value": 1, "size": "S"}, "venture:v2:loot-drop": {"kind": "venture", "value": 5, "size": "S"}} +tick170(v2a170) +assert not calls170, "a v2 starts no new venture" +assert [m[0] for m in made80] == ["Loot Drop v2: build the next version from what signups asked for"] and "Base: gh-pages" in made80[0][1] +assert "DONE:" in made80[0][1] and labelled80 == [(91, "queued")] and "Loot Drop v2: no card yet, wrote and queued #91" in said80[-1][1] +open80.append(iss80(77, "Venture 2026-10-19: Loot Drop v2", body="x", labels=["venture"])) +tick170(v2a170) +open80.pop() +assert not calls170 and not made80 and "Loot Drop v2: already on #77" in said80[-1][1], said80[-1][1] +astra.tm.gh, astra.tm.default_branch, astra.venture_registry = real170[:3] +if real170[3] is None: os.environ.pop("TIME_VENTURE_MODE", None) +else: os.environ["TIME_VENTURE_MODE"] = real170[3] +# the loop: TIME_VENTURE=router makes no daily card and starts one only on TIME_VENTURE_START; off starts nothing +vspec170 = importlib.util.spec_from_file_location("vt170", Path(__file__).resolve().parent / "venture.py") +vt170 = importlib.util.module_from_spec(vspec170); vspec170.loader.exec_module(vt170) +cards170 = [] +vt170.registry = lambda: {"ventures": []} +vt170.ack_signups = vt170.refresh_daily = vt170.refresh_backends = lambda reg: None +vt170.venture_cards = vt170.batch_cards = lambda state="open", n=30: [] +vt170.new_card = lambda title: cards170.append(title) or {"number": len(cards170), "labels": [{"name": "venture"}]} +vt170.is_due = lambda *a: False +vt170.comments_of = lambda n: [] +def loop170(mode, start=""): + cards170.clear() + for k, v in (("TIME_VENTURE", mode), ("TIME_VENTURE_START", start)): + if v: os.environ[k] = v + else: os.environ.pop(k, None) + assert vt170.tick() == 0 +loop170("router"); assert cards170 == [], "router: no daily card" +loop170("router", "1"); assert cards170 == [f"Venture {vt170.today()}"], cards170 +loop170("off", "1"); assert cards170 == [], "off: the start input does nothing" +loop170("on"); assert cards170 == [f"Venture {vt170.today()}"], "on: the daily card, as before" +os.environ.pop("TIME_VENTURE", None); os.environ.pop("TIME_VENTURE_START", None) # the heartbeat promotes queued cards in the live ranking's order, oldest first without one real80 = (tm.issues, tm.gh, tm.label, tm.unlabel) moved80 = [] diff --git a/.github/time/venture.py b/.github/time/venture.py index 20c94af..48298cc 100644 --- a/.github/time/venture.py +++ b/.github/time/venture.py @@ -39,6 +39,9 @@ Measure: after 7 days, distinct people who signed up or 👍'd the signal issue, lines: iterate (a v2 card), kill, or watch one more week and then park. Batch (TIME_VENTURE_BATCH=k, off by default): one card scouts and pitches a field, the board's top k become cards in one milestone, launched together after a hold, with one batch retry for a held child and a cohort retro (see "batches" below). +TIME_VENTURE=router (a third mode beside on / off): no daily card on the schedule; a card starts only when asked + (TIME_VENTURE_START, the dispatch input start=true), which is how Astra's router starts a venture. The tick still + advances and measures the open venture cards, as under off. Nothing is posted outside the repo: launch posts are drafts for the chair. """ from __future__ import annotations @@ -2025,6 +2028,9 @@ def tick() -> int: except Exception as e: # noqa: BLE001 chores never block the cards print("chores failed:", e) fresh, yes = [], ("1", "true", "yes") + if env("TIME_VENTURE", "off") == "router" and env("TIME_VENTURE_START", "").lower() in yes: + # router mode: no daily card; Astra's pick (venture.yml dispatched with start=true) starts one, the tick below advances it + fresh.append(new_card(next_title({c["title"] for c in venture_cards("all")}))) if env("TIME_VENTURE", "off") == "on": asked_batch = env("TIME_VENTURE_BATCH_START", "").lower() in yes if batch_size() or asked_batch: # batches replace the daily single card