Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4506a1b3de | ||
|
|
6baa7127f9 | ||
|
|
c5e557ea93 | ||
|
|
59d5eaa64f | ||
|
|
fe24d53552 | ||
|
|
9e65e5b151 | ||
|
|
d19fb49086 | ||
|
|
9d6a610576 | ||
|
|
95f80f8f17 | ||
|
|
934f699df4 | ||
|
|
3b7fec9a14 | ||
|
|
d2d1bdff4c | ||
|
|
5ef43871ae | ||
|
|
da19dcfea5 | ||
|
|
13075204cb | ||
|
|
698ad13995 | ||
|
|
4ffeb70e84 | ||
|
|
9d8f7686ce | ||
|
|
a801cccd25 | ||
|
|
b5376e9dee | ||
|
|
f853d7c97a |
No files matched your search
@@ -11,22 +11,38 @@ record; nothing here is scaffolding from before 2026-10-14.
|
||||
└── <your repos>
|
||||
Time = the automation layer. It lives here, on prod (teahubxyz/time); the GitHub copy is an archived proof of concept.
|
||||
Time acts as the Gitea user `time` (its own token, TIME_TOKEN) for every write except comments and commit statuses,
|
||||
which stay with the Actions identity (`gitea-actions`); code goes in as git pushes.
|
||||
Rhythm: Astra (daily, 13:13 UTC) queues cards; the heartbeat (hourly) builds them one at a time with a seat, gets a
|
||||
review, and merges on approve + green CI. PRs under .github/ (Time's own engine) never merge themselves: the steward,
|
||||
a scheduled Claude session every 6 hours, reads and merges them.
|
||||
Seats: Claude (haiku / sonnet / opus / fable, ANTHROPIC_API_KEY, user-level secret) by default; Venice wired but idle.
|
||||
Spend: every model call is booked to time/ledger/<YYYY-MM>.jsonl; TIME_DAILY_USD=5, TIME_MONTHLY_USD=100 stop the
|
||||
clock and open an `andon` issue. No model decides whether to spend.
|
||||
which stay with the Actions identity (`gitea-actions`); code goes in as git pushes. Seats post as themselves: a
|
||||
review by `haiku` is a Gitea review by the user `haiku` (one Gitea user per model line, TIME_SEAT_<SEAT>).
|
||||
Rhythm: Astra (daily, 13:13 UTC) is the router: its seat judges every candidate card (kind, value, size) and
|
||||
`router.py` ranks them with the owner's weights per kind in teahubxyz/time's `time/router.json` (mode dry: it posts the ranking on
|
||||
the Astra log; live: it queues from the top and the heartbeat promotes in ranked order). The heartbeat (hourly, and on demand) builds `ready` cards with a
|
||||
seat, gets each PR reviewed by the council, and merges on approve + green CI, engine code included. Only the workflow
|
||||
directories (`.github/workflows/`) stay a human's merge: the CEO batches them. A card whose PR already exists (Codex, a
|
||||
chat, the CEO) is adopted: labelled `ready`, it is reviewed and landed, never rebuilt. Housekeeping sweeps daily (13:17).
|
||||
Ventures: paused since 2026-10-19 (TIME_VENTURE=off; the launched ones are still measured) until the router starts
|
||||
them (time#170). When on: one a day, built for quality; the day's card starts once every other venture card is launched or held. Opus leads alone: a research round over the trending repos and the
|
||||
studio's track record, one pick, the plan, the build (`claude:opus`) and the judging (a 1-10 score: under 8 earns
|
||||
polish rounds, then it launches with its score); sonnet drafts the launch posts. No fable. Batches (off) keep the board. Each venture lives at <slug>.yolopreneur.lol (gh-pages `ventures/<slug>/`); the
|
||||
portfolio at yolopreneur.lol is gh-pages `ventures/index.html`, made by hand, and reads `ventures.json`.
|
||||
Seat workers (and the venture builder) carry Ponytail's rules (time `.github/time/rules/ponytail.md`); reviewers don't.
|
||||
Tiers: Luna = haiku (Codex: luna), Terra = sonnet (terra), Sol = opus (sol), Astra = fable (astra), the C level. Venice
|
||||
seats (kimi, grok, ...) break ties. The operator hears from the Astra tier only on exception or check-in.
|
||||
Spend: every model call is booked to time/ledger/<YYYY-MM>.jsonl, cached tokens priced. TIME_MONTHLY_USD=100 is the
|
||||
owner's envelope; TIME_DAILY_USD (15) paces it and gates launches; a run launched within budget finishes (1.5x). A
|
||||
breach opens an `andon` issue. No model decides whether to spend.
|
||||
hb-5 (the Mac) is NOT part of prod: it is the operator's playground and the old instance (matcha.teahub.xyz).
|
||||
Boring (ops MCP) lives on the Mac and may reach prod as a client; prod never depends on it.
|
||||
```
|
||||
|
||||
## Moves
|
||||
- Read first: this file, `docs/decisions.md` (why things are the way they are), `docs/rebuild.md` (what it took).
|
||||
- Work is an issue with a `DONE:` line. Open it, do it, receipt it on the issue, close it. Label it `queued` and Time
|
||||
runs it when its turn comes (oldest first, `Depends on #N` respected); `ready` runs it on the next beat, beside
|
||||
whatever else is running; `spec` has Time split it into cards.
|
||||
- Work is a card: an issue with a `DONE:` line. Open it, do it, receipt it on the issue, close it. `queued` runs it when
|
||||
its turn comes (oldest first, `Depends on #N` respected); `ready` runs it on the next beat; `spec` has Time split it.
|
||||
- A card is what a Terra worker finishes in forty turns. Larger work is a parent labelled `planned` with children that
|
||||
carry their own `DONE:` lines; a parent is never `queued`. Three cards died at the turn ceiling before this rule.
|
||||
- A card says where it lives. `Base: <branch>` (every venture card: `Base: gh-pages`) makes the worker cut its branch
|
||||
from there and open the PR against it; without the line, the default branch. `PR: #N` names a PR that already answers
|
||||
the card, so Time reviews it instead of building again.
|
||||
- Before a task, find its route in `SKILLS.md` and load only the skills it names, by path. No model decides which
|
||||
skill to load.
|
||||
- You act as yourself (Time too: it is the `time` user). There is no shared service token on prod. If you can't do
|
||||
@@ -37,7 +53,23 @@ record; nothing here is scaffolding from before 2026-10-14.
|
||||
`docs/decisions.md`. Nothing else. Do not add one without a decision there. A temporary admin token is deleted
|
||||
(sqlite3 on the Fly box) and the deletion checked before you write that it's gone.
|
||||
- Be boring by default; get clever only on an ANDON. Deterministic scripts before cheap inference before expensive inference.
|
||||
- Cost ladder for seats: Luna = haiku, Sol = sonnet, Astra = opus. Fable is for the operator's own maths, not the factory.
|
||||
|
||||
## Situations
|
||||
What to do when you meet one of these. Each row names the procedure; the procedure names the skill or the code.
|
||||
|
||||
| You see | Do |
|
||||
|---|---|
|
||||
| A `ready` card with no worker yet, and a PR already answers it | Nothing: Time adopts the PR on its next beat (a `pr` beat with `adopted: true`). Make sure the PR body says `Closes #N` or the card says `PR: #N`. |
|
||||
| The reviewer objected; the PR is `needs-human` | Read the objection on the PR. If it is right and small, comment `/time retry` on the card: the worker gets the review text and answers on the same branch. If it is wrong, say so on the PR as yourself (Approve/Object comments from trusted users count) and remove the label. |
|
||||
| A PR is approved but `mergeable: false` | It fell behind its base. Until time#123 lands, a human re-cuts the branch from the base and re-applies the change (or merges the base into the branch); a `Closes #N` line keeps the card's adoption. |
|
||||
| A worker stopped at the turn ceiling ("stopped without a PR after N steps") | First check the card's `Base:` line (a venture card without `Base: gh-pages` cannot find its files). Then split the card; never retry the same card unchanged twice. |
|
||||
| An open venture card whose history is comments by a person, not `gitea-actions` (the GitHub-era cards, migrated) | Close it. Time counts only its own beats, so the loop takes such a card as unstarted and pitches a new venture on it (batch time#26 was reused that way on 2026-10-18). The venture itself stays in `ventures.json`. |
|
||||
| An `andon` issue: spend cap reached | Read the ledger for the day. The daily cap is pacing (the CEO may raise it with the owner's OK; log it in `docs/decisions.md`); the monthly cap is the owner's alone. Close the andon once handled; Astra queues nothing while one is open. |
|
||||
| A PR head shows `heartbeat / tick: failure` | Not a check. Time's own workflow statuses (heartbeat, mention, venture, astra, doctor, update, steward) are ignored by the merge gate (time#124). CI decides. |
|
||||
| Housekeeping posted a stale list on the Astra log | Follow `.agents/skills/housekeeping/SKILL.md` in teahubxyz/time: decide each `needs-human`, idle PR, parked or `error` card. Housekeeping never acts on the list itself. |
|
||||
| A workflow file needs to change | Open the PR, label it `needs-human`, and note it on the Astra log. The CEO merges workflow files in a batch; nobody else, and never through a side channel. |
|
||||
| A card touches money or a human-only step (a purchase, a signature, a Whop upload) | Stop at the step, write what is ready and what is needed on the card, and escalate to the owner. Everything up to that step still gets done. |
|
||||
| You want a new skill, or a vendored one changed | `SKILLS.md` → Moves. One PR with the skill, its katas and a ledger line. |
|
||||
|
||||
## Where things are
|
||||
- MCP source: `mcp/` (FastMCP, deploy with `fly deploy`).
|
||||
@@ -45,4 +77,7 @@ record; nothing here is scaffolding from before 2026-10-14.
|
||||
- Rebuild log: `docs/rebuild.md`.
|
||||
- Skills: `SKILLS.md` (routes, ledger, belts); the vendored folders are in `.agents/skills/`.
|
||||
- Time's engine: `teahubxyz/time` `.github/time/` (`time.py` the clock, `venture.py` the venture loop, `astra.py` the
|
||||
daily planner, `seats.json` the seats). The planner and the steward both write to the "Astra log" issue there.
|
||||
daily planner, `router.py` its ranking, `housekeeping.py` the daily sweep, `sync.py` copies on other forges
|
||||
(`time/sync.json`: one home, one writer per branch; main out fast-forward, `claude/*` branches home as PRs),
|
||||
`seats.json` the seats). Spend pools (expiring credit, refilling windows) are `time/pools.json`. Its SOPs: `.agents/skills/<name>/SKILL.md`
|
||||
in that repo. The planner, the steward and housekeeping all write to the "Astra log" issue there (time#57).
|
||||
@@ -75,3 +75,25 @@ Format: date · decision · why. Add at the bottom. Never edit an old entry; sup
|
||||
- 2026-10-15 · **Orgchain v1.13: whole trees change hands.** The operator asked for full holdings transfer next. An org now changes hands with its tree (the derived orgs and agents below it, and the items they hold), and a holding changes hands whole, sold by its owner, whom the lab wallet plays; the price goes there, and the holding becomes its buyer's next org. Sales stay cash-free: the money in the tree, every gas tank included, goes to the seller first, and the items go with the tree. An ask signs one input, so listing a tree bundles every inscription in it into one output at the lot's key; an offer spends every output the tree sits in and merges them. After the `join` and `adopt` the buyer splits the output back, one inscription to an output at its new key, each item with the node that held it, by the layout the deal recorded. A listed or sold tree is frozen whole, and a listed holding mines nothing (the default mining rule would otherwise have paid keys it was about to leave). Transactions take up to 64 inputs and outputs. Rehearsed on Bitcoin Core regtest from height 144, beside the earlier rehearsals (sixteen beats and 131 transactions: time with its six below it on an offer, then acme whole on an ask, eleven inscriptions in one output, each split back), and ord puts every inscription in the same output as the lab. `lineage/orgchain` main `74ed801`, time's `gh-pages` `07c3810`; 95 unit tests and Playwright 24/24. v1.12's commit went out under the Mac's default author by mistake; v1.13 is teahubxyz's again. Token 70 (`orgchain-holdings-20261015`, `write:repository`) did the pushes and is deleted by id right after; the table is checked. Next: pledge, a lien with a timelock. Why: a business is sold with what it runs and what it made, an owner can sell a holding whole, and the buyer should leave the deal holding every key.
|
||||
- 2026-10-15 · **Orgchain v1.14: soulbound belts.** The operator said belts shouldn't trade: they are soulbound, tied to an agent and moving with it, and pointed at ord's charms. A belt is now inscribed on its seat's own sat, as a reinscription: one reveal spends the seat's coin (the seat's own key co-signs, accepting the belt), the issuer's coin (the parent) and a commit that pays the fee, returns both coins unchanged, and points the belt at the seat's sat. ord gives it two charms, ♻️ reinscription and ❤️🔥 vindicated (a pointer and an envelope past the first input would have cursed it before the jubilee). So a belt travels with its seat through every act, graduation and sale, a tree splits back one inscribed sat to an output, and a belt is never a lot. An award costs two fees (1,000 sats) and no postage. The reducer is unchanged, since a belt was already bound to the seat it names; belts from before v1.14 stay with their issuers. The engine's sixth local change is that three-input reveal, and nothing looser. Rehearsed on Bitcoin Core regtest from height 161 (sixteen beats, 131 transactions): ord charms the three belts as the lab does, and after two whole-tree sales each belt still shares its seat's satpoint; the compare now checks satpoints and charms, and the v1.13 recording passes it too. `lineage/orgchain` main `c8c18b0`, time's `gh-pages` `7fa9f05`; 98 unit tests and Playwright 24/24. Token 71 (`orgchain-belts-20261015`, `write:repository`) did the pushes and is deleted by id right after; the table is checked. Next: pledge, a lien with a timelock (v1.15). Why: a belt certifies a seat, so it must never be sold apart from it, and keeping it on the seat's sat makes ord's account of where a belt is match the reducer's.
|
||||
- 2026-10-15 · **Orgchain v1.14.1: a belt counts only on its seat's own sat.** The operator agreed reinscription is the mechanism, since a belt is re-inscribed as it grows (white, green, black), and said older belts can go, as nobody uses the system yet. So placement is now a rule of `lineage/v0`, not just Orgchain's habit: an award counts only if it is inscribed on its recipient's own sat, which any indexer can check (the reducer compares sat numbers, or, without ord's sat index, satpoints, which two inscriptions share exactly when they share a sat). Belts inscribed elsewhere, as before v1.14, are rejected. A promotion is another reinscription on the same sat, over the belt before it; the reducer keeps each skill's ladder, Inspect shows it, and the sample's sol goes white, then green. An award and its recipient's own close no longer conflict within a block, since both spend the recipient's inscription. Rehearsed again on Bitcoin Core regtest from height 178 (sixteen beats, 133 transactions): ord shows sol's sat carrying sol, its white belt and its green belt, in order, each belt charmed ♻️ and ❤️🔥, and the compare matches; the fixture is re-recorded. `lineage/orgchain` main `5a7f11e`, time's `gh-pages` `4359351`; 100 unit tests and Playwright 24/24. Token 72 (`orgchain-belt-rule-20261015`, `write:repository`) did the pushes and is deleted by id right after; the table is checked. Next: pledge, a lien with a timelock (v1.15). Why: with the rule in the protocol, a belt is provably soulbound to anyone reading the chain, not only to Orgchain.
|
||||
- 2026-10-16 · **MCP 2.3.0: `tea_file_edit`, exact-text replacements in one file.** A commit through the MCP carries the whole file in one call, and one model answer carries about 100 KB at most, so `ventures/havre/app.js` (174 KB) could not take an eight-hunk change: three attempts stopped mid-call. The tool reads the file at the branch, applies the edits in order (each old text exactly once in the file as it stands), commits one update with the file's sha under the caller's own authority, and answers with the new size and sha256 to check against the caller's copy; same bounds as `tea_commit`, no deletes. Tests ran from a clean checkout on hb-5 (the Claude sandbox cannot reach PyPI). Issue #12, PR #13, merged on the operator's word in the chat; deployed as b5376e9d. Why: long files are normal in the factory, and a commit that cannot be made is a stuck card.
|
||||
- 2026-10-17 · **The merge gate moves into Time.** `touches_protected()` hard-codes only `.github/workflows/` and `.gitea/workflows/`; the rest is `TIME_PROTECTED`'s call, default unchanged (time #81). On `teahubxyz/time` the variable is `.github/workflows/,.gitea/workflows/,deploy,infra/,.env,secret`, so a card PR under `.github/time/` with a trusted Approve and green checks merges through `time`. #59 had sat three days with haiku's Approve, green CI and eleven steward sign-offs, each refused by the steward session's permission dialog; the CEO session merged #59, #74 and #81 on the operator's explicit word. The heartbeat job timeout is 55 minutes (workers overlap in one job, #75). Why: a steward that reports a block is a status page; a reviewed engine change should land without a hand.
|
||||
- 2026-10-17 · **Seats are Gitea users, one per model line.** This restores the standing rule that every agent acts as itself. Users `haiku`, `sonnet`, `opus`, `fable` (Claude) and `luna`, `terra`, `sol`, `astra` (Codex), write collaborators on `time`, each with one `time-seat` token staged as the repo secret `TIME_SEAT_<SEAT>`; these are the seat keys the standing rule asks to list here, plus the earlier `TIME_SEAT_CLAUDE` and `TIME_SEAT_CODEX` for the umbrella users `claude` and `codex`, which retire once the per-seat tokens are in use. A seat is a line, not a version. Attended sessions (claude.ai, ChatGPT, Cowork) are the operator, `teahubxyz`; the worker's pushes stay on `time`; a second route under a seat gets its own token so a route can be revoked without the identity. Tiers run across families: Astra = fable/astra (the C tier), Sol = opus/sol, Terra = sonnet/terra, Luna = haiku/luna, and the four loops are the four tiers. Each batch of secrets was placed from inside the Gitea container by a temporary admin token deleted by id right after; the table is checked and the operator account holds no tokens. Cards #76 (seats review as themselves) and #77 carry the code. Why: Gitea's own rules, no approving your own PR and trusted logins, mean something only when seats are distinct logins, and a council needs votes that carry identities.
|
||||
- 2026-10-17 · **Council: votes per seat, Venice breaks ties.** `TIME_REVIEW` lists the standing seats (`claude:haiku, codex` once Codex has a CLI route, time #79; `claude:haiku, claude:opus` until then); `TIME_REVIEW_TIEBREAK=venice:kimi` is called only on a split; `TIME_REVIEW_QUORUM` defaults to 1. Why: one reviewer is one opinion; cross-family agreement is the stronger evidence, and Venice credits go to ties alone.
|
||||
- 2026-10-17 · **Routing: Time wakes, Beth allocates, seats execute, reviewers verify, the operator changes the envelope.** Consequential acts (merge, retry, label, spend) run on the governor-gated routes, Time's code and the API and headless CLI seats on Fly, under the ledger and seat identities. Interactive chats carry the operator's authority, contribute judgment and may execute what their harness already authorizes, but production never waits on a chat waking up, and a permission denial is a property of a route, not a scheduling inconvenience. "What next" belongs to Beth and the agent board; the operator changes objectives, delegation and the financial envelope. Prod on Fly never depends on hb-5. Time retries its own parked cards (time #78), mentions are handoffs with receipts (#79), and the steward tick moves inside Time (#80), after which the desktop-app steward retires. Why: the operator's attention is the bottleneck to remove, and the arrangement was agreed between the Claude and Codex lanes on the operator's word.
|
||||
- 2026-10-17 · **The operator hears from the Astra tier only on exception or check-in.** Astra, the steward and the CEO session write to the bus (the Astra log, cards, decisions here) and do not report to the operator; they reach out only for an andon that needs money or a human-only step, or when the operator asks. Why: escalation runs up the tiers, and the top of the ladder is the operator's attention, which is the resource the factory exists to spare.
|
||||
- 2026-10-18 · **The monthly cap is the envelope; the daily cap is pacing, and a launched run finishes.** `TIME_MONTHLY_USD` (100) is the owner's number and changes only on the owner's word. `TIME_DAILY_USD` paces spend inside it and the CEO may set it with the owner's OK (set to 15 today, with `TIME_DAILY_CAP` 10 launches, from the defaults of 5 and 5); the daily cap gates launches, and a run that began within budget finishes up to `TIME_CAP_SLACK` x the cap (1.5), as does the review of a PR that is already open. Why: the first live wave tripped the $5 default after three worker runs, stopped a worker at step 27 with its spend lost, and left two green PRs unreviewed for a cheap haiku call; a cap that destroys work it already paid for is not thrift. (time #117)
|
||||
- 2026-10-18 · **A card names its base branch, and venture cards say `Base: gh-pages`.** The ventures live on `gh-pages`, an orphan branch; the worker cuts its branch from the card's `Base:` line and opens the PR against it, and every venture card carries the line. Why: the worker had branched from `main` whatever a card said, which is why #60, #63 and #95 ran out of turns and #109 landed the right files on the wrong branch. (time #117)
|
||||
- 2026-10-18 · **Claude seats call Anthropic's own Messages API with prompt caching.** The OpenAI-compatible endpoint has no caching; `TIME_ANTHROPIC_API=compat` is the way back if the native path misbehaves. Why: a 43-step run re-sent its whole context at full price every step and cost $2.80 for nothing; the same run with caching costs a fraction. (time #119)
|
||||
- 2026-10-18 · **A card is what a Terra worker finishes in forty turns; larger work is split before it is queued.** A spec-sized issue (time #72, #60, #63) is a parent labelled `planned`, never `queued`, and its children carry their own `DONE:` lines. Why: three cards died at the turn ceiling; each one cost a full run and produced nothing.
|
||||
- 2026-10-18 · **The venture loop runs on Teahub with the Claude seats, every 6 hours.** Fable chairs (the launch plan, the launch drafts) and judges the build; haiku, sonnet and opus pitch, vote and write plans; `claude:sonnet` builds and `claude:opus` takes the second fix round. The engine had coerced every builder to a Venice seat, which is where the token burn that paused the 20-minute loop came from. Ported to Gitea in time#140 (three council rounds with haiku; the second found that the loop would have written over the yolopreneur.lol portfolio), schedule in time#141. The GitHub-era venture cards were closed: their migrated history is authored by `teahubxyz`, Time trusts only its own beats, and they would have been re-pitched as new ventures. Why: the owner's call, to try the loop on the Claude API with fable leading and the other three voting and executing.
|
||||
- 2026-10-18 · **Daily pacing at $25 for today's first Claude venture run**, on the owner's OK; back to $15 once it is through. `TIME_MONTHLY_USD` stays 100 (the month was at $30.40). The andon (time#136) is closed. Why: the day's $15 was spent before the loop came back, and the owner wanted the run now rather than after midnight.
|
||||
- 2026-10-18 · **Ponytail on prod.** Seat workers, and the venture builder through them, carry Ponytail's rules (DietrichGebert/ponytail, MIT, vendored at time `.github/time/rules/ponytail.md`); reviewers, Astra and the board don't (a coding ruleset distorts answers that aren't a diff). `TIME_PONYTAIL=off` drops it. Why: the owner's call; it had run on hb-5's seats since 2026-10-12 and prod never had it. (time#145)
|
||||
- 2026-10-18 · **One venture a day, built for quality.** The day's card waits until every other venture card is launched or held. The board picks it from a field of six pitches, made in rounds where each round is told what is already pitched. `claude:opus` builds: the code's default, and `TIME_VENTURE_WORKER` on teahubxyz/time. Fable's judge also scores the product 1–10 and names its top three changes. A page under 8 gets up to two polish rounds; the third round stays for fixing whatever a polish broke. After that it launches with its score: quality never holds a launch for a human, only a broken page does. Batches stay off, and an open batch card with no Time beats no longer starts one. The caps are unchanged. October has $61 left in the $100 envelope; if one opus venture a day and the heartbeat outrun that, the loop waits at the cap rather than spending past it. Why: the owner's call ("would rather 1 higher quality"). Today made four ventures: three from migrated batch #26 and the daily card. All four passed on the first sonnet build in about ten steps, so nothing had pushed any of them past "works". (time#162, #163)
|
||||
- 2026-10-18 · **Opus leads the day's venture alone; fable is out of the venture loop.** One research round replaces the pitches and the vote: opus reads the top six trending repos with the start of their READMEs, plus the studio's track record (each venture's status and signups), lists six candidates and picks one. Opus also writes the plan without a synthesis step and judges the build, and hands the launch-post drafts to sonnet. The chair and the judge are opus. That is the code's default, and `TIME_VENTURE_CHAIR` / `TIME_VENTURE_JUDGE` are set to opus so that tonight's run is already fable-free. Batches keep the board; `TIME_VENTURE_LEAD=board` brings it back for the daily venture. Why: the owner's call ("fable eats a lot of credit"). Fable was $2.23 of the day's $23.66, the dearest seat per call, on work opus does as well; the builds were most of the rest. (time#164, #165)
|
||||
- 2026-10-19 · **The venture loop is paused, and the factory picks its own work through the router.** `TIME_VENTURE=off`; the four ventures launched on the 18th are still measured. Astra is now the router (time#68, #72). Its seat judges every candidate card's kind (venture, feature, refinement or optimization), value and size. `router.py` ranks them with the owner's weights per kind in `time/router.json`; all 1 is balanced, the owner's choice. A kind not picked lately climbs (the restless part), and an L is split before it is queued. The router starts in `dry` mode: it posts the ranked table on the Astra log and touches nothing. Going `live` (queue from the top, heartbeat promotes in ranked order) is a one-word edit of that file, made once the rankings look right. The follow-ups are cards the router ranks itself: ventures only when picked (#170), optimization candidates from the ledger and CI (#171), pick outcomes as reward data for a bandit policy (#172), the spine's cards (#173). Why: the owner's call, to have the factory allocate across ventures, features, refinement and optimization rather than run a venture cron beside everything else. The venture register held 33 ventures and 0 signups, so producing more ventures wasn't the bottleneck. (time#168, #169)
|
||||
- 2026-10-19 · **Claude spend is paced to the Anthropic credit.** Time's `time/pools.json` holds one expiring pool: $161.49 as of the 19th, lapsing 2026-11-08. That paces the anthropic route to about $8/day; the $100 monthly cap stays hard and binds first in October. Why: the owner's credit (a $200 grant, $162.22 left on the 18th) expires; a fixed daily cap would either leave it unspent or burn it early. Worker PR #167 was re-cut as #174, with `pools.json` read through the API, since every workflow checks out only `.github/time`. (time#82, #174)
|
||||
- 2026-10-19 · **Repos sync across forges with one home and one writer per branch.** The home pushes its `main` to every copy, fast-forward only; a diverged copy becomes a `needs-human` issue and is never overwritten. A copy's work branches (`claude/*` from cloud sessions) come home as PRs for the council. Config is `time/sync.json`; tokens are secrets named there. Gitea's own mirrors don't fit: a push mirror force-pushes every ref, and a pull mirror makes the home read-only. The first copy is `github.com/teahubxyz/time`, so cloud sessions clone a current engine; it waits on the owner (unarchive it, turn GitHub Actions off on it, set `GITHUB_MIRROR_TOKEN`). The same script syncs Gitea with Gitea, run from the side that can reach both. Why: the owner's $250 cloud-session credit (expires 2026-11-04) covers only sessions that clone from GitHub, and the owner wants several Gitea instances to stay synced on chosen repos. (time#177)
|
||||
- 2026-10-19 · **The GitHub copy of Time is live, pushed with a deploy key.** github.com/teahubxyz/time is unarchived, GitHub Actions is off on it, and its `main` follows Teahub's. It was forced once at 04:07 UTC to 7452318; the proof-of-concept history stays on GitHub as `archive/poc-main` (e85709e). Sync pushes over SSH from prod's runner with a write deploy key for that one repo (GitHub key 166898032), held in Teahub's `SYNC_GITHUB_KEY`, so prod doesn't depend on hb-5. This corrects the entry above: there is no `GITHUB_MIRROR_TOKEN`, because Gitea refuses secret names starting `GITHUB_` or `GITEA_`. The key was added through `gh` on hb-5, signed in as teahubxyz by the owner's device-code approvals. GitHub holds a key that an OAuth app added to that app's scopes, so `gh`'s authorization needed `workflow` before a push touching `.github/workflows/` was accepted; revoking GitHub CLI's access on GitHub may take the key with it. hb-5's `gh` token (repo, workflow) sits in plain text in `~/.config/gh/hosts.yml`. Cloud sessions on teahubxyz/time now clone the current engine, and their `claude/*` branches come home as PRs within 15 minutes. Why: the owner's $250 cloud-session credit (expires 2026-11-04) covers only sessions that clone from GitHub. (time#179, #180)
|
||||
- 2026-10-19 · **The router is live.** `time/router.json` is `live` from 13:49 UTC: Astra queues from the top of the ranking each day, writes its new cards unqueued, and the heartbeat promotes queued cards in ranked order. The two dry rankings (01:02 and 13:14) put the engine's own blockers on top (#135, #175), dropped #84 once #75 was known unmergeable, and held L cards back. Three things were fixed before the flip. Money left is now the worker pool's allowance (about $8/day from the Anthropic credit), not the $15 daily cap. The seat sees the cards closed in the last 14 days, and a new card repeating an open or closed one isn't written; both dry runs had proposed the label-filter fix that landed as #138. And a `ready` card waiting on a person no longer holds a slot: three parked cards (#79, #80, #85) had stalled promotion for ten hours while 22 cards sat `queued`, and #135, the fix, could never be promoted past them. Weights stay all 1 (balanced). Why: the owner's call to have the factory pick its own work across ventures, features, refinement and optimization, live once the rankings looked right. (time#181, #183)
|
||||
- 2026-10-19 · **The monthly cap counts out-of-pocket money; prepaid credit is limited by its own pool.** `TIME_MONTHLY_USD` ($100) leaves out what an expiring pool's credit paid for: its routes, from its `as_of`, until it lapses, up to its balance. Spend before `as_of`, past the balance or the lapse, and on routes without a pool still counts. October's out-of-pocket figure is $38.51, counting the spend before the balance was read. Why: the owner's word that the budget should factor in the usage pool. Counting credit against the cap would have tripped it about Oct 26, stopping the factory for five days with credit left to lapse on Nov 8. The $250 cloud-session credit (expires Nov 4) is not a factory pool: `claude --cloud` refuses to start without an interactive terminal, and scheduled routines don't count toward the credit. So it is drawn only by sessions a person starts on the GitHub copy, whose `claude/*` branches come home through sync. (time#193)
|
||||
- 2026-10-19 · **The cloud lane: the factory spends the cloud-session credit itself.** This corrects the entry above. The credit's terms cover cloud sessions started "from web, desktop, CLI, or mobile"; only Routines, Remote Control and Chat are excluded. A CLI-launched session qualifies, and `claude --cloud` only needs a terminal, which a pseudo-terminal provides. How it runs: Astra labels the best-ranked actionable M cards `cloud`, up to 3 in the lane at once (`time/router.json`). A launcher on hb-5, `~/claude/cloud-lane/launch.py`, runs hourly as the user agent `xyz.teahub.cloud-lane` inside the desktop session. It starts one cloud session per card on the GitHub copy, at most 3 a day and 2 hours apart, and none after Nov 4 noon PT. The session's branch comes home through sync as a PR that closes the card, sync moves the card from `cloud` to `ready`, and the council lands it. Why the desktop session: the claude.ai sign-in lives in the login keychain, which SSH sessions can't read, and the `CLAUDE_CODE_OAUTH_TOKEN` in hb-5's `.zshenv` is inference-only (cloud session create: 403). Separation holds: the launcher reads Teahub's public issue list and holds no Teahub credentials, and prod doesn't depend on it; with the Mac off, the lane simply waits. Off switch: `touch ~/claude/cloud-lane/OFF`. Extra usage is off on the account, so once the credit is gone, sessions count against plan limits and cost no money. Why: the owner asked to use the credit through the factory rather than by hand. (time#197)
|
||||
+16
-6
@@ -6,16 +6,26 @@ when it starts, so a template change needs a restart; files under `public/` are
|
||||
- `templates/home.tmpl`: the signed-out home page, Teahub = Git + Tea + Time beside the cup.
|
||||
- `templates/user/dashboard/dashboard.tmpl`: Gitea 28's dashboard with the repository list and the contribution
|
||||
heatmap moved into the sidebar.
|
||||
- `templates/custom/header.tmpl`, `templates/custom/footer.tmpl`: load `teahub.css` and `teahub.js`. Bump their
|
||||
`?v=` when those files change, because browsers keep assets for hours.
|
||||
- `templates/base/head_opengraph.tmpl`: Gitea 28's link-preview tags. Pages that aren't a repository or a profile
|
||||
(home, explore, sign-in) point `og:image` at the absolute URL of `og-card.png`, give its size, and ask for the large
|
||||
card (`twitter:card`), so Discord, WhatsApp, iMessage and Slack show it. Repositories, issues and profiles keep
|
||||
Gitea's avatars.
|
||||
- `templates/custom/header.tmpl`, `templates/custom/footer.tmpl`: load `teahub.css` and `teahub.js`, and link the
|
||||
home-screen icon. Bump their `?v=` when those files change, because browsers keep assets for hours.
|
||||
- `public/assets/css/teahub.css`: green tea colours for Gitea's light, dark and auto themes, the name beside the
|
||||
navbar's cup, the home page and the sidebar.
|
||||
- `public/assets/js/teahub.js`: the home page's title, "Powered by Teahub", and the dashboard's Domains tab, which asks
|
||||
Tea MCP's `GET /domains` (Cloudflare Registrar) and shows exact names and suggestions with their prices.
|
||||
- `public/assets/img/`: the 🍵 mark. The SVGs draw the emoji in the reader's own emoji font. The PNGs (link previews,
|
||||
browser tabs, phone home screens) are Noto Emoji's U+1F375 (googlefonts/noto-emoji, Apache License 2.0).
|
||||
- `public/assets/img/`: the 🍵 mark. The SVGs draw the emoji in the reader's own emoji font. The PNGs are Noto Emoji's
|
||||
U+1F375 (googlefonts/noto-emoji, Apache License 2.0). `apple-touch-icon.png` is the cup on Teahub green (#2f9e44),
|
||||
180×180 and opaque (iOS turns transparency black), for phone home screens. `og-card.png` is the 1200×630 link
|
||||
preview, the home page's hero; keep it under 300 KB, because WhatsApp drops bigger preview images.
|
||||
- `src/`: the HTML those two PNGs are rendered from. Not copied to prod. To redo one: headless Chrome at 1× with the
|
||||
page's size (`--headless=new --force-device-scale-factor=1 --window-size=1200,630 --virtual-time-budget=6000
|
||||
--screenshot`), then quantise to 256 colours (Pillow `quantize(256, MEDIANCUT, dither NONE)`) and look at it.
|
||||
|
||||
The two page templates are Gitea 28.0.0's with Teahub's changes. After a Gitea upgrade, compare them with the new
|
||||
release's (`gitea embedded extract --destination x 'templates/**'`) before deploying it.
|
||||
The three Gitea templates here (home, dashboard, head_opengraph) are Gitea 28.0.0's with Teahub's changes. After a
|
||||
Gitea upgrade, compare them with the new release's (`gitea embedded extract --destination x 'templates/**'`) before
|
||||
deploying it.
|
||||
|
||||
`../sync-custom.sh` puts this directory on prod (it needs flyctl signed in to Teahub's Fly organisation).
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 2.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,4 @@
|
||||
<!doctype html><html><head><meta charset="utf-8"><title>Teahub home-screen icon, 180x180</title><style>
|
||||
html,body{margin:0;width:180px;height:180px;overflow:hidden;background:#2f9e44}
|
||||
img{position:absolute;left:15px;top:13px;width:150px;height:150px}
|
||||
</style></head><body><img src="../public/assets/img/logo.png" alt=""></body></html>
|
||||
@@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html><head><meta charset="utf-8">
|
||||
<title>Teahub link preview card, 1200x630</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400..800&display=block" rel="stylesheet">
|
||||
<style>
|
||||
html,body{margin:0;width:1200px;height:630px;overflow:hidden}
|
||||
body{background:#f3f9f4;color:#181c21;font-family:Inter,sans-serif;position:relative}
|
||||
.wrap{position:absolute;inset:0;display:grid;grid-template-columns:1fr 360px;align-items:center;gap:32px;padding:0 84px 0 96px}
|
||||
.brand{margin:0 0 42px;font-size:60px;font-weight:700;letter-spacing:-.045em;line-height:1}
|
||||
.eq{display:flex;align-items:baseline;white-space:nowrap;margin:0;font-size:112px;font-weight:540;line-height:.9}
|
||||
.eq>span:not(.plus),.eq em{letter-spacing:-.07em}
|
||||
.eq em{font-family:Georgia,"Times New Roman",serif;font-size:.94em;font-weight:400;letter-spacing:-.055em}
|
||||
.plus{color:#2f9e44;font-size:.64em;font-weight:560;margin-inline:.16em}
|
||||
.cup{width:360px;height:360px;display:block}
|
||||
.url{position:absolute;left:96px;bottom:56px;font-size:28px;font-weight:560;color:#2f9e44;letter-spacing:-.01em}
|
||||
</style></head><body>
|
||||
<div class="wrap"><div><h1 class="brand">Teahub</h1><div class="eq"><span>Git</span><span class="plus">+</span><span>Tea</span><span class="plus">+</span><em>Time</em></div></div><img class="cup" src="../public/assets/img/logo.png" alt=""></div>
|
||||
<div class="url">teahub.xyz</div>
|
||||
</body></html>
|
||||
@@ -0,0 +1,51 @@
|
||||
{{- /* og:description - a one to two sentence description of your object, maybe it only needs at most 300 bytes */ -}}
|
||||
{{if .PageIsUserProfile}}
|
||||
<meta property="og:title" content="{{.ContextUser.DisplayName}}">
|
||||
<meta property="og:type" content="profile">
|
||||
<meta property="og:image" content="{{.ContextUser.AvatarLink ctx}}">
|
||||
<meta property="og:url" content="{{.ContextUser.HTMLURL ctx}}">
|
||||
{{if .ContextUser.Description}}
|
||||
<meta property="og:description" content="{{StringUtils.EllipsisString .ContextUser.Description 300}}">
|
||||
{{end}}
|
||||
{{else if .Repository}}
|
||||
{{if .Issue}}
|
||||
<meta property="og:title" content="{{.Issue.Title}}">
|
||||
<meta property="og:url" content="{{.Issue.HTMLURL ctx}}">
|
||||
{{if .Issue.Content}}
|
||||
<meta property="og:description" content="{{StringUtils.EllipsisString .Issue.Content 300}}">
|
||||
{{end}}
|
||||
{{else if or .PageIsDiff .IsViewFile}}
|
||||
<meta property="og:title" content="{{.Title}}">
|
||||
<meta property="og:url" content="{{ctx.AppFullLink $.Link}}">
|
||||
{{if and .PageIsDiff .Commit}}
|
||||
{{- $commitMessageBody := .Commit.MessageBody -}}
|
||||
{{- if $commitMessageBody -}}
|
||||
<meta property="og:description" content="{{StringUtils.EllipsisString $commitMessageBody 300}}">
|
||||
{{- end -}}
|
||||
{{end}}
|
||||
{{else}}
|
||||
<meta property="og:title" content="{{.Repository.Name}}">
|
||||
<meta property="og:url" content="{{.Repository.HTMLURL ctx}}">
|
||||
{{if .Repository.Description}}
|
||||
<meta property="og:description" content="{{StringUtils.EllipsisString .Repository.Description 300}}">
|
||||
{{end}}
|
||||
{{end}}
|
||||
<meta property="og:type" content="object">
|
||||
{{if (.Repository.AvatarLink ctx)}}
|
||||
<meta property="og:image" content="{{.Repository.AvatarLink ctx}}">
|
||||
{{else}}
|
||||
<meta property="og:image" content="{{.Repository.Owner.AvatarLink ctx}}">
|
||||
{{end}}
|
||||
{{else}}
|
||||
{{/* Teahub: the 1200x630 card at an absolute URL (WhatsApp needs one), its size, and the large-card layout. */}}
|
||||
<meta property="og:title" content="{{AppName}}">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:image" content="{{ctx.AppFullLink "/assets/img/og-card.png"}}">
|
||||
<meta property="og:image:width" content="1200">
|
||||
<meta property="og:image:height" content="630">
|
||||
<meta property="og:image:alt" content="Teahub: Git + Tea + Time, beside a cup of green tea">
|
||||
<meta property="og:url" content="{{ctx.AppFullLink}}">
|
||||
<meta property="og:description" content="{{MetaDescription}}">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
{{end}}
|
||||
<meta property="og:site_name" content="{{AppName}}">
|
||||
@@ -1,2 +1,3 @@
|
||||
<link rel="stylesheet" href="{{AssetUrlPrefix}}/css/teahub.css?v=20261015">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🍵</text></svg>">
|
||||
<link rel="apple-touch-icon" href="{{AssetUrlPrefix}}/img/apple-touch-icon.png?v=20261016">
|
||||
+2
-2
@@ -4,11 +4,11 @@ Teahub's MCP (Fly app `green-tea-mcp`, `https://mcp.teahub.xyz/mcp`) against the
|
||||
|
||||
Env: `GITEA_URL`, `PUBLIC_URL`, `ALLOWED_HOSTS`; secrets `GITEA_OAUTH_CLIENT_ID`, `GITEA_OAUTH_CLIENT_SECRET` (an OAuth2 application on the Gitea with redirect `PUBLIC_URL/auth/callback`), `JWT_SIGNING_KEY`. State: `/data` (FastMCP's OAuth store).
|
||||
|
||||
Tools (31), all acting as you. Their parameters are typed, so a bad argument is refused before Gitea is asked anything.
|
||||
Tools (32), all acting as you. Their parameters are typed, so a bad argument is refused before Gitea is asked anything.
|
||||
|
||||
- Issues and pull requests: `tea_issues`, `tea_issue_get/create/edit`, `tea_issue_comments/comment`, `tea_labels`, `tea_label` (add or remove by name), `tea_pull_get` (branches, mergeability, checks, diff), `tea_pull_create`, `tea_pull_review`, `tea_pull_merge` (keeps the head branch).
|
||||
- Repositories and organisations: `tea_whoami`, `tea_repo`, `tea_repo_edit` (description, website, visibility, default branch, archived), `tea_repo_create`, `tea_org_create`, `tea_org_repo_create`, `tea_org_add_member` (name the team), `tea_hook_create` (one hook per URL).
|
||||
- Files: `tea_tree`, `tea_file_get`, `tea_file_change`, `tea_commit` (up to 50 files, text or base64, 3 MB in all), `tea_branch_create`.
|
||||
- Files: `tea_tree`, `tea_file_get`, `tea_file_change`, `tea_file_edit` (exact-text replacements in one file, for a file too long to send whole; it answers with the new file's size and sha256), `tea_commit` (up to 50 files, text or base64, 3 MB in all), `tea_branch_create`.
|
||||
- Domains: `tea_domains` (Cloudflare Registrar: exact names checked live, and suggestions, with prices).
|
||||
- Actions: `tea_actions` (recent runs), `tea_action_logs` (a run's jobs, steps and log tails), `tea_workflow_dispatch`, `tea_secret_set` (never echoed), `tea_variable_set`.
|
||||
|
||||
|
||||
+39
-1
@@ -46,7 +46,7 @@ GITEA_OAUTH_CLIENT_SECRET = required_env("GITEA_OAUTH_CLIENT_SECRET")
|
||||
JWT_SIGNING_KEY = required_env("JWT_SIGNING_KEY")
|
||||
ALLOWED_HOSTS = [h.strip() for h in os.getenv("ALLOWED_HOSTS", "mcp.teahub.xyz,localhost").split(",") if h.strip()]
|
||||
|
||||
VERSION = "2.2.0"
|
||||
VERSION = "2.3.0"
|
||||
OAUTH_SCOPES = ["openid", "profile", "email"]
|
||||
CLAUDE_CIMD_CLIENT_ID = "https://claude.ai/oauth/mcp-oauth-client-metadata"
|
||||
CLAUDE_REDIRECT_URI = "https://claude.ai/api/mcp/auth_callback"
|
||||
@@ -143,6 +143,13 @@ class FileChange(BaseModel):
|
||||
content_base64: str | None = None
|
||||
|
||||
|
||||
class Edit(BaseModel):
|
||||
"""One replacement in a text file: `old` occurs exactly once in the file as it stands (after the edits before it),
|
||||
and `new` takes its place (empty removes it)."""
|
||||
old: Annotated[str, Field(min_length=1, max_length=MAX_BYTES)]
|
||||
new: Annotated[str, Field(max_length=MAX_BYTES)] = ""
|
||||
|
||||
|
||||
class InputError(ToolError):
|
||||
pass
|
||||
|
||||
@@ -595,6 +602,37 @@ async def commit(repo: str, message: str, files: list[FileChange], branch: str,
|
||||
"files": {o["path"]: o["operation"] for o in ops}}
|
||||
|
||||
|
||||
@mcp.tool(description="Edit one text file on a branch by exact-text replacements, as a commit by you: for a file too "
|
||||
"long to send whole (tea_file_change takes 200 KB). The edits apply in order; each `old` must "
|
||||
"occur exactly once in the file as it stands, and `new` takes its place. The answer gives the new "
|
||||
"file's size and sha256, to check against your copy. Branch protection applies.",
|
||||
annotations=WRITE)
|
||||
async def tea_file_edit(repo: Repo, path: FilePath, edits: Annotated[list[Edit], Field(min_length=1, max_length=50)],
|
||||
message: Message, branch: Ref = "") -> dict[str, Any]:
|
||||
rp, p = repo_path(repo), file_path(path)
|
||||
branch = branch or await default_branch(rp)
|
||||
x = await gitea("GET", contents(rp, p), params={"ref": branch})
|
||||
if not isinstance(x, dict) or x.get("type") != "file":
|
||||
raise InputError(f"{p} is not a file")
|
||||
try:
|
||||
text = base64.b64decode(x.get("content") or "").decode("utf-8")
|
||||
except (binascii.Error, ValueError):
|
||||
raise InputError(f"{p} is not UTF-8 text") from None
|
||||
for n, e in enumerate(edits, 1):
|
||||
found = text.count(e.old)
|
||||
if found != 1:
|
||||
raise InputError(f"edit {n}: its old text occurs {found} times in {p}, not once")
|
||||
text = text.replace(e.old, e.new, 1)
|
||||
data = text.encode("utf-8")
|
||||
if len(data) > MAX_COMMIT_BYTES:
|
||||
raise InputError(f"{p} would come to more than {MAX_COMMIT_BYTES} bytes")
|
||||
payload = {"files": [{"operation": "update", "path": p, "sha": x.get("sha"),
|
||||
"content": base64.b64encode(data).decode()}], "message": message.strip(), "branch": branch}
|
||||
c = (await gitea("POST", f"{rp}/contents", json=payload)).get("commit") or {}
|
||||
return {"repo": repo, "branch": branch, "path": p, "edits": len(edits), "size": len(data),
|
||||
"sha256": hashlib.sha256(data).hexdigest(), "commit": (c.get("sha") or "")[:12], "url": c.get("html_url")}
|
||||
|
||||
|
||||
@mcp.tool(description="Create a branch from another branch, a tag or a commit (default: the default branch).",
|
||||
annotations=WRITE)
|
||||
async def tea_branch_create(repo: Repo, name: Branch, from_ref: Ref = "") -> dict[str, Any]:
|
||||
|
||||
@@ -25,8 +25,8 @@ READS = {"tea_whoami", "tea_issues", "tea_issue_get", "tea_issue_comments", "tea
|
||||
"tea_tree", "tea_file_get", "tea_actions", "tea_action_logs", "tea_domains"}
|
||||
WRITES = {"tea_issue_create", "tea_issue_edit", "tea_issue_comment", "tea_label", "tea_pull_create", "tea_pull_review",
|
||||
"tea_pull_merge", "tea_repo_edit", "tea_repo_create", "tea_org_repo_create", "tea_org_create",
|
||||
"tea_org_add_member", "tea_hook_create", "tea_file_change", "tea_commit", "tea_branch_create",
|
||||
"tea_workflow_dispatch", "tea_secret_set", "tea_variable_set"}
|
||||
"tea_org_add_member", "tea_hook_create", "tea_file_change", "tea_file_edit", "tea_commit",
|
||||
"tea_branch_create", "tea_workflow_dispatch", "tea_secret_set", "tea_variable_set"}
|
||||
AGAIN = {"tea_issue_edit", "tea_label", "tea_repo_edit", "tea_org_add_member", "tea_hook_create", "tea_secret_set",
|
||||
"tea_variable_set"}
|
||||
|
||||
@@ -346,6 +346,46 @@ class TeaMCP(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertEqual((created["files"][0]["operation"], created["branch"]), ("create", "trunk"))
|
||||
self.assertEqual((updated["files"][0]["operation"], updated["branch"]), ("update", "dev"))
|
||||
|
||||
async def test_file_edit_replaces_exact_text_in_order_and_says_what_it_made(self):
|
||||
import hashlib
|
||||
self.gitea.files["notes.md"] = "one\ntwo — deux\nthree\n".encode("utf-8")
|
||||
out = await self.call("tea_file_edit", repo="o/r", path="/notes.md", message=" edit ", edits=[
|
||||
{"old": "two — deux", "new": "two"}, {"old": "two\nthree", "new": "two\nthree\nfour"}, {"old": "one\n"}])
|
||||
payload = self.sent("POST", f"{R}/contents")[0][3]
|
||||
(op,) = payload["files"]
|
||||
self.assertEqual((payload["message"], payload["branch"], "new_branch" in payload), ("edit", "trunk", False))
|
||||
self.assertEqual((op["operation"], op["path"], op["sha"]), ("update", "notes.md", "sha-notes.md"))
|
||||
made = base64.b64decode(op["content"])
|
||||
self.assertEqual(made, b"two\nthree\nfour\n")
|
||||
self.assertEqual((out["path"], out["edits"], out["size"], out["sha256"], out["branch"], out["commit"]),
|
||||
("notes.md", 3, len(made), hashlib.sha256(made).hexdigest(), "trunk", "c0ffeec0ffee"))
|
||||
self.assertEqual(self.sent("GET", f"{R}/contents/notes.md")[0][2], {"ref": "trunk"}, "read from the branch it edits")
|
||||
await self.call("tea_file_edit", repo="o/r", path="README.md", message="m", branch="dev", edits=[{"old": "hello", "new": "hi"}])
|
||||
self.assertEqual((self.sent("GET", f"{R}/contents/README.md")[-1][2], self.sent("POST", f"{R}/contents")[-1][3]["branch"]),
|
||||
({"ref": "dev"}, "dev"))
|
||||
|
||||
async def test_file_edit_refuses_what_it_cannot_do_exactly(self):
|
||||
self.gitea.files["notes.md"] = b"one two one\n"
|
||||
for edits, expected in [([{"old": "three"}], "edit 1: its old text occurs 0 times in notes.md, not once"),
|
||||
([{"old": "one"}], "edit 1: its old text occurs 2 times in notes.md, not once"),
|
||||
([{"old": "two", "new": "one"}, {"old": "one"}], "edit 2: its old text occurs 3 times")]:
|
||||
with self.subTest(expected=expected):
|
||||
self.assertIn(expected, await self.refused("tea_file_edit", repo="o/r", path="notes.md", message="m", edits=edits))
|
||||
self.assertIn("is not a file", await self.refused("tea_file_edit", repo="o/r", path="docs", message="m", edits=[{"old": "x"}]))
|
||||
self.assertIn("is not UTF-8 text", await self.refused("tea_file_edit", repo="o/r", path="img/logo.png", message="m",
|
||||
edits=[{"old": "x"}]))
|
||||
self.assertEqual(self.sent("POST"), [], "nothing was committed")
|
||||
for args in [{"edits": []}, {"edits": [{"old": ""}]}, {"edits": [{"new": "x"}]}, {"edits": [{"old": "x"}], "path": "../x"}]:
|
||||
with self.subTest(args=args):
|
||||
await self.refused("tea_file_edit", **{"repo": "o/r", "path": "notes.md", "message": "m", **args})
|
||||
cap, server.MAX_COMMIT_BYTES = server.MAX_COMMIT_BYTES, 10
|
||||
try:
|
||||
self.assertIn("more than 10 bytes", await self.refused("tea_file_edit", repo="o/r", path="notes.md", message="m",
|
||||
edits=[{"old": "two", "new": "x" * 20}]))
|
||||
finally:
|
||||
server.MAX_COMMIT_BYTES = cap
|
||||
self.assertEqual(self.sent("POST"), [])
|
||||
|
||||
async def test_branch_create(self):
|
||||
branch = await self.call("tea_branch_create", repo="o/r", name="feature/x")
|
||||
self.assertEqual(self.sent("POST", f"{R}/branches")[0][3], {"new_branch_name": "feature/x", "old_ref_name": "trunk"})
|
||||
|
||||
+2
-1
@@ -10,7 +10,8 @@ CNAME, then A/AAAA to the app's IPs (`fly ips list`), and a push webhook on the
|
||||
|
||||
Live: persimmons.app → `persimmons/app` (repo root, as its `pages/page.conf` says), phly.cloud → `phly/site`,
|
||||
supertestnet.lol → `supertestnet/app#pages`, yolopreneur.lol and
|
||||
`*.yolopreneur.lol` → `teahubxyz/time#gh-pages/ventures` (`PAGES_WILDCARDS`). A site can be any public repo,
|
||||
`*.yolopreneur.lol` → `teahubxyz/time#gh-pages/ventures` (`PAGES_WILDCARDS`); gandora.yolopreneur.lol → `gandora/site` (a venture in
|
||||
its own repo: an exact host in `PAGES_SITES` wins over the wildcard, so a venture graduates by adding one line). A site can be any public repo,
|
||||
`org/repo#branch/subdir`; it doesn't have to be called `site`. A product with a backend serves its own pages
|
||||
instead (phast.app is `phast-app`), so there is never a copy to drift.
|
||||
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ primary_region = "iad"
|
||||
|
||||
[env]
|
||||
GITEA_URL = "https://teahub.xyz"
|
||||
PAGES_SITES = '{"persimmons.app":"persimmons/app","phly.cloud":"phly/site","supertestnet.lol":"supertestnet/app#pages","yolopreneur.lol":"teahubxyz/time#gh-pages/ventures"}'
|
||||
PAGES_SITES = '{"persimmons.app":"persimmons/app","phly.cloud":"phly/site","supertestnet.lol":"supertestnet/app#pages","yolopreneur.lol":"teahubxyz/time#gh-pages/ventures","gandora.lol":"gandora/site","www.gandora.lol":"gandora/site","gandora.yolopreneur.lol":"gandora/site","dev.gandora.lol":"gandora/site#dev","asmr.gandora.lol":"gandora/site#main/asmr"}'
|
||||
PAGES_WILDCARDS = '{"yolopreneur.lol":"teahubxyz/time#gh-pages/ventures"}'
|
||||
PAGES_PULL_SECONDS = "300"
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# Teahub plugins
|
||||
|
||||
Each folder is one plugin: a `plugin.json` manifest plus the files it drops into Gitea's custom dir (`$GITEA_CUSTOM`, `/data/gitea` on prod).
|
||||
|
||||
- `slots` — templates appended into Gitea's custom template slots (e.g. `footer`).
|
||||
- `assets` — files served under `/assets/...`.
|
||||
- `config` — plugin settings.
|
||||
|
||||
Install today is manual: back up the slot template, append the plugin's template, copy assets, restart Gitea. An install Action that reads the manifest is next.
|
||||
|
||||
| Plugin | Status |
|
||||
|---|---|
|
||||
| webamp | live on prod |
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "webamp",
|
||||
"title": "Webamp",
|
||||
"version": "0.1.1",
|
||||
"description": "A Winamp-style player on every Teahub page, fed by gandora.",
|
||||
"slots": {
|
||||
"footer": "templates/custom/footer.tmpl"
|
||||
},
|
||||
"assets": ["public/assets/plugins/webamp/webamp-mod.js"],
|
||||
"vendor": {
|
||||
"public/assets/plugins/webamp/webamp.bundle.min.js": "https://registry.npmjs.org/webamp/-/webamp-1.5.0.tgz#package/built/webamp.bundle.min.js"
|
||||
},
|
||||
"config": {
|
||||
"playlist_url": "https://gandora.yolopreneur.lol/playlist.json"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// teahub plugin: webamp — a Winamp-style player docked on every Teahub page.
|
||||
// Loads the self-hosted Webamp bundle on first click, so pages stay fast for everyone else.
|
||||
|
||||
// Module scripts have no document.currentScript, so find our tag by its src.
|
||||
const script = document.querySelector('script[src*="plugins/webamp/webamp-mod.js"]');
|
||||
const PLAYLIST_URL = script?.dataset.playlist;
|
||||
const KEY = 'teahub-webamp-open';
|
||||
|
||||
const FALLBACK = [
|
||||
{
|
||||
metaData: { artist: 'DJ Mike Llama', title: "Llama Whippin' Intro" },
|
||||
url: 'https://cdn.jsdelivr.net/gh/captbaritone/webamp@43434d82/mp3/llama-2.91.mp3',
|
||||
duration: 5.322286,
|
||||
},
|
||||
];
|
||||
|
||||
async function loadTracks() {
|
||||
if (!PLAYLIST_URL) return FALLBACK;
|
||||
try {
|
||||
const res = await fetch(PLAYLIST_URL, { mode: 'cors' });
|
||||
if (!res.ok) throw new Error(res.status);
|
||||
const list = await res.json();
|
||||
// gandora format: [{ title, artist, url, duration? }]
|
||||
const tracks = list
|
||||
.filter((t) => t && t.url)
|
||||
.map((t) => ({
|
||||
metaData: { artist: t.artist || 'gandora', title: t.title || 'untitled' },
|
||||
url: t.url,
|
||||
...(t.duration ? { duration: t.duration } : {}),
|
||||
}));
|
||||
return tracks.length ? tracks : FALLBACK;
|
||||
} catch {
|
||||
return FALLBACK;
|
||||
}
|
||||
}
|
||||
|
||||
let webamp = null;
|
||||
|
||||
function loadWebamp() {
|
||||
if (window.Webamp) return Promise.resolve(window.Webamp);
|
||||
return new Promise((ok, fail) => {
|
||||
const s = document.createElement('script');
|
||||
s.src = new URL('webamp.bundle.min.js', import.meta.url).href;
|
||||
s.onload = () => ok(window.Webamp);
|
||||
s.onerror = fail;
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
}
|
||||
|
||||
async function open() {
|
||||
if (webamp) return;
|
||||
const Webamp = await loadWebamp();
|
||||
if (!Webamp.browserIsSupported()) return;
|
||||
webamp = new Webamp({ initialTracks: await loadTracks() });
|
||||
webamp.onClose(() => {
|
||||
webamp.dispose();
|
||||
webamp = null;
|
||||
try { localStorage.removeItem(KEY); } catch {}
|
||||
});
|
||||
await webamp.renderWhenReady(document.body.appendChild(document.createElement('div')));
|
||||
try { localStorage.setItem(KEY, '1'); } catch {}
|
||||
}
|
||||
|
||||
// Small launcher in the corner: the "sound system" button.
|
||||
const btn = document.createElement('button');
|
||||
btn.textContent = '▶ webamp';
|
||||
btn.title = 'Open Webamp';
|
||||
btn.style.cssText = [
|
||||
'position:fixed', 'right:12px', 'bottom:12px', 'z-index:9999',
|
||||
'font:12px/1 monospace', 'padding:6px 10px', 'border-radius:4px',
|
||||
'border:1px solid #1d3', 'background:#000', 'color:#1d3', 'cursor:pointer',
|
||||
].join(';');
|
||||
btn.addEventListener('click', open);
|
||||
document.body.appendChild(btn);
|
||||
|
||||
// Reopen if it was open on the last page.
|
||||
try { if (localStorage.getItem(KEY)) open(); } catch {}
|
||||
@@ -0,0 +1,2 @@
|
||||
{{/* teahub plugin: webamp */}}
|
||||
<script type="module" nonce="{{ctx.CspScriptNonce}}" src="{{AssetUrlPrefix}}/plugins/webamp/webamp-mod.js?v=011" data-playlist="https://gandora.yolopreneur.lol/playlist.json"></script>
|
||||
Reference in new issue
Block a user