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
5 changed files with 250 additions and 6 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
+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
@@ -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".
+1
View File
@@ -6,6 +6,7 @@ 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.