Sync: a repo's copies on other forges follow its home (GitHub first, for cloud sessions) #177
No files matched your search
@@ -0,0 +1,197 @@
|
||||
"""Sync: keep a repo's copies on other forges in step with its home (time/sync.json).
|
||||
|
||||
python3 .github/time/sync.py # every repo in time/sync.json; this forge is the home
|
||||
|
||||
One rule keeps it safe: each repo has one home, and every branch has one writer. Two writers on one branch is how work
|
||||
gets lost, which is why neither of Gitea's own mirrors fits: a push mirror force-pushes every branch (it would delete a
|
||||
cloud session's branch on the copy), and a pull mirror makes the receiving repo read-only.
|
||||
|
||||
- Out: the home's branches in `out` (default: main) are pushed to every copy, fast-forward only. A copy whose branch
|
||||
has commits of its own is reported (one issue on this repo), never overwritten. SYNC_FORCE=1 (the workflow's `force`
|
||||
input) force-pushes once, e.g. to replace an archived copy's old history.
|
||||
- In: branches on a copy that match its `import` patterns (claude/* for cloud sessions on GitHub) are the copy's to
|
||||
write. They are pushed to the home under the same name and get a PR there, which Time's council reviews and lands
|
||||
like any other; the landed work then flows back out with main. A branch already in main, or one whose PR was closed
|
||||
at the same commit, is left alone.
|
||||
|
||||
time/sync.json:
|
||||
{"repos": [{"repo": "teahubxyz/time", "out": ["main"],
|
||||
"copies": [{"name": "github", "url": "https://github.com/teahubxyz/time.git",
|
||||
"token": "GITHUB_MIRROR_TOKEN", "import": ["claude/*"]}]}]}
|
||||
|
||||
`token` names a secret (an environment variable the workflow passes), never the token itself; a copy whose token isn't
|
||||
set is skipped with a note. The same script syncs Gitea with Gitea: it runs on the home, so a pair the home can't
|
||||
reach (hb-5's local Gitea from prod) runs from the side that can reach both, with that side's own sync.json.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import fnmatch
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
_spec = importlib.util.spec_from_file_location("tm", HERE / "time.py")
|
||||
tm = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(tm)
|
||||
CONFIG = "time/sync.json"
|
||||
SECRETS: list[str] = [] # every token used this run, scrubbed from anything printed or posted
|
||||
|
||||
|
||||
def scrub(text: str) -> str:
|
||||
for s in SECRETS:
|
||||
if s:
|
||||
text = text.replace(s, "***").replace(urllib.parse.quote(s, safe=""), "***")
|
||||
return text
|
||||
|
||||
|
||||
def config() -> list[dict]:
|
||||
"""time/sync.json on the default branch, through the API (the workflow checks out only .github/time)."""
|
||||
try:
|
||||
f = tm.gh("GET", f"/repos/{tm.REPO}/contents/{CONFIG}")
|
||||
except RuntimeError as e:
|
||||
if "-> 404" in str(e):
|
||||
return []
|
||||
raise
|
||||
data = json.loads(base64.b64decode(f.get("content") or "").decode() or "{}")
|
||||
return [r for r in data.get("repos") or [] if isinstance(r, dict)]
|
||||
|
||||
|
||||
def authed(url: str, token: str, user: str = "x-access-token") -> str:
|
||||
"""The URL with credentials, for https; file:// and the like as given."""
|
||||
p = urllib.parse.urlsplit(url)
|
||||
if p.scheme not in ("https", "http") or not token:
|
||||
return url
|
||||
SECRETS.append(token)
|
||||
cred = f"{urllib.parse.quote(user, safe='')}:{urllib.parse.quote(token, safe='')}"
|
||||
return urllib.parse.urlunsplit((p.scheme, f"{cred}@{p.netloc}", p.path, p.query, p.fragment))
|
||||
|
||||
|
||||
def git(*args: str, cwd: str | None = None, check: bool = True) -> subprocess.CompletedProcess:
|
||||
p = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True, timeout=300,
|
||||
env={**os.environ, "GIT_TERMINAL_PROMPT": "0"})
|
||||
if check and p.returncode:
|
||||
raise RuntimeError(f"git {args[0]} failed: {scrub(p.stderr.strip())[-400:]}")
|
||||
return p
|
||||
|
||||
|
||||
def heads(url: str, cwd: str) -> dict[str, str]:
|
||||
"""{branch: sha} of a remote."""
|
||||
out = git("ls-remote", "--heads", url, cwd=cwd).stdout
|
||||
return {ref.split("refs/heads/", 1)[1]: sha for sha, ref in (line.split("\t") for line in out.splitlines() if "\t" in line)}
|
||||
|
||||
|
||||
def push_out(work: str, home: dict[str, str], url: str, branch: str, force: bool) -> str:
|
||||
"""One home branch to one copy: 'up to date', 'pushed', 'forced' or 'diverged' (the copy has commits of its own)."""
|
||||
if branch not in home:
|
||||
return "missing on the home"
|
||||
p = git("push", "--porcelain", url, f"{'+' if force else ''}refs/home/{branch}:refs/heads/{branch}", cwd=work, check=False)
|
||||
text = p.stdout + p.stderr
|
||||
if p.returncode == 0:
|
||||
return "up to date" if "[up to date]" in text else ("forced" if force else "pushed")
|
||||
if "[rejected]" in text or "non-fast-forward" in text or "fetch first" in text:
|
||||
return "diverged"
|
||||
raise RuntimeError(f"push {branch} failed: {scrub(text.strip())[-400:]}")
|
||||
|
||||
|
||||
def landed(work: str, ref: str, base: str) -> bool:
|
||||
return git("merge-base", "--is-ancestor", ref, base, cwd=work, check=False).returncode == 0
|
||||
|
||||
|
||||
def ensure_pr(repo: str, work: str, branch: str, base: str, copy: str, where: str) -> int | None:
|
||||
"""A PR on the home for an imported branch, unless one is open for it, it is already in the base, or a PR for it
|
||||
was closed at this same commit (a human or the council said no; a new push brings it back)."""
|
||||
ref = f"refs/copy/{branch}"
|
||||
if landed(work, ref, f"refs/home/{base}"):
|
||||
return None
|
||||
tip = git("rev-parse", ref, cwd=work).stdout.strip()
|
||||
for p in tm.gh("GET", f"/repos/{repo}/pulls?state=all&limit=50") or []:
|
||||
head = p.get("head") or {}
|
||||
if head.get("ref") == branch and (p.get("state") == "open" or head.get("sha") == tip):
|
||||
return None
|
||||
log = git("log", "--reverse", "--format=%s%n%n%b%n---", f"refs/home/{base}..{ref}", cwd=work).stdout.strip()
|
||||
title = (log.splitlines() or [branch])[0][:200]
|
||||
body = (f"From {copy}: `{branch}` ({where}), brought in by sync.py. Time's council reviews it like any PR.\n\n"
|
||||
f"Commits:\n\n{log[:6000]}\n\n<sub>sync.py: edit this branch on {copy}; it is the copy's to write, and the "
|
||||
f"next sync brings the change here.</sub>")
|
||||
return tm.gh("POST", f"/repos/{repo}/pulls", {"head": branch, "base": base, "title": title, "body": body})["number"]
|
||||
|
||||
|
||||
def sync_repo(entry: dict, home_url: str, force: bool = False) -> dict:
|
||||
repo = entry.get("repo") or tm.REPO
|
||||
out = [b for b in entry.get("out") or ["main"] if isinstance(b, str)]
|
||||
base = out[0]
|
||||
report: dict = {"repo": repo, "copies": []}
|
||||
with tempfile.TemporaryDirectory() as work:
|
||||
git("init", "--bare", "-q", work)
|
||||
home = heads(home_url, work)
|
||||
wanted = [b for b in out if b in home]
|
||||
if wanted:
|
||||
git("fetch", "-q", home_url, *[f"+refs/heads/{b}:refs/home/{b}" for b in wanted], cwd=work)
|
||||
for c in entry.get("copies") or []:
|
||||
name, where = str(c.get("name") or c.get("url")), str(c.get("url") or "")
|
||||
token = os.environ.get(str(c.get("token") or ""), "") if c.get("token") else ""
|
||||
r: dict = {"copy": name, "out": {}, "imported": [], "prs": [], "note": ""}
|
||||
report["copies"].append(r)
|
||||
if c.get("token") and not token:
|
||||
r["note"] = f"skipped: the secret {c['token']} isn't set"
|
||||
continue
|
||||
url = authed(where, token, str(c.get("user") or "x-access-token"))
|
||||
for b in out:
|
||||
r["out"][b] = push_out(work, home, url, b, force)
|
||||
pats = [p for p in c.get("import") or [] if isinstance(p, str)]
|
||||
if not pats:
|
||||
continue
|
||||
theirs = heads(url, work)
|
||||
mine = heads(home_url, work)
|
||||
for branch, sha in sorted(theirs.items()):
|
||||
if branch in out or not any(fnmatch.fnmatch(branch, p) for p in pats):
|
||||
continue
|
||||
git("fetch", "-q", url, f"+refs/heads/{branch}:refs/copy/{branch}", cwd=work)
|
||||
if mine.get(branch) != sha: # the copy writes this branch: the home takes it as it is
|
||||
git("push", "-q", "--force", home_url, f"refs/copy/{branch}:refs/heads/{branch}", cwd=work)
|
||||
r["imported"].append(branch)
|
||||
n = ensure_pr(repo, work, branch, base, name, where)
|
||||
if n:
|
||||
r["prs"].append(n)
|
||||
return report
|
||||
|
||||
|
||||
def flag(repo: str, copy: str, branch: str) -> None:
|
||||
"""One open issue per diverged copy branch, labelled needs-human: whether to force is a human's (or the CEO's)
|
||||
call, so neither the worker nor the router takes it."""
|
||||
title = f"Sync: {copy}'s {branch} has commits of its own ({repo})"
|
||||
if any(i.get("title") == title for i in tm.issues("state=open")):
|
||||
return
|
||||
tm.new_issue(title, f"sync.py pushes `{branch}` from the home to {copy} fast-forward only, and {copy}'s `{branch}` has "
|
||||
f"commits the home doesn't. Nothing was overwritten. Either bring those commits to the home (a branch "
|
||||
f"and a PR), or replace {copy}'s history: Actions → sync → Run workflow with `force`.\n\n"
|
||||
f"DONE: {copy}'s `{branch}` follows the home again and this sync run is green.", ["needs-human"])
|
||||
|
||||
|
||||
def main() -> int:
|
||||
entries = config()
|
||||
if not entries:
|
||||
print(f"sync: no {CONFIG}, nothing to sync")
|
||||
return 0
|
||||
force = (os.environ.get("SYNC_FORCE") or "").lower() in ("1", "true", "yes")
|
||||
token = os.environ.get("TIME_TOKEN") or os.environ.get("GITHUB_TOKEN") or ""
|
||||
for e in entries:
|
||||
repo = e.get("repo") or tm.REPO
|
||||
rep = sync_repo(e, authed(f"{tm.SERVER}/{repo}.git", token), force)
|
||||
for c in rep["copies"]:
|
||||
print(f"sync {repo} -> {c['copy']}: out {c['out']} imported {c['imported']} prs {c['prs']} {c['note']}".rstrip())
|
||||
for b, state in c["out"].items():
|
||||
if state == "diverged" and repo == tm.REPO:
|
||||
flag(repo, c["copy"], b)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1287,6 +1287,85 @@ assert tm.pools() == [] and tm.pool_for("anthropic") is None, "no pools.json: no
|
||||
tm.gh = real_gh82; tm._pools = None
|
||||
print("ALL OK 82")
|
||||
|
||||
# 83 sync.py: a repo's copies on other forges follow its home. main goes out fast-forward only (a copy with commits of
|
||||
# its own is reported, never overwritten, unless forced once); a copy's claude/* branches come back as PRs on the home
|
||||
import subprocess as _sp83, tempfile as _tf83
|
||||
sspec = importlib.util.spec_from_file_location("sync83", Path(__file__).resolve().parent / "sync.py")
|
||||
sy = importlib.util.module_from_spec(sspec); sspec.loader.exec_module(sy)
|
||||
root83 = _tf83.mkdtemp()
|
||||
def g83(*a, cwd=None):
|
||||
return _sp83.run(["git", "-c", "user.email=t@t", "-c", "user.name=t", "-c", "init.defaultBranch=main", *a], cwd=cwd,
|
||||
capture_output=True, text=True, check=True).stdout.strip()
|
||||
def commit83(cwd, name, msg):
|
||||
Path(cwd, name).write_text(msg); g83("add", "-A", cwd=cwd); g83("commit", "-qm", msg, cwd=cwd)
|
||||
home83, copy83, wk83, cw83 = (os.path.join(root83, x) for x in ("home.git", "copy.git", "w", "cw"))
|
||||
g83("init", "--bare", "-q", home83); g83("init", "--bare", "-q", copy83); g83("clone", "-q", home83, wk83)
|
||||
commit83(wk83, "a.txt", "A"); g83("push", "-q", "origin", "HEAD:main", cwd=wk83)
|
||||
prs83, made83 = [], []
|
||||
def gh83(method, path, body=None, **k):
|
||||
if path.startswith("/repos/o/r/pulls?"):
|
||||
return [dict(p) for p in prs83]
|
||||
if path == "/repos/o/r/pulls" and method == "POST":
|
||||
made83.append(body); prs83.append({"number": 100 + len(made83), "state": "open", "head": {"ref": body["head"], "sha": "?"}})
|
||||
return {"number": 100 + len(made83)}
|
||||
raise AssertionError((method, path))
|
||||
sy.tm.gh = gh83
|
||||
entry83 = {"repo": "o/r", "copies": [{"name": "github", "url": "file://" + copy83, "import": ["claude/*"]}]}
|
||||
sync83 = lambda **k: sy.sync_repo(entry83, "file://" + home83, **k)["copies"][0]
|
||||
c83 = sync83()
|
||||
assert c83["out"] == {"main": "pushed"} and c83["imported"] == [] and not made83, c83
|
||||
assert g83("rev-parse", "main", cwd=copy83) == g83("rev-parse", "main", cwd=home83)
|
||||
assert sync83()["out"] == {"main": "up to date"}
|
||||
# a cloud session's branch on the copy comes home as a PR, its commit messages in the body (so `Closes #N` adopts a card)
|
||||
g83("clone", "-q", copy83, cw83)
|
||||
g83("checkout", "-qb", "claude/fix", cwd=cw83); commit83(cw83, "b.txt", "Fix the thing\n\nCloses #5"); g83("push", "-q", "origin", "claude/fix", cwd=cw83)
|
||||
g83("checkout", "-qb", "other/y", cwd=cw83); commit83(cw83, "c.txt", "Not for the home"); g83("push", "-q", "origin", "other/y", cwd=cw83)
|
||||
c83 = sync83()
|
||||
assert c83["imported"] == ["claude/fix"] and c83["prs"] == [101], c83
|
||||
assert made83[0]["head"] == "claude/fix" and made83[0]["base"] == "main" and made83[0]["title"] == "Fix the thing" and "Closes #5" in made83[0]["body"]
|
||||
assert g83("rev-parse", "claude/fix", cwd=home83) == g83("rev-parse", "claude/fix", cwd=copy83)
|
||||
assert "other/y" not in g83("branch", cwd=home83), "only the copy's import patterns come home"
|
||||
c83 = sync83()
|
||||
assert c83["imported"] == [] and c83["prs"] == [] and len(made83) == 1, "nothing new: no second PR"
|
||||
# the copy rewrites its branch (a rebase): the home takes it as it is, since the copy is that branch's one writer
|
||||
g83("checkout", "-q", "claude/fix", cwd=cw83); g83("commit", "-q", "--amend", "-m", "Fix the thing, better\n\nCloses #5", cwd=cw83)
|
||||
g83("push", "-q", "--force", "origin", "claude/fix", cwd=cw83)
|
||||
assert sync83()["imported"] == ["claude/fix"] and g83("rev-parse", "claude/fix", cwd=home83) == g83("rev-parse", "claude/fix", cwd=copy83)
|
||||
# landed on the home: main goes out to the copy, and a branch already in main gets no PR
|
||||
g83("fetch", "-q", "origin", "claude/fix", cwd=wk83); g83("merge", "-q", "--ff-only", "FETCH_HEAD", cwd=wk83); g83("push", "-q", "origin", "HEAD:main", cwd=wk83)
|
||||
prs83[0]["state"] = "closed"
|
||||
c83 = sync83()
|
||||
assert c83["out"] == {"main": "pushed"} and c83["prs"] == [] and g83("rev-parse", "main", cwd=copy83) == g83("rev-parse", "main", cwd=home83)
|
||||
# a PR closed at the branch's current commit stays closed; a new push to the branch brings it back
|
||||
g83("checkout", "-qb", "claude/nope", cwd=cw83); g83("fetch", "-q", "origin", cwd=cw83); g83("reset", "-q", "--hard", "origin/main", cwd=cw83)
|
||||
commit83(cw83, "d.txt", "A bad idea"); g83("push", "-q", "origin", "claude/nope", cwd=cw83)
|
||||
assert sync83()["prs"] == [102]
|
||||
prs83[-1].update(state="closed", head={"ref": "claude/nope", "sha": g83("rev-parse", "claude/nope", cwd=copy83)})
|
||||
assert sync83()["prs"] == [], "closed at this commit: left alone"
|
||||
commit83(cw83, "d.txt", "A better idea"); g83("push", "-q", "origin", "claude/nope", cwd=cw83)
|
||||
assert sync83()["prs"] == [103], "a new commit reopens the question"
|
||||
# the copy's main has commits of its own: reported, never overwritten, unless forced once
|
||||
g83("checkout", "-q", "main", cwd=cw83); g83("reset", "-q", "--hard", "origin/main", cwd=cw83)
|
||||
commit83(cw83, "e.txt", "Written on the copy's main"); g83("push", "-q", "origin", "main", cwd=cw83)
|
||||
commit83(wk83, "f.txt", "The home moves on"); g83("push", "-q", "origin", "HEAD:main", cwd=wk83)
|
||||
copy_main83 = g83("rev-parse", "main", cwd=copy83)
|
||||
assert sync83()["out"] == {"main": "diverged"} and g83("rev-parse", "main", cwd=copy83) == copy_main83
|
||||
flagged83 = []
|
||||
sy.tm.issues = lambda q: [{"title": t} for t, _, _ in flagged83]
|
||||
sy.tm.new_issue = lambda title, body, labels: flagged83.append((title, body, labels)) or {"number": 1}
|
||||
sy.flag("o/r", "github", "main"); sy.flag("o/r", "github", "main")
|
||||
assert len(flagged83) == 1 and "force" in flagged83[0][1] and flagged83[0][2] == ["needs-human"], "one issue per diverged branch, a human's"
|
||||
assert sync83(force=True)["out"] == {"main": "forced"} and g83("rev-parse", "main", cwd=copy83) == g83("rev-parse", "main", cwd=home83)
|
||||
# a copy whose token isn't set is skipped; tokens go in the URL and never into anything printed
|
||||
c83 = sy.sync_repo({"repo": "o/r", "copies": [{"name": "gh", "url": "https://github.com/o/r.git", "token": "NO_SUCH_TOKEN_83"}]},
|
||||
"file://" + home83)["copies"][0]
|
||||
assert c83["note"] == "skipped: the secret NO_SUCH_TOKEN_83 isn't set" and c83["out"] == {}
|
||||
assert sy.authed("https://github.com/o/r.git", "t0k/en") == "https://x-access-token:t0k%2Fen@github.com/o/r.git"
|
||||
assert sy.authed("file:///x", "t0k") == "file:///x" and sy.scrub("https://x-access-token:t0k%2Fen@github.com said t0k/en") == "https://x-access-token:***@github.com said ***"
|
||||
sy.tm.gh = lambda *a, **k: (_ for _ in ()).throw(RuntimeError("GET x -> 404: not found"))
|
||||
assert sy.config() == [] and sy.main() == 0, "no sync.json: nothing to sync"
|
||||
print("ALL OK 83")
|
||||
|
||||
|
||||
# ---------- venture loop lifecycle: these still run against the old GitHub-shaped fake (Pages API, author_association,
|
||||
# per_page); the Gitea shapes are pinned by 79 above. They run only with TIME_TEST_VENTURE=1 until the fake is ported.
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
name: sync
|
||||
# Keeps this repo's copies on other forges in step with it (time/sync.json, .github/time/sync.py). One home, one writer
|
||||
# per branch: main goes out to every copy, fast-forward only (a copy with commits of its own is reported, never
|
||||
# overwritten); the branches a copy is told to send back (claude/* from cloud sessions on GitHub) come in as PRs here,
|
||||
# and Time's council reviews and lands them like any other. Each copy's token is a secret named in sync.json and passed
|
||||
# below; a copy without one is skipped. On a GitHub copy, turn GitHub Actions off: these workflows must run here only.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: "*/15 * * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
force:
|
||||
description: "Force-push main to the copies once (replaces a copy's own history, e.g. an archived copy's)"
|
||||
type: boolean
|
||||
default: false
|
||||
concurrency:
|
||||
group: sync
|
||||
cancel-in-progress: false
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
sparse-checkout: .github/time
|
||||
persist-credentials: false
|
||||
- run: python3 .github/time/sync.py
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TIME_TOKEN: ${{ secrets.TIME_TOKEN }} # branches and PRs on the home go in as the `time` user
|
||||
SYNC_FORCE: ${{ inputs.force && '1' || '' }}
|
||||
GITHUB_MIRROR_TOKEN: ${{ secrets.GITHUB_MIRROR_TOKEN }} # github.com/teahubxyz/time: Contents read/write
|
||||
@@ -24,6 +24,7 @@
|
||||
- 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
|
||||
- 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. The first copy is `github.com/teahubxyz/time`, so cloud sessions clone a current engine; it is skipped until `GITHUB_MIRROR_TOKEN` is set, and GitHub Actions must be 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.
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"_about": "Copies of repos on other forges, kept in step by .github/time/sync.py (the sync workflow). Each repo has one home (this forge) and every branch one writer: the `out` branches go from the home to each copy, fast-forward only; a copy's branches matching its `import` patterns come back to the home as PRs. `token` names the secret holding the copy's token, never the token itself; a copy whose secret isn't set is skipped. A GitHub copy needs GitHub Actions turned off, since these workflows must only run here.",
|
||||
"repos": [
|
||||
{"repo": "teahubxyz/time", "out": ["main"],
|
||||
"copies": [{"name": "github", "url": "https://github.com/teahubxyz/time.git", "token": "GITHUB_MIRROR_TOKEN", "import": ["claude/*"]}]}
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user