Steward tick inside Time: repair the block, escalate only money or a human-only step (#292)
sync / sync (push) Successful in 1m16s
ci / test (push) Successful in 1m1s

Co-authored-by: time <time@users.noreply.teahub.xyz>
This commit was merged in pull request #292.
This commit is contained in:
time authored and teahubxyz committed 2026-10-29 19:44:31 +00:00
1 parent 5bec04fb3a
commit 20ba8dd0b6
5 files changed
+803

No files matched your search

+435
View File
@@ -0,0 +1,435 @@
"""Steward: the factory's repair tick, inside Time. A block that is only reported is a status page; this one repairs.
python3 .github/time/steward.py # every 6 hours (steward.yml) and on workflow_dispatch
Two parts, in order.
1. Deterministic, free, no model: merge card PRs that carry a trusted Approve and green checks and that `touches_protected`
does not hold (the same tests `land()` applies, TIME_AUTOMERGE included); close an `andon` whose cause has healed (a
spend window that has refilled -- a daily cap past midnight UTC, a pool back under its allowance -- or a key that is
now set); re-dispatch the heartbeat if its latest run failed.
2. Only for what is left and is a real block: one call to the steward seat (TIME_STEWARD, default `claude:opus`; `off`
disables the whole tick) with the repair charter. It books to the ledger like every other call. It repairs with the
tools below (labels, the few variables it may touch, a heartbeat retry, a comment that hands a card back to its
worker) and may escalate to the operator only for money beyond the caps or a human-only step. Code checks that, not
the prompt: `t_set_variable` and `t_escalate` refuse anything else whatever the charter says.
One entry per run goes to the Astra log (#57), in Astra's thread and style: Merged / Fixed / Left, with the reason for
anything left. A comment the steward leaves on a card carries `{"steward": true}` and no `beat`, so it cannot change the
card's state; the one exception is the `merged` beat of a PR it merged, which is the beat `land()` writes for the same act.
"""
from __future__ import annotations
import datetime as dt
import importlib.util
import json
import os
import re
import sys
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)
LOG_TITLE = "Astra log" # astra.py's thread: one place a human reads the factory's day
DEFAULT_SEAT = "claude:opus"
HEARTBEAT = "heartbeat.yml"
STEWARD_LABELS = {"ready", "queued", "needs-human"}
# the only variables the steward may set, and the values it may set them to; never money (the caps), merge policy or trust
STEWARD_VARS = {"TIME_MODE": r"live|dry-run", "TIME_PARALLEL": r"[1-9]", "TIME_STUCK_HOURS": r"\d{1,3}", "TIME_DAILY_CAP": r"\d{1,3}"}
PARKED = ("error", "stuck", "blocked") # a card whose last beat says its worker stopped
# the reason sentences budget_check writes into an andon's body. The title says "... / $9.00 out of pocket this month"
# whichever cap tripped, so only the reason tells a daily breach from a monthly one.
MONTHLY = re.compile(r"month's (?:[\w-]+ )?spend is")
POOL = re.compile(r"\bpool (\S+) has spent\b")
DAILY = "today's spend is"
CHARTER = """You are the steward of a small, fully AI-run software factory (Time). The deterministic part of this run has
already merged what could be merged and closed what had healed. What follows is what is left, as JSON.
Your job, in order, for each block: name it; repair it with the tools you have; escalate only if it cannot be repaired.
- Tools: `label` (ready / queued / needs-human), `comment` (on a card or PR: hand a card back to its worker with the reason,
or leave a note the next run reads), `set_variable` (TIME_MODE, TIME_PARALLEL, TIME_STUCK_HOURS, TIME_DAILY_CAP only),
`dispatch_heartbeat` (a retry of the clock), `escalate`.
- Escalate to the operator ONLY for money beyond the caps (the caps are never yours to raise) or a human-only step: a login, a
payment, a legal signature. A failing check, an objection, a parked card, a conflict, a wrong label is yours: fix it or hand
the card back to its worker. "Needs a human" about something you could have repaired is the failure this seat exists to end.
- Never touch workflows, secrets, the spend caps, TIME_AUTOMERGE, TIME_PROTECTED or TIME_TRUSTED, and never merge anything.
- Be brief. When you are done, reply with 2-4 sentences: what you found, what you did, what you left and why."""
TOOLS = [
{"type": "function", "function": {"name": "label", "description": "Add or remove one label on a card or PR.",
"parameters": {"type": "object", "properties": {"number": {"type": "integer"}, "name": {"type": "string", "enum": sorted(STEWARD_LABELS)},
"action": {"type": "string", "enum": ["add", "remove"]}}, "required": ["number", "name", "action"]}}},
{"type": "function", "function": {"name": "comment", "description": "Comment on a card or PR as Time. Use it to hand a card back to its worker.",
"parameters": {"type": "object", "properties": {"number": {"type": "integer"}, "text": {"type": "string"}}, "required": ["number", "text"]}}},
{"type": "function", "function": {"name": "set_variable", "description": "Set one Actions variable: " + ", ".join(sorted(STEWARD_VARS)) + ".",
"parameters": {"type": "object", "properties": {"name": {"type": "string", "enum": sorted(STEWARD_VARS)}, "value": {"type": "string"}},
"required": ["name", "value"]}}},
{"type": "function", "function": {"name": "dispatch_heartbeat", "description": "Run the heartbeat workflow once, now.",
"parameters": {"type": "object", "properties": {}, "required": []}}},
{"type": "function", "function": {"name": "escalate", "description": "Open one issue for the operator. Only for money beyond the caps or a human-only step.",
"parameters": {"type": "object", "properties": {"kind": {"type": "string", "enum": ["money", "human-only"]},
"title": {"type": "string"}, "reason": {"type": "string", "description": "What is blocked, what you tried, what exactly the human must do."}},
"required": ["kind", "title", "reason"]}}},
]
class Report:
"""What this run did: the three sections of the log entry."""
def __init__(self):
self.merged: list[str] = []
self.fixed: list[str] = []
self.left: list[dict] = [] # {"what", "why", "ask", "card", "since"}: ask = a real block the model may repair
self.escalated: list[str] = []
def leave(self, what: str, why: str, ask: bool = False, card: int | None = None, since: str = "") -> None:
self.left.append({"what": what, "why": why, "ask": ask, "card": card, "since": since})
def log_entry(seat: str, spend: tuple[float, float], rep: Report, note: str) -> tuple[str, dict]:
"""The log comment, in Astra's style: a header with the spend, the run's note, then Merged / Fixed / Left."""
def section(name: str, items: list[str]) -> list[str]:
return [f"{name}: none"] if not items else [f"{name}:"] + [f"- {i}" for i in items]
lines = [f"🧭 Steward ({seat}), spend ${spend[0]:.2f} of ${spend[1]:.2f} today:", "", note, ""]
lines += section("Merged", rep.merged) + section("Fixed", rep.fixed)
lines += section("Left", [f"{l['what']}: {l['why']}" for l in rep.left])
if rep.escalated:
lines += section("Escalated to the operator", rep.escalated)
meta = {"beat": "steward", "merged": len(rep.merged), "fixed": len(rep.fixed), "left": len(rep.left), "escalated": len(rep.escalated)}
return "\n".join(lines), meta
def log_issue() -> int:
"""Astra's thread, or a fresh one in Astra's words: the steward writes into the same log a human already reads."""
for i in tm.issues("state=open"):
if i["title"] == LOG_TITLE:
return i["number"]
return tm.new_issue(LOG_TITLE, "Astra, the daily planner, writes one comment here per day: what it read, what it "
"queued, and why. Object in a reply; Astra reads this thread too.", ["astra"])["number"]
def say(number: int, text: str, **meta) -> None:
tm.comment(number, f"🧭 Steward: {text}", {"steward": True, **meta})
# ---------- 1. deterministic: no model call, no money ----------
def card_of(pr: dict) -> int | None:
"""The card a PR answers: `Closes #N` in its body (sync.py puts one on every branch it brings in), or its branch
`time/card-N`. A PR that answers no card is nobody's to merge here."""
m = re.search(r"\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)", pr.get("body") or "", re.I) \
or re.match(r"time/card-(\d+)", (pr.get("head") or {}).get("ref") or "")
return int(m.group(1)) if m else None
def merge_ready(rep: Report) -> None:
"""Merge every card PR that `land()` would merge. What it would not merge is left, with the reason."""
automerge = (os.environ.get("TIME_AUTOMERGE") or "off").strip() == "on"
for listed in tm.gh("GET", f"/repos/{tm.REPO}/pulls?state=open&limit=50"):
n = card_of(listed)
if n is None or listed.get("state", "open") != "open":
continue
what = f"PR #{listed['number']} (card #{n})"
try:
comments = tm.gh("GET", f"/repos/{tm.REPO}/issues/{n}/comments?limit=100")
beats = tm.beats_of(comments)
last = beats[-1]["at"] if beats else ""
pr = tm.gh("GET", f"/repos/{tm.REPO}/pulls/{listed['number']}")
if pr.get("merged_at") or pr.get("state", "open") != "open":
continue
# the same window land() reads: a verdict from before the worker answered a review no longer counts
revised = max((b["at"] for b in beats if b.get("revised")), default="")
cast = tm.votes(pr, max(pr["created_at"], revised))
v = tm.decide(cast)
if v == "object":
rep.leave(what, "a reviewer objected", ask=True, card=n, since=last)
elif v is None:
rep.leave(what, "no trusted verdict yet" if not cast else "the council is split or short of the quorum",
ask=bool(cast), card=n, since=last)
elif (hit := tm.touches_protected(pr["number"])):
rep.leave(what, f"approved, but it touches protected paths ({', '.join(hit[:5])}): a human merges this one")
elif not automerge:
rep.leave(what, "approved, but TIME_AUTOMERGE is not on")
else:
_merge(pr, n, what, beats, rep)
except Exception as e: # noqa: BLE001 one PR must not stop the others or the log
rep.leave(what, f"could not be checked: {type(e).__name__}: {str(e)[:160]}", ask=True)
def _merge(pr: dict, n: int, what: str, beats: list[dict], rep: Report) -> None:
"""An approved, unprotected card PR: merge it on green, or leave it with what is in the way (land()'s order)."""
last = beats[-1] if beats else {}
if pr.get("mergeable") is False: # the heartbeat merges its base in; the steward only says what it sees
rep.leave(what, "approved, but it no longer merges cleanly", ask=True, card=n, since=last.get("at", ""))
return
c = tm.checks(pr["head"]["sha"])
need = (os.environ.get("TIME_REQUIRE_CHECKS") or "on").strip() != "off"
if c == "pending":
rep.leave(what, "approved; checks are pending")
elif c == "none" and need:
if last.get("beat") in ("no-checks", "ci-dispatched"):
rep.leave(what, "approved, but there are still no checks and I only merge on green", ask=True, card=n, since=last.get("at", ""))
elif tm.dispatch_ci(pr):
say(n, f"approved; running CI on PR #{pr['number']} before merging.", beat="ci-dispatched", pr=pr["number"])
rep.fixed.append(f"dispatched CI on {what}, which had none")
else:
rep.leave(what, "approved, but it has no checks and there is no CI workflow to dispatch", ask=True)
elif c == "fail":
rep.leave(what, "approved, but checks are failing", ask=True, card=n, since=last.get("at", ""))
else:
tm.gh("POST", f"/repos/{tm.REPO}/pulls/{pr['number']}/merge", {"Do": "squash", "delete_branch_after_merge": True}, as_time=True)
if tm.gh("GET", f"/repos/{tm.REPO}/issues/{n}")["state"] == "open":
tm.gh("PATCH", f"/repos/{tm.REPO}/issues/{n}", {"state": "closed"})
# the one steward comment that keeps a beat: it is the `merged` beat land() writes for the same act, so the card reads as done
tm.comment(n, f"⏱️ Time: approved and green, merged PR #{pr['number']}. Card done.", {"beat": "merged", "pr": pr["number"], "steward": True})
rep.merged.append(what)
def healed(andon: dict) -> str | None:
"""Why this andon's cause is gone, or None when it is not (or cannot be told to be).
A spend window that has refilled and a key that is now set are facts the code can read. The monthly cap is money: the
operator raises it, nobody else."""
text = f"{andon.get('title') or ''}\n{andon.get('body') or ''}"
today, day_cap = tm.budget()[:2]
if MONTHLY.search(text): # monthly: a human raises the cap and sets TIME_MODE back; not mine to close
return None
if (m := POOL.search(text)): # a capacity pool refills on its own schedule
p = next((p for p in tm.pools() if p.get("name") == m.group(1)), None)
if p and tm.pool_spent(p) < tm.allowance(p):
return f"pool {p['name']} has ${tm.allowance(p) - tm.pool_spent(p):.2f} of its allowance again"
return None
if DAILY in text: # budget_check's daily reason: it heals itself at midnight UTC
if (andon.get("created_at") or "9")[:10] < dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%d") and today < day_cap:
return f"it was a daily cap, and today's spend is ${today:.2f} of ${day_cap:.2f} after midnight UTC"
return None
keys = re.findall(r"\bno ([A-Z][A-Z0-9_]+) secret\b", text)
if keys and all(os.environ.get(k) for k in keys):
return f"{', '.join(keys)} {'is' if len(keys) == 1 else 'are'} set now"
return None
def close_healed(rep: Report) -> None:
"""Close each open `andon` whose cause has healed, saying why on the issue."""
for a in tm.issues("state=open&labels=andon"):
why = healed(a)
if not why:
continue
try:
say(a["number"], f"closing this andon: {why}.", healed=True)
tm.gh("PATCH", f"/repos/{tm.REPO}/issues/{a['number']}", {"state": "closed"})
rep.fixed.append(f"closed andon #{a['number']}: {why}")
except Exception as e: # noqa: BLE001
rep.leave(f"andon #{a['number']}", f"healed ({why}) but I could not close it: {str(e)[:160]}", ask=True)
def still_open(rep: Report) -> None:
"""Every andon the deterministic part did not close, with what it waits on. Money is named as money: the model is not
asked to repair a cap it may not raise."""
spoken = {l["what"] for l in rep.left}
for a in tm.issues("state=open&labels=andon"):
what = f"andon #{a['number']}"
if what in spoken:
continue
text = f"{a.get('title') or ''}\n{a.get('body') or ''}"
if MONTHLY.search(text):
rep.leave(what, "the monthly cap is money: the operator raises it, and sets TIME_MODE back to live")
elif POOL.search(text):
rep.leave(what, "a capacity pool is spent for now; it refills on its own schedule")
elif DAILY in text:
rep.leave(what, "a daily cap, which heals after midnight UTC")
else:
rep.leave(what, "its cause has not healed", ask=True)
def heartbeat_failed() -> dict | None:
"""The heartbeat's latest run if it failed, else None. Raises when the forge cannot list runs."""
out = tm.gh("GET", f"/repos/{tm.REPO}/actions/runs?limit=50")
runs = out.get("workflow_runs") if isinstance(out, dict) else out
# Gitea gives the workflow as `path`, bare or as `.github/workflows/heartbeat.yml@refs/heads/main`; `name` is its `name:`
mine = [r for r in runs or [] if HEARTBEAT in str(r.get("path") or "") or r.get("name") == "heartbeat"]
if not mine:
return None
latest = max(mine, key=lambda r: r.get("id") or 0)
# only `failure`: a run the concurrency group cancelled, or one still going, is not a broken clock
return latest if str(latest.get("conclusion") or latest.get("status") or "").lower() == "failure" else None
def dispatch_heartbeat() -> None:
tm.gh("POST", f"/repos/{tm.REPO}/actions/workflows/{HEARTBEAT}/dispatches", {"ref": tm.default_branch()})
def retry_heartbeat(rep: Report) -> None:
"""A clock that stopped is the factory stopped: one dispatch, this run."""
try:
run = heartbeat_failed()
except Exception as e: # noqa: BLE001 the forge may not list runs at all; that is not a block to repair
rep.leave("heartbeat", f"I could not read its runs: {str(e)[:160]}")
return
if not run:
return
try:
dispatch_heartbeat()
rep.fixed.append(f"the heartbeat's latest run (#{run.get('id')}) failed; dispatched it once")
except Exception as e: # noqa: BLE001
rep.leave("heartbeat", f"its latest run (#{run.get('id')}) failed and I could not dispatch it: {str(e)[:160]}", ask=True)
def parked_cards(rep: Report) -> None:
"""A `ready` card whose last beat says its worker stopped is a block the steward can hand back."""
for card in tm.issues("state=open&labels=ready"):
try:
cs = tm.gh("GET", f"/repos/{tm.REPO}/issues/{card['number']}/comments?limit=100")
real = [b for b in tm.beats_of(cs) if b["beat"] not in ("capped",)]
if real and real[-1]["beat"] in PARKED:
rep.leave(f"card #{card['number']}", f"parked on `{real[-1]['beat']}`", ask=True, card=card["number"], since=real[-1]["at"])
except Exception as e: # noqa: BLE001
print(f"card #{card['number']}: {e}")
def already_handled(rep: Report) -> None:
"""A block the steward has already spoken on since the card's last beat goes back to waiting: no second model call,
and no second comment, for the same thing. The card moves, or a human does."""
for item in rep.left:
if not (item["ask"] and item["card"]):
continue
cs = tm.gh("GET", f"/repos/{tm.REPO}/issues/{item['card']}/comments?limit=100")
if any(tm.is_time(c) and '"steward":true' in (c.get("body") or "") and c["created_at"] > item["since"] for c in cs):
item["ask"], item["why"] = False, item["why"] + " (the steward has handed this back already; it waits for the card to move)"
# ---------- 2. the model, for what is left ----------
class Tools:
"""The steward's hands. Every fence is here, not in the charter: a prompt cannot talk its way past code."""
def __init__(self, rep: Report):
self.rep = rep
def call(self, name: str, a: dict) -> str:
try:
return getattr(self, f"t_{name}")(a)
except AttributeError:
return f"unknown tool {name}"
except Exception as e: # noqa: BLE001 the model reads the error and tries something else
return f"error: {type(e).__name__}: {str(e)[:300]}"
def t_label(self, a: dict) -> str:
n, name = int(a["number"]), str(a["name"])
if name not in STEWARD_LABELS:
return f"refused: the steward may only use {', '.join(sorted(STEWARD_LABELS))}"
(tm.label if a.get("action") == "add" else tm.unlabel)(n, name)
self.rep.fixed.append(f"{a.get('action')} label `{name}` on #{n}")
return "done"
def t_comment(self, a: dict) -> str:
say(int(a["number"]), str(a["text"])[:3000])
self.rep.fixed.append(f"commented on #{a['number']}")
return "done"
def t_set_variable(self, a: dict) -> str:
name, value = str(a["name"]), str(a["value"])
if name not in STEWARD_VARS or not re.fullmatch(STEWARD_VARS[name], value):
return f"refused: allowed variables are {', '.join(f'{k} ({v})' for k, v in STEWARD_VARS.items())}"
if name == "TIME_MODE" and value == "live" and tm.budget()[2] >= tm.budget()[3]:
return "refused: the monthly cap is reached; raising it is the operator's"
path = f"/repos/{tm.REPO}/actions/variables/{name}"
try:
tm.gh("PUT", path, {"value": value})
except RuntimeError as e: # Gitea: PUT updates an existing variable, POST creates it
if "-> 404" not in str(e):
raise
tm.gh("POST", path, {"value": value})
self.rep.fixed.append(f"set variable {name}={value}")
return "done"
def t_dispatch_heartbeat(self, a: dict) -> str:
dispatch_heartbeat()
self.rep.fixed.append("dispatched the heartbeat")
return "dispatched"
def t_escalate(self, a: dict) -> str:
kind = str(a.get("kind"))
if kind not in ("money", "human-only"):
return "refused: escalate only for `money` beyond the caps or a `human-only` step (a login, a payment, a legal signature)"
title = f"Steward: {str(a['title'])[:150]}"
if any(i["title"] == title for i in tm.issues("state=open&labels=needs-human")):
return "already open"
tm.new_issue(title, f"🧭 The steward cannot repair this and hands it to the operator ({kind}).\n\n{str(a['reason'])[:3000]}\n\n"
"Close this once it's handled.", ["needs-human"])
self.rep.escalated.append(f"{kind}: {title}")
return "opened"
def ask_steward(spec: str, blocks: list[dict], context: dict, rep: Report) -> str:
"""One conversation with the steward seat. Books to the ledger through tm.chat; a cap or a missing key ends it politely."""
seat, model, effort = tm.worker_seat_model(spec)
user = "Blocks:\n" + json.dumps(blocks, indent=1) + "\n\nContext:\n" + json.dumps(context, indent=1)
msgs = [{"role": "system", "content": CHARTER}, {"role": "user", "content": user}]
tools, last = Tools(rep), ""
for _ in range(int(os.environ.get("TIME_STEWARD_STEPS") or 8)):
body = {"model": model, "temperature": 0.2, "max_tokens": 4000, "messages": msgs, "tools": TOOLS,
"venice_parameters": {"include_venice_system_prompt": False}}
if effort:
body["reasoning_effort"] = effort
try:
msg = tm.chat(body, timeout=240)["choices"][0]["message"]
except (tm.Budget, LookupError) as e: # out of money, or no key: the deterministic part still gets its log entry
return f"No model call: {e}."
msgs.append({k: v for k, v in msg.items() if k in ("role", "content", "tool_calls")})
last = msg.get("content") or last
calls = msg.get("tool_calls") or []
if not calls:
break
for c in calls:
try:
args = json.loads(c["function"].get("arguments") or "{}")
except ValueError:
args = {}
msgs.append({"role": "tool", "tool_call_id": c["id"], "content": tools.call(c["function"]["name"], args)})
return re.sub(r"<think>.*?</think>", "", last, flags=re.S).strip()[:1200] or "(the steward gave no summary)"
def context() -> dict:
"""What the seat needs to judge a block without reading the whole forge."""
today, day_cap, month, month_cap = tm.budget()
return {"mode": os.environ.get("TIME_MODE") or "dry-run", "automerge": os.environ.get("TIME_AUTOMERGE") or "off",
"spend": {"today": round(today, 2), "daily_cap": day_cap, "month": round(month, 2), "monthly_cap": month_cap},
"open_andons": [{"n": i["number"], "title": i["title"], "body": (i.get("body") or "")[:500]} for i in tm.issues("state=open&labels=andon")],
"needs_human": [{"n": i["number"], "title": i["title"]} for i in tm.issues("state=open&labels=needs-human")]}
# ---------- the tick ----------
def tick() -> int:
spec = (os.environ.get("TIME_STEWARD") or DEFAULT_SEAT).strip()
if spec.lower() == "off":
print("steward: off")
return 0
rep = Report()
for step in (merge_ready, close_healed, retry_heartbeat, parked_cards, still_open):
try:
step(rep)
except Exception as e: # noqa: BLE001 a forge hiccup in one step must not skip the rest or the log
rep.leave(step.__name__, f"{type(e).__name__}: {str(e)[:160]}")
try:
already_handled(rep)
except Exception as e: # noqa: BLE001
print(f"steward: {e}")
blocks = [l for l in rep.left if l["ask"]]
note = "Nothing needed a model call."
if blocks:
note = ask_steward(spec, [{"what": b["what"], "why": b["why"]} for b in blocks], context(), rep)
spend = tm.budget()
text, meta = log_entry(spec, (spend[0], spend[1]), rep, note)
tm.comment(log_issue(), text, meta)
print(f"steward: merged={len(rep.merged)} fixed={len(rep.fixed)} left={len(rep.left)} asked={bool(blocks)}")
return 0
if __name__ == "__main__":
sys.exit(tick())
+291
View File
@@ -0,0 +1,291 @@
"""Offline tests for steward.py: a fake forge, no network, no model. Run: python3 .github/time/test_steward.py
(test_time.py runs this file too, so the one CI command covers it.)"""
import datetime as dt, importlib.util, json, os, urllib.parse
from pathlib import Path
for _k in [k for k in os.environ if k.startswith("TIME_")] + ["ANTHROPIC_API_KEY", "VENICE_API_KEY", "CURSOR_API_KEY"]:
os.environ.pop(_k, None)
os.environ.update(GITHUB_REPOSITORY="o/r", GITHUB_TOKEN="gh", GITHUB_SERVER_URL="https://forge.test", GITHUB_API_URL="https://forge.test/api/v1")
spec = importlib.util.spec_from_file_location("steward", Path(__file__).resolve().parent / "steward.py")
st = importlib.util.module_from_spec(spec); spec.loader.exec_module(st)
tm = st.tm
NOW = dt.datetime.now(dt.timezone.utc)
iso = lambda h=0: (NOW - dt.timedelta(hours=h)).strftime("%Y-%m-%dT%H:%M:%SZ")
BOT = {"login": "gitea-actions"}
# the andon bodies tm.andon() writes: the title names both figures whichever cap tripped, so only the reason sentence
# (budget_check's) tells a daily breach from a monthly one or a pool's
CAP_TITLE = "Spend cap reached: $5.20 today / $9.00 out of pocket this month"
CAPS = "\n\n- Daily cap (`TIME_DAILY_USD`): $5.00\n- Monthly cap (`TIME_MONTHLY_USD`): $100.00"
DAILY = "⏱️ Time stopped calling models: today's spend is $5.20 of the $5.00 cap; carrying on tomorrow." + CAPS
MONTHLY = ("⏱️ Time stopped calling models: this month's out-of-pocket spend is $100.10 of the $100.00 cap (credit-paid "
"spend excluded); Time is back in dry-run until a human raises it." + CAPS)
POOLED = "⏱️ Time stopped calling models: pool anthropic-credits has spent $8.10 of its $8.00 allowance; carrying on when it refills." + CAPS
class Forge:
def __init__(self):
self.prs, self.comments, self.reviews, self.files, self.statuses = {}, {}, {}, {}, {}
self.issues = {57: {"number": 57, "title": "Astra log", "body": "", "state": "open", "labels": ["astra"], "created_at": iso(100)}}
self.merged, self.dispatched, self.runs, self.ci, self.vars, self.chats = [], [], [], [], {}, 0
self.trusted = {"rev"}
def pr(self, n, card, files=("app.py",), status="success", approve=True, mergeable=True, body=None):
self.prs[n] = {"number": n, "state": "open", "merged_at": None, "created_at": iso(3), "mergeable": mergeable, "body": body or f"Closes #{card}",
"head": {"ref": f"time/card-{card}", "sha": f"sha{n}"}}
self.files[n] = list(files)
self.statuses[f"sha{n}"] = [{"context": "ci / test", "status": status}] if status else []
self.issues.setdefault(card, {"number": card, "title": f"card {card}", "body": "", "state": "open", "labels": [], "created_at": iso(5)})
self.comments.setdefault(card, [])
if approve:
self.comments[n] = [{"body": "**Approve**\nlooks right", "user": {"login": "rev"}, "created_at": iso(1)}]
def add_issue(self, n, title, body, labels, created=0):
self.issues[n] = {"number": n, "title": title, "body": body, "state": "open", "labels": labels, "created_at": iso(created)}
def gh(self, method, path, body=None, *, as_time=None):
base = "/repos/o/r"
p, _, q = path.partition("?")
q = urllib.parse.parse_qs(q)
seg = p[len(base):].strip("/").split("/")
if method == "GET" and seg[0] == "pulls" and len(seg) == 1:
return [dict(x) for x in self.prs.values() if x["state"] == "open"]
if seg[0] == "pulls" and len(seg) == 2 and method == "GET":
return dict(self.prs[int(seg[1])])
if seg[0] == "pulls" and seg[-1] == "files":
return [{"filename": f} for f in self.files[int(seg[1])]]
if seg[0] == "pulls" and seg[-1] == "reviews":
return self.reviews.get(int(seg[1]), [])
if seg[0] == "pulls" and seg[-1] == "merge":
n = int(seg[1]); assert body["Do"] == "squash"
self.prs[n].update(merged_at=iso(), state="closed"); self.merged.append(n); return {}
if seg[0] == "commits":
return {"statuses": self.statuses[seg[1]]}
if seg[0] == "collaborators":
if seg[1] in self.trusted: return {}
raise RuntimeError(f"GET {path} -> 404: no")
if seg[0] == "issues" and len(seg) == 1 and method == "GET":
out = [i for i in self.issues.values() if i["state"] == q.get("state", ["open"])[0]
and all(l in i["labels"] for l in q.get("labels", [""])[0].split(",") if l)]
return [dict(i, labels=[{"name": l} for l in i["labels"]]) for i in out]
if seg[0] == "issues" and len(seg) == 1 and method == "POST":
n = max(self.issues) + 1
self.add_issue(n, body["title"], body["body"], [self.lname(l) for l in body["labels"]])
return {"number": n}
if seg[0] == "issues" and seg[-1] == "comments":
n = int(seg[1])
if method == "POST":
self.comments.setdefault(n, []).append({"body": body["body"], "user": BOT, "created_at": iso()}); return {}
return self.comments.get(n, [])
if seg[0] == "issues" and len(seg) == 2:
n = int(seg[1])
if method == "PATCH": self.issues[n].update(body); return {}
return dict(self.issues[n])
if seg[0] == "issues" and seg[2] == "labels":
n = int(seg[1])
if method == "POST": self.issues[n]["labels"] += [self.lname(l) for l in body["labels"]]
else: self.issues[n]["labels"].remove(self.lname(int(seg[3])))
return {}
if seg[0] == "labels":
if method == "POST": return {"id": 99}
return [{"name": n, "id": i} for i, n in enumerate(["ready", "queued", "needs-human"], 1)]
if seg[0] == "actions" and seg[1] == "runs":
return {"workflow_runs": self.runs}
if seg[0] == "actions" and seg[1] == "workflows":
if seg[2] == "ci.yml": self.ci.append(body["ref"]); return {}
self.dispatched.append(seg[2]); return {}
if seg[0] == "actions" and seg[1] == "variables":
if method == "PUT" and seg[2] not in self.vars: raise RuntimeError(f"PUT {p} -> 404: not found") # Gitea: PUT updates, POST creates
self.vars[seg[2]] = body["value"]; return {}
if p == base:
return {"default_branch": "main"}
raise AssertionError(f"unmocked {method} {path}")
def lname(self, x):
return x if isinstance(x, str) else {1: "ready", 2: "queued", 3: "needs-human"}.get(x, f"label{x}")
def log(self):
return [c["body"] for c in self.comments.get(57, [])]
def world(**env):
f = Forge()
tm.gh = f.gh
tm._trust.clear(); tm._label_ids = None
for k in [k for k in os.environ if k.startswith("TIME_")] + ["VENICE_API_KEY", "ANTHROPIC_API_KEY"]: os.environ.pop(k, None)
os.environ.update({"TIME_AUTOMERGE": "on", **env})
tm.budget = lambda: (1.0, 5.0, 10.0, 100.0)
tm.pools = lambda: []
def no_model(body, timeout): f.chats += 1; raise AssertionError("the deterministic part must not call a model")
tm.chat = no_model
return f
def run():
assert st.tick() == 0
def beat_of(comment):
return json.loads(comment.split(tm.MARK, 1)[1].rsplit("-->", 1)[0])
# 1. a green, approved, unprotected card PR is merged without a model call; the log says so
f = world()
f.pr(59, 12)
run()
assert f.merged == [59] and f.chats == 0, (f.merged, f.chats)
assert f.issues[12]["state"] == "closed", "the card closes with its PR"
assert any(beat_of(c["body"])["beat"] == "merged" for c in f.comments[12]), "the card gets its merged beat, as land() writes it"
entry = f.log()
assert len(entry) == 1, "one entry per run"
assert "Merged:\n- PR #59 (card #12)" in entry[0] and "Fixed: none" in entry[0] and "Left: none" in entry[0], entry[0]
assert beat_of(entry[0]) == {"beat": "steward", "merged": 1, "fixed": 0, "left": 0, "escalated": 0}
# ... but not on a stranger's Approve, without an approval, on red checks, with TIME_AUTOMERGE off, or on a PR that closes no card
f = world(); f.pr(60, 13); f.comments[60] = [{"body": "Approve", "user": {"login": "stranger"}, "created_at": iso(1)}]; run()
assert f.merged == [] and "no trusted verdict yet" in f.log()[0]
f = world(); f.pr(60, 13, approve=False); run()
assert f.merged == [] and "no trusted verdict yet" in f.log()[0]
f = world(); f.pr(60, 13, status="failure")
tm.chat = lambda body, timeout: {"choices": [{"message": {"role": "assistant", "content": "Checks fail on #13; handed back."}}], "usage": {}}
run()
assert f.merged == [] and "checks are failing" in f.log()[0] and "handed back" in f.log()[0]
f = world(TIME_AUTOMERGE="off"); f.pr(60, 13); run()
assert f.merged == [] and "TIME_AUTOMERGE is not on" in f.log()[0]
f = world(); f.pr(61, 14, body="no link"); f.prs[61]["head"]["ref"] = "feature/x"; run()
assert f.merged == [] and "PR #61" not in f.log()[0], "only card PRs are the steward's"
f = world(); f.pr(60, 13, status="pending"); run()
assert f.merged == [] and "checks are pending" in f.log()[0] and f.chats == 0, "waiting on CI is not a block"
# a PR brought in from the copy on its own branch is a card PR: sync.py puts `Closes #N` in the body
f = world(); f.pr(62, 15, body="Closes #15\n\nFrom the copy: `claude/card-15-x`, brought in by sync.py.")
f.prs[62]["head"]["ref"] = "claude/card-15-x"; run()
assert f.merged == [62] and st.card_of(f.prs[62]) == 15
# an approved PR that no longer merges cleanly is left for the heartbeat's base merge, not merged
f = world(); f.pr(63, 16, mergeable=False)
tm.chat = lambda body, timeout: {"choices": [{"message": {"role": "assistant", "content": "The branch is behind; the heartbeat merges it."}}], "usage": {}}
run()
assert f.merged == [] and "no longer merges cleanly" in f.log()[0]
# no checks yet: one CI dispatch, then it waits (no second dispatch, no merge)
f = world(); f.pr(60, 13, status=None); run()
assert f.merged == [] and f.ci == ["time/card-13"] and "dispatched CI" in f.log()[0]
# 2. a protected PR is left, with the reason, and no model is asked
f = world(); f.pr(64, 17, files=("app.py", ".github/workflows/ci.yml")); run()
assert f.merged == [] and f.chats == 0
assert "Left:\n- PR #64 (card #17): approved, but it touches protected paths (.github/workflows/ci.yml): a human merges this one" in f.log()[0], f.log()[0]
# 3. andons: a healed daily cap closes, an unhealed monthly one does not
f = world()
f.add_issue(70, CAP_TITLE, DAILY, ["andon"], created=30)
f.add_issue(71, CAP_TITLE, MONTHLY, ["andon"], created=30)
f.add_issue(72, CAP_TITLE, DAILY, ["andon"], created=0) # tripped today: not healed yet
run()
assert f.issues[70]["state"] == "closed" and f.issues[71]["state"] == "open" and f.issues[72]["state"] == "open", [f.issues[n]["state"] for n in (70, 71, 72)]
assert f.chats == 0
assert "closed andon #70" in f.log()[0] and "andon #71: the monthly cap is money" in f.log()[0] and "andon #72: a daily cap" in f.log()[0], f.log()[0]
# a daily andon from yesterday is not healed while today is over the cap again
f = world(); tm.budget = lambda: (5.5, 5.0, 10.0, 100.0)
f.add_issue(70, CAP_TITLE, DAILY, ["andon"], created=30); run()
assert f.issues[70]["state"] == "open" and "a daily cap" in f.log()[0]
# a pool breach closes once the pool is back under its allowance, and not before
POOL = {"name": "anthropic-credits", "kind": "window", "routes": ["anthropic"], "resets_at": "hours:5"}
f = world(); tm.pools = lambda: [POOL]; tm.pool_spent = lambda p, now=None: 2.0; tm.allowance = lambda p, now=None: 8.0
f.add_issue(70, CAP_TITLE, POOLED, ["andon"], created=1); run()
assert f.issues[70]["state"] == "closed" and "$6.00 of its allowance again" in f.log()[0], f.log()[0]
f = world(); tm.pools = lambda: [POOL]; tm.pool_spent = lambda p, now=None: 8.1; tm.allowance = lambda p, now=None: 8.0
f.add_issue(70, CAP_TITLE, POOLED, ["andon"], created=1); run()
assert f.issues[70]["state"] == "open" and "a capacity pool is spent for now" in f.log()[0], f.log()[0]
# a missing key that is set now
f = world(VENICE_API_KEY="k")
f.add_issue(73, "Venice key missing", "Time can't go further: no VENICE_API_KEY secret on this repo yet.", ["andon"]); run()
assert f.issues[73]["state"] == "closed" and "VENICE_API_KEY is set now" in f.log()[0]
f = world(); f.add_issue(73, "Venice key missing", "no VENICE_API_KEY secret on this repo yet", ["andon"])
tm.chat = lambda body, timeout: {"choices": [{"message": {"role": "assistant", "content": "A key is a human-only step."}}], "usage": {}}
run(); assert f.issues[73]["state"] == "open"
# 4. the heartbeat: a failed latest run gets one dispatch; a green one, or another workflow's failure, none
HB = lambda i, c: {"id": i, "path": ".github/workflows/heartbeat.yml@refs/heads/main", "name": "heartbeat", "status": c, "conclusion": c}
f = world(); f.runs = [HB(1, "success"), HB(2, "failure")]; run()
assert f.dispatched == ["heartbeat.yml"] and "dispatched it once" in f.log()[0] and f.chats == 0, f.dispatched
f = world(); f.runs = [HB(1, "failure"), HB(2, "success")]; run()
assert f.dispatched == [], "only the latest run counts"
f = world(); f.runs = [HB(1, "success"), {"id": 5, "path": ".github/workflows/ci.yml@refs/heads/main", "name": "ci", "status": "failure"}]; run()
assert f.dispatched == []
f = world(); f.runs = [HB(1, "cancelled")]; run()
assert f.dispatched == [], "a run the concurrency group cancelled is not a failure"
# 5. the log entry format
rep = st.Report(); rep.merged.append("PR #1 (card #2)"); rep.fixed.append("closed andon #3"); rep.leave("PR #4 (card #5)", "a reviewer objected")
text, meta = st.log_entry("claude:opus", (1.234, 5), rep, "Note.")
assert text.splitlines()[0] == "🧭 Steward (claude:opus), spend $1.23 of $5.00 today:"
assert text.splitlines()[2:] == ["Note.", "", "Merged:", "- PR #1 (card #2)", "Fixed:", "- closed andon #3", "Left:", "- PR #4 (card #5): a reviewer objected"], text.splitlines()
assert meta == {"beat": "steward", "merged": 1, "fixed": 1, "left": 1, "escalated": 0}
# 6. what is left and blocking: one model call, booked through tm.chat, with the charter; its tools are fenced
f = world(); f.pr(80, 20, approve=False); f.comments[80] = [{"body": "**Object**\nno test", "user": {"login": "rev"}, "created_at": iso(1)}]
f.add_issue(20, "card 20", "", ["ready"])
calls = []
def steward_model(body, timeout):
calls.append(body)
if len(calls) == 1:
tc = lambda i, name, args: {"id": f"c{i}", "type": "function", "function": {"name": name, "arguments": json.dumps(args)}}
return {"choices": [{"message": {"role": "assistant", "content": None, "tool_calls": [
tc(1, "comment", {"number": 20, "text": "The reviewer wants a test; please add one."}),
tc(2, "set_variable", {"name": "TIME_AUTOMERGE", "value": "on"}),
tc(3, "set_variable", {"name": "TIME_MODE", "value": "live"}),
tc(4, "escalate", {"kind": "boredom", "title": "x", "reason": "y"}),
tc(5, "label", {"number": 80, "name": "andon", "action": "add"})]}}], "usage": {}}
return {"choices": [{"message": {"role": "assistant", "content": "PR #80 was objected to; I handed card #20 back with the reviewer's point."}}], "usage": {}}
tm.chat = steward_model
run()
assert len(calls) == 2 and calls[0]["model"] == "claude-opus-5-5"
assert "ONLY for money" in calls[0]["messages"][0]["content"] and "a reviewer objected" in calls[0]["messages"][1]["content"]
tool_results = [m["content"] for m in calls[1]["messages"] if m["role"] == "tool"]
assert tool_results[0] == "done" and tool_results[1].startswith("refused") and tool_results[2] == "done" and tool_results[3].startswith("refused") and tool_results[4].startswith("refused"), tool_results
assert f.vars == {"TIME_MODE": "live"}, f.vars
assert "-> 404" not in "".join(tool_results), "the first set_variable on a fresh repo creates the variable"
st.Tools(st.Report()).t_set_variable({"name": "TIME_MODE", "value": "dry-run"}); assert f.vars == {"TIME_MODE": "dry-run"}, "an existing one is updated"
assert any('"steward":true' in c["body"] and "The reviewer wants a test" in c["body"] for c in f.comments[20])
assert not any('"beat"' in c["body"] for c in f.comments[20]), "a steward comment carries no beat: it cannot move the card"
assert "handed card #20 back" in f.log()[0] and "Left:\n- PR #80 (card #20): a reviewer objected" in f.log()[0]
# the steward has spoken on card #20 since its last beat: the next run does not ask again
tm.chat = lambda body, timeout: (_ for _ in ()).throw(AssertionError("asked twice"))
run(); assert "the steward has handed this back already" in f.log()[1]
# a parked `ready` card is a block the model is asked about
f = world(); f.add_issue(21, "card 21", "", ["ready"])
f.comments[21] = [{"body": f"x\n\n{tm.MARK}{json.dumps({'beat': 'error'})} -->", "user": BOT, "created_at": iso(2)}]
asked = []
tm.chat = lambda body, timeout: (asked.append(body), {"choices": [{"message": {"role": "assistant", "content": "Retried card #21."}}], "usage": {}})[1]
run()
assert "card #21" in asked[0]["messages"][1]["content"] and "parked on `error`" in f.log()[0]
# real money or a human-only step: one issue for the operator, once
f = world(); f.add_issue(75, "odd", "not a cap or a key", ["andon"])
def esc(body, timeout):
if not any(m["role"] == "tool" for m in body["messages"]):
return {"choices": [{"message": {"role": "assistant", "content": None, "tool_calls": [{"id": "e", "type": "function", "function": {"name": "escalate",
"arguments": json.dumps({"kind": "human-only", "title": "Log in to the registrar", "reason": "Domain renewal needs a login."})}}]}}], "usage": {}}
return {"choices": [{"message": {"role": "assistant", "content": "Needs a login."}}], "usage": {}}
tm.chat = esc; run()
assert any(i["title"] == "Steward: Log in to the registrar" and "needs-human" in i["labels"] for i in f.issues.values()) and "Escalated to the operator:" in f.log()[0]
assert st.Tools(st.Report()).t_escalate({"kind": "human-only", "title": "Log in to the registrar", "reason": "y"}) == "already open"
# over the cap: the call is refused by the ledger's governor and the log says so
f = world(); f.add_issue(75, "odd", "not a cap or a key", ["andon"])
def capped(body, timeout): raise tm.Budget("today's spend is $5.00 of the $5.00 cap")
tm.chat = capped; run()
assert "No model call: today's spend" in f.log()[0]
# no key for the seat: the same, and the deterministic part still gets its entry
f = world(); f.pr(90, 30); f.add_issue(75, "odd", "not a cap or a key", ["andon"])
def nokey(body, timeout): raise LookupError("no ANTHROPIC_API_KEY secret on this repo yet")
tm.chat = nokey; run()
assert f.merged == [90] and "No model call: no ANTHROPIC_API_KEY" in f.log()[0]
# TIME_STEWARD=off disables the whole tick; a seat spec is read from it
f = world(TIME_STEWARD="off"); f.pr(59, 12); run()
assert f.merged == [] and f.log() == []
assert tm.worker_seat_model("claude:opus")[:2] == ("opus", "claude-opus-5-5")
assert tm.worker_seat_model(st.DEFAULT_SEAT)[1] == "claude-opus-5-5"
print("ALL OK steward")
+5
View File
@@ -3070,3 +3070,8 @@ assert got[1][0] == "wrote" and got[1][1].startswith("refused") and got[2][0].st
_dj = importlib.util.spec_from_file_location("dojo_run", Path(__file__).resolve().parents[2] / "dojo" / "run.py")
dojo = importlib.util.module_from_spec(_dj); _dj.loader.exec_module(dojo)
assert dojo.selftest(tm) == 11
# the steward's own suite, last: it loads its own copy of time.py and clears TIME_* on the way in, so the one CI
# command covers it without any check above seeing its environment
_sw = importlib.util.spec_from_file_location("test_steward", Path(__file__).resolve().parent / "test_steward.py")
steward_suite = importlib.util.module_from_spec(_sw); _sw.loader.exec_module(steward_suite)
+1
View File
@@ -10,6 +10,7 @@
- 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).
### Added
- 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.
- 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
+71
View File
@@ -0,0 +1,71 @@
name: steward
# PROPOSED, NOT ACTIVE. A human installs this as .github/workflows/steward.yml after independent review
# (workflow files are off limits to the worker, and Time never auto-merges anything under .github/).
#
# The factory's repair tick, every 6 hours: `.github/time/steward.py` merges the card PRs that are approved, green and
# unprotected, closes the andons whose cause has healed, and re-dispatches a failed heartbeat — all of that free, with no
# model call. Only what is left and is a real block goes to one Opus call with the repair charter, booked to the ledger
# like every other call. It writes one entry per run to the Astra log (#57): Merged / Fixed / Left.
#
# TIME_STEWARD the seat (default `claude:opus`); `off` disables the tick
# escalation the seat may open a `needs-human` issue ONLY for money beyond the caps or a human-only step
# (a login, a payment, a legal signature); the code refuses anything else, whatever the prompt says.
#
# Once this runs, the desktop-app scheduled steward is retired.
on:
schedule:
- cron: "41 */6 * * *"
workflow_dispatch:
# (GitHub-style `permissions:` are ignored by Gitea; the Actions token has write access to this repo)
concurrency:
group: steward
cancel-in-progress: false
jobs:
tick:
if: vars.TIME_STEWARD != 'off'
runs-on: cron
timeout-minutes: 20
steps:
- uses: actions/checkout@v5
with:
sparse-checkout: .github/time
persist-credentials: false
# the heartbeat's own env, so the steward judges a block against exactly the configuration the clock runs under,
# plus TIME_STEWARD (the seat) and TIME_STEWARD_STEPS
- run: python3 .github/time/steward.py
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }}
VENICE_API_KEY: ${{ secrets.VENICE_API_KEY }}
LANGFUSE_PUBLIC_KEY: ${{ secrets.LANGFUSE_PUBLIC_KEY }}
LANGFUSE_SECRET_KEY: ${{ secrets.LANGFUSE_SECRET_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
TIME_TOKEN: ${{ secrets.TIME_TOKEN }} # repo secret: the `time` bot merges PRs (Gitea denies that to the Actions identity)
TIME_SEAT_CLAUDE: ${{ secrets.TIME_SEAT_CLAUDE }}
TIME_SEAT_HAIKU: ${{ secrets.TIME_SEAT_HAIKU }}
TIME_SEAT_SONNET: ${{ secrets.TIME_SEAT_SONNET }}
TIME_SEAT_OPUS: ${{ secrets.TIME_SEAT_OPUS }}
TIME_SEAT_FABLE: ${{ secrets.TIME_SEAT_FABLE }}
TIME_TRUSTED: ${{ vars.TIME_TRUSTED }} # whose Approve counts; collaborators are trusted without being listed
TIME_RUN_IMAGE: node:22-bookworm
TIME_STEWARD: ${{ vars.TIME_STEWARD }} # the seat, default `claude:opus`; `off` disables the tick
TIME_STEWARD_STEPS: ${{ vars.TIME_STEWARD_STEPS }} # tool steps in the one repair call (default 8)
TIME_MODE: ${{ vars.TIME_MODE }}
TIME_DAILY_CAP: ${{ vars.TIME_DAILY_CAP }}
TIME_DAILY_USD: ${{ vars.TIME_DAILY_USD }} # spend governor: dollars per day (default 5) …
TIME_MONTHLY_USD: ${{ vars.TIME_MONTHLY_USD }} # … and per month (default 100); ledger in time/ledger/
TIME_CAP_SLACK: ${{ vars.TIME_CAP_SLACK }}
TIME_ANTHROPIC_API: ${{ vars.TIME_ANTHROPIC_API }}
TIME_CURSOR_MODEL: ${{ vars.TIME_CURSOR_MODEL }}
TIME_REVIEW: ${{ vars.TIME_REVIEW }} # the council whose verdict the merge test reads
TIME_REVIEW_TIEBREAK: ${{ vars.TIME_REVIEW_TIEBREAK }}
TIME_REVIEW_QUORUM: ${{ vars.TIME_REVIEW_QUORUM }}
TIME_STUCK_HOURS: ${{ vars.TIME_STUCK_HOURS }}
TIME_AUTOMERGE: ${{ vars.TIME_AUTOMERGE }}
TIME_REQUIRE_CHECKS: ${{ vars.TIME_REQUIRE_CHECKS }}
TIME_PROTECTED: ${{ vars.TIME_PROTECTED }}
TIME_PARALLEL: ${{ vars.TIME_PARALLEL }}
TIME_PLANNER: ${{ vars.TIME_PLANNER }}
TIME_WORKER: ${{ vars.TIME_WORKER }}
TIME_WORKER_STEPS: ${{ vars.TIME_WORKER_STEPS }}
TIME_WORKER_MAX_TOKENS: ${{ vars.TIME_WORKER_MAX_TOKENS }}