Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2eb49c5e2 | ||
|
|
7fec9f0c3d | ||
|
|
5709ebec51 | ||
|
|
5332098a67 |
No files matched your search
+155
-5
@@ -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
|
||||
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user