Files
time/.github/time/astra.py
T
Claude 31f382d0e9
ci / test (pull_request) Successful in 44s
heartbeat / tick (pull_request_review) Successful in 10s
Router: answer haiku's review of the optimization candidates
Two of the four points hold and are fixed; two don't, and nothing changed for
them. The review was read from the diff alone, and the two it gets wrong are
both about code the diff doesn't carry.

**The cloud lane (holds as a test, not as a bug).** `pick_cloud` already skips a
candidate with no card: `router.py:333` reads `if r["n"] is not None and
r["actionable"] and ...`, and so does `order()` at `router.py:357`. Both guards
came with the cloud lane in #197, for `venture:new`, which has `n: None` for the
same reason an `opt:` candidate does; this card added a second kind of cardless
candidate and inherited them. So `tm.label(None, "cloud")` cannot happen and the
table cannot say "to the cloud today" for an `opt:` row. No code changed. The
test the review asks for is worth having and is now there: an `opt:` candidate
judged value 5 ranks first (3.75 against 0.765) under `per_day: 2, sizes:
["S", "M"]`, and the cloud lane takes only the real card behind it. Dropping
the `n is not None` from `pick_cloud` fails it.

**The parked window (holds as wording only).** The arithmetic is already what
the review proposes. `_recent(at, today, 1)` is `at >= today - 1 day`, and a
parked card is a candidate when that is false, i.e. when `at < today - 1 day` —
the review's own suggested fix. In whole days that is "parked the day before
yesterday or earlier", which is what "older than a day" means at day
granularity, so the threshold matches the card. What was wrong was the
docstring's "nor is one parked only today": it also holds back one parked
yesterday. `parked_candidates` now says so, and the boundary is pinned by test
(0 and 1 days yield nothing, 2 yields the candidate). No code changed.

**A closed card no longer lets its source come back (fixed).** This was real.
`named` was given only the open cards, so the day an optimization card closed
the same finding was proposed again — the ledger's window is seven days and
outlives the card written about it, and `repeat_of` cannot catch it because the
title carries the dollar amount, which moves. `router_tick` now keys the cards
closed lately too (`named(open_issues + closed)`; `closed_lately()` moved above
the candidate gathering, no extra call). Suppression lasts as long as
`closed_lately`'s fortnight, by which time the seven-day window no longer holds
the week the card was about, so a finding that is still true comes back as a
fresh question rather than never.

**`parked_cards` ignored a human retry (fixed).** The fourth point arrived
truncated at "astra.py, `parked_c", so I audited the function instead of
guessing at it, and it had a gap of its own: it checked #78's automatic retries
but not `/time retry`. The heartbeat clears a parked card's `last` for either
one (`time.py:1613` for the typed retry, `time.py:1622` for the automatic), so a
card someone trusted had already asked to retry was still proposed as "retry or
split #N" — a card for work the next heartbeat was about to do. It now skips
those, reusing `tm.retry_requested` so the rule is the heartbeat's, and the
tests cover the three cases that distinguish it: a trusted retry after the beat
holds the card back, a stranger's does not, and neither does one older than the
beat it would answer.

Audited and left alone: `open_pr` stays on `linked_prs`, not `adoptable`. It
looks like the broader check, but a card whose last beat is `error` is never
adopted (`time.py:1637` adopts only from `None`/`needs-done`/`would-dispatch`),
so a `PR: #N` line on a parked card does not put it in the review path, and
using `adoptable` would have hidden a genuinely stuck card. `linked_prs` is also
exactly what the heartbeat uses for this same decision at `time.py:1622`.

`python3 .github/time/test_time.py`, `test_seed.py` and `test_housekeeping.py`
are green. Each fix fails its new check when reverted.

Closes #171

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqK2e5z4sMp2BKghDt1D8w
2026-10-19 22:10:29 +00:00

425 lines
26 KiB
Python

"""Astra: the factory's daily planner. One model call a day that reads the ledger and the board and decides what
to work on next. It queues; it never spends on its own beyond its one call.
python3 .github/time/astra.py tick # daily (astra.yml): read, decide, queue up to a few cards
Luna is Time's deterministic tick (no model); Sol is the hourly heartbeat that executes cards with a seat; Astra is the
slow outer loop that orients them. Astra labels its picks `queued`, never `ready`: the heartbeat's promote_next moves
one queued card at a time to `ready` (oldest first, `Depends on #N` respected), so Astra's work lines up behind
whatever is already queued instead of running beside it on the same files. Guardrails are code, not prompt: at most
TIME_ASTRA_MAX_READY cards queued and TIME_ASTRA_MAX_NEW cards created per run; an existing issue is only queued if it
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.
The router (time/router.json, `mode`: off | dry | live; router.py): instead of picking cards in prose, the seat judges
every candidate (its kind, its value 1-5, its size) and code ranks them with the owner's weights per kind, a restless
boost for kinds not picked lately, and the card's age. The candidates include the optimizations the factory's own
records show (#171): the ledger's spend by model and entry point, workflows whose runs keep failing, and cards parked
with their retries spent. Those have no card yet, so a picked one is written as one (unqueued) instead of being queued,
and a source a card already names is not proposed again, open or closed lately. The log shows the ranked table. `dry`
writes it and queues nothing; `live` queues from the top while the day's money left covers each card's estimated cost
(the allowance left in the pool that takes the worker's route, else the daily cap's remainder, never more than the month has left),
creates the seat's new cards unqueued (they are candidates the next day; never a repeat of an open or lately closed
card), and the heartbeat promotes queued cards in the ranked order.
`off`, or no router.json, is the planner as before.
"""
from __future__ import annotations
import base64
import datetime as dt
import importlib.util
import json
import os
import re
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
_spec = importlib.util.spec_from_file_location("tm", HERE / "time.py")
tm = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(tm)
_rspec = importlib.util.spec_from_file_location("router", HERE / "router.py")
router = importlib.util.module_from_spec(_rspec)
_rspec.loader.exec_module(router)
ROUTER_FILE = "time/router.json"
SPINE = os.environ.get("TIME_SPINE") or "teahubxyz/teahub" # where AGENTS.md and docs/decisions.md live
LOG_TITLE = "Astra log"
SKIP = {"queued", "ready", "needs-human", "andon", "astra", # already moving, a human's, or Astra's own log
"venture", "venture-batch", "signal", "signup"} # the venture loop's own: it runs them itself
RULES = """You are Astra, the daily planner of a small, fully AI-run software factory (Teahub). Once a day you read the
state below and decide what the factory should work on next. You do not do the work: you queue cards, and a worker seat
builds queued cards one at a time; you only choose and write cards.
Rules:
- Prefer finishing over starting. Unblock open PRs and half-done cards before opening new work. Cards already labelled
`queued` or `ready` are already lined up; don't queue them again.
- A card is an issue with a clear list of changes and one `DONE:` line (what must be true for it to be finished, e.g.
"DONE: PR open, tests green"). Small, one PR each, a worker can finish in one session.
- Never touch workflows, secrets, variables, or anything under docs/decisions.md; those are a human's. Never queue
anything that posts outside the repo.
- Respect the budget: the day's cap is small. At most {max_ready} cards may be queued and {max_new} created.
- If nothing is worth doing, say so; an empty day is fine.
Answer with JSON only, no prose around it:
{{"note": "<3-6 sentences: what the factory did yesterday, what matters today, and why you chose these>",
"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}}]}}"""
ROUTER = """You are Astra, the daily planner of a small, fully AI-run software factory (Teahub). Today you judge the
candidates below; code ranks them with the owner's weights and queues from the top, so don't choose: judge each one.
For every candidate give:
- kind: venture (a new venture, or a venture's next version), feature (new functionality), refinement (making what
exists better: fixes, polish, a live venture's page or skills) or optimization (cost, speed, reliability)
- value: 1-5, what finishing it is worth to the factory now (5: it unblocks the factory, or the owner asked for it and
it matters today; 1: nice to have, stale, or a duplicate of another card)
- size: S (well under one worker session), M (one session), L (more than one: it must be split first)
- why: one short sentence
- blocked: only when it can't move today, what it waits on (a PR that can't merge, a human, something outside the
repo); leave it out otherwise. A blocked card is never queued, however it scores. A card whose `Depends on #N` card
is still open is already held back by code.
A candidate whose key starts `opt:` is not a card yet: it is something the factory's own records show (a route that took
over half the week's spend, a workflow that keeps failing, a card parked with its retries spent), with a title and body
already drafted. Judge it like the rest; if it is picked it is written as a card for a worker to take later, never
worked on today, so judge whether that work is worth a card at all.
You may also write up to {max_new} new cards for work the board is missing; they are candidates from tomorrow on.
Never write one that posts outside the repo or touches workflows, secrets or variables, and never one for work that is
already a candidate or is in the list of cards closed lately: that work is done or under way.
Answer with JSON only, no prose around it:
{{"note": "<3-6 sentences: what happened yesterday and what matters today>",
"assess": [{{"key": "<the candidate's key, e.g. #82>", "kind": "feature", "value": 3, "size": "M", "why": "..."}}],
"new": [{{"title": "<card title>", "body": "<what to change, as a list, ending with a DONE: line>"}}]}}"""
def env_int(name: str, default: int) -> int:
try:
return int(os.environ.get(name) or default)
except ValueError:
return default
def spine_file(path: str) -> str:
try:
import base64
return base64.b64decode(tm.gh("GET", f"/repos/{SPINE}/contents/{path}")["content"]).decode(errors="replace")
except Exception as e: # noqa: BLE001 the spine is context, not a dependency
return f"(could not read {SPINE}/{path}: {str(e)[:80]})"
def board() -> dict:
"""What Astra reads: open issues and PRs, yesterday's closed issues, andons, spend."""
open_issues = tm.issues("state=open")
closed = tm.gh("GET", f"/repos/{tm.REPO}/issues?type=issues&state=closed&limit=20")
prs = tm.gh("GET", f"/repos/{tm.REPO}/pulls?state=open&limit=20")
today, day_cap, month, month_cap = tm.budget()
trim = lambda i: {"n": i["number"], "title": i["title"], "labels": [l["name"] for l in i.get("labels") or []],
"has_done": bool(re.search(r"^\s*DONE:", i.get("body") or "", re.M | re.I)),
"body": (i.get("body") or "")[:600], "updated": i.get("updated_at")}
return {"issues": [trim(i) for i in open_issues if i["title"] != LOG_TITLE],
"closed_recently": [{"n": i["number"], "title": i["title"], "closed": i.get("closed_at")} for i in closed[:20]],
"prs": [{"n": p["number"], "title": p["title"], "head": p["head"]["ref"], "mergeable": p.get("mergeable")} for p in prs],
"andons": [i["number"] for i in open_issues if any(l["name"] == "andon" for l in i.get("labels") or [])],
"spend": {"today": round(today, 2), "daily_cap": day_cap, "month": round(month, 2), "monthly_cap": month_cap,
"pools": {p["name"]: {"spent": round(tm.pool_spent(p), 2), "allowance": round(tm.allowance(p), 2)} for p in tm.pools()}}}
def log_issue() -> int:
for i in tm.issues("state=open"):
if i["title"] == LOG_TITLE:
return i["number"]
return tm.new_issue(LOG_TITLE, "Astra, the daily planner, writes one comment here per day: what it read, what it "
"queued, and why. Object in a reply; Astra reads this thread too.", ["astra"])["number"]
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)
m = re.search(r"\{.*\}", raw, re.S)
try:
out = json.loads(m.group(0)) if m else {}
except ValueError:
out = {}
picks = out.get("queue") if out.get("queue") is not None else out.get("ready") # older answers said "ready"
return {"note": str(out.get("note") or raw[:800]), "queue": [int(n) for n in picks or [] if str(n).isdigit()],
"new": [c for c in out.get("new") or [] if isinstance(c, dict) and c.get("title")]}
def router_config() -> dict:
"""time/router.json on the default branch; missing or unreadable, the router is off and Astra plans as before."""
try:
f = tm.gh("GET", f"/repos/{tm.REPO}/contents/{ROUTER_FILE}")
return router.config(base64.b64decode(f.get("content") or "").decode(errors="replace"))
except Exception as e: # noqa: BLE001
if "-> 404" not in str(e):
print(f"router config unreadable: {str(e)[:120]}")
return router.config(None)
def recent(log: int, days: int = 14) -> list[dict]:
"""The log's comments of the last `days` days, oldest first (Gitea pages from the oldest, so a plain limit=20
read the thread's first twenty comments forever)."""
since = (dt.datetime.now(dt.timezone.utc) - dt.timedelta(days=days)).strftime("%Y-%m-%dT%H:%M:%SZ")
out: list[dict] = []
for page in range(1, 5):
batch = tm.gh("GET", f"/repos/{tm.REPO}/issues/{log}/comments?since={since}&limit=50&page={page}") or []
out += batch
if len(batch) < 50:
break
return out
def parse(raw: str) -> dict:
"""The first JSON object in the seat's reply."""
dec = json.JSONDecoder()
for m in re.finditer(r"\{", raw or ""):
try:
out, _ = dec.raw_decode(raw, m.start())
except ValueError:
continue
if isinstance(out, dict):
return out
return {}
def money_left() -> tuple[float, str]:
"""What the router may queue against today, and where it comes from (#175): the allowance left in the pool that takes
the worker's route (as budget_check sees it), or the daily cap's remainder without one; either way no more than
what's left of the monthly cap."""
today, day_cap, month, month_cap = tm.budget()
spec = (os.environ.get("TIME_WORKER") or "cursor").strip()
p = tm.pool_for(tm.provider_of(tm.worker_seat_model(spec)[1])) if tm.seat_worker(spec) else None
left, source = (tm.allowance(p) - tm.pool_spent(p), f"pool {p['name']}") if p else (day_cap - today, "daily cap")
return max(0.0, min(left, month_cap - month)), source
def closed_lately(days: int = 14) -> list[dict]:
"""Cards closed (done, or dropped) in the last `days` days, newest first: the seat sees them so it doesn't write a
card for work already finished (it proposed the housekeeping label fix two days running after #137 had landed)."""
since = (dt.datetime.now(dt.timezone.utc) - dt.timedelta(days=days)).strftime("%Y-%m-%dT%H:%M:%SZ")
try:
return [i for i in tm.issues(f"state=closed&since={since}") if i.get("title") != LOG_TITLE]
except Exception as e: # noqa: BLE001 context, not a dependency
print(f"closed cards unreadable: {str(e)[:120]}")
return []
def ledger_week(days: int = router.OPT_DAYS) -> list[dict]:
"""The ledger lines the router reads (#171): this month's file, and the previous one when the window straddles it.
router.ledger_candidates keeps the last `days` days of them."""
now = dt.datetime.now(dt.timezone.utc)
months = sorted({now.strftime("%Y-%m"), (now - dt.timedelta(days=days)).strftime("%Y-%m")})
try:
return [r for m in months for r in tm.ledger_lines(m) if isinstance(r, dict)]
except Exception as e: # noqa: BLE001 a source, not a dependency: unreadable is no candidates from it
print(f"ledger unreadable: {str(e)[:120]}")
return []
def action_runs(limit: int = 50) -> list[dict]:
"""The newest workflow runs, from the endpoint `tea_actions` reads (`/actions/runs`; Gitea wraps them in
`workflow_runs`)."""
try:
got = tm.gh("GET", f"/repos/{tm.REPO}/actions/runs?limit={limit}")
except Exception as e: # noqa: BLE001
print(f"actions runs unreadable: {str(e)[:120]}")
return []
return got if isinstance(got, list) else (got or {}).get("workflow_runs") or []
def parked_cards() -> list[dict]:
"""The `ready` cards whose last real beat parked them, with what the router needs of each: the beat and when it was
written, whether a PR of theirs is open, and how many of #78's automatic retries are left. A card someone trusted
has typed `/time retry` on since that beat is left out: the next heartbeat dispatches it (it clears `last` exactly
as a spare automatic retry does), so proposing "retry or split" for it would be a card for work already under way."""
auto_max = env_int("TIME_AUTO_RETRY", 1)
out: list[dict] = []
try:
prs = tm.gh("GET", f"/repos/{tm.REPO}/pulls?state=open&limit=50") or []
for card in tm.issues("state=open&labels=ready"):
cs = tm.gh("GET", f"/repos/{tm.REPO}/issues/{card['number']}/comments?limit=100") or []
beats = [b for b in tm.beats_of(cs) if b.get("beat") not in ("blocked", "capped")]
if not beats or beats[-1].get("beat") not in ("error", "stuck"):
continue
if tm.retry_requested(cs, beats[-1]["at"]):
continue # a person already asked for the retry: the next heartbeat runs it, so it needs no card
# an automatic retry is one run of consecutive `auto_retry` beats, counted as the heartbeat counts it
used = sum(1 for k, b in enumerate(beats) if b.get("auto_retry") and not (k and beats[k - 1].get("auto_retry")))
out.append({"n": card["number"], "title": card.get("title") or "", "beat": beats[-1]["beat"],
"at": beats[-1]["at"], "retries_left": max(0, auto_max - used),
"open_pr": any(p.get("state", "open") == "open" for p in tm.linked_prs(prs, card["number"]))})
except Exception as e: # noqa: BLE001
print(f"parked cards unreadable: {str(e)[:120]}")
return out
def _norm(title: str) -> str:
return re.sub(r"[^a-z0-9]+", " ", (title or "").lower()).strip()
def repeat_of(title: str, known: list[dict]) -> int | None:
"""The open or lately closed card a proposed title repeats, if any: the same words, or the whole of one title (four
words or more) inside the other. Not a similarity score: sibling cards share most of their words ("Tool: havre_check",
"Tool: havre_new") and are not repeats. Paraphrases are the seat's to avoid, with the closed list in front of it."""
t = _norm(title)
for i in known:
k = _norm(i.get("title", ""))
short, long_ = sorted((t, k), key=len)
if t and k and (t == k or (len(short.split()) >= 4 and short in long_)):
return i.get("number")
return None
def router_tick(cfg: dict, seat: str, log: int, context: str, thread: list[dict], max_ready: int, max_new: int) -> int:
"""The seat judges every candidate; router.py ranks them; live queues from the top, dry only shows the ranking."""
open_issues = [i for i in tm.issues("state=open") if i.get("title") != LOG_TITLE]
closed = closed_lately()
today_d = dt.datetime.now(dt.timezone.utc).date()
cands = router.candidates(open_issues) + [dict(router.NEW_VENTURE)]
# what no one wrote a card for (#171): the ledger, the Actions runs and the parked cards, each proposed once. The
# cards closed lately name their sources too: a week's spend outlives the card written about it, so keying the open
# ones alone would propose the same finding again the day its card closed.
cands += router.opt_candidates(ledger_week(), action_runs(), parked_cards(), today_d, router.named(open_issues + closed))
listing = "\n\n".join(f"### {c['key']}: {c['title']}\nlabels: {', '.join(c['labels']) or '-'}; opened "
f"{c['created'][:10] or '-'}; cards waiting on it: {c['unblocks']}\n{c['body'][:400]}" for c in cands)
today, day_cap, month, month_cap = tm.budget()
left, source = money_left()
done = "\n".join(f"- #{i['number']} {i['title']}" for i in closed[:50]) or "- (none)"
user = (f"Factory context (the spine's AGENTS.md and the newest decisions):\n{context}\n\nSpend: ${today:.2f} of "
f"${day_cap:.2f} today, ${month:.2f} of ${month_cap:.2f} this month out of pocket (credit-paid spend excluded); "
f"${left:.2f} left to queue against ({source})."
f"\n\nCards closed in the last 14 days (done or dropped; don't write these again):\n{done}"
f"\n\nCandidates ({len(cands)}):\n\n{listing}")
try:
out = parse(tm.think(seat, None, None, ROUTER.format(max_new=max_new), user))
except tm.Budget as e:
tm.comment(log, f"🔭 Astra: {e}. No ranking today.", {"beat": "astra", "router": cfg["mode"], "queued": 0, "capped": True})
return 0
assess = {str(a.get("key") or "").strip(): a for a in out.get("assess") or [] if isinstance(a, dict)}
ranked = router.rank(cands, assess, cfg, router.last_picked(tm.beats_of(thread)), today_d)
in_lane = sum(1 for i in open_issues if router.CLOUD in router.labels_of(i))
to_cloud = router.pick_cloud(ranked, cfg, in_lane) # the cloud lane takes its cards first: its credit is separate
sent = {r["key"] for r in to_cloud}
picks = router.pick([r for r in ranked if r["key"] not in sent], cfg, left, max_ready)
new, repeats = [], []
for c in [c for c in out.get("new") or [] if isinstance(c, dict) and c.get("title")]:
n = repeat_of(str(c["title"]), open_issues + closed)
(repeats.append((c, n)) if n else new.append(c))
new = new[:max_new]
queued, found = [r for r in picks if r["n"] is not None], [r for r in picks if r["n"] is None]
live, created = cfg["mode"] == "live", []
if live:
for r in queued:
tm.label(r["n"], "queued") # the heartbeat promotes it, in ranked order
for r in found:
# a candidate with no card (an optimization the records showed): it becomes a card, never worked on in place
r["written"] = tm.new_issue(r["title"][:200], r["body"] + "\n\n<sub>card written by Astra</sub>", [])["number"]
for r in to_cloud:
tm.label(r["n"], router.CLOUD) # the Mac's launcher starts a cloud session for it
for c in new:
body = str(c.get("body") or "")
if not router.DONE.search(body):
body += "\n\nDONE: PR open that does the above, tests green."
created.append(tm.new_issue(str(c["title"])[:200], body + "\n\n<sub>card written by Astra</sub>", [])["number"])
unjudged = sum(1 for r in ranked if not r["judged"])
w = cfg["weights"]
head = (f"🔭 Astra ({seat}), router `{cfg['mode']}`, spend ${today:.2f} of ${day_cap:.2f} today, ${left:.2f} left to "
f"queue against ({source}). Weights: "
+ ", ".join(f"{k} {w[k]:g}" for k in router.KINDS) + f"; restless {cfg['restless']:g}/day."
+ (f" Cloud lane: {in_lane} in it, up to {cfg['cloud']['per_day']}." if cfg["cloud"]["per_day"] else ""))
lines = [head, "", str(out.get("note") or "(no note)"), "", router.table(ranked, picks, cfg["mode"], cloud=to_cloud), ""]
if unjudged:
lines.append(f"_{unjudged} candidate(s) the seat didn't judge are scored at value 3, size M._")
if live:
lines.append(("Queued: " + ", ".join(f"#{r['n']}" for r in queued)) if queued else "Queued nothing today.")
if found:
lines.append("Written from the factory's own records (candidates from tomorrow): "
+ ", ".join(f"#{r['written']} ({r['key']})" for r in found))
if to_cloud:
lines.append("To the cloud lane: " + ", ".join(f"#{r['n']}" for r in to_cloud))
if created:
lines.append("New cards (candidates from tomorrow): " + ", ".join(f"#{n}" for n in created))
else:
lines.append("Dry run: nothing queued or created. Live, it would queue: "
+ (", ".join(f"#{r['n']}" for r in queued) or "nothing") + "."
+ (" And write a card for: " + ", ".join(r["key"] for r in found) + "." if found else "")
+ (" To the cloud lane: " + ", ".join(f"#{r['n']}" for r in to_cloud) + "." if to_cloud else ""))
if new:
lines.append("It would write: " + "; ".join(router._txt(c["title"], 100) for c in new) + ".")
if repeats:
lines.append("Not written, repeats of existing cards: "
+ "; ".join(f"{router._txt(c['title'], 80)} (#{n})" for c, n in repeats) + ".")
meta = {"beat": "astra", "router": cfg["mode"], "queued": len(queued) if live else 0, "order": router.order(ranked),
"picked": [{"n": r.get("written", r["n"]), "kind": r["kind"]} for r in picks] if live else [],
"would": [r["n"] for r in queued] if not live else [], "new": created,
"opt": [{"key": r["key"], "card": r.get("written")} for r in found],
"cloud": [r["n"] for r in to_cloud] if live else [], "would_cloud": [r["n"] for r in to_cloud] if not live else []}
tm.comment(log, "\n".join(lines), meta)
print(f"astra router {cfg['mode']}: picks={[r['n'] for r in queued]} opt={[r['key'] for r in found]} "
f"new={created} ranked={len(ranked)}")
return 0
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)
state = board()
log = log_issue()
if state["andons"]:
tm.comment(log, f"🔭 Astra: an andon is open (#{', #'.join(map(str, state['andons']))}), so I'm queuing nothing today. "
"Close it once it's handled and I'll plan tomorrow.", {"beat": "astra", "queued": 0, "andon": True})
return 0
context = spine_file("AGENTS.md")[:6000] + "\n\n" + "\n".join(spine_file("docs/decisions.md").splitlines()[-25:])
# the log thread is part of the context: humans object there
thread = recent(log)
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)
cfg = router_config()
if cfg["mode"] != "off":
return router_tick(cfg, seat, log, context, thread, max_ready, max_new)
try:
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
by_n = {i["n"]: i for i in state["issues"]}
queued, created, skipped = [], [], []
for n in plan["queue"][:max_ready]:
i = by_n.get(n)
if not i or not i["has_done"] or SKIP & set(i["labels"]):
skipped.append(n)
continue
tm.label(n, "queued")
queued.append(n)
for c in plan["new"][:max_new]:
body = str(c.get("body") or "")
if not re.search(r"^\s*DONE:", body, re.M | re.I):
body += "\n\nDONE: PR open that does the above, tests green."
issue = tm.new_issue(str(c["title"])[:200], body + "\n\n<sub>card written by Astra</sub>", [])
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
per = ", ".join(f"{k} ${v['spent']:.2f} of ${v['allowance']:.2f}" for k, v in state["spend"]["pools"].items()) \
or f"${state['spend']['today']:.2f} of ${state['spend']['daily_cap']:.2f}"
lines = [f"🔭 Astra ({seat}), spend today, {per}:", "", plan["note"], ""]
if queued:
lines.append("Queued: " + ", ".join(f"#{n}" for n in queued))
if created:
lines.append("New cards: " + ", ".join(f"#{n}" for n in created))
if skipped:
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})
print(f"astra: queued={queued} new={created} skipped={skipped}")
return 0
if __name__ == "__main__":
sys.exit(tick() if sys.argv[1:2] == ["tick"] else __doc__)