Compare commits

..
Author SHA1 Message Date
teahubxyzandClaude Fable 5.1 e2eb49c5e2 changelog: Astra's board
ci / test (pull_request) Successful in 36s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LaVWDr6h62YJk9LJqvQH7k
2026-10-17 17:40:41 +00:00
teahubxyzandClaude Fable 5.1 7fec9f0c3d README: Astra, the daily planner, and its board
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LaVWDr6h62YJk9LJqvQH7k
2026-10-17 17:40:36 +00:00
teahubxyzandClaude Fable 5.1 5709ebec51 tests: Astra, the single planner and the board (fake forge, scripted seats)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LaVWDr6h62YJk9LJqvQH7k
2026-10-17 17:40:22 +00:00
teahubxyzandClaude Fable 5.1 5332098a67 astra: a board for internal work (slates, a Borda ranking, the quorum of two)
With TIME_ASTRA_PITCH set, every planning seat proposes a slate of cards; the slates merge into one
field (the same issue or title from two seats is one card with both proposers), the board seats
(TIME_ASTRA_BOARD, default the pitching seats) rank the field with the venture board's own tally and
`top` (Borda 3/2/1, quorum of two), and only the top picks are queued or created, under the single
planner's guards and caps. A card one seat alone wants is never opened. Unset, Astra is unchanged.
The Astra log carries the slates, the score/backer table and each seat's reason.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LaVWDr6h62YJk9LJqvQH7k
2026-10-17 17:37:31 +00:00
time d786ec6500 ledger: 1 calls, $0.12 2026-10-17 13:13:44 +00:00
time 498285e136 ledger: 1 calls, $0.12 2026-10-16 13:13:33 +00:00
teahubxyzandClaude Opus 5.5 e705ca00ac refresh: rebuild a gh-pages venture in place, from its own tools/build.sh (#67)
ci / test (push) Successful in 34s
refresh: rebuild a gh-pages venture in place, from its own tools/build.sh (#67)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012sekpYMwTXuMdza1isMQA1
2026-10-16 05:18:37 +00:00
11 changed files with 348 additions and 229 deletions

No files matched your search

+155 -5
View File
@@ -11,6 +11,12 @@ TIME_ASTRA_MAX_READY cards queued and TIME_ASTRA_MAX_NEW cards created per run;
has a `DONE:` line and none of `queued`/`ready`/`needs-human`/`andon`; an open `andon` issue means Astra writes its note
and queues nothing; the spend cap is checked by the engine like any other call. Everything Astra does is a comment on
one issue, "Astra log", so a human can read the day's reasoning in one place and object there.
With a board (TIME_ASTRA_PITCH: seats that each propose a slate; TIME_ASTRA_BOARD: seats that rank it, default the
same), the day is the venture loop's ceremony turned inward. Every planning seat proposes up to a few cards, the
slates merge into one field, the board ranks the field (Borda 3/2/1 with a quorum of two, the venture board's own
tally and `top`), and only the top picks are queued or created: a card wanted by one seat alone is never opened. The
same guardrails apply to every pick. Unset, Astra is the single planner above.
"""
from __future__ import annotations
@@ -25,6 +31,10 @@ 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)
_vspec = importlib.util.spec_from_file_location("vt", HERE / "venture.py")
vt = importlib.util.module_from_spec(_vspec)
_vspec.loader.exec_module(vt) # the venture board's machinery: each(), ask_json(), top(), seat_list()
vt.tm = tm # its seats spend through this run's governor and ledger, not a second copy of them
SPINE = os.environ.get("TIME_SPINE") or "teahubxyz/teahub" # where AGENTS.md and docs/decisions.md live
LOG_TITLE = "Astra log"
@@ -49,6 +59,35 @@ Answer with JSON only, no prose around it:
"queue": [<existing open issue numbers to queue now; they must already have a DONE: line>],
"new": [{{"title": "<card title>", "body": "<what to change, as a list, ending with a DONE: line>", "queue": true}}]}}"""
SLATE = """You are `{seat}`, one of the planners of a small, fully AI-run software factory (Teahub). Once a day each planner
proposes a slate of work; the board ranks every slate together, and only cards that two seats want get queued. You do
not do the work: a worker seat builds queued cards one at a time.
Propose up to {n} cards. A card is either an existing open issue to queue now (it must already have a `DONE:` line and
carry none of the labels queued/ready/needs-human/andon) or a new card: a clear list of changes ending in one `DONE:`
line, small enough for one PR and one worker session.
Rules:
- Prefer finishing over starting. Unblock open PRs and half-done cards before opening new work.
- Never touch workflows, secrets, variables, or anything under docs/decisions.md; those are a human's. Never propose
anything that posts outside the repo.
- If nothing is worth doing, propose nothing; an empty slate is fine.
Answer with JSON only, no prose around it:
{{"note": "<2-4 sentences: what matters today and why these>",
"cards": [{{"issue": <existing open issue number>, "why": "<one line>"}},
{{"title": "<card title>", "body": "<what to change, as a list, ending with a DONE: line>", "why": "<one line>"}}]}}"""
RANK = """You are `{seat}` on the board of a small, fully AI-run software factory (Teahub), ranking the cards the planners
proposed today:
{cards}
Rank up to three by: does it finish or unblock something already in flight, is it small enough for one worker session
and one PR, is its DONE line checkable, does it stay inside the repo and away from workflows, secrets, variables and
docs/decisions.md, and is it worth part of a small daily budget. If nothing is, rank nothing.
Reply with ONLY JSON: {{"ranking": ["<card id>", "<card id>"], "reason": "one sentence"}} (ranking may be [])."""
def env_int(name: str, default: int) -> int:
try:
@@ -89,9 +128,13 @@ def log_issue() -> int:
"queued, and why. Object in a reply; Astra reads this thread too.", ["astra"])["number"]
def context_for(state: dict, context: str) -> str:
return f"Factory context (the spine's AGENTS.md and the newest decisions):\n{context}\n\nBoard:\n{json.dumps(state, indent=1)}"
def decide(state: dict, context: str, seat: str) -> dict:
user = f"Factory context (the spine's AGENTS.md and the newest decisions):\n{context}\n\nBoard:\n{json.dumps(state, indent=1)}"
raw = tm.think(seat, None, None, RULES.format(max_ready=env_int("TIME_ASTRA_MAX_READY", 2), max_new=env_int("TIME_ASTRA_MAX_NEW", 2)), user)
raw = tm.think(seat, None, None, RULES.format(max_ready=env_int("TIME_ASTRA_MAX_READY", 2), max_new=env_int("TIME_ASTRA_MAX_NEW", 2)),
context_for(state, context))
m = re.search(r"\{.*\}", raw, re.S)
try:
out = json.loads(m.group(0)) if m else {}
@@ -102,9 +145,108 @@ def decide(state: dict, context: str, seat: str) -> dict:
"new": [c for c in out.get("new") or [] if isinstance(c, dict) and c.get("title")]}
# ---------- the board for internal work: slates, a ranking, the quorum ----------
def slate(state: dict, context: str, seats: list[str], n: int) -> tuple[list[dict], dict[str, str]]:
"""Every planning seat proposes up to n cards at once; the slates merge into one field. An existing issue passes
the single planner's guards or is dropped; the same issue (or the same new title) from two seats is one card
with both proposers. Returns the field and each seat's note."""
by_n = {i["n"]: i for i in state["issues"]}
user = context_for(state, context)
answers = vt.each(seats, lambda s: vt.ask_json(s, SLATE.format(seat=s, n=n), user))
field: list[dict] = []
notes: dict[str, str] = {}
for s, out in zip(seats, answers):
if not out:
continue
notes[s] = str(out.get("note") or "")[:400]
for c in (out.get("cards") or [])[:n]:
if not isinstance(c, dict):
continue
why = str(c.get("why") or "")[:200]
if str(c.get("issue") or "").isdigit():
num = int(c["issue"])
i = by_n.get(num)
if not i or not i["has_done"] or SKIP & set(i["labels"]):
continue
same = next((d for d in field if d.get("issue") == num), None)
cand = {"slug": f"#{num}", "issue": num, "title": i["title"], "body": i["body"], "by": s, "why": why}
elif c.get("title"):
cand = {"slug": f"new-{len(field) + 1}", "title": str(c["title"])[:200], "body": str(c.get("body") or ""),
"by": s, "why": why}
same = next((d for d in field if "issue" not in d and vt._forms(d["title"]) & vt._forms(cand["title"])), None)
else:
continue
if same:
if s not in same["by"].split(", "):
same["by"] += f", {s}"
continue
field.append(cand)
return field, notes
def rank(field: list[dict], seats: list[str]) -> dict:
"""The board ranks the field: every seat up to three cards at once, Borda 3/2/1, as on the venture board.
Returns {"votes", "score", "backers"}, the shape venture.top reads."""
text = "\n\n".join(f"### {c['slug']} (proposed by {c['by']})\n{c['title']}\n{c['body'][:600]}\nWhy: {c['why']}" for c in field)
score = {c["slug"]: 0 for c in field}
backers = {c["slug"]: 0 for c in field}
votes: dict[str, dict] = {}
results = vt.each(seats, lambda s: vt.ask_json(s, f"You are `{s}`.", RANK.format(seat=s, cards=text)))
for s, v in zip(seats, results):
if not v:
continue
seen: list[str] = []
for slug in v.get("ranking") or []: # a seat listing the same card thrice gets one vote for it
slug = str(slug).strip()
if slug in score and slug not in seen:
seen.append(slug)
v["ranking"] = seen
votes[s] = v
for pts, slug in zip((3, 2, 1), seen):
score[slug] += pts
backers[slug] += 1
return {"votes": votes, "score": score, "backers": backers}
def deliberate(state: dict, context: str, pitchers: list[str], judges: list[str], max_ready: int, max_new: int) -> tuple[dict, list[str]]:
"""The venture ceremony turned inward: slates, a ranking, and the top picks wanted by two seats become the plan,
at most max_ready in all and max_new of them new. Returns the plan (as decide() shapes it) and the minutes."""
field, notes = slate(state, context, pitchers, max_ready + max_new)
minutes = [f"Slates from {', '.join(pitchers)}; the board is {', '.join(judges)}."]
minutes += [f"- `{s}`: {note}" for s, note in notes.items() if note]
if not field:
minutes.append("No cards were proposed.")
return {"note": "The planners proposed nothing today.", "queue": [], "new": []}, minutes
meta = rank(field, judges)
by_slug = {c["slug"]: c for c in field}
picks: list[str] = []
fresh = 0
for slug in vt.top(field, meta, len(field)): # ranked by Borda, each wanted by two seats
if "issue" not in by_slug[slug]:
if fresh >= max_new:
continue
fresh += 1
picks.append(slug)
if len(picks) >= max_ready:
break
minutes += ["", "| card | proposed by | score | backers | |", "|---|---|---|---|---|"]
for c in field:
minutes.append(f"| {c['slug']} {c['title'][:60]} | {c['by']} | {meta['score'][c['slug']]} | {meta['backers'][c['slug']]} | "
f"{'picked' if c['slug'] in picks else ''} |")
minutes += [f"- `{s}` ranked {', '.join(v['ranking']) or 'nothing'}: {str(v.get('reason') or '')[:200]}" for s, v in meta["votes"].items()]
chosen = [by_slug[s] for s in picks]
note = ("The board picked " + ", ".join(f"{c['slug']} ({c['title'][:40]})" for c in chosen) + ".") if chosen else \
"No card was wanted by two seats; nothing queued."
return {"note": note, "queue": [c["issue"] for c in chosen if "issue" in c],
"new": [{"title": c["title"], "body": c["body"], "queue": True} for c in chosen if "issue" not in c]}, minutes
def tick() -> int:
seat = (os.environ.get("TIME_ASTRA_SEAT") or "opus").strip()
max_ready, max_new = env_int("TIME_ASTRA_MAX_READY", 2), env_int("TIME_ASTRA_MAX_NEW", 2)
pitchers = vt.seat_list("TIME_ASTRA_PITCH", "")
judges = vt.seat_list("TIME_ASTRA_BOARD", "") or pitchers
state = board()
log = log_issue()
if state["andons"]:
@@ -117,8 +259,12 @@ def tick() -> int:
human = [c["body"][:500] for c in thread if not tm.is_time(c)][-5:]
if human:
context += "\n\nRecent human replies on the Astra log (follow these):\n- " + "\n- ".join(human)
minutes: list[str] = []
try:
plan = decide(state, context, seat)
if pitchers:
plan, minutes = deliberate(state, context, pitchers, judges, max_ready, max_new)
else:
plan = decide(state, context, seat)
except tm.Budget as e:
tm.comment(log, f"🔭 Astra: {e}. No plan today.", {"beat": "astra", "queued": 0, "capped": True})
return 0
@@ -139,7 +285,10 @@ def tick() -> int:
created.append(issue["number"])
if c.get("queue", c.get("ready", True)) and len(queued) + len(created) <= max_ready:
tm.label(issue["number"], "queued") # the heartbeat promotes it when nothing else is in flight
lines = [f"🔭 Astra ({seat}), spend ${state['spend']['today']:.2f} of ${state['spend']['daily_cap']:.2f} today:", "", plan["note"], ""]
who = f"board of {', '.join(judges)}" if pitchers else seat
lines = [f"🔭 Astra ({who}), spend ${state['spend']['today']:.2f} of ${state['spend']['daily_cap']:.2f} today:", "", plan["note"], ""]
if minutes:
lines += minutes + [""]
if queued:
lines.append("Queued: " + ", ".join(f"#{n}" for n in queued))
if created:
@@ -148,7 +297,8 @@ def tick() -> int:
lines.append("Skipped (no DONE line, already moving, or a human's): " + ", ".join(f"#{n}" for n in skipped))
if not (queued or created):
lines.append("Queued nothing today.")
tm.comment(log, "\n".join(lines), {"beat": "astra", "queued": len(queued) + len(created), "queue": queued, "new": created})
tm.comment(log, "\n".join(lines), {"beat": "astra", "queued": len(queued) + len(created), "queue": queued, "new": created,
"board": judges if pitchers else []})
print(f"astra: queued={queued} new={created} skipped={skipped}")
return 0
-56
View File
@@ -1,56 +0,0 @@
#!/usr/bin/env python3
"""Ship a venture built in this run: land the bundle its build left the way seed.py lands a downloaded one.
Run by .github/workflows/ship.yml once it has cloned the venture's source repo and run its tools/build.sh. The bundle
never travelled, so its own sha256 is the check; everything else is seed's (paths, file types, size, an index.html,
the ledger line, one commit to gh-pages). The environment:
SLUG the folder and the subdomain
BUNDLE_FILE the .tgz the build left, with <slug>/ at the top
SOURCE what was built (owner/repo@commit), for the log
NAME, ONE_LINER the ledger line, as for seed
python3 ship.py PAGES_DIR # PAGES_DIR: a checkout of gh-pages
"""
import hashlib
import importlib.util
import os
import sys
import tarfile
from pathlib import Path
spec = importlib.util.spec_from_file_location("seed", Path(__file__).with_name("seed.py"))
seed = importlib.util.module_from_spec(spec)
spec.loader.exec_module(seed)
def read(path):
"""the bundle the build left, within seed's size limit"""
p = Path(path or "")
if not path or not p.is_file():
raise seed.SeedError(f"no bundle at {path!r}")
if p.stat().st_size > seed.MAX_BYTES:
raise seed.SeedError(f"bundle is over {seed.MAX_BYTES // 2**20} MB")
return p.read_bytes()
def main():
pages = Path(sys.argv[1] if len(sys.argv) > 1 else ".")
slug = os.environ.get("SLUG", "").strip()
try:
if not seed.SLUG.fullmatch(slug):
raise seed.SeedError(f"bad slug: {slug!r}")
data = read(os.environ.get("BUNDLE_FILE", "").strip())
sha = seed.land(pages, slug, data, hashlib.sha256(data).hexdigest(), os.environ.get("NAME", "").strip(),
os.environ.get("ONE_LINER", "").strip())
except (seed.SeedError, OSError, tarfile.TarError) as e:
print(f"::error::ship {slug}: {e}")
sys.exit(1)
pushed = seed.commit(pages, slug, sha)
source = os.environ.get("SOURCE", "").strip() or "this run"
print(f"{'landed' if pushed else 'unchanged'}: ventures/{slug}/ built from {source} ({len(data)} bytes, "
f"sha256 {sha[:12]}) -> " + seed.SITE.format(slug=slug))
if __name__ == "__main__":
main()
-75
View File
@@ -1,75 +0,0 @@
#!/usr/bin/env python3
"""Tests for ship.py: a bundle built in the run lands like a seeded one; a missing or oversized one never does."""
import hashlib
import importlib.util
import io
import json
import os
import subprocess
import sys
import tarfile
import tempfile
from pathlib import Path
HERE = Path(__file__).resolve().parent
spec = importlib.util.spec_from_file_location("ship", HERE / "ship.py")
ship = importlib.util.module_from_spec(spec)
spec.loader.exec_module(ship)
def tgz(files):
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tf:
for name, body in files.items():
ti = tarfile.TarInfo(name)
ti.size = len(body)
tf.addfile(ti, io.BytesIO(body))
return buf.getvalue()
def refused(fn, *a):
try:
fn(*a)
except ship.seed.SeedError as e:
return str(e)
raise AssertionError("expected SeedError")
def test_lands_the_bundle_the_build_left():
d = Path(tempfile.mkdtemp())
b = tgz({"hv/index.html": b"<!doctype html>harbor", "hv/shots/desktop.png": b"png"})
(d / "hv.tgz").write_bytes(b)
assert ship.read(str(d / "hv.tgz")) == b
pages = d / "pages"
(pages / "ventures").mkdir(parents=True)
(pages / "ventures" / "ventures.json").write_text(json.dumps({"ventures": []}, indent=1), encoding="utf-8")
ship.seed.land(pages, "hv", b, hashlib.sha256(b).hexdigest(), "Havre", "A harbor.", today="2026-10-16")
assert (pages / "ventures/hv/index.html").read_bytes() == b"<!doctype html>harbor"
line = json.loads((pages / "ventures/ventures.json").read_text())["ventures"][0]
assert line["slug"] == "hv" and line["shot"] == "hv/shots/desktop.png"
def test_refuses_a_missing_or_oversized_bundle():
d = Path(tempfile.mkdtemp())
assert "no bundle" in refused(ship.read, str(d / "none.tgz"))
assert "no bundle" in refused(ship.read, "")
big = d / "big.tgz"
with open(big, "wb") as f:
f.truncate(ship.seed.MAX_BYTES + 1) # sparse: nothing that size is really written
assert "over" in refused(ship.read, str(big))
def test_a_bad_slug_stops_before_anything_is_read():
env = dict(os.environ, SLUG="../etc", BUNDLE_FILE="/etc/passwd")
r = subprocess.run([sys.executable, str(HERE / "ship.py"), tempfile.mkdtemp()], env=env, capture_output=True,
text=True)
assert r.returncode == 1 and "bad slug" in r.stdout, r.stdout
if __name__ == "__main__":
tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
for t in tests:
t()
print("ok", t.__name__)
print(f"{len(tests)} ship tests passed")
sys.exit(0)
+88
View File
@@ -478,6 +478,94 @@ if _sh.which("node"):
assert res.returncode == 0 and "aicharts: all good" in res.stdout, res.stdout[-2000:] + res.stderr[-2000:]
print("ALL OK 41")
# ---------- Astra: the single planner, then the board for internal work (offline: fake forge, scripted seats) ----------
aspec = importlib.util.spec_from_file_location("astra", Path(__file__).resolve().parent / "astra.py")
astra = importlib.util.module_from_spec(aspec); aspec.loader.exec_module(astra)
assert astra.vt.tm is astra.tm # the board's seats spend through Astra's own governor and ledger
def astra_world(issues):
"""One day's forge for Astra: these open issues, no PRs, nothing closed, no spend, no spine. Records what Astra does."""
rec = {"labels": [], "new": [], "comments": []}
nums = {"n": 100}
def gh(method, path, body=None, **kw):
if method == "GET" and ("state=closed" in path or "/pulls?" in path or "/comments" in path):
return []
if method == "GET" and "/contents/" in path:
raise RuntimeError("GET -> 404 (no spine in the test)")
raise AssertionError(f"astra made an unexpected call: {method} {path}")
astra.tm.gh = gh
astra.tm.issues = lambda q: [dict(i) for i in issues]
astra.tm.budget = lambda: (0.0, 5.0, 0.0, 100.0)
astra.tm.is_time = lambda c: True
astra.tm.label = lambda n, name: rec["labels"].append((n, name))
def new_issue(title, body, labels):
nums["n"] += 1; rec["new"].append((nums["n"], title, body, labels)); return {"number": nums["n"]}
astra.tm.new_issue = new_issue
astra.tm.comment = lambda n, text, meta: rec["comments"].append((n, text, meta))
return rec
def _issue(n, title, body="", labels=()):
return {"number": n, "title": title, "body": body, "labels": [{"name": l} for l in labels], "updated_at": "2026-10-17T00:00:00Z"}
astra_issues = [_issue(1, "Astra log", "the log", ["astra"]),
_issue(2, "Fix the flaky heartbeat test", "- pin the clock\n\nDONE: test green three runs"),
_issue(3, "Big refactor", "- everything"), # no DONE line: never queueable
_issue(4, "Already moving", "- x\n\nDONE: y", ["ready"])] # already in flight: never queued again
for _k in ("TIME_ASTRA_PITCH", "TIME_ASTRA_BOARD", "TIME_ASTRA_MAX_NEW", "TIME_ASTRA_MAX_READY"):
os.environ.pop(_k, None)
# the single planner, as before: one call, its JSON applied under the guards
rec = astra_world(astra_issues)
astra.tm.think = lambda seat, model, effort, system, user: (
'{"note": "finish first", "queue": [4, 2, 3], "new": [{"title": "Add a retry to the heartbeat", "body": "- retry once\\n\\nDONE: tests green", "queue": true}]}')
assert astra.tick() == 0
assert rec["labels"] == [(2, "queued"), (101, "queued")], rec["labels"] # #4 is already moving; #3 falls outside the cap of two
assert [t for _, t, _, _ in rec["new"]] == ["Add a retry to the heartbeat"] and "DONE: tests green" in rec["new"][0][2]
(n, text, meta), = rec["comments"]
assert n == 1 and "Astra (opus)" in text and "Queued: #2" in text and "New cards: #101" in text and "a human's): #4" in text and meta["board"] == [], text
print("ALL OK astra 1")
# the board: two planners each propose a slate, the field folds duplicates, the board ranks it, the quorum decides
os.environ["TIME_ASTRA_PITCH"] = "sonnet, haiku"
slates = {"sonnet": '{"note": "unblock the test, then a small retry", "cards": [{"issue": 2, "why": "flaky"}, '
'{"title": "Add a retry to the heartbeat", "body": "- retry once\\n\\nDONE: tests green", "why": "cheap"}, {"issue": 4, "why": "move it"}]}',
"haiku": '{"note": "same test; and a rewrite", "cards": [{"issue": 2, "why": "flaky too"}, '
'{"title": "Add a retry to the heartbeat!", "body": "- retry twice\\n\\nDONE: green", "why": "same idea"}, '
'{"title": "Rewrite everything", "body": "- all of it\\n\\nDONE: never", "why": "big"}, {"issue": 3, "why": "no done line"}]}'}
ranks = {"sonnet": '{"ranking": ["#2", "new-2", "new-3"], "reason": "finish first"}',
"haiku": '{"ranking": ["new-2", "#2", "new-2"], "reason": "small and checkable"}'}
def seated(seat, model, effort, system, user):
assert "Board:" in user or "ranking the cards" in user, (system[:60], user[:60]) # slates read the board; the vote reads the field
return slates[seat] if "one of the planners" in system else ranks[seat]
astra.tm.think = seated
rec = astra_world(astra_issues)
assert astra.tick() == 0
assert rec["labels"] == [(2, "queued"), (101, "queued")], rec["labels"]
assert [t for _, t, _, _ in rec["new"]] == ["Add a retry to the heartbeat"], rec["new"] # "Rewrite everything" had one backer: never opened
(n, text, meta), = rec["comments"]
assert "board of sonnet, haiku" in text and meta["board"] == ["sonnet", "haiku"], text
assert "| #2 Fix the flaky heartbeat test | sonnet, haiku | 5 | 2 | picked |" in text, text
assert "| new-2 Add a retry to the heartbeat | sonnet, haiku | 5 | 2 | picked |" in text, text
assert "| new-3 Rewrite everything | haiku | 1 | 1 | |" in text and "`haiku` ranked new-2, #2:" in text, text
assert "| #4" not in text and "| #3" not in text, text # the guards dropped them before the vote, so the board never saw them
print("ALL OK astra 2")
# no quorum: a card one seat alone wants is not queued; the cap on new cards still holds
ranks = {"sonnet": '{"ranking": ["new-3"], "reason": "bold"}', "haiku": '{"ranking": [], "reason": "nothing worth it"}'}
rec = astra_world(astra_issues)
assert astra.tick() == 0 and rec["labels"] == [] and rec["new"] == [] and "No card was wanted by two seats" in rec["comments"][0][1]
ranks = {"sonnet": '{"ranking": ["new-2", "#2"], "reason": "r"}', "haiku": '{"ranking": ["new-2", "#2"], "reason": "r"}'}
os.environ["TIME_ASTRA_MAX_NEW"] = "0"
rec = astra_world(astra_issues)
assert astra.tick() == 0 and rec["labels"] == [(2, "queued")] and rec["new"] == [], (rec["labels"], rec["new"])
os.environ["TIME_ASTRA_BOARD"] = "opus" # a board of one seat can never reach the quorum of two
ranks["opus"] = '{"ranking": ["#2"], "reason": "r"}'
rec = astra_world(astra_issues)
assert astra.tick() == 0 and rec["labels"] == [] and "board of opus" in rec["comments"][0][1]
for _k in ("TIME_ASTRA_PITCH", "TIME_ASTRA_BOARD", "TIME_ASTRA_MAX_NEW"):
os.environ.pop(_k, None)
print("ALL OK astra 3")
# ---------- venture loop: still GitHub-shaped (Pages, author_association, per_page). Ported in its own card;
# until then these run only with TIME_TEST_VENTURE=1 against the old fake.
if not os.environ.get("TIME_TEST_VENTURE"):
+5 -1
View File
@@ -1,6 +1,8 @@
name: astra
# The slow loop: once a day, one planning call (TIME_ASTRA_SEAT, default `opus`) reads the board and the ledger and
# labels a few cards `ready` for the heartbeat. It writes its reasoning to the "Astra log" issue; reply there to steer it.
# labels a few cards `queued` for the heartbeat to promote. It writes its reasoning to the "Astra log" issue; reply there to steer it.
# With TIME_ASTRA_PITCH (and TIME_ASTRA_BOARD) set, several seats propose slates and a board ranks them, the venture
# ceremony turned inward: only cards two seats want get queued.
# Guardrails are code: TIME_ASTRA_MAX_READY (2) / TIME_ASTRA_MAX_NEW (2) per day, nothing while an `andon` is open,
# and the same spend caps as every other call. Off until TIME_ASTRA=on.
on:
@@ -27,6 +29,8 @@ jobs:
TIME_TOKEN: ${{ secrets.TIME_TOKEN }} # repo secret: Astra's issues and labels go out as the `time` bot
VENICE_API_KEY: ${{ secrets.VENICE_API_KEY }}
TIME_ASTRA_SEAT: ${{ vars.TIME_ASTRA_SEAT }}
TIME_ASTRA_PITCH: ${{ vars.TIME_ASTRA_PITCH }} # the board: seats that each propose a slate (unset: one planner)
TIME_ASTRA_BOARD: ${{ vars.TIME_ASTRA_BOARD }} # the board: seats that rank the field (default: the pitching seats)
TIME_ASTRA_MAX_READY: ${{ vars.TIME_ASTRA_MAX_READY }}
TIME_ASTRA_MAX_NEW: ${{ vars.TIME_ASTRA_MAX_NEW }}
TIME_SPINE: ${{ vars.TIME_SPINE }}
-1
View File
@@ -19,4 +19,3 @@ jobs:
node-version: 22
- run: python3 .github/time/test_time.py
- run: python3 .github/time/test_seed.py
- run: python3 .github/time/test_ship.py
+95
View File
@@ -0,0 +1,95 @@
name: refresh
# Refresh a venture that lives on gh-pages and makes some of its own files (fonts, art, screenshots, generated
# code): run ventures/<slug>/tools/build.sh on the runner, in place, then commit what changed to gh-pages in one
# commit and ask Teahub pages to pull. Actions -> refresh -> Run workflow.
# `from` takes the venture's folder from another branch (say, a rework in progress), so the new version is built and
# lands whole instead of file by file. The build runs in a checkout without credentials; only ventures/<slug>/ comes
# back. Chrome for screenshots: CHROME (Playwright's Chromium is installed when the runner has none), with
# CHROME_FLAGS for running as root.
on:
workflow_dispatch:
inputs:
slug:
description: "The venture: ventures/<slug>/ on gh-pages, with a tools/build.sh"
required: true
type: string
from:
description: "Branch to take the venture's folder from (empty: gh-pages itself)"
required: false
type: string
permissions:
contents: write
concurrency:
group: venture # the venture loop and seed write gh-pages too: take turns
cancel-in-progress: false
jobs:
refresh:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: check the inputs
env: # inputs reach the scripts through the environment, never interpolated into them
SLUG: ${{ inputs.slug }}
FROM: ${{ inputs.from }}
run: |
printf '%s' "$SLUG" | grep -Eqx '[a-z0-9]([a-z0-9-]{0,40}[a-z0-9])?' || { echo "::error::bad slug: $SLUG"; exit 1; }
[ -z "$FROM" ] || printf '%s' "$FROM" | grep -Eqx '[A-Za-z0-9._/-]{1,100}' || { echo "::error::bad branch: $FROM"; exit 1; }
- uses: actions/checkout@v5
with:
ref: ${{ inputs.from || 'gh-pages' }}
path: work
fetch-depth: 1
sparse-checkout: ventures/${{ inputs.slug }}
persist-credentials: false # the build that runs next must not find a token in .git/config
- uses: actions/setup-node@v4
with:
node-version: 22
- name: find Chrome, or install Playwright's Chromium
run: |
set -eu
for c in google-chrome google-chrome-stable chromium chromium-browser; do
if command -v "$c" >/dev/null 2>&1; then
echo "CHROME=$(command -v "$c")" >> "$GITHUB_ENV"; echo "using $(command -v "$c")"; exit 0
fi
done
echo "no Chrome on this runner: installing Playwright's Chromium"
npx -y playwright@1.55.0 install --with-deps chromium > /tmp/pw.log 2>&1 || { tail -20 /tmp/pw.log; exit 1; }
c=$(find "$HOME/.cache/ms-playwright" -type f -name chrome -path '*chrome-linux*' | head -1)
[ -n "$c" ] && [ -x "$c" ] || { echo "::error::no Chromium after the install"; exit 1; }
echo "CHROME=$c" >> "$GITHUB_ENV"; echo "using $c"
- name: build
env:
SLUG: ${{ inputs.slug }}
CHROME_FLAGS: "--no-sandbox --disable-dev-shm-usage --disable-gpu"
run: |
set -eu
[ -f "work/ventures/$SLUG/tools/build.sh" ] || { echo "::error::ventures/$SLUG has no tools/build.sh"; exit 1; }
sh "work/ventures/$SLUG/tools/build.sh"
- uses: actions/checkout@v5
with:
ref: gh-pages
path: pages
fetch-depth: 1
- name: commit what changed
env:
SLUG: ${{ inputs.slug }}
FROM: ${{ inputs.from }}
run: |
set -eu
[ -f "pages/ventures/ventures.json" ] || { echo "::error::no ledger on gh-pages"; exit 1; }
rm -rf "pages/ventures/$SLUG"
cp -R "work/ventures/$SLUG" "pages/ventures/$SLUG"
find "pages/ventures/$SLUG" -name __pycache__ -type d -prune -exec rm -rf {} +
cd pages
git add --all "ventures/$SLUG"
if git diff --cached --quiet; then echo "nothing changed in ventures/$SLUG"; exit 0; fi
git diff --cached --stat | tail -25
git -c user.name='github-actions[bot]' -c user.email='github-actions[bot]@users.noreply.teahub.xyz' \
commit -q -m "ventures: refresh $SLUG${FROM:+ from $FROM}"
if ! git push -q origin HEAD:gh-pages; then # gh-pages moved under us: replay this one commit on top
git pull -q --rebase origin gh-pages
git push -q origin HEAD:gh-pages
fi
echo "refreshed: ventures/$SLUG -> https://$SLUG.yolopreneur.lol/"
- name: tell Teahub pages to pull
run: curl -fsS -X POST "https://yolopreneur.lol/_hook/${GITHUB_REPOSITORY}" || echo "pages pulls by itself within five minutes"
-90
View File
@@ -1,90 +0,0 @@
name: ship
# Ship a venture whose source lives in its own repo on Teahub: clone it, build it here, and land the result the way
# seed does (ventures/<slug>/ on gh-pages, its line in ventures/ventures.json, served at <slug>.yolopreneur.lol).
# Actions -> ship -> Run workflow. Nothing is built or carried outside the factory.
# The source repo can be private: the `time` bot's token clones it, for that one step, and isn't kept. Its build is
# `tools/build.sh OUT`, which leaves OUT/<slug>.tgz with <slug>/ at the top; Chrome for its screenshots comes from
# CHROME (Playwright's Chromium is installed when the runner has none), with CHROME_FLAGS for running as root.
on:
workflow_dispatch:
inputs:
repo:
description: "The venture's source repo on Teahub, owner/repo"
required: true
type: string
slug:
description: "Folder and subdomain: ventures/<slug>/ is served at <slug>.yolopreneur.lol"
required: true
type: string
ref:
description: "Branch, tag or commit to build (empty: the repo's default branch)"
required: false
type: string
name:
description: "Name in the ledger (needed for a new venture)"
required: false
type: string
one_liner:
description: "One line in the ledger (needed for a new venture)"
required: false
type: string
permissions:
contents: write
concurrency:
group: venture # the venture loop and seed write gh-pages too: take turns
cancel-in-progress: false
jobs:
ship:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v5
with:
repository: ${{ inputs.repo }}
ref: ${{ inputs.ref }}
token: ${{ secrets.TIME_TOKEN }}
path: src
persist-credentials: false # the build that runs next must not find the token in .git/config
- uses: actions/setup-node@v4
with:
node-version: 22
- name: find Chrome, or install Playwright's Chromium
run: |
set -eu
for c in google-chrome google-chrome-stable chromium chromium-browser; do
if command -v "$c" >/dev/null 2>&1; then
echo "CHROME=$(command -v "$c")" >> "$GITHUB_ENV"; echo "using $(command -v "$c")"; exit 0
fi
done
echo "no Chrome on this runner: installing Playwright's Chromium"
npx -y playwright@1.55.0 install --with-deps chromium > /tmp/pw.log 2>&1 || { tail -20 /tmp/pw.log; exit 1; }
c=$(find "$HOME/.cache/ms-playwright" -type f -name chrome -path '*chrome-linux*' | head -1)
[ -n "$c" ] && [ -x "$c" ] || { echo "::error::no Chromium after the install"; exit 1; }
echo "CHROME=$c" >> "$GITHUB_ENV"; echo "using $c"
- name: build
working-directory: src
env: # inputs reach the scripts through the environment, never interpolated into them
REPO: ${{ inputs.repo }}
CHROME_FLAGS: "--no-sandbox --disable-dev-shm-usage --disable-gpu"
run: |
set -eu
sh tools/build.sh "$GITHUB_WORKSPACE/out"
echo "SOURCE=$REPO@$(git rev-parse --short HEAD)" >> "$GITHUB_ENV"
- uses: actions/checkout@v5
with:
sparse-checkout: .github/time
path: engine
persist-credentials: false
- uses: actions/checkout@v5
with:
ref: gh-pages
path: pages
fetch-depth: 1
- name: land
env:
SLUG: ${{ inputs.slug }}
NAME: ${{ inputs.name }}
ONE_LINER: ${{ inputs.one_liner }}
run: BUNDLE_FILE="$GITHUB_WORKSPACE/out/$SLUG.tgz" python3 engine/.github/time/ship.py pages
- name: tell Teahub pages to pull
run: curl -fsS -X POST "https://yolopreneur.lol/_hook/${GITHUB_REPOSITORY}" || echo "pages pulls by itself within five minutes"
+1
View File
@@ -18,6 +18,7 @@
- The second fix round escalates to Kimi's code model (`venice:kimi code`) instead of grok 4.7: still a different model from the first round, at a fraction of the cost. `TIME_VENTURE_ESCALATE` overrides it.
### Added
- Astra's board: `TIME_ASTRA_PITCH` (seats that each propose a slate of cards) and `TIME_ASTRA_BOARD` (seats that rank the merged field; default the pitching seats) turn the daily planner into the venture loop's ceremony pointed at the factory's own work. The slates merge (the same issue or title from two seats is one card with both proposers), the board ranks the field with the venture board's own Borda tally and quorum of two, and only the top picks are queued or created, under the single planner's guards and caps (`TIME_ASTRA_MAX_READY`, `TIME_ASTRA_MAX_NEW`); a card one seat alone wants is never opened. The Astra log carries the slates, the table of scores and backers, and each seat's reason. Unset, Astra is unchanged.
- A batch's children start in the tick that spawned them (GitHub lists a card made a moment ago only later, so they used to wait a tick).
- Build briefs keep claims to what the code does: no promised features or schedules, and the trending repos are the wave a venture rides, never something it "builds on". Fix rounds take a failing claim off the page rather than building a feature in a hurry. In the first live batch, every fix round was a claim the code didn't back.
- Venture batches (multi-ceremony): one batch card scouts once, the seats pitch a field (rounds until there are 2k pitches), and the board ranks the whole field; the top k, each wanted by two seats, become venture cards in the batch's own milestone. They run the ordinary loop side by side and launch together: a child that passes its checks waits at `ready` until every sibling is settled or the hold (`TIME_VENTURE_BATCH_HOLD_HOURS`, 6) runs out; stragglers then launch alone. No child left behind: a held child gets one batch retry, and held again it is counted dead with its reason. A retro on the batch card compares the cohort's signups against each plan's lines, then the card and milestone close. Set `TIME_VENTURE_BATCH=3` to make batches the daily default, or Actions → venture → Run workflow → "batch".
+2 -1
View File
@@ -6,10 +6,11 @@ A Gitea-native agent factory, running on Teahub prod (teahub.xyz). The Gitea rep
- **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 seat such as `claude:sonnet` or `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. **Spend governor:** every model call is booked to `time/ledger/<YYYY-MM>.jsonl` (tokens and dollars); once today's spend reaches `TIME_DAILY_USD` (default 5) or the month's reaches `TIME_MONTHLY_USD` (default 100), Time stops calling models, opens an `andon` issue, and a monthly breach also sets `TIME_MODE=dry-run`. No model decides whether to spend.
- **Mentions** (`mention.yml`): `@sonnet`, `@opus`, `@kimi`, `@grok 4.7`, `@qwen max high` in any issue, PR or comment gets that seat's reply.
- **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.
- **Astra** (`astra.yml`, daily once `TIME_ASTRA=on`): the slow loop. One planning seat (`TIME_ASTRA_SEAT`, default `opus`) reads the board, the ledger and the spine's `AGENTS.md`, then queues at most `TIME_ASTRA_MAX_READY` existing cards (each needs a `DONE:` line) and writes at most `TIME_ASTRA_MAX_NEW` new ones, all labelled `queued` for the heartbeat to promote one at a time. It writes its reasoning to the "Astra log" issue, and replies there steer it; nothing while an `andon` is open. With a board (`TIME_ASTRA_PITCH`: seats that each propose a slate; `TIME_ASTRA_BOARD`: seats that rank it, default the same), the venture ceremony runs inward: every planning seat proposes a few cards, the board ranks the merged field (Borda 3/2/1, a quorum of two), and only the top picks are queued or created, so a card one seat alone wants is never opened.
- **Venture loop** (`venture.yml`, every 20 min once `TIME_VENTURE=on`): one venture card a day, or one right away from Actions → venture → Run workflow → "Start a new venture now". It scouts the repos that gained the most stars this week; seats pitch; the board votes (Borda, quorum of two); every board seat writes a launch plan (positioning, first users, pricing, channels, 7-day success and kill lines) and one seat synthesizes them into `LAUNCH.md`; the worker builds the product as the page on `gh-pages` (the tool fills the first screen under a slim header with the one signup button; pricing, limits and the why sit below); deterministic checks and headless Chrome (phone and desktop) gate it, with two fix rounds before an andon; GitHub Pages ships it at `/ventures/<slug>/`. Signups go through an issue form (`.github/ISSUE_TEMPLATE/early-access.yml`), each one thanked on its issue; after 7 days the count of distinct people decides: build v2, kill, or watch a week more. The studio index lives at `/ventures/`. Launch posts are drafts for a human; nothing is posted outside the repo.
- **Batches** (`TIME_VENTURE_BATCH=3`, or Run workflow → "batch"): one scout pass, a field of pitches, and the board's top k become venture cards in one milestone, built side by side and launched together. A held child gets one batch retry before it's counted dead; a retro compares the cohort once every child is measured.
- **Seed** (`seed.yml`, manual): land a venture that was built somewhere else. Give it a slug, the URL of a .tgz whose top folder is that slug (or of its parts, in order) and the .tgz's sha256, plus a name and a one-liner for a new venture. `seed.py` checks the bundle, copies it to `ventures/<slug>/` on `gh-pages` in one commit, adds or refreshes its line in `ventures/ventures.json`, and asks Teahub pages to pull, so it is live at `<slug>.yolopreneur.lol` within the minute. Nothing in the bundle runs.
- **Ship** (`ship.yml`, manual): seed for a venture whose source lives in its own repo on Teahub, so nothing is built or carried outside the factory. Give it the repo (private is fine: the `time` bot's token clones it), the slug, and a name and a one-liner for a new venture. It runs the repo's `tools/build.sh OUT`, which leaves `OUT/<slug>.tgz`, on the runner (installing Chromium for screenshots when the runner has no Chrome), then `ship.py` lands that bundle the way seed does.
- **Refresh** (`refresh.yml`, manual): for a venture that lives on gh-pages and makes some of its own files (fonts, art, screenshots, generated code). It runs `ventures/<slug>/tools/build.sh` on the runner, in place and without credentials, then commits what changed to gh-pages in one commit and asks Teahub pages to pull. `from` takes the folder from another branch first, so a reworked venture lands whole.
- **Backends** (optional, `CLOUDFLARE_API_TOKEN` + `CLOUDFLARE_ACCOUNT_ID`): each venture also gets its own Cloudflare Worker, D1 database and Workers AI, serving its Pages folder at `<slug>.<TIME_VENTURE_DOMAIN>`. The page talks to it through `time.js` (share links that save a creation, a rate-limited small model, and accounts with passkeys: no passwords, no email); every call degrades gracefully, so products still work on Pages alone. Code: `.github/time/cloudflare.py` and `cloudflare/worker.js`.
- **Doctor** (`doctor.yml`, manual): runs the tests and checks label, settings, keys and today's spend.
- **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.
+2
View File
@@ -165,3 +165,5 @@
{"at":"2026-10-16T00:10:39Z","model":"claude-sonnet-5-5","in":47674,"out":1158,"usd":0.106928}
{"at":"2026-10-16T00:11:05Z","model":"claude-sonnet-5-5","in":49438,"out":3150,"usd":0.130376}
{"at":"2026-10-16T00:11:37Z","model":"claude-sonnet-5-5","in":51544,"out":3967,"usd":0.142758}
{"at":"2026-10-16T13:13:33Z","model":"claude-opus-5-5","in":27046,"out":777,"usd":0.123724}
{"at":"2026-10-17T13:13:44Z","model":"claude-opus-5-5","in":26444,"out":667,"usd":0.119116}