Files
time/CHANGELOG.md
T
2026-10-29 14:46:34 -07:00

57 KiB
Raw Blame History

Changelog

[Unreleased]

Fixed

  • A card branch is never rewound to its base, so an open card PR cannot lose its work (#277, which cost #249 and #212 theirs: both heads became commits already in main and both diffs went empty). Writing to a branch that is missing from the forge used to cut a new one from the base and push it; now git_commit only makes a branch that ls-remote says is not there, and only when the caller allows it (create=True). The worker's hands never allow it after the first commit: a branch gone while its pull request is open parks the card (the parked label, a beat the daily sweep lists, and one andon issue) with what to do — push the branch back from the PR's own history, then /time retry — instead of resetting the PR to the base. A branch that is on the forge and still will not clone is a transient error to raise and retry, not an excuse for base-only history, and update_branch says the same when the branch it should merge into has gone. The daily sweep stops before it can delete the branch in the first place: besides the open-PR list (which Gitea can answer with null, as it does other empty lists) it now deletes a branch only when its tip is the very commit its merged PR landed, so a branch carrying anything main does not have is kept whatever that list says.

Changed

  • A judge that returns nothing is a loud no-op, never a flat ranking (#211). A reply with no assess leaves every candidate at the unjudged default (value 3, size M), which is a flat table rather than a ranking: on 2026-10-20 (log #57) the seat sent back no usable JSON — (no note), 37 of 37 unjudged, every score 1.911 — and the router still queued from the top, sent #66, #198 and #202 to the cloud lane and promoted "start a new venture" to #1. The router now checks how much of the board came back judged; below a quarter of it (a key that matches no candidate counts for nothing) the day's comment is 🔭 Astra: the judge returned no assessments (<the first 200 chars of the reply>); nothing queued or sent today. with "unjudged": true in its beat, and nothing is ranked, queued, sent to the cloud lane or created. The run log carries the raw reply's length and the API's stop reason on every run, so a reply cut off by the output limit (stop length) is visible as the cause. Partial judging over that floor is unchanged: the unjudged rows score at value 3 / size M and the note says how many.
  • The planner asks its seat for 4 to 6 coarse cards per spec, each one shippable on its own (a module and its tests, a group of endpoints, a schema and its migration), instead of as many small cards as it cared to write. Every card it files carries what it waits on: one Depends on: #N line per card, or Depends on: none. The seat declares those dependencies (depends_on in its reply), so cards that wait on nothing are built in parallel up to TIME_PARALLEL, where before every card waited on the one filed before it and a spec was built strictly in single file; a card the seat says nothing about still waits on the step before it, so a human's card lines keep their order. The router and the heartbeat read both Depends on: #N and the older Depends on #N, so cards filed before this still wait on the right ones (#244).
  • TIME_PARALLEL defaults to 3 (was 1) and a bad value (not a positive integer) falls back to 3 (#243).
  • Cards that wait on nothing start side by side (#245). One heartbeat used to promote a single card; promote_next now fills every free slot in one pass — the best queued cards whose every Depends on: #N card is closed, the router's live ranking first and otherwise oldest, up to TIME_PARALLEL in flight at once — and the same run dispatches them, so independent cards build at the same time instead of each waiting for the one before it to merge. A card whose dependency is still open stays queued however free the slots are; cards already in flight hold their slots (and only the ones that are moving, #135); TIME_PARALLEL=1 is the old one-at-a-time chain. The run log says promote: ready=N moving=M free=F. The gate reads the dependency line with the same DEPENDS pattern as the planner and the router, so the planner's Depends on: #N (#244) and the older Depends on #N both hold a card back, and a spec Time plans itself now opens with every card that waits on nothing rather than one.
  • A card waiting on a person (needs-human, error, stuck, and now parked too) holds only the cards that wait on it, directly or through another card: it holds no slot (#135) and every other queued card is still promoted, with the dependency each held card waits on printed in the heartbeat log. A dependency Time can't read counts as open, as before. A card parked because its branch is gone from under an open pull request (#277) waits for a human to push it back, which is as human a wait as an objection, so it frees its slot as the other three do (#247).

Added

  • The router proposes optimization work the factory's own records show, instead of waiting for a human to notice it (#171; router.py, astra.py). Three sources, read by arithmetic over the last seven days: the ledger (a model or an entry point that booked over half the week's spend, where there is a second one to compare it with; a model whose spend per call is double its own median, so a few heavy calls carry the bill; pennies are left alone), the Actions runs (a workflow with two or more failed or cancelled runs, with the count and the last run's id) and the parked cards (an error or stuck beat older than a day — by the day, so one parked today or yesterday still waits — with no open PR, #78's automatic retries spent and no trusted /time retry the next heartbeat will act on: "retry or split #N"). Each finding is a candidate with no card yet, keyed opt:<source>:<id> and kind optimization, with a title and body already drafted; Astra's seat judges it like any other candidate. Live, a picked one is written as a card (unqueued, a candidate from tomorrow) and never worked on in place; dry, the ranked table shows it. The cloud lane never takes one, however it ranks: there is no card to label cloud. A source a card already names (Router: opt:<source>:<id> in the body) is not proposed again, whether that card is open or was closed in the last fortnight — the week a finding came from outlives the card written about it, so keying the open ones alone would propose the same spend again the day its card closed.
  • Astra's board (#279): TIME_ASTRA_PITCH (seats that each propose a slate of cards) and TIME_ASTRA_BOARD (seats that rank the merged field; default the pitching seats) turn the daily planner into the venture loop's ceremony pointed at the factory's own work. The slates merge (the same issue or title from two seats is one card with both proposers), the board ranks the field with the venture board's own Borda tally and quorum of two, and only the top picks are queued or created, under the single planner's guards and caps (TIME_ASTRA_MAX_READY, TIME_ASTRA_MAX_NEW); a card one seat alone wants is never opened. The Astra log carries each seat's note, a table of score and backers per card, each seat's reason, and what was queued, so humans object there as before. astra.py points venture.py at its own time module, so the board's seats spend through the run's governor and ledger, not a second copy. The board is the explicit variable, so with TIME_ASTRA_PITCH set it decides the day whatever time/router.json says; unset, Astra is the router or the single planner, unchanged. Turning it on needs astra.yml to pass the two variables through: that workflow change is a proposal for a human (see the commit message).
  • An andon note on the spec: when a card of a planned spec (Part of #N) parks for a person, Time comments once on the spec naming the card, the beat it stalled at, its PR, the cards held behind it and that everything else keeps moving. One note per stall — a card that is retried and parks again is said again (#247).
  • The router proposes optimization work the factory's own records show, instead of waiting for a human to notice it (#171; router.py, astra.py). Three sources, read by arithmetic over the last seven days: the ledger (a model or an entry point that booked over half the week's spend, where there is a second one to compare it with; a model whose spend per call is double its own median, so a few heavy calls carry the bill; pennies are left alone), the Actions runs (a workflow with two or more failed or cancelled runs, with the count and the last run's id) and the parked cards (an error or stuck beat older than a day — by the day, so one parked today or yesterday still waits — with no open PR, #78's automatic retries spent and no trusted /time retry the next heartbeat will act on: "retry or split #N"). Each finding is a candidate with no card yet, keyed opt:<source>:<id> and kind optimization, with a title and body already drafted; Astra's seat judges it like any other candidate. Live, a picked one is written as a card (unqueued, a candidate from tomorrow) and never worked on in place; dry, the ranked table shows it. The cloud lane never takes one, however it ranks: there is no card to label cloud. A source a card already names (Router: opt:<source>:<id> in the body) is not proposed again, whether that card is open or was closed in the last fortnight — the week a finding came from outlives the card written about it, so keying the open ones alone would propose the same spend again the day its card closed.
  • Astra's board (#279): TIME_ASTRA_PITCH (seats that each propose a slate of cards) and TIME_ASTRA_BOARD (seats that rank the merged field; default the pitching seats) turn the daily planner into the venture loop's ceremony pointed at the factory's own work. The slates merge (the same issue or title from two seats is one card with both proposers), the board ranks the field with the venture board's own Borda tally and quorum of two, and only the top picks are queued or created, under the single planner's guards and caps (TIME_ASTRA_MAX_READY, TIME_ASTRA_MAX_NEW); a card one seat alone wants is never opened. The Astra log carries each seat's note, a table of score and backers per card, each seat's reason, and what was queued, so humans object there as before. astra.py points venture.py at its own time module, so the board's seats spend through the run's governor and ledger, not a second copy. The board is the explicit variable, so with TIME_ASTRA_PITCH set it decides the day whatever time/router.json says; unset, Astra is the router or the single planner, unchanged. Turning it on needs astra.yml to pass the two variables through: that workflow change is a proposal for a human (see the commit message).
  • Mentions are handoffs (#79). @codex <ask> runs a headless codex exec in a throwaway checkout and @claude [haiku|sonnet|opus|fable] <ask> a Claude seat (opus by default) with the README and, on a PR, its diff and checks; each posts as its own Gitea user (TIME_SEAT_CODEX, TIME_SEAT_CLAUDE; Time posts for it when the token is missing or refused), signed <sub>codex via Time · <model></sub>. Codex's work becomes a branch and a PR opened by codex (Time pushes it; the token never enters codex's environment, and neither do Time's other secrets). codex exec runs under a wall-clock cap (TIME_SEAT_RUN_MINUTES, default 15), is killed at it with nothing pushed, and books one ledger line with launch: 1 on the codex pool (tokens from --json, usd null: no price); TIME_CODEX_MODEL picks its model. A reply whose last line starts with another seat's mention is a handoff: Time runs that seat on the reply, up to TIME_HANDOFF_MAX seat runs per comment (default 6). @a x → @b y → @c z is a chain of stages; seats on one stage, or on separate lines, run side by side. Strangers' mentions are still ignored. mention.yml needs TIME_SEAT_CODEX/TIME_SEAT_CLAUDE passed in, a runner with codex, and a timeout-minutes above the cap.
  • The steward runs inside Time (#80): .github/time/steward.py, every 6 hours, tests in test_steward.py (which test_time.py runs). It was a desktop-app scheduled session that had found PR #59 approved, green and mergeable eleven times in three days, been refused by its own permission dialog every time, and written "needs a human" every time; a steward that reports a block instead of repairing it is a status page. Inside Time it runs under the ledger's rules and the time / claude identities. The deterministic part is first and costs nothing: it merges every card PR that land() would merge (a trusted Approve, green checks, not held by touches_protected, TIME_AUTOMERGE=on), closes each open andon whose cause has healed (a daily cap past midnight UTC with today under it, a capacity pool back under its allowance, a key that is now set), and re-dispatches the heartbeat once if its latest run failed. Only what is left and is a real block — an objection, a failing check, a conflict, a parked card, an andon that has not healed — goes to one call to TIME_STEWARD (default claude:opus; off disables the tick), booked to the ledger like every other call, with five tools and the fences in code rather than in the prompt: labels (ready/queued/needs-human only), a comment that hands a card back to its worker, four variables (TIME_MODE, TIME_PARALLEL, TIME_STUCK_HOURS, TIME_DAILY_CAP — never the caps, TIME_AUTOMERGE, TIME_PROTECTED or TIME_TRUSTED), a heartbeat dispatch, and an escalation that is refused unless it is money beyond the caps or a human-only step (a login, a payment, a legal signature). A block it has already handed back waits for the card to move instead of being asked about twice. Each run writes one entry to the Astra log (#57): Merged / Fixed / Left, with the reason for anything left. Its comments carry {"steward": true} and no beat, so they cannot move a card; the one exception is the merged beat of a PR it merged. The workflow is a proposal in time/steward/steward.yml: it needs a human to review and install it under .github/workflows/, and once it runs the desktop-app scheduled steward is retired.
  • typy's typing engine owns the per-letter colouring state (#261): letterStates(word, typed) in typy/src/game.js marks every letter correct, incorrect, pending or extra from the word and the keys typed into it, and each snapshot carries it as letters. The engine already advanced on space or on the last correct letter, fixed letters on backspace, and fed its word queue from a list; this card adds the pure letter state those three behaviours colour, with unit tests for advance, inline error marking, overflow keys and backspace. Nothing in the UI changed yet.
  • Claude delegation over workflow_dispatch (.github/time/delegate.py, tests in test_delegate.py): a bounded task for the sonnet or opus seat with an idempotency key, run through time.py's own think, seat routing and dollar governor. Each job has a stable correlation ID (dlg-<20 hex>, from caller and key) and a receipt (queued/running/succeeded/failed, source SHA, run URL, model, tokens, dollars, result and its hash) at time/delegations/<id>.json and on TIME-RECEIPT log lines. Duplicate keys replay the stored result without spending, a failed key retries (3 attempts), and the task text, secrets and key-shaped strings never reach a receipt or log. No issue comments. The workflow itself is a proposal in time/delegate/delegate.yml: it needs a human to review and install it under .github/workflows/.

Security

  • A label filter is checked on every row. Gitea ignores labels=<name> for a label the repo does not have and returns every issue: housekeeping's first live run listed all 59 open issues as parked cards (there is no parked label), and the same answer to labels=spec would have had Time split every open issue as a spec. issues() in time.py and the sweep's queries now keep only rows that carry the labels they asked for.
  • A commit status from one of Time's own workflows (heartbeat /, mention /, venture /, astra /, doctor /, update /, steward /) is not a check: it neither blocks nor earns a merge. A seat's review is a pull_request_review event that wakes heartbeat.yml during a heartbeat; the concurrency group cancels the run and Gitea records it as a failure on the PR head, which had stopped approved, CI-green PRs from landing.
  • The worker's own commit status (time/worker-run) can fail or delay a merge but never earns one: with no other check on the PR head, Time dispatches the project's CI workflow on the PR branch (TIME_CI_WORKFLOW, default ci.yml, which must accept workflow_dispatch; GitHub skips pull_request runs for pushes made with the Actions token) and merges on that. off keeps the old "no checks" stop.
  • .github/ is protected from auto-merge whatever TIME_PROTECTED says.
  • labs-aicharts.yml passes the deploy URL through env instead of interpolating it into the shell.
  • Time's state comments (<!-- time {...} --> beats) count only when written by github-actions[bot]; a pasted marker from anyone else is ignored. A stranger on a public repo could otherwise steer a card to merge a chosen PR or build a chosen page.
  • /time retry and "Approve"/"Object" comments count only from the owner, members, collaborators and Time itself; any other bot is a stranger too.
  • The protected-path check reads every file of a PR (not the first 100) and counts a rename under both names.
  • The browser pass clones the Pages branch, then drops .git (the clone URL carried the token), serves only the venture's own folder, and runs headless Chrome with no network except localhost, so a model-written page can't read or send anything.
  • The worker's run tool executes inside a throwaway container (docker run, the checkout mounted; the workflows set TIME_RUN_IMAGE: node:22-bookworm), so a command can't read the orchestrator's environment through /proc. Unset, or without a docker daemon, it is the host shell as before.
  • A venture build writes only inside its own ventures/<slug>/ folder on the Pages branch, which also carries the studio's site and every other venture: a fix round once wrote its page over the site's front page.
  • The Venice worker's shell no longer sees secrets: its environment drops tokens and keys, its checkout keeps no token in .git/config, and the workflows check out without persisted credentials.

Changed

  • A review that runs past the output limit says so, and never objects by accident (#202). The reviewer's call reads the API's stop reason (finish_reason: length in the engine's OpenAI shape, stop_reason: max_tokens from the Messages API): a review cut off at the usual 4000 tokens is asked once more with TIME_REVIEW_MAX_TOKENS (default 8000), and one cut off again is posted with a line saying so and casts no vote, so the council waits or the tie-break seat votes instead of sending the card to a human on half a verdict. haiku's review of PR #199 ended mid-word at "- **astra.py, `parked_c" and its Object, the only one, parked the card on a last point nobody could read. The reviewer prompt now asks for the verdict on the first line and at most five points, most important first, each a line or two.
  • Time's merge commit on a cloud PR's branch survives sync. When Time merges the base into an approved PR that fell behind, on a branch sync.py brought in from the GitHub copy, sync now sends that commit back to the copy fast-forward (back in its log line); it used to see the home's branch differ from the copy's and force the copy's older tip over it on the next run, undoing the merge. When both sides have commits of their own the copy, the branch's writer, still wins and Time merges again later. Time's merge keeps both sides' lines when CHANGELOG.md conflicts (git's union driver; the new .gitattributes says the same to anyone merging), the conflict that sent #172's approved PR #203 to a human. A conflict on a branch sync.py brought in now says the fix goes to the copy rather than /time retry, whose worker would commit to the home and be overwritten by the next sync.
  • Time's merge commit on a cloud PR's branch survives sync. When Time merges the base into an approved PR that fell behind, on a branch sync.py brought in from the GitHub copy, sync now sends that commit back to the copy fast-forward (back in its log line); it used to see the home's branch differ from the copy's and force the copy's older tip over it on the next run, undoing the merge. Only Time's own merges go back, and only while the copy's branch is where sync read it: anything else ahead at the home (the copy rewinding its own branch, a push at the home) is not sent out, and the copy, the branch's writer, wins as before. Time's merge keeps both sides' lines when the root CHANGELOG.md conflicts (git's union driver; the new .gitattributes says the same to anyone merging), the conflict that sent #172's approved PR #203 to a human. The cost: a bullet one side reworded or deleted beside the other side's new one comes back next to it, with no flag. A conflict on a branch sync.py brought in now says the fix goes to the copy rather than /time retry, whose worker would commit to the home and be overwritten by the next sync.
  • The monthly cap (TIME_MONTHLY_USD) counts the owner's own money only. Spend that an expiring pool's prepaid credit paid for (its routes, from its as_of until it lapses, up to the balance it had) is left out of the month, since the credit's own balance limits it; spend from before as_of, past the balance or the lapse, or on a route with no pool counts as before. With anthropic-credits pacing Claude at about $8/day, the $100 cap would have tripped around 2026-10-26 and put Time in dry-run for five days with credit left to lapse on 2026-11-08. budget()'s month, the cap's refusal, the andon, doctor and Astra's line all say "out of pocket".
  • The router never picks a card that waits, and promotes it after the ones that can move. A card waits when its Depends on #N card is still open, or when the seat marks it blocked with what it waits on (a PR that can't merge, a human): the router's first live run queued #84, whose PR #75 can't merge against main, because the seat scored it top while writing that it was blocked. The table shows what a card waits on. astra.yml now passes TIME_WORKER and TIME_PACE, without which Astra's money left fell back to the daily cap.
  • The router's money left is the worker's pool (#175): live mode queues against the allowance left in the pool that takes TIME_WORKER's route (about $8/day from anthropic-credits while the credit lasts), or the daily cap's remainder without one, never more than the month has left; it was TIME_DAILY_USD minus today's spend, money the worker couldn't spend. The Astra line and the seat's prompt show the figure and its source. The seat also sees the cards closed in the last 14 days and is told not to write them again (it proposed the housekeeping label fix two days running after #137 had landed), and a new card whose title is an open or lately closed card's, or contains or is contained in one of four words or more, is not written; the log lists it under "Not written".
  • The lead runs the day's venture alone, and fable is out. One seat, TIME_VENTURE_LEAD (opus), does a research round (the top six trending repos with the start of their READMEs, the rest as a list, and every studio venture with its status and signups), lists TIME_VENTURE_FIELD candidates with a verdict each, and picks one; no pitches, no vote. A pick that repeats a studio venture gets one more go, told so. The lead writes the launch plan alone (no synthesis) and judges the build; it hands the launch-post drafts to its helper, TIME_VENTURE_HELPER (sonnet). The chair and the judge default to opus (they were fable, the dearest seat, on calls opus does as well). Batches keep the board, and TIME_VENTURE_LEAD=board brings it back for the daily venture.
  • One venture a day, built for quality. The day's card starts only once every other venture card is launched or held, so one venture at a time gets the day's worker runs and budget; the seats pitch a field of TIME_VENTURE_FIELD (6) in rounds, each told what is already pitched, and the board picks one from it (it was one pitch per seat); claude:opus builds (BUILDER, and TIME_VENTURE_WORKER on teahubxyz/time); and the judge scores the product 1–10 as a stranger would. A page under the bar (TIME_VENTURE_BAR, 8) gets a polish round with the judge's top three changes; fix and polish rounds share TIME_VENTURE_FIXES (now 3); polish never takes the last round, which stays free to fix whatever a polish broke; then a page under the bar launches with its score in the launch post, so quality never holds a launch for a human, only a broken page does. An open batch card runs only while batches are on (TIME_VENTURE_BATCH) or once it has Time's beats: batch #26, migrated from GitHub with none, had started a three-venture batch on a single-venture day.
  • The venture loop runs on Teahub with Claude seats. Fable chairs (synthesizes LAUNCH.md, writes the launch drafts, judges the build); haiku, sonnet and opus pitch, vote and write plans; claude:sonnet builds and claude:opus takes the second fix round. TIME_WORKER is no longer coerced to a Venice seat, which is how a Claude-configured factory kept spending Venice credits on venture builds; any claude: or venice: spec is taken as given. The port to Gitea: the scout asks GitHub's public search unauthenticated (the forge has no repository search, and no token of ours goes to GitHub); venture, batch and signal cards are listed through tm.issues (Gitea's pull_request: null had kept every card out) and created with label ids; reactions answered with null and Gitea's 50-row pages are handled; a /time retry counts from a trusted login (Gitea has no author_association); signups are the form's Venture field, or the prefilled Early access: <name> title on a body the form wrote when the field was left empty, never the owner, Time's users or a seat; there is no Pages API to call, and each venture is served at https://<slug>.<TIME_VENTURE_PAGES_DOMAIN>/ (yolopreneur.lol). The domain's root is the Pages branch's ventures/, whose index is the hand-made yolopreneur.lol portfolio reading ventures.json: the loop writes ventures.json (now with shot, sheet and source at launch, which the portfolio shows) and writes its own list page only where the page is its own, instead of replacing the site's front page on its first save. Links point at the forge (/src/branch/, /raw/commit/). Astra never queues the loop's own issues.
  • Claude calls go to Anthropic's own Messages API with prompt caching. The tools and system prompt are one cache breakpoint and the conversation so far the other, so each step of a worker run pays for the context the step before already sent at a tenth of the price (the OpenAI-compatible endpoint has no caching: a failed 43-step run cost $2.80, most of it the same context re-sent 43 times). The engine still speaks the OpenAI shape inside; an assistant turn that issued tool calls goes back exactly as the API gave it. The ledger prices cached tokens (cached, written on the line). TIME_ANTHROPIC_API=compat goes back to the old endpoint without a code change.
  • Housekeeping reads a null page as an empty one. Gitea answers an issue nobody reacted to with null, not [], so the signal step failed ('NoneType' object is not iterable) on 2026-10-19, the first day a signal issue reached its 7-day window; the fake forge in test_housekeeping.py now answers the same way.
  • 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

  • A new venture starts when the router picks it, not when the clock comes round (#170). TIME_VENTURE=router is a third mode of the venture loop beside on and off: no daily card on the schedule, a card only when asked (TIME_VENTURE_START, the start=true dispatch input), and the 6-hour tick still advances and measures the open venture cards as it does under off. Astra's router then ranks "start a new venture" as actionable work with the seat's size and cost, against every card on the board, instead of the venture loop spending a day's worker runs on its own schedule; the candidate says what's missing when it isn't actionable. Every live venture in ventures/ventures.json with signups is also a "v2 of <venture>" candidate, shown with its signups — no signups, no candidate. Picked live, a new venture dispatches venture.yml with start=true (the Actions call the heartbeat already makes to run CI) and is never queued as a card, since there is no card; a v2 posts the venture's own open v2 card if the loop wrote one, else writes and queues a card against gh-pages. The Astra log says which. A venture is picked from a ranking like anything else, so a reply that judged too little of the board to be one (#211) starts no venture either: nothing is dispatched and no v2 card is written. Turning it on is still the owner's or the CEO's call: TIME_VENTURE=router, and astra.yml must pass it as TIME_VENTURE_MODE for Astra to see it.
  • The daily sweep lists open cards whose work a closed card already did (#198). Housekeeping compares every open card with the cards closed in the last 30 days — the same title (the planner's repeat rule), a closed card that ports or follows the open one (Port of #48, Follows #48), or both carrying the same card over — and names each likely-superseded card with the number of the card that did its work under the stale list, for Astra and the CEO. It closes nothing and labels nothing: the rule is a hint a reader checks, not a verdict, so a card whose work landed under another number stops being silently ranked and queued again. The venture loop's own issues are left out of both lists (two signups for one venture carry the same title by design), and the repeat rule now lives in router.py, where the sweep and the planner share one copy of it.
  • Taskmaster, rebuilt (#66): taskmaster/, the factory's Whop coaching app, where you haggle for your price, pay through a real Whop checkout, and only then get coached. The old version never took money — the character switched to coaching when the member typed "Paid $X via …" and the webhook handler only logged invoices. Two rules carry the rebuild. The character haggles; the deal desk decides: the character never sets a price, it calls one tool, quote(tier, price, reason), and dealdesk.js answers accept, counter or refuse against the tier's anchor, its floor and the concession rules (OpenPoke's split between the voice and the workers, applied to price). The app decides the mode, never the model: one character sheet, two prompts, and the server picks which to send from one fact — whether Whop says the member has paid. A valid membership on prod_EZ7UkE4pkW04U, or a place on a valid pack's seat list, is the receipt; typing "Paid" changes nothing and a test proves it. The haggle is Poke's beta bouncer with four rules: the opening is the list price and the desk never quotes above it or walks a price back up; a quote under the floor is countered at the floor whatever reason came with it; a step down needs one of four reasons the member gave (scope, urgency, commitment, pay_now), each worth a slice of the anchor and each spendable once; and three rounds, the opening included, then the standing price is the price. Two more refusals are about honesty and spend no round: a reason that reaches for the member's profile, following, employer, means or country is refused (price the project, never the person — the one place this deviates from Poke, which sized you up before it priced you), and so is invented scarcity or a fake deadline. The agreed price becomes a quote card the model cannot edit, sealed with an HMAC over its fields, and a share card follows the deal ("I got Taskmaster down to $X. Anyone get it lower?"), which was Poke's loop. Every deal is logged, closed or walked away from — opening, counters, final price, rounds, reasons, outcome, and the fee and net, never a word the member wrote: one JSON line per deal through an injected sink, validated so prose cannot get in, which is the price discovery that replaces the haggle with fixed tiers later. Orgs buy a seat pack: a Whop checkout has no quantity, so a pack is one membership and the seat list lives in its metadata (no database), swaps are rate-limited, and the buyer sees seats and usage but never anyone's chats. The tiers start from the old prompt's prices ($100 sprint with an $80 floor, $50 lite, $300/$250 for three, $10 and $5 a day, $100-a-seat packs), and the 3-sprint pack saves 17%, not the 15% the old prompt claimed. The voice is Venice with the key in Whop app secrets, one tool, short replies and a limit per member; there is no uncensored default (the old code defaulted to venice-uncensored) and the sheet is plain text rather than a Venice character, so it is portable. The persona and the two prompts stay with the operator. Three experiments are bucketed by user through Whop's Experiments API with a local fallback: haggling against a fixed price, a high anchor against a modest one, and one free push against none. Nothing is created on Whop: the Whop client refuses every write unless constructed with live: true, a daily-billing tier refuses to be created until the operator confirms Whop supports it, and the 75 tests run against an injected fetch with simulated signed webhooks. test_time.py runs them.
  • Taskmaster, rebuilt (#66): taskmaster/, the factory's Whop coaching app, where you haggle for your price, pay through a real Whop checkout, and only then get coached. The old version never took money — the character switched to coaching when the member typed "Paid $X via …" and the webhook handler only logged invoices. Two rules carry the rebuild. The character haggles; the deal desk decides: the character never sets a price, it calls one tool, quote(tier, price, reason), and dealdesk.js answers accept, counter or refuse against the tier's anchor, its floor and the concession rules (OpenPoke's split between the voice and the workers, applied to price). The app decides the mode, never the model: one character sheet, two prompts, and the server picks which to send from one fact — whether Whop says the member has paid. A valid membership on prod_EZ7UkE4pkW04U, or a place on a valid pack's seat list, is the receipt; typing "Paid" changes nothing and a test proves it. The haggle is Poke's beta bouncer with four rules: the opening is the list price and the desk never quotes above it or walks a price back up; a quote under the floor is countered at the floor whatever reason came with it; a step down needs one of four reasons the member gave (scope, urgency, commitment, pay_now), each worth a slice of the anchor and each spendable once; and three rounds, the opening included, then the standing price is the price. Two more refusals are about honesty and spend no round: a reason that reaches for the member's profile, following, employer, means or country is refused (price the project, never the person — the one place this deviates from Poke, which sized you up before it priced you), and so is invented scarcity or a fake deadline. The agreed price becomes a quote card the model cannot edit, sealed with an HMAC over its fields, and a share card follows the deal ("I got Taskmaster down to $X. Anyone get it lower?"), which was Poke's loop. Every deal is logged, closed or walked away from — opening, counters, final price, rounds, reasons, outcome, and the fee and net, never a word the member wrote: one JSON line per deal through an injected sink, validated so prose cannot get in, which is the price discovery that replaces the haggle with fixed tiers later. Orgs buy a seat pack: a Whop checkout has no quantity, so a pack is one membership and the seat list lives in its metadata (no database), swaps are rate-limited, and the buyer sees seats and usage but never anyone's chats. The tiers start from the old prompt's prices ($100 sprint with an $80 floor, $50 lite, $300/$250 for three, $10 and $5 a day, $100-a-seat packs), and the 3-sprint pack saves 17%, not the 15% the old prompt claimed. The voice is Venice with the key in Whop app secrets, one tool, short replies and a limit per member; there is no uncensored default (the old code defaulted to venice-uncensored) and the sheet is plain text rather than a Venice character, so it is portable. The persona and the two prompts stay with the operator. Three experiments are bucketed by user through Whop's Experiments API with a local fallback: haggling against a fixed price, a high anchor against a modest one, and one free push against none. Nothing is created on Whop: the Whop client refuses every write unless constructed with live: true, a daily-billing tier refuses to be created until the operator confirms Whop supports it, and the 83 tests run against an injected fetch with simulated signed webhooks. test_time.py runs them. Four paths are the review's: a variant is reused when a later deal lands on the same price, but every deal gets its own checkout configuration, because a configuration's metadata is what maps the payment back to a member, a thread and a deal (sharing one would have sent the second buyer's payment to a deal already paid, leaving their own open and their thread haggling); a member who comes back from checkout to a membership Whop has already issued closes and logs the deal there, since the webhook can be late, lost or named something other than the events the handler matches (an agreed deal left open was logged as a walkaway when the thread closed, a paid deal recorded as a lost one in the log that is the price discovery); a seat holder is found through a pack index, because a seat holder holds no membership — the pack is the lead's — so asking Whop what that user holds answered nothing and coach mode for a pack only ever worked for its buyer (the index says which pack to read, Whop's own metadata still says whether the seat is there, and a thread now carries both names a person has on Whop, since memberships answer to a user id and seat lists are written in usernames); and the seats routes are the buyer's alone, named by a thread, since a membership id is not a password. Writes to Whop that are bookkeeping no longer take a turn down with them: a refused seat-usage write is reported (usage_noted: false) rather than 500ing a coaching turn the member paid for, and a refused checkout costs the quote card its button, not the deal — /api/agree mints it when Whop will take it.
  • 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.
  • Time brings a PR branch up to date with its base (#123). An approved PR Gitea reports as not mergeable (or whose merge call answers 409) gets git merge origin/<base> (the PR's own base) pushed as one commit by Time, card #N: merge <base>; CI runs on the new head and landing follows on a later beat (a ci-dispatched or updated beat now reaches land() again). A conflict is not guessed at: the merge is aborted, the branch left as it was, and the conflicting files and <<<<<<< blocks (trimmed) are posted on the card and the PR with the needs-human label; /time retry hands them to the worker as the reviewer's text. Known limit: the worker's write_file of a resolved file is a plain commit, not the merge, so Time's next try can conflict again.
  • A card waiting on a person holds no ready slot (#135). promote_next counts toward TIME_PARALLEL only the ready cards that are moving; one whose last real beat is needs-human, error or stuck (an objection, or a failure Time won't retry again) keeps its label, so /time retry still finds it, but frees its slot. Three such cards held every slot twice, the second time for ten hours on 2026-10-19 while 22 cards sat queued. The run log prints promote: ready=N moving=M.
  • Sync (sync.py, time/sync.json, the sync workflow): a repo's copies on other forges follow its home, under one rule, one home per repo and one writer per branch. The home's main (the out branches) goes to every copy fast-forward only; a copy with commits of its own is reported in a needs-human issue and never overwritten, unless the force dispatch input replaces its history once. A copy's branches matching its import patterns (claude/* from cloud sessions) are the copy's to write: they come back to the home under the same name with a PR, commit messages in the body (so a Closes #N adopts the card), and Time's council reviews and lands them; a branch already in main, or one whose PR was closed at the same commit, is left alone. Gitea's own mirrors don't fit: a push mirror force-pushes every branch (it would delete the copy's work branches) and a pull mirror makes the home read-only. Tokens are secrets named in the file, scrubbed from every message; a copy can instead use an SSH deploy key (ssh_key: write access to that one repo and nothing else), with the host's keys pinned, never learned on first use. Gitea refuses secret names starting GITHUB_ or GITEA_, so the names start SYNC_. The first copy is github.com/teahubxyz/time, so cloud sessions clone a current engine; it pushes with the deploy key in SYNC_GITHUB_KEY, and GitHub Actions is off on it. The same script syncs Gitea with Gitea, run from whichever side can reach both.
  • Adaptive budget: spend is paced to each capacity pool instead of one fixed daily cap. time/pools.json (kept by the operator) lists the pools: expiring credit allows (balance - spent since as_of) / days to resets_at x TIME_PACE a day (default 1.0), so a quiet day raises tomorrow's share; a window pool (weekly:mon, hours:5) allows window_usd per window and refills at its reset; a balance pool keeps TIME_DAILY_USD. A call is checked against the pool its provider books to, and TIME_DAILY_USD is the fallback for a route with no pool; the monthly cap stays global. Ledger rows carry a pool field, doctor prints each pool's balance, spend, next reset and today's allowance, and Astra's log line reports spend per pool. It ships with an empty pools.json, so nothing changes until the operator fills it in. pools.json is read through the API once per run, like the ledger, since every workflow checks out only .github/time.
  • The router (time#68, #72; router.py, time/router.json): the factory picks its own next work across ventures, features, refinements and optimizations. Astra's seat no longer picks cards in prose; it judges every candidate (each open card with a DONE: line that isn't a parent, a human's or the venture loop's own, plus "start a new venture") with a kind, a value 1–5 and a size S/M/L, and code ranks them: weight[kind] × restless(kind) × age × (value + 0.5 × cards waiting on it) / size. The weights are the owner's dial per kind (all 1, balanced); a kind not picked lately climbs (restless, 0.05 a day for up to 10 days) so none starves; an L is ranked but never queued (split it first). The Astra log shows the ranked table. mode: dry posts it and touches nothing; live queues from the top while the day's money left covers each card's estimated cost (cost_usd), writes the seat's new cards unqueued (candidates the next day), and the heartbeat's promote_next promotes queued cards in the live ranking's order instead of oldest first. off, or no file, is the planner as before. It starts dry. The policy is one function, so a bandit policy (Beth, Jev, Fable's maths) can replace it. Astra also reads the last 14 days of its log instead of the thread's first twenty comments.
  • A signup's Venture field matches the venture's slug, name, home folder or an old slug, ignoring case, spaces and punctuation. The field is typed by a person (Teahub does not prefill it from the link), who writes "Cartridge" or "Loot Drop", not box-art-night; those signups were not counted.
  • A worker's tool call that is cut off at the output limit, or arrives with malformed arguments, is an error the worker reads on its next step instead of a crash that throws the run away. The first Claude venture builds (time#148, #149) died on 'content': a whole launch page written in one write_file ran past the output limit and arrived with its path and no content. The error says so and asks for smaller files.
  • Ponytail on prod: a seat worker (and so the venture builder) carries Ponytail's rules in its system prompt: the smallest complete change, reuse before new code, a check for new logic, never cut validation, security or accessibility, and one closing line on what it skipped. Vendored from DietrichGebert/ponytail (MIT) at .github/time/rules/ponytail.md, upstream commit 09c434a. Reviewers, Astra and the venture board never get it (a coding ruleset distorts answers that aren't a diff). TIME_PONYTAIL=off turns it off. It ran on hb-5's seats since 2026-10-12; prod had never had it.
  • Time retries its own parked cards. A card parked on error or stuck with no open PR is dispatched again on the next beat, up to TIME_AUTO_RETRY times (default 1; 0 leaves it to a human), instead of waiting for a human /time retry; the beats carry auto_retry, a seat worker resumes the card's branch with half as many turns again (TIME_WORKER_STEPS x 1.5), and a card with an open PR still goes to review. Dry-run retries nothing. The stuck and error comments say what Time will do next.
  • Council verdict: TIME_REVIEW takes a comma-separated list of seats (claude:haiku, claude:opus); every seat reviews each PR and posts as itself, and each identity's latest vote counts once (Time's own comments are one identity). approve when approves minus objects reaches TIME_REVIEW_QUORUM (default 1), object when objects exceed approves. TIME_REVIEW_TIEBREAK names one seat (e.g. venice:kimi) called only when every standing seat has voted and they split; if it doesn't settle it either, one "the council is split N–N; over to a human" comment with a needs-human beat. One seat and quorum 1 behave as before. Codex's ladder (luna, terra, sol, astra) is in seats.json on the CLI route (its review run lands with #79; the astra login is the Codex seat, not the planning tick).
  • Seats review as themselves: with a TIME_SEAT_<SEAT> token (one Gitea user per seat, e.g. TIME_SEAT_KIMI) the reviewer's verdict is a real Gitea review by that user (Approve → approved, Object → changes requested, prose → comment), so reviews carry an identity and can count. Without a token, or if Gitea refuses (a seat on its own PR), it is Time's comment as before.
  • Cards can live on another branch. A Base: <branch> line in a card (Base: gh-pages for a venture) makes the seat worker cut its branch from that base, name it time/card-N-<base>, see that branch's files, and open the PR against it; without the line nothing changes. Until now every worker branched from the default branch whatever the card said, which is why the cards on gh-pages ran out of turns or opened PRs against main.
  • A launched worker finishes what it starts. The daily spend cap gates launches; a run that began within budget may go on to TIME_CAP_SLACK x the cap (default 1.5), and a PR that is already open gets its review on a capped day too. A run stopped mid-way had cost its whole spend and produced nothing. The monthly cap stays hard everywhere; TIME_CAP_SLACK=1 restores the old stop.
  • The tick takes the oldest ready card first, the order promote_next already uses, instead of the newest the API lists first, so a card promoted first is built first.
  • TIME_PROTECTED now governs every protected path except the workflow directories (.github/workflows/, .gitea/workflows/), which stay a human's merge whatever it says. The default still protects all of .github/; a repo that trusts its reviewer can set TIME_PROTECTED=.github/workflows/,.gitea/workflows/,deploy,infra/,.env,secret so the engine under .github/time/ merges itself on Approve + green like any other code.
  • Objections go back to the worker. /time retry on a card whose PR is open hands the worker the reviewer's latest comments (marked as the reviewer's, strangers' left out) on the same branch; open_pr on an open PR updates its description instead of failing; the card goes back to dispatched, so the next beat reviews again and an objection older than the push no longer counts.
  • A batch's children start in the tick that spawned them (GitHub lists a card made a moment ago only later, so they used to wait a tick).
  • Build briefs keep claims to what the code does: no promised features or schedules, and the trending repos are the wave a venture rides, never something it "builds on". Fix rounds take a failing claim off the page rather than building a feature in a hurry. In the first live batch, every fix round was a claim the code didn't back.
  • Venture batches (multi-ceremony): one batch card scouts once, the seats pitch a field (rounds until there are 2k pitches), and the board ranks the whole field; the top k, each wanted by two seats, become venture cards in the batch's own milestone. They run the ordinary loop side by side and launch together: a child that passes its checks waits at ready until every sibling is settled or the hold (TIME_VENTURE_BATCH_HOLD_HOURS, 6) runs out; stragglers then launch alone. No child left behind: a held child gets one batch retry, and held again it is counted dead with its reason. A retro on the batch card compares the cohort's signups against each plan's lines, then the card and milestone close. Set TIME_VENTURE_BATCH=3 to make batches the daily default, or Actions → venture → Run workflow → "batch".
  • Parallel builds: a tick runs up to TIME_VENTURE_HEAVY (default 3) worker runs at once, one per card, so a batch builds in one tick instead of three. Each build keeps its own folder lock, ventures.json is updated under a lock with a fresh read, and a Pages write that loses the race for the branch head (409) is retried. TIME_VENTURE_HEAVY=1 is the old one-at-a-time.
  • Fix rounds yield the worker runs to cards still due their first build, so a page that keeps failing can't starve the others.
  • Accounts for ventures, with passkeys: each venture's backend can sign people up and in (Face ID, Touch ID, Windows Hello, a phone), keep a 30-day session, and list what each person saved. No passwords, no email, nothing to configure; accounts live in the venture's own database on its own domain. time.js gains Time.signUp, Time.signIn, Time.signOut, Time.user and Time.mine, and builds are told to use them for the paid tier's promise without ever putting sign-in in front of the free product.
  • Venture backends use Gemma 4 (@cf/google/gemma-4-26b-a4b-it) by default and read both the older {response} and the OpenAI-style answer from Workers AI (the first default had been retired).
  • Ventures can be backed by Cloudflare: with CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID, each venture gets its own Worker, D1 database and Workers AI, and lives at <slug>.<TIME_VENTURE_DOMAIN> (or workers.dev) while its page stays on GitHub Pages. The page's time.js gives share links and a small model, and degrades to null so products still work without it. Existing ventures are backfilled a couple per tick; a new worker redeploys every venture; the doctor checks the token.
  • Venture loop launches products, not pages: a launch round where board seats write plans and one synthesizes LAUNCH.md with attribution; launch pages where the working product fills the first screen under a slim header with one call to action, then pricing and honest limits, in a look that follows the product; early-access signups through a GitHub issue form, thanked on their issues; deterministic and headless-Chrome checks with fix rounds and an andon; a 7-day decision against success and kill lines; a studio index at /ventures/.
  • Start a venture whenever you like: Actions → venture → Run workflow → "Start a new venture now" (gh workflow run venture.yml -f start=true); the schedule still starts one a day.
  • The board's taste: a delight anyone gets within a minute ranks above another developer utility, and when a product makes something people read (a deck, a card), its preloaded sample is the product's own pitch.
  • The studio index at /ventures/ wears the Time site's blueprint theme (title block, grid paper, type), with each launched venture's laptop screenshot.
  • The headless-Chrome check fails a launch page where something sits on top of the product (a page fixed to the screen's height lets the next section slide over it).
  • The board sees the studio's own ventures: pitches that repeat one (by slug, name or an earlier slug) are set aside before the vote, since the week's trending list barely moves from day to day.
  • Cards adopt an open PR that already closes them. A ready card with no beats and an open PR whose body closes it (or a card whose body says PR: #N) gets a pr beat with adopted: true and a review in the same tick, then lands through land() on Approve + green like any card PR, instead of launching a worker to build it again. No launch is counted and no worker budget is spent; closed and merged PRs are never adopted; dry-run says what it would adopt. Work built outside the worker (Codex, a seat's chat) no longer waits for a human merge.
  • Hands-off upgrade support with spec planner, auto-merge, and Venice worker integration