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
The router only ranked work someone had written a card for, so optimization
showed up only when a human noticed it. It now reads three of the factory's own
records over the last seven days and proposes the rest itself, deterministically.
router.py, one synthetic candidate per finding (`opt:<source>:<id>`, kind
optimization, no card yet, a drafted title and body that carries its own key and
a `DONE:` line):
- `ledger_candidates`: the week's spend grouped by model and by entry point (a
line's `entry` where it has one, else `pool`, the route it booked to). A group
that took over half the bill is a candidate, where there is a second group in
that dimension to compare it with; so is a model whose spend per call is double
its own median, which is a few heavy calls behind a cheap average. Pennies
(under $0.25 a week) are left alone and one group yields at most one candidate.
- `run_candidates`: Actions runs as `/actions/runs` gives them (the shape
`tea_actions` reads: the list, or `workflow_runs`). A workflow with two or more
failed or cancelled runs in the window is a candidate, with the failure count
and the last run's id.
- `parked_candidates`: a card parked by an `error` or `stuck` beat older than a
day, with no open PR and #78's automatic retries spent ("retry or split #N").
One Time will still retry on its own is left to it.
- `named` / `opt_candidates`: a source an open card already names (`Router:
opt:<source>:<id>` in the body) is not proposed again.
astra.py gathers the three sources (`ledger_week`, `action_runs`, `parked_cards`;
each is context, so unreadable means no candidates from it, never a failed run)
and the seat judges them like any other candidate. Live, a picked one is written
as a card, unqueued and a candidate from tomorrow, and is never worked on in
place; dry, the ranked table shows it as "would be written as a card". The log
line and the beat's `opt` say which source became which card.
Tests (test_time.py section 171): a ledger with one dominant route yields one
candidate, and so does the entry-point dimension, the median rule, pennies, a
single group and a stale window; two failed runs of one workflow yield one
candidate with its count and last run id; a parked card yields one unless it has
an open PR, retries left or was parked today; a source an open card names yields
none; Astra live writes the card unqueued while dry writes nothing.
Closes#171
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EzRXxLDJF4YDoZgzSsbW24
The cloud lane: cloud sessions build cards on the cloud-session credit (#197)
Astra labels the best M cards `cloud` (up to 3 in the lane); the launcher on hb-5 starts a claude --cloud session per card on the GitHub copy; sync brings the branch home as a PR that closes the card and moves it cloud -> ready; the heartbeat adopts and the council lands it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017K4vyY3yUD3GsvoZaAtKkD
time/worker-run sed -n 845,885p .github/time/test_time.py; tail -5 .github/time/test_time.py
Budget: the monthly cap counts out-of-pocket money; credit is limited by its pool (#193)
credit_covered(): what an expiring pool's prepaid credit paid this month, within its balance and before it lapses, is left out of the month TIME_MONTHLY_USD caps.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017K4vyY3yUD3GsvoZaAtKkD
Router: a card that waits is never picked; Astra gets TIME_WORKER (#185)
An open Depends on #N, or the seat's blocked note, keeps a card out of the picks and after the movable cards in the promotion order. astra.yml passes TIME_WORKER and TIME_PACE.
Closes#184.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017K4vyY3yUD3GsvoZaAtKkD
Two dry rankings (2026-10-19 01:02 and 13:14 UTC) put the right cards on top (#135, #175), dropped blocked #84 once it was known stale, and kept L cards out. The two gaps they showed are fixed: money left is the worker's pool (#181, closes#175) and repeat cards aren't written (#181); the stall that kept promotion from happening at all is fixed (#183, closes#135).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017K4vyY3yUD3GsvoZaAtKkD
Router: money left is the worker pool's allowance; no repeat cards (#181)
money_left(): the allowance left in the pool that takes TIME_WORKER's route, else the daily cap's remainder, capped by the month's. The seat sees cards closed in the last 14 days; a new card repeating an open or lately closed one is not written.
Closes#175.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017K4vyY3yUD3GsvoZaAtKkD
Sync: a repo's copies on other forges follow its home (#177)
sync.py + time/sync.json + the sync workflow. One home per repo, one writer per branch: main goes out to every copy
fast-forward only (a diverged copy is a needs-human issue, never overwritten, unless forced once); a copy's claude/*
branches come home as PRs for the council. First copy: github.com/teahubxyz/time, skipped until GITHUB_MIRROR_TOKEN.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017K4vyY3yUD3GsvoZaAtKkD
time/worker-run cd .github/time; grep -n "stuck_h =\|auto_max =\|TIME_AUTO_RETRY" time.py | head | cut -c1-220; sed
The owner's credit: $200 granted 2026-10-13, $162.22 left on the 18th by the console, expiring 2026-11-08. The ledger
books $38.51 of Claude spend since the grant, so $161.49 as of the 19th. Paced over the 20 days left that is about
$8/day for the anthropic route; the $100 monthly cap stays hard and binds first in October.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017K4vyY3yUD3GsvoZaAtKkD
Adaptive budget: pace spend to each capacity pool (#174, re-cut of #167)
The worker's #82 change (sonnet via Time): budget_check(route) paces each call to its capacity pool in
time/pools.json (expiring credit spread over the days left, refilling windows, or TIME_DAILY_USD without a pool);
the monthly cap stays hard. pools.json is read through the API once per run, since every workflow checks out only
.github/time. Ships empty: nothing changes until it is filled in. Closes#82.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017K4vyY3yUD3GsvoZaAtKkD
Router v1: the factory ranks its own next work, dry to start (#169)
Astra's seat judges every candidate (kind, value, size); router.py ranks them with the owner's weights per kind
(time/router.json), a restless boost and card age. dry posts the table; live queues from the top within the day's
money and the heartbeat promotes queued cards in ranked order. Astra reads the last 14 days of its log. Closes#168.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017K4vyY3yUD3GsvoZaAtKkD
venture: the lead (opus) researches and picks the day's venture alone; fable is out (#164)
One seat, TIME_VENTURE_LEAD (opus), runs the day's venture: a research round over the top trending repos (with the
start of their READMEs) and the studio's track record, TIME_VENTURE_FIELD candidates with a verdict each, one pick;
no pitches, no vote. It writes the launch plan alone and judges the build, and hands the launch drafts to its helper
(TIME_VENTURE_HELPER, sonnet). The chair and judge default to opus. Batches keep the board; TIME_VENTURE_LEAD=board
brings it back for the daily venture. Closes#165.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017K4vyY3yUD3GsvoZaAtKkD
venture: one venture a day, built for quality (#162)
The day's card starts only once every other venture card is launched or held; the board picks one from a field of
TIME_VENTURE_FIELD (6) pitches; opus builds; the judge scores the product 1-10 and a page under TIME_VENTURE_BAR (8)
gets polish rounds (never the last of TIME_VENTURE_FIXES=3), then launches with its score. An open batch card with
no Time beats no longer starts a batch while batches are off. Closes#163.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017K4vyY3yUD3GsvoZaAtKkD
venture.yml: install Chromium for the browser pass (#157)
The first Claude ventures launched without the browser pass (no Chrome on the runner): no headless checks, no
contact sheet for the portfolio. One step installs Chromium in the job image; venture.py's chrome() finds it.
Merged on the owner's word, CI green (run 1014).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017K4vyY3yUD3GsvoZaAtKkD
venture.yml: every 6 hours, Claude seats (#141)
Owner's call: the venture loop runs every 6 hours instead of every 20 minutes, on the Claude seats.
The builder is vars.TIME_VENTURE_WORKER || claude:sonnet, decoupled from the heartbeat's worker.
Merged on the owner's word after the council approved the engine half (#140).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017K4vyY3yUD3GsvoZaAtKkD