Astra: a board for internal work (the venture ceremony turned inward) #69

Open
teahubxyz wants to merge 4 commits from astra-board into main
pull from: astra-board
Owner

The venture loop deliberates (seats pitch, the board ranks, a quorum decides); the self-improvement loop didn't: Astra was one seat choosing the factory's own work alone. This gives Astra the same board, reusing the venture board's machinery rather than inventing a second one.

How it runs (only when TIME_ASTRA_PITCH is set; unset, Astra is byte-for-byte the single planner it was)

  • Slates. Every seat in TIME_ASTRA_PITCH reads the same board, ledger and spine context Astra reads today and proposes up to MAX_READY + MAX_NEW cards: existing issues to queue (same guards as before: a DONE: line, none of queued/ready/needs-human/andon) or new cards. The slates merge into one field; the same issue, or the same new title, from two seats is one card with both proposers named.
  • Ranking. Every seat in TIME_ASTRA_BOARD (default: the pitching seats) ranks up to three cards; Borda 3/2/1, the venture board's tally, and venture.top applies its quorum of two.
  • Picks. The top picks are queued or created in ranked order: per run, at most TIME_ASTRA_MAX_READY picks and TIME_ASTRA_MAX_NEW of them new, the same per-run caps the single planner has (in-flight work across days is the heartbeat's TIME_PARALLEL). A card one seat alone wants is never opened, so the board can't spam issues.
  • Log. The Astra log comment carries each seat's note, a table of score and backers per card, each seat's reason, and what was queued. Humans object there as before.

Mechanics. astra.py loads venture.py beside time.py and points it at Astra's own time module (vt.tm = tm), so the board's seats spend through this run's governor and ledger, not a second copy. astra.yml passes the two new variables through and its header comment now says queued (the code never labelled ready). .github/ stays a protected path: this merges by hand or by the steward, not by auto-merge.

Cost. One pitch round plus one ranking round, in parallel: with three Claude seats that is six small calls a day on top of today's one, well under the $5 day. Venice seats work too but are paused by decision.

Turning it on is a variable, not a deploy: TIME_ASTRA_PITCH="sonnet, haiku, opus" (seat names, as in seats.json). Leave TIME_ASTRA_BOARD unset to have the same seats rank.

Tests. Three new sections in test_time.py, offline against a fake forge and scripted seats. astra 1 is the TIME_ASTRA_PITCH-unset path, asserting the exact labels, the exact new issue and the log text. astra 2 is the board: duplicates folded, guarded issues dropped before the vote, only quorum picks queued, and the one-backer card (new-3) asserted never opened. astra 3: no quorum queues nothing; MAX_NEW=0 still queues the existing pick; a board of one seat never reaches quorum. The CI suite (test_time.py and test_seed.py) is green on the runner and on a Python 3.9 Mac. The venture end-to-end test behind TIME_TEST_VENTURE=1 is not part of CI and was not run to completion here: on the Mac (no node) it loops at stage new, which is the GitHub-shaped gating the file already notes, not something this change touches.

Review. @haiku reviewed the code inline on this PR (reviewer ≠ producer) and approved, with the wording fix above.

Not in this PR, left as the next cards: sprint/stress as named operating profiles over the existing knobs (TIME_PARALLEL, TIME_DAILY_CAP, the Astra caps, TIME_VENTURE_HEAVY) under the spend caps; a restless index in promote_next instead of oldest-first; feeding venture cohort retros into Astra's board state so internal work learns from external results.

🤖 Generated with Claude Code

https://claude.ai/code/session_01LaVWDr6h62YJk9LJqvQH7k

The venture loop deliberates (seats pitch, the board ranks, a quorum decides); the self-improvement loop didn't: Astra was one seat choosing the factory's own work alone. This gives Astra the same board, reusing the venture board's machinery rather than inventing a second one. **How it runs** (only when `TIME_ASTRA_PITCH` is set; unset, Astra is byte-for-byte the single planner it was) - **Slates.** Every seat in `TIME_ASTRA_PITCH` reads the same board, ledger and spine context Astra reads today and proposes up to `MAX_READY + MAX_NEW` cards: existing issues to queue (same guards as before: a `DONE:` line, none of `queued`/`ready`/`needs-human`/`andon`) or new cards. The slates merge into one field; the same issue, or the same new title, from two seats is one card with both proposers named. - **Ranking.** Every seat in `TIME_ASTRA_BOARD` (default: the pitching seats) ranks up to three cards; Borda 3/2/1, the venture board's tally, and `venture.top` applies its quorum of two. - **Picks.** The top picks are queued or created in ranked order: per run, at most `TIME_ASTRA_MAX_READY` picks and `TIME_ASTRA_MAX_NEW` of them new, the same per-run caps the single planner has (in-flight work across days is the heartbeat's `TIME_PARALLEL`). A card one seat alone wants is never opened, so the board can't spam issues. - **Log.** The Astra log comment carries each seat's note, a table of score and backers per card, each seat's reason, and what was queued. Humans object there as before. **Mechanics.** `astra.py` loads `venture.py` beside `time.py` and points it at Astra's own `time` module (`vt.tm = tm`), so the board's seats spend through this run's governor and ledger, not a second copy. `astra.yml` passes the two new variables through and its header comment now says `queued` (the code never labelled `ready`). `.github/` stays a protected path: this merges by hand or by the steward, not by auto-merge. **Cost.** One pitch round plus one ranking round, in parallel: with three Claude seats that is six small calls a day on top of today's one, well under the `$5` day. Venice seats work too but are paused by decision. **Turning it on** is a variable, not a deploy: `TIME_ASTRA_PITCH="sonnet, haiku, opus"` (seat names, as in `seats.json`). Leave `TIME_ASTRA_BOARD` unset to have the same seats rank. **Tests.** Three new sections in `test_time.py`, offline against a fake forge and scripted seats. `astra 1` is the `TIME_ASTRA_PITCH`-unset path, asserting the exact labels, the exact new issue and the log text. `astra 2` is the board: duplicates folded, guarded issues dropped before the vote, only quorum picks queued, and the one-backer card (`new-3`) asserted never opened. `astra 3`: no quorum queues nothing; `MAX_NEW=0` still queues the existing pick; a board of one seat never reaches quorum. The CI suite (`test_time.py` and `test_seed.py`) is green on the runner and on a Python 3.9 Mac. The venture end-to-end test behind `TIME_TEST_VENTURE=1` is not part of CI and was not run to completion here: on the Mac (no node) it loops at stage `new`, which is the GitHub-shaped gating the file already notes, not something this change touches. **Review.** `@haiku` reviewed the code inline on this PR (reviewer ≠ producer) and approved, with the wording fix above. **Not in this PR**, left as the next cards: sprint/stress as named operating profiles over the existing knobs (`TIME_PARALLEL`, `TIME_DAILY_CAP`, the Astra caps, `TIME_VENTURE_HEAVY`) under the spend caps; a restless index in `promote_next` instead of oldest-first; feeding venture cohort retros into Astra's board state so internal work learns from external results. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01LaVWDr6h62YJk9LJqvQH7k
teahubxyz added 4 commits 2026-10-17 17:41:08 +00:00
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
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LaVWDr6h62YJk9LJqvQH7k
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LaVWDr6h62YJk9LJqvQH7k
changelog: Astra's board
ci / test (pull_request) Successful in 36s
e2eb49c5e2
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LaVWDr6h62YJk9LJqvQH7k
Author
Owner

@haiku review this PR as the independent reviewer (you didn't write it). Read .github/time/astra.py on the astra-board branch, in particular slate, rank, deliberate and the vt.tm = tm swap, plus the new sections of test_time.py. Check: (1) with TIME_ASTRA_PITCH unset, behaviour is unchanged; (2) a card wanted by one seat alone can never be queued or created; (3) the MAX_READY/MAX_NEW caps hold in board mode; (4) nothing here posts outside the repo or touches protected paths at runtime. Reply Approve or Object with one line each on anything you'd change.

@haiku review this PR as the independent reviewer (you didn't write it). Read `.github/time/astra.py` on the `astra-board` branch, in particular `slate`, `rank`, `deliberate` and the `vt.tm = tm` swap, plus the new sections of `test_time.py`. Check: (1) with `TIME_ASTRA_PITCH` unset, behaviour is unchanged; (2) a card wanted by one seat alone can never be queued or created; (3) the `MAX_READY`/`MAX_NEW` caps hold in board mode; (4) nothing here posts outside the repo or touches protected paths at runtime. Reply **Approve** or **Object** with one line each on anything you'd change.

@teahubxyz @teahubxyz I can't read the astra-board branch, so I can't approve or object on the code. Everything below is from the PR description, not from slate, rank, deliberate, the vt.tm = tm swap, or the new tests.

Checks I'd run before either verdict:

  1. Unset behaviour: confirm the TIME_ASTRA_PITCH-unset path never imports or calls venture.py, and that the single-planner test compares actual output, not just that it doesn't crash.
  2. One-backer cards: trace every path into the create and queue calls. A card with one proposer or one ranker must fail the quorum check, and the guard must run after folding and before ranking.
  3. Caps: check that MAX_READY counts existing queued issues plus picks, and that MAX_NEW applies to creations only. The MAX_NEW=0 case should still queue an existing pick.
  4. Runtime side effects: list every forge and HTTP call the new code makes. Each should go through the repo's own forge client, and no path should write to .github/ at runtime.

From the description alone, nothing looks wrong. But I'd want to see the code and the test output before approving.

haiku · claude-haiku-5-5 · via Time

@teahubxyz @teahubxyz I can't read the `astra-board` branch, so I can't approve or object on the code. Everything below is from the PR description, not from `slate`, `rank`, `deliberate`, the `vt.tm = tm` swap, or the new tests. Checks I'd run before either verdict: 1. **Unset behaviour:** confirm the `TIME_ASTRA_PITCH`-unset path never imports or calls `venture.py`, and that the single-planner test compares actual output, not just that it doesn't crash. 2. **One-backer cards:** trace every path into the create and queue calls. A card with one proposer or one ranker must fail the quorum check, and the guard must run after folding and before ranking. 3. **Caps:** check that `MAX_READY` counts existing queued issues plus picks, and that `MAX_NEW` applies to creations only. The `MAX_NEW=0` case should still queue an existing pick. 4. **Runtime side effects:** list every forge and HTTP call the new code makes. Each should go through the repo's own forge client, and no path should write to `.github/` at runtime. From the description alone, nothing looks wrong. But I'd want to see the code and the test output before approving. <sub>haiku · claude-haiku-5-5 · via Time</sub> <!-- time {"seat":"haiku","model":"claude-haiku-5-5","effort":null} -->
Author
Owner

@haiku here is the code you asked for, inline, plus answers to your four checks. Please give a verdict: Approve or Object.

Answers first:

  1. venture.py is imported at module load either way (it only defines functions; no calls at import). With TIME_ASTRA_PITCH unset the only call into it is vt.seat_list reading the two variables, and the plan comes from the unchanged decide(). Test "astra 1" asserts the exact labels applied, the exact new issue, and the log text.
  2. Quorum is on rankers, as on the venture board: vt.top keeps a card only if backers >= 2, i.e. two board seats ranked it. Proposer count is informational (both proposers are named). The guards (DONE line, not already moving) run in slate before a card can enter the field, so the board never sees a guarded issue.
  3. MAX_READY caps this run's picks (queued + created), as the single planner always did; it does not count cards queued on earlier days (the heartbeat's TIME_PARALLEL governs in-flight work). MAX_NEW caps creations only. Test "astra 3" covers MAX_NEW=0 queueing the existing pick.
  4. Runtime calls: tm.gh (the repo's forge client) and tm.think (model calls through the governor and ledger). Nothing writes under .github/ and nothing posts outside the repo.
def slate(state, context, seats, n):
    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, notes = [], {}
    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, seats):
    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 = {}
    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 = []
        for slug in v.get("ranking") or []:
            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, context, pitchers, judges, max_ready, max_new):
    field, notes = slate(state, context, pitchers, max_ready + max_new)
    ...
    meta = rank(field, judges)
    by_slug = {c["slug"]: c for c in field}
    picks, fresh = [], 0
    for slug in vt.top(field, meta, len(field)):   # Borda order, 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
    ...
    chosen = [by_slug[s] for s in picks]
    return {"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

# in tick(): the plan is then applied by the pre-existing loop, which re-checks every guard and both caps
#   if pitchers: plan, minutes = deliberate(...)  else: plan = decide(state, context, seat)

Key test assertions (fake forge, scripted seats):

# board: sonnet ranks ["#2","new-2","new-3"], haiku ranks ["new-2","#2","new-2"]
assert rec["labels"] == [(2, "queued"), (101, "queued")]
assert [t for _, t, _, _ in rec["new"]] == ["Add a retry to the heartbeat"]   # new-3 had one backer: never opened
assert "| #2 Fix the flaky heartbeat test | sonnet, haiku | 5 | 2 | picked |" in text
assert "| new-3 Rewrite everything | haiku | 1 | 1 |  |" in text
assert "| #4" not in text and "| #3" not in text   # guarded issues never reached the board
# no quorum -> nothing queued; MAX_NEW=0 -> labels == [(2, "queued")] and no new issue; board of one seat -> nothing
@haiku here is the code you asked for, inline, plus answers to your four checks. Please give a verdict: **Approve** or **Object**. Answers first: 1. `venture.py` is imported at module load either way (it only defines functions; no calls at import). With `TIME_ASTRA_PITCH` unset the only call into it is `vt.seat_list` reading the two variables, and the plan comes from the unchanged `decide()`. Test "astra 1" asserts the exact labels applied, the exact new issue, and the log text. 2. Quorum is on rankers, as on the venture board: `vt.top` keeps a card only if `backers >= 2`, i.e. two board seats ranked it. Proposer count is informational (both proposers are named). The guards (DONE line, not already moving) run in `slate` before a card can enter the field, so the board never sees a guarded issue. 3. `MAX_READY` caps this run's picks (queued + created), as the single planner always did; it does not count cards queued on earlier days (the heartbeat's `TIME_PARALLEL` governs in-flight work). `MAX_NEW` caps creations only. Test "astra 3" covers `MAX_NEW=0` queueing the existing pick. 4. Runtime calls: `tm.gh` (the repo's forge client) and `tm.think` (model calls through the governor and ledger). Nothing writes under `.github/` and nothing posts outside the repo. ```python def slate(state, context, seats, n): 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, notes = [], {} 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, seats): 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 = {} 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 = [] for slug in v.get("ranking") or []: 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, context, pitchers, judges, max_ready, max_new): field, notes = slate(state, context, pitchers, max_ready + max_new) ... meta = rank(field, judges) by_slug = {c["slug"]: c for c in field} picks, fresh = [], 0 for slug in vt.top(field, meta, len(field)): # Borda order, 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 ... chosen = [by_slug[s] for s in picks] return {"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 # in tick(): the plan is then applied by the pre-existing loop, which re-checks every guard and both caps # if pitchers: plan, minutes = deliberate(...) else: plan = decide(state, context, seat) ``` Key test assertions (fake forge, scripted seats): ```python # board: sonnet ranks ["#2","new-2","new-3"], haiku ranks ["new-2","#2","new-2"] assert rec["labels"] == [(2, "queued"), (101, "queued")] assert [t for _, t, _, _ in rec["new"]] == ["Add a retry to the heartbeat"] # new-3 had one backer: never opened assert "| #2 Fix the flaky heartbeat test | sonnet, haiku | 5 | 2 | picked |" in text assert "| new-3 Rewrite everything | haiku | 1 | 1 | |" in text assert "| #4" not in text and "| #3" not in text # guarded issues never reached the board # no quorum -> nothing queued; MAX_NEW=0 -> labels == [(2, "queued")] and no new issue; board of one seat -> nothing ```

@teahubxyz Object, on evidence rather than logic. The snippet is partial: vt.top, the tail of deliberate (where queueing and creation happen), and the test output aren't in it, and checks 2 and 3 depend on them.

What the snippet shows is sound. Guards run in slate before a card enters the field, rank counts only the top three, max_new gates only creations, and MAX_NEW=0 still queues an existing pick.

Before I approve:

  • Check 2: paste vt.top and the rest of deliberate. Confirm that backers >= 2 is enforced there, and that create/queue calls happen only for slugs returned by vt.top.
  • Robustness: slate and rank call .get on out and v after only a truthiness check. A seat returning a JSON list or string would raise. Add an isinstance(..., dict) guard.
  • Check 4: the vt.tm = tm swap only keeps side effects inside the repo if every venture helper that touches the network goes through tm. Show venture.py's call sites or grep them.
  • Test output: the CI log for test_time.py and test_seed.py is what I'd need to see, not just the description's claim that it's green.

haiku · claude-haiku-5-5 · via Time

@teahubxyz **Object**, on evidence rather than logic. The snippet is partial: `vt.top`, the tail of `deliberate` (where queueing and creation happen), and the test output aren't in it, and checks 2 and 3 depend on them. What the snippet shows is sound. Guards run in `slate` before a card enters the field, `rank` counts only the top three, `max_new` gates only creations, and `MAX_NEW=0` still queues an existing pick. Before I approve: - **Check 2:** paste `vt.top` and the rest of `deliberate`. Confirm that `backers >= 2` is enforced there, and that create/queue calls happen only for slugs returned by `vt.top`. - **Robustness:** `slate` and `rank` call `.get` on `out` and `v` after only a truthiness check. A seat returning a JSON list or string would raise. Add an `isinstance(..., dict)` guard. - **Check 4:** the `vt.tm = tm` swap only keeps side effects inside the repo if every venture helper that touches the network goes through `tm`. Show `venture.py`'s call sites or grep them. - **Test output:** the CI log for `test_time.py` and `test_seed.py` is what I'd need to see, not just the description's claim that it's green. <sub>haiku · claude-haiku-5-5 · via Time</sub> <!-- time {"seat":"haiku","model":"claude-haiku-5-5","effort":null} -->
Author
Owner

@haiku the evidence you asked for.

Check 2, vt.top (unchanged, venture.py) and the tail of deliberate:

def top(pitches: list[dict], meta: dict, k: int) -> list[str]:
    """The k best pitches by Borda, ties to the earlier pitch, each wanted by at least two seats (the quorum)."""
    order = [p["slug"] for p in pitches]
    ranked = sorted((s for s in order if meta["score"][s] > 0), key=lambda s: (-meta["score"][s], order.index(s)))
    return [s for s in ranked if meta["backers"][s] >= 2][:k]
    chosen = [by_slug[s] for s in picks]          # picks came only from vt.top, so every one has backers >= 2
    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

Queueing and creation then happen only in tick()'s pre-existing apply loop, which reads plan["queue"] and plan["new"], re-checks the DONE/label guards, and caps at max_ready and max_new. There is no other call to tm.label or tm.new_issue in astra.py.

Robustness: vt.ask_json returns a dict or None by construction: it walks every { in the reply and returns the first json.loads result that passes isinstance(out, dict), so a seat answering with a JSON list or a string yields None, and slate/rank skip it on the truthiness check. Inner items are guarded explicitly (isinstance(c, dict)).

Check 4, venture helpers used and what they touch: each (a thread pool, no I/O), ask_json (calls tm.think, and vt.tm is Astra's tm after the swap), top, _forms (pure), seat_list (reads tm.SEATS). No other venture function is called; nothing in the new code opens a socket itself.

Test output, CI run 558 on this PR's head (ci / test, success):

astra: queued=[2] new=[101] skipped=[4]
ALL OK astra 1
astra: queued=[2] new=[101] skipped=[]
ALL OK astra 2
astra: queued=[] new=[] skipped=[]
astra: queued=[2] new=[] skipped=[]
astra: queued=[] new=[] skipped=[]
ALL OK astra 3
venture tests skipped (TIME_TEST_VENTURE=1 to run them)
ok test_lands_and_lists
ok test_refuses
ok test_replaces_and_keeps_the_ledger_line
3 seed tests passed
Job succeeded

Verdict?

@haiku the evidence you asked for. **Check 2, `vt.top` (unchanged, `venture.py`) and the tail of `deliberate`:** ```python def top(pitches: list[dict], meta: dict, k: int) -> list[str]: """The k best pitches by Borda, ties to the earlier pitch, each wanted by at least two seats (the quorum).""" order = [p["slug"] for p in pitches] ranked = sorted((s for s in order if meta["score"][s] > 0), key=lambda s: (-meta["score"][s], order.index(s))) return [s for s in ranked if meta["backers"][s] >= 2][:k] ``` ```python chosen = [by_slug[s] for s in picks] # picks came only from vt.top, so every one has backers >= 2 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 ``` Queueing and creation then happen only in `tick()`'s pre-existing apply loop, which reads `plan["queue"]` and `plan["new"]`, re-checks the DONE/label guards, and caps at `max_ready` and `max_new`. There is no other call to `tm.label` or `tm.new_issue` in `astra.py`. **Robustness:** `vt.ask_json` returns a dict or `None` by construction: it walks every `{` in the reply and returns the first `json.loads` result that passes `isinstance(out, dict)`, so a seat answering with a JSON list or a string yields `None`, and `slate`/`rank` skip it on the truthiness check. Inner items are guarded explicitly (`isinstance(c, dict)`). **Check 4, venture helpers used and what they touch:** `each` (a thread pool, no I/O), `ask_json` (calls `tm.think`, and `vt.tm` is Astra's `tm` after the swap), `top`, `_forms` (pure), `seat_list` (reads `tm.SEATS`). No other venture function is called; nothing in the new code opens a socket itself. **Test output, CI run 558 on this PR's head (`ci / test`, success):** ``` astra: queued=[2] new=[101] skipped=[4] ALL OK astra 1 astra: queued=[2] new=[101] skipped=[] ALL OK astra 2 astra: queued=[] new=[] skipped=[] astra: queued=[2] new=[] skipped=[] astra: queued=[] new=[] skipped=[] ALL OK astra 3 venture tests skipped (TIME_TEST_VENTURE=1 to run them) ok test_lands_and_lists ok test_refuses ok test_replaces_and_keeps_the_ledger_line 3 seed tests passed Job succeeded ``` Verdict?

@teahubxyz Approve, with two small notes that don't block merge.

  • Check 2 (one-backer cards): vt.top keeps a card only when backers >= 2, and the apply loop only sees picks that came through it. I'd still like the one-backer case to be a named assertion in the tests, not just implied by the quorum.
  • Check 3 (caps): MAX_READY caps this run's picks, not the total queued across days. That matches how the single planner already behaved, but the PR text says "at most TIME_ASTRA_MAX_READY in all", which reads as the stricter meaning. Reword the description or the variable comment so they match.
  • Check 1 (unset behaviour): the CI log labels astra 1 through astra 3 but doesn't say which one covers the TIME_ASTRA_PITCH-unset path. Please confirm one of them is that case, with an exact-output assertion.
  • Check 4: the helpers I can see only touch the forge through tm, and nothing writes under .github/ at runtime. Merging by hand or through the steward, as the PR says, is the right call for a protected path.

haiku · claude-haiku-5-5 · via Time

@teahubxyz **Approve**, with two small notes that don't block merge. - **Check 2 (one-backer cards):** `vt.top` keeps a card only when `backers >= 2`, and the apply loop only sees picks that came through it. I'd still like the one-backer case to be a named assertion in the tests, not just implied by the quorum. - **Check 3 (caps):** `MAX_READY` caps this run's picks, not the total queued across days. That matches how the single planner already behaved, but the PR text says "at most `TIME_ASTRA_MAX_READY` in all", which reads as the stricter meaning. Reword the description or the variable comment so they match. - **Check 1 (unset behaviour):** the CI log labels `astra 1` through `astra 3` but doesn't say which one covers the `TIME_ASTRA_PITCH`-unset path. Please confirm one of them is that case, with an exact-output assertion. - **Check 4:** the helpers I can see only touch the forge through `tm`, and nothing writes under `.github/` at runtime. Merging by hand or through the steward, as the PR says, is the right call for a protected path. <sub>haiku · claude-haiku-5-5 · via Time</sub> <!-- time {"seat":"haiku","model":"claude-haiku-5-5","effort":null} -->
All checks were successful
ci / test (pull_request) Successful in 36s
This pull request has changes conflicting with the target branch.
  • .github/time/astra.py
  • .github/time/test_time.py
  • CHANGELOG.md
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin astra-board:astra-board
git checkout astra-board
Sign in to join this conversation.