Compare commits

...
Author SHA1 Message Date
Claude 961e8dfa83 Router: rank the spine's cards too, shown and never queued (#173)
heartbeat / tick (pull_request_review) Successful in 10s
ci / test (pull_request) Successful in 44s
Astra reads the open issues of TIME_SPINE (teahubxyz/teahub: Tea MCP, the Pages server, the Gitea look) through the
same API, and every one with a `DONE:` line joins the ranked table as `teahub#N`, judged by the seat like any other
candidate. Those rows carry `why_not` "spine: not queued (Time builds in this repo only)", so the table marks them and
code cannot act on them: `n` is None, as for the new-venture candidate, which keeps them out of `pick`, `pick_cloud`
and the promotion `order`, and they are never labelled from here. Their value is for the owner or the CEO to read:
what the platform needs next, now visible beside Time's own cards instead of invisible.

The spine's rows get the same label check `tm.issues` uses (Gitea ignores a filter on a label a repo does not have and
answers with every issue, #137) and PRs are left out. The spine stays context, not a dependency: a failed read is a log
line and the day's ranking goes up without it.

Checks: a spine card with a `DONE:` line is ranked, never picked, never sent to the cloud lane, never in the promotion
order and never labelled; one without a `DONE:` line is not a candidate; a PR of the spine is not an issue; a label
filter is checked on every row; a read failure prints one line and the tick still queues its two cards.

No workflow change is needed: astra.yml already passes nothing the spine read depends on (TIME_SPINE has a default).

Closes #173

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017E7Qf55mVnHmowgaGseNRh
2026-10-20 00:57:53 +00:00
4 changed files with 98 additions and 7 deletions

No files matched your search

+28 -3
View File
@@ -19,7 +19,9 @@ nothing; `live` queues from the top while the day's money left covers each card'
in the pool that takes the worker's route, else the daily cap's remainder, never more than the month has left),
creates the seat's new cards unqueued (they are candidates the next day; never a repeat of an open or lately closed
card), and the heartbeat promotes queued cards in the ranked order. Over the table the log shows what each kind's past
picks returned (picks, merged share, median days, median $), from the outcomes housekeeping records (#172).
picks returned (picks, merged share, median days, median $), from the outcomes housekeeping records (#172). The spine's
own open cards (`TIME_SPINE`, `teahubxyz/teahub`) are ranked beside Time's, keyed `teahub#N` and marked "spine: not
queued": a worker here builds in this repo only, so their value only tells the owner what the platform needs next (#173).
`off`, or no router.json, is the planner as before.
"""
from __future__ import annotations
@@ -31,6 +33,7 @@ import json
import os
import re
import sys
import urllib.parse
from pathlib import Path
HERE = Path(__file__).resolve().parent
@@ -43,6 +46,7 @@ _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
SPINE_KEY = SPINE.split("/")[-1] or "spine" # the prefix of a spine card's key in the table: teahub#N
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
@@ -80,6 +84,8 @@ For every candidate give:
- 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 keyed `{spine_key}#N` is a card of the spine, the platform's own repo: judge its value like any other (it
tells the owner what the platform needs next), but no worker here can build it, so code never queues it.
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.
@@ -105,6 +111,25 @@ def spine_file(path: str) -> str:
return f"(could not read {SPINE}/{path}: {str(e)[:80]})"
def spine_issues(query: str = "state=open") -> list[dict]:
"""The spine's issues (never PRs) through the same API, each row checked against the labels the query names exactly
as `tm.issues` checks this repo's: Gitea ignores a filter on a label the repo does not have and answers with every
issue instead (#137)."""
want = [l for l in (urllib.parse.parse_qs(query).get("labels") or [""])[0].split(",") if l]
rows = [i for i in tm.gh("GET", f"/repos/{SPINE}/issues?type=issues&limit=50&{query}") or [] if not i.get("pull_request")]
return [i for i in rows if "labels" not in i or all(tm.has_label(i, l) for l in want)]
def spine_candidates() -> list[dict]:
"""The spine's open cards as candidates (#173): ranked and shown, never queued. The spine is context, not a
dependency, so a read failure is a log line and the day's ranking goes up without it."""
try:
return router.spine(spine_issues(), SPINE_KEY)
except Exception as e: # noqa: BLE001
print(f"spine issues unreadable: {str(e)[:120]}")
return []
def board() -> dict:
"""What Astra reads: open issues and PRs, yesterday's closed issues, andons, spend."""
open_issues = tm.issues("state=open")
@@ -234,7 +259,7 @@ def repeat_of(title: str, known: list[dict]) -> int | 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]
cands = router.candidates(open_issues) + [dict(router.NEW_VENTURE)]
cands = router.candidates(open_issues) + spine_candidates() + [dict(router.NEW_VENTURE)]
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()
@@ -247,7 +272,7 @@ def router_tick(cfg: dict, seat: str, log: int, context: str, thread: list[dict]
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))
out = parse(tm.think(seat, None, None, ROUTER.format(max_new=max_new, spine_key=SPINE_KEY), 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
+22 -3
View File
@@ -5,8 +5,9 @@ judgments into one ranked list, in arithmetic anyone can check, and picks from t
promotes queued cards in the same order instead of oldest first.
A candidate is an open card a worker can take (a `DONE:` line; not a parent, not a human's, not the venture loop's
own), or work with no card yet that the factory could start (a new venture). Each has a kind: venture, feature,
refinement or optimization.
own), work with no card yet that the factory could start (a new venture), or a card of the spine (`teahubxyz/teahub`:
Tea MCP, the Pages server, the Gitea look), which is ranked and shown but never queued, since Time's worker builds in
this repo only (#173). Each has a kind: venture, feature, refinement or optimization.
score = weight[kind] x restless(kind) x age x (value + 0.5 x unblocks) / size
@@ -47,6 +48,7 @@ HELD = {"needs-human", "andon", "astra", "planned"} | LOOP # a human's, an a
LINED_UP = {"queued", "ready", "cloud"} # already moving: ranked (for promotion), never queued again
CLOUD = "cloud" # a card in the cloud lane: a cloud session builds it
DONE = re.compile(r"^\s*DONE:", re.M | re.I)
SPINE_WHY_NOT = "spine: not queued (Time builds in this repo only)"
NEW_VENTURE = {"key": "venture:new", "n": None, "title": "Start a new venture: research, pick, build and launch one page",
"body": "The venture loop (venture.py): one venture, researched and picked by the lead, built and judged.",
"labels": [], "lined_up": False, "actionable": False, "kind": "venture", "unblocks": 0, "created": "",
@@ -125,6 +127,23 @@ def candidates(issues: list[dict]) -> list[dict]:
return out
def spine(issues: list[dict], prefix: str = "teahub") -> list[dict]:
"""The spine's open cards as candidates, keyed `<prefix>#N` (#173). Every one with a `DONE:` line counts: they are
ranked and shown so the owner (or the CEO) reads what the spine needs next, and never queued or labelled from here,
since Time's worker builds in this repo only. `n` is None, as for any candidate with no card in this repo, so no
pick, no cloud pick and no promotion order can reach one."""
out = []
for i in issues:
labels, body = labels_of(i), i.get("body") or ""
if not DONE.search(body):
continue
out.append({"key": f"{prefix}#{i['number']}", "n": None, "title": _txt(i.get("title"), 120), "body": body[:500],
"labels": sorted(labels), "lined_up": False, "actionable": False,
"kind": guess_kind(i.get("title") or "", body, labels), "unblocks": 0,
"created": str(i.get("created_at") or ""), "why_not": SPINE_WHY_NOT})
return out
def last_picked(beats: list[dict]) -> dict[str, str]:
"""The day each kind was last picked, from Astra's live beats."""
out: dict[str, str] = {}
@@ -307,7 +326,7 @@ def table(ranked: list[dict], picked: list[dict], mode: str, limit: int = 15, cl
lane = "cloud lane" if CLOUD in r.get("labels", ()) else "lined up" if r["lined_up"] else ""
note = (f"**{mark}**" if r["key"] in chosen else ("**to the cloud today**" if mode == "live" else "**would go to the cloud**")
if r["key"] in sent else "; ".join(x for x in (lane, r.get("why_not", "") if not r["actionable"] else "") if x))
name = f"{r['key']} {r['title']}" if r["n"] is not None else r["title"]
name = f"{r['key']} {r['title']}" if "#" in r["key"] else r["title"] # a card's number, here or on the spine
rows.append(f"| {k} | {_txt(name, 80).replace('|', '/')} | {r['kind']} | {r['value']:g} | {r['size']} | "
f"{r['unblocks']} | {r['restless']:g} | {r['score']:g} | {_txt(r['why'], 140).replace('|', '/')} | "
f"{_txt(note, 90).replace('|', '/')} |")
+47 -1
View File
@@ -1228,8 +1228,11 @@ astra.tm.label = lambda n, name: labelled80.append((n, name))
astra.tm.new_issue = lambda title, body, labels: made80.append((title, body, labels)) or {"number": 90 + len(made80)}
import base64 as _b8172 # noqa: E402
settled80 = [] # the outcomes log (#172); empty, so nothing is summarised over the table
spine80 = [] # the spine's open issues (#173); empty, so the ranking is Time's own cards
def gh80(method, path, body=None, **k):
"""The outcomes log is the only file this world serves; anything else is a fresh repo's 404."""
"""The outcomes log and the spine's issue list are all this world serves; anything else is a fresh repo's 404."""
if f"/repos/{astra.SPINE}/issues" in path:
return list(spine80)
if rt.OUTCOMES not in path:
raise RuntimeError(f"{method} {path} -> 404: not found")
return {"content": _b8172.b64encode("".join(json.dumps(r) + "\n" for r in settled80).encode()).decode()}
@@ -1389,6 +1392,49 @@ assert astra.outcomes() == [], "the outcomes log is context: unreadable, the tab
astra.tm.gh = gh80
print("ALL OK 172")
# 173 the spine's own cards are ranked beside Time's and never queued: a worker here builds in this repo only
sp173 = rt.spine([iss80(4, "Tea MCP: havre_new", created="2026-10-01T00:00:00Z"), iss80(9, "Just an idea", body="no done line"),
iss80(12, "Pages server: directory listings", labels=["needs-human"])])
assert [c["key"] for c in sp173] == ["teahub#4", "teahub#12"], "a DONE line is all it takes; the key carries the spine's number"
assert rt.spine([iss80(4, "x")], "spine")[0]["key"] == "spine#4", "the prefix follows TIME_SPINE"
s173 = sp173[0]
assert s173["n"] is None and s173["actionable"] is False and not s173["lined_up"], s173
assert s173["why_not"] == "spine: not queued (Time builds in this repo only)" and s173["unblocks"] == 0
assert s173["kind"] == "feature" and s173["created"] == "2026-10-01T00:00:00Z"
# ranked with Time's own cards, and out of every path that queues, sends to the cloud or promotes
r173 = rt.rank(cands80 + sp173, {**assess80, "teahub#4": {"kind": "feature", "value": 5, "size": "S", "why": "the MCP"}},
live80, {}, today80)
assert [r["key"] for r in r173] == ["teahub#4", "#2", "#1", "#5", "#7", "#9", "teahub#12"], [(r["key"], r["score"]) for r in r173]
assert [r["key"] for r in rt.pick(r173, live80, 10.0, 3)] == ["#1", "#5"], "the top scorer is the spine's: never queued"
assert [r["key"] for r in rt.pick_cloud(r173, cl80, 0)] == ["#1"] and rt.order(r173) == [2, 1, 5, 7, 9]
t173 = rt.table(r173, rt.pick(r173, live80, 10.0, 2), "live")
assert "| 1 | teahub#4 Tea MCP: havre_new |" in t173 and rt.SPINE_WHY_NOT in t173, t173
# Astra reads them through the same API: never a PR, and every row checked against the labels the query names (#137)
spine80[:] = [iss80(4, "Tea MCP: havre_new"), iss80(5, "Gitea look", labels=["card"]), iss80(9, "Just an idea", body="no done line"),
{"number": 13, "title": "A PR of the spine", "body": "DONE: x", "labels": [], "pull_request": {"url": "u"}}]
assert [i["number"] for i in astra.spine_issues()] == [4, 5, 9], "a PR is not an issue"
assert [i["number"] for i in astra.spine_issues("state=open&labels=card")] == [5], "a label filter is checked on every row"
labelled80.clear(); made80.clear()
astra.tm.think = lambda seat, a, b, system, user: (asked80.append((seat, system, user)), "Sure:\n" + json.dumps(reply80))[1]
assert astra.router_tick(rt.config('{"mode": "live"}'), "opus", 57, "ctx", [], 2, 2) == 0
assert "teahub#4 Tea MCP: havre_new" in said80[-1][1] and rt.SPINE_WHY_NOT in said80[-1][1], said80[-1][1]
assert "### teahub#4:" in asked80[-1][2] and "teahub#9" not in asked80[-1][2], "no DONE line, no candidate"
assert "teahub#13" not in asked80[-1][2] and "card of the spine" in asked80[-1][1], "the seat is told what the prefix means"
assert labelled80 == [(1, "queued"), (5, "queued")], "the spine's cards are never labelled from here"
assert said80[-1][2]["order"] == [2, 1, 5, 7, 9] and said80[-1][2]["queued"] == 2, said80[-1][2]
# a read failure on the spine is a log line, not a failed tick
real173 = astra.tm.gh
astra.tm.gh = lambda method, path, body=None, **k: (_ for _ in ()).throw(RuntimeError("GET x -> 500")) \
if f"/repos/{astra.SPINE}/issues" in path else real173(method, path, body, **k)
out173 = io.StringIO()
with contextlib.redirect_stdout(out173):
assert astra.spine_candidates() == [], "the spine is context, not a dependency"
assert astra.router_tick(rt.config('{"mode": "live"}'), "opus", 57, "ctx", [], 2, 2) == 0
assert "spine issues unreadable" in out173.getvalue(), out173.getvalue()
assert said80[-1][2]["queued"] == 2 and "teahub#" not in said80[-1][1], "the day's ranking goes up without the spine"
astra.tm.gh, spine80[:] = real173, []
print("ALL OK 173")
# 82 adaptive budget: each route is paced to its capacity pool; no pool, no change
import io, contextlib
real_pools = tm.pools
+1
View File
@@ -28,6 +28,7 @@
- The second fix round escalates to a different model from the first round: `claude:opus` with the Claude seats (it was `venice:kimi code`, and grok 4.7 before that). `TIME_VENTURE_ESCALATE` overrides it.
### Added
- The router ranks the spine's cards too (#173). Astra reads the open issues of `TIME_SPINE` (`teahubxyz/teahub`: Tea MCP, the Pages server, the Gitea look) through the same API, and every one with a `DONE:` line joins the ranked table as `teahub#N`, judged by the seat like any candidate. Those rows are marked "spine: not queued (Time builds in this repo only)": a worker here builds in this repo only, so they are never picked, never sent to the cloud lane, never in the promotion order and never labelled from here — their value tells the owner (or the CEO) what the platform needs next, and platform work no longer competes with Time's own cards by being invisible. The spine's rows get the same label check as this repo's (Gitea answers a filter on a label a repo does not have with every issue), and PRs are left out. The spine stays context, not a dependency: a failed read is a log line and the day's ranking goes up without it.
- The router records what each pick turned into (#172), so a learning policy (a restless bandit) has rewards to read. Housekeeping's daily sweep settles every card the router picked in live mode into one line of `time/ledger/router-outcomes.jsonl` — `{"n", "kind", "picked", "settled", "merged", "pr", "days", "usd", "signups"}` — once the card closes: merged or not and its PR from the card's own beats, the days from pick to close, the dollars the ledger booked while the card's worker ran (each `dispatched` beat until the next beat on the card: the ledger has no card field, so this is the runs'-window proxy, not the card's exact bill), and, for a venture pick, the venture's signups at day 7 (that pick waits until day 7 has passed). A pick whose card is still open is in flight and produces nothing; one already in the file is never written twice. Over the ranked table Astra's log now shows one line per kind — picks, merged share, median days, median $ — so the owner can see what a kind returns before changing its weight. The file sits under `time/ledger/`, the one path CI ignores, since the sweep appends to it daily. No policy change: `router.score` still makes every pick.
- A cloud PR's revision comes from the cloud, and Time reviews it again. The branch of a cloud-lane PR is the copy's to write, so `/time retry` (the API worker pushing to it) would be overwritten by the next sync. When the reviewer objects instead, the launcher on hb-5 starts one more cloud session on the same branch with the review in its prompt (once; then a human). When the copy's branch moves under a PR that is open and already reviewed, sync.py posts a `dispatched` beat marked `revised` on each card the PR closes and takes `needs-human` off the PR, so the heartbeat reviews the new head and a verdict from before the push no longer counts. A PR not reviewed yet gets nothing; its first review sees the new head.
- The cloud lane: Claude Code cloud sessions build cards on the owner's cloud-session credit, beside the API worker. With `cloud: {"per_day": N, "sizes": ["M"]}` in `time/router.json`, Astra's live run labels the best-ranked actionable cards of those sizes `cloud`, until N cards are in the lane, before the API worker's picks; a `cloud` card is lined up, so it is never queued for the API worker. A launcher on hb-5 (a user agent in the desktop session, where the claude.ai sign-in lives; it reads the public issue list and holds no Teahub credentials) starts one `claude --cloud` session per card, with the card as the prompt, on the GitHub copy. The session pushes a `claude/card-N-*` branch; sync.py brings it home as a PR that closes the card (adding `Closes #N` when the commits don't), moves the card from `cloud` to `ready` with a note that carries no beat, and the heartbeat adopts the PR, so the council reviews and lands it like any card PR. A card the commits close leaves the lane too, whatever the branch is called. `per_day: 0`, the default, is no lane.