Part of #72, follows #168. The router's policy is still one function (`router.score`); a restless bandit needs rewards, and nothing recorded what a pick turned into. This card is the data, not the policy. - `router.py`: the shape of an outcome and the arithmetic over the log. `picks()` reads every live pick out of Astra's beats (a card picked twice keeps its first pick; a dry run picked nothing), `outcome()` settles one pick into a line and returns None while its card is open, `read_outcomes()`/`summary()`/`outcome_lines()` are the read side. - `housekeeping.py` step 4: one line per settled pick appended to `time/ledger/router-outcomes.jsonl` in one commit, dry-run writes nothing. A pick already in the file is never written twice. - `astra.py`: one summary line per kind (picks, merged share, median days, median $) over the ranked table, so the owner sees what a kind returns before changing its weight. Two notes on what the card asked for: - Spend proxy: the card offered the card's worker beats' `usd` or the runs' time window. Beats carry no `usd` today, so this uses the runs' time window — each `dispatched` beat until the next beat on the card — which attributes a run's spend to the card that ran it instead of charging it the whole factory's spend over the days it was open. Review and planning calls on the card are not counted; two cards whose runs overlap are each charged their own window. Documented in `time/ledger/README.md`. - The line carries `pr` as well as the shape the card listed: the card asks for the PR number and the line is the only place to keep it. - The file lives under `time/ledger/`, as the card says, since that is the one path `ci.yml` ignores and the sweep appends to it daily. No workflow change is needed: `housekeeping.yml` already passes `GITHUB_TOKEN` (the push) and `TIME_VENTURE_MEASURE_DAYS` (day 7). A venture pick waits until its venture's day 7 has passed and then carries the signups counted at that day; with no registry entry for the card the line goes in with no count rather than waiting forever. Tests: `test_housekeeping.py` covers a merged pick, an unmerged pick, a pick still in flight (no line), idempotence, the venture's day-7 count and dry-run; `test_time.py` section 172 covers the pure functions, the summary's arithmetic and the log's header. All three suites green. Closes #172 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0198aoK6ddbLN4d6q8r3J7vg
Spend ledger
Time keeps a record of what it spends on models. This folder holds it.
- One file per month:
time/ledger/<YYYY-MM>.jsonl, for example2026-10.jsonl. - One JSON line per model call. Fields:
at: when the call was made, ISO 8601 in UTC (2026-10-14T09:30:00Z).model: the model that was called.in: input (prompt) tokens.out: output (completion) tokens.pool: the provider the call booked to (anthropic,venice);time/pools.jsonmaps it to a capacity pool.usd: the cost in US dollars, ornullif the seat has no price (unpriced seats).
Example line:
{"at":"2026-10-14T09:30:00Z","model":"some-model","in":1200,"out":350,"usd":0.0123}
Who writes and reads it
- Written by Time itself. At the end of each run, Time appends that run's calls to the current month's file in one
ledger:commit. - Read before every model call. Whichever workflow is about to call a model (heartbeat, mentions, venture loop, Astra) adds up
usdfor today and for the month and refuses the call if either total has reached its cap (TIME_DAILY_USD,TIME_MONTHLY_USD). Anullcounts as zero.
When a cap trips
Time stops calling models. The next time the heartbeat has a card to run, it opens one issue labelled andon (never a second while that one is open). A monthly cap also switches TIME_MODE to dry-run, or says in the issue that it couldn't, until a human raises the cap. A daily cap clears itself at midnight UTC. Close the issue once it is handled.
Don't edit it by hand
Humans should never edit these files. The caps rely on them being exact, and Time only ever appends. A hand edit can hide spend or break the next append.
The router's outcomes (router-outcomes.jsonl)
Not spend, but it lives here for the same reason: Time appends to it daily and CI ignores this folder. One JSON line per
router pick that has settled, written by housekeeping (.github/time/housekeeping.py, step 4), so a learning policy has
rewards to read. Fields:
n,kind: the card and the kind it was picked as.picked,settled: the day Astra picked it, and the day the card closed.merged,pr: whether its PR landed, and which PR.days: whole days frompickedtosettled.usd: the dollars this ledger booked while the card's worker ran (eachdispatchedbeat until the next beat on the card). The ledger has no card field, so this is a proxy: review and planning calls on the card are not in it, and two cards whose runs overlap are each charged what was booked in their own window.signups: for a venture pick, the venture's signups at day 7 (TIME_VENTURE_MEASURE_DAYS);nullfor any other kind.
{"n":168,"kind":"feature","picked":"2026-10-17","settled":"2026-10-19","merged":true,"pr":190,"days":2,"usd":1.25,"signups":null}
A pick is written once, when its card closes, and never rewritten. Astra's daily log shows one summary line per kind over
the ranked table (picks, merged share, median days, median $). No pick reads the file yet: router.score is still the
whole policy.
Pools (time/pools.json)
The operator keeps one entry per capacity pool; a route with no entry is paced by TIME_DAILY_USD. Time reads the file through the API once per run. Fields: name, kind, routes, and by kind:
expiring:balance_usd(as ofas_of, a date),resets_at(the date the credit lapses). Allows(balance - spent since as_of) / days to resets_at x TIME_PACEa day.window:window_usd,resets_atasweekly:monorhours:5. Allowswindow_usdper window.balance:balance_usd. AllowsTIME_DAILY_USDa day.
[{"name": "anthropic-credits", "kind": "expiring", "balance_usd": 30, "as_of": "2026-10-19", "resets_at": "2026-10-29", "routes": ["anthropic"]}]