Steward tick inside Time: repair the block, escalate only money or a human-only step #292

Merged
teahubxyz merged 1 commits from claude/card-80-steward-tick-xxz0jw into main 2026-10-29 19:44:51 +00:00
Collaborator

From github: claude/card-80-steward-tick-xxz0jw (git@github.com:teahubxyz/time.git), brought in by sync.py. Time's council reviews it like any PR.

Commits:

Steward tick inside Time: repair the block, escalate only money or a human-only step

The steward was a desktop-app scheduled session. It ran eleven times in three days, found PR #59 approved, green and
mergeable every time, was refused by its own permission dialog every time, and wrote "needs a human" every time. A
steward that reports a block instead of repairing it is a status page. This moves it inside Time, under the ledger's
rules and the time / claude identities.

.github/time/steward.py does the deterministic part first, for free, with no model call:

  • merges every card PR that land() would merge (a trusted Approve past the quorum, green checks, not held by
    touches_protected, TIME_AUTOMERGE=on), in land()'s own order, and writes the same merged beat for the card;
  • 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. The monthly cap is money and stays open — the operator raises it.
    Only budget_check's reason sentence tells those apart: the andon's title names both figures whichever cap tripped;
  • re-dispatches the heartbeat once if its latest run failed (a cancelled or running one is not a broken clock).

Then, only for what is left and is a real block — an objection, a failing check, a conflict, a parked card, an andon
that has not healed — one call to TIME_STEWARD (default claude:opus; off disables the tick), booked to the ledger
through tm.chat like every other call. The fences are code, not prompt: labels are ready/queued/needs-human
only; variables are TIME_MODE, TIME_PARALLEL, TIME_STUCK_HOURS, TIME_DAILY_CAP and nothing else (never the caps,
TIME_AUTOMERGE, TIME_PROTECTED or TIME_TRUSTED); escalate is refused unless it is money beyond the caps or a
human-only step — a login, a payment, a legal signature. A block the steward has already handed back waits for the card
to move instead of being asked about twice. Its comments carry {"steward": true} and no beat, so they cannot move a
card; the merged beat above is the one exception.

One entry per run goes to the Astra log (#57) in Astra's format: Merged / Fixed / Left, with the reason for anything
left, and the day's spend in the header. A cap or a missing key ends the model call politely and the deterministic part
still gets its entry.

Tests: .github/time/test_steward.py, offline against a fake forge, which test_time.py now runs last (it loads its
own copy of time.py and clears TIME_* on the way in, so no check above sees its environment). A green, approved,
unprotected card PR merges with no model call; a protected one is left with the paths; a stranger's Approve, no verdict,
red checks, a conflict, pending checks and TIME_AUTOMERGE=off each leave it with their reason; a PR with no checks
gets one CI dispatch and then waits; a healed daily-cap andon closes and an unhealed monthly one does not; a pool andon
closes only once the pool is back under its allowance; a failed latest heartbeat run gets one dispatch and a green one
or another workflow's failure none; the refused tools; the escalation, once; the log entry format.

Rebuilt on current main from the closed PR #134 (refs/attic/pr-134), keeping its tests. The monthly andon's wording
had changed to "out-of-pocket" since, the pool reason is new, and the merge order now follows land() exactly.

.github/time/test_delegate.py fails on main already, before this change (its fake World.http takes no headers
argument, which _post now passes); it is not in ci.yml and not this card's.

WORKFLOW CHANGE THE OPERATOR / CEO MUST MAKE (workflow files are off limits to the worker):

Install time/steward/steward.yml as .github/workflows/steward.yml, unchanged. It is the file in this commit:
name: steward, on: schedule: - cron: "41 */6 * * *" plus workflow_dispatch, concurrency: group: steward,
one job tick with if: vars.TIME_STEWARD != 'off', runs-on: cron, timeout-minutes: 20, a sparse checkout of
.github/time with persist-credentials: false, and run: python3 .github/time/steward.py with the heartbeat's
own env plus TIME_STEWARD: ${{ vars.TIME_STEWARD }} and TIME_STEWARD_STEPS: ${{ vars.TIME_STEWARD_STEPS }}.

Set the Actions variable TIME_STEWARD to claude:opus (or leave it unset for the same default; off disables the
tick). Once this runs, retire the desktop-app scheduled steward.

Closes #80

Co-Authored-By: Claude Opus 5 noreply@anthropic.com
Claude-Session: https://claude.ai/code/session_01FAqjv45p51V9ywh78ya5au


sync.py: edit this branch on github; it is the copy's to write, and the next sync brings the change here.

From github: `claude/card-80-steward-tick-xxz0jw` (git@github.com:teahubxyz/time.git), brought in by sync.py. Time's council reviews it like any PR. Commits: Steward tick inside Time: repair the block, escalate only money or a human-only step The steward was a desktop-app scheduled session. It ran eleven times in three days, found PR #59 approved, green and mergeable every time, was refused by its own permission dialog every time, and wrote "needs a human" every time. A steward that reports a block instead of repairing it is a status page. This moves it inside Time, under the ledger's rules and the `time` / `claude` identities. `.github/time/steward.py` does the deterministic part first, for free, with no model call: - merges every card PR that `land()` would merge (a trusted Approve past the quorum, green checks, not held by `touches_protected`, `TIME_AUTOMERGE=on`), in land()'s own order, and writes the same `merged` beat for the card; - 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. The monthly cap is money and stays open — the operator raises it. Only budget_check's reason sentence tells those apart: the andon's title names both figures whichever cap tripped; - re-dispatches the heartbeat once if its latest run failed (a cancelled or running one is not a broken clock). Then, only for what is left and is a real block — an objection, a failing check, a conflict, a parked card, an andon that has not healed — one call to `TIME_STEWARD` (default `claude:opus`; `off` disables the tick), booked to the ledger through `tm.chat` like every other call. The fences are code, not prompt: labels are `ready`/`queued`/`needs-human` only; variables are `TIME_MODE`, `TIME_PARALLEL`, `TIME_STUCK_HOURS`, `TIME_DAILY_CAP` and nothing else (never the caps, `TIME_AUTOMERGE`, `TIME_PROTECTED` or `TIME_TRUSTED`); `escalate` is refused unless it is money beyond the caps or a human-only step — a login, a payment, a legal signature. A block the steward has already handed back waits for the card to move instead of being asked about twice. Its comments carry `{"steward": true}` and no `beat`, so they cannot move a card; the `merged` beat above is the one exception. One entry per run goes to the Astra log (#57) in Astra's format: Merged / Fixed / Left, with the reason for anything left, and the day's spend in the header. A cap or a missing key ends the model call politely and the deterministic part still gets its entry. Tests: `.github/time/test_steward.py`, offline against a fake forge, which `test_time.py` now runs last (it loads its own copy of time.py and clears TIME_* on the way in, so no check above sees its environment). A green, approved, unprotected card PR merges with no model call; a protected one is left with the paths; a stranger's Approve, no verdict, red checks, a conflict, pending checks and `TIME_AUTOMERGE=off` each leave it with their reason; a PR with no checks gets one CI dispatch and then waits; a healed daily-cap andon closes and an unhealed monthly one does not; a pool andon closes only once the pool is back under its allowance; a failed latest heartbeat run gets one dispatch and a green one or another workflow's failure none; the refused tools; the escalation, once; the log entry format. Rebuilt on current `main` from the closed PR #134 (`refs/attic/pr-134`), keeping its tests. The monthly andon's wording had changed to "out-of-pocket" since, the pool reason is new, and the merge order now follows `land()` exactly. `.github/time/test_delegate.py` fails on `main` already, before this change (its fake `World.http` takes no `headers` argument, which `_post` now passes); it is not in `ci.yml` and not this card's. WORKFLOW CHANGE THE OPERATOR / CEO MUST MAKE (workflow files are off limits to the worker): Install `time/steward/steward.yml` as `.github/workflows/steward.yml`, unchanged. It is the file in this commit: `name: steward`, `on: schedule: - cron: "41 */6 * * *"` plus `workflow_dispatch`, `concurrency: group: steward`, one job `tick` with `if: vars.TIME_STEWARD != 'off'`, `runs-on: cron`, `timeout-minutes: 20`, a sparse checkout of `.github/time` with `persist-credentials: false`, and `run: python3 .github/time/steward.py` with the heartbeat's own env plus `TIME_STEWARD: ${{ vars.TIME_STEWARD }}` and `TIME_STEWARD_STEPS: ${{ vars.TIME_STEWARD_STEPS }}`. Set the Actions variable `TIME_STEWARD` to `claude:opus` (or leave it unset for the same default; `off` disables the tick). Once this runs, retire the desktop-app scheduled steward. Closes #80 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FAqjv45p51V9ywh78ya5au --- <sub>sync.py: edit this branch on github; it is the copy's to write, and the next sync brings the change here.</sub>
time added 1 commit 2026-10-29 19:25:55 +00:00
The steward was a desktop-app scheduled session. It ran eleven times in three days, found PR #59 approved, green and
mergeable every time, was refused by its own permission dialog every time, and wrote "needs a human" every time. A
steward that reports a block instead of repairing it is a status page. This moves it inside Time, under the ledger's
rules and the `time` / `claude` identities.

`.github/time/steward.py` does the deterministic part first, for free, with no model call:

- merges every card PR that `land()` would merge (a trusted Approve past the quorum, green checks, not held by
  `touches_protected`, `TIME_AUTOMERGE=on`), in land()'s own order, and writes the same `merged` beat for the card;
- 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. The monthly cap is money and stays open — the operator raises it.
  Only budget_check's reason sentence tells those apart: the andon's title names both figures whichever cap tripped;
- re-dispatches the heartbeat once if its latest run failed (a cancelled or running one is not a broken clock).

Then, only for what is left and is a real block — an objection, a failing check, a conflict, a parked card, an andon
that has not healed — one call to `TIME_STEWARD` (default `claude:opus`; `off` disables the tick), booked to the ledger
through `tm.chat` like every other call. The fences are code, not prompt: labels are `ready`/`queued`/`needs-human`
only; variables are `TIME_MODE`, `TIME_PARALLEL`, `TIME_STUCK_HOURS`, `TIME_DAILY_CAP` and nothing else (never the caps,
`TIME_AUTOMERGE`, `TIME_PROTECTED` or `TIME_TRUSTED`); `escalate` is refused unless it is money beyond the caps or a
human-only step — a login, a payment, a legal signature. A block the steward has already handed back waits for the card
to move instead of being asked about twice. Its comments carry `{"steward": true}` and no `beat`, so they cannot move a
card; the `merged` beat above is the one exception.

One entry per run goes to the Astra log (#57) in Astra's format: Merged / Fixed / Left, with the reason for anything
left, and the day's spend in the header. A cap or a missing key ends the model call politely and the deterministic part
still gets its entry.

Tests: `.github/time/test_steward.py`, offline against a fake forge, which `test_time.py` now runs last (it loads its
own copy of time.py and clears TIME_* on the way in, so no check above sees its environment). A green, approved,
unprotected card PR merges with no model call; a protected one is left with the paths; a stranger's Approve, no verdict,
red checks, a conflict, pending checks and `TIME_AUTOMERGE=off` each leave it with their reason; a PR with no checks
gets one CI dispatch and then waits; a healed daily-cap andon closes and an unhealed monthly one does not; a pool andon
closes only once the pool is back under its allowance; a failed latest heartbeat run gets one dispatch and a green one
or another workflow's failure none; the refused tools; the escalation, once; the log entry format.

Rebuilt on current `main` from the closed PR #134 (`refs/attic/pr-134`), keeping its tests. The monthly andon's wording
had changed to "out-of-pocket" since, the pool reason is new, and the merge order now follows `land()` exactly.

`.github/time/test_delegate.py` fails on `main` already, before this change (its fake `World.http` takes no `headers`
argument, which `_post` now passes); it is not in `ci.yml` and not this card's.

WORKFLOW CHANGE THE OPERATOR / CEO MUST MAKE (workflow files are off limits to the worker):

  Install `time/steward/steward.yml` as `.github/workflows/steward.yml`, unchanged. It is the file in this commit:
  `name: steward`, `on: schedule: - cron: "41 */6 * * *"` plus `workflow_dispatch`, `concurrency: group: steward`,
  one job `tick` with `if: vars.TIME_STEWARD != 'off'`, `runs-on: cron`, `timeout-minutes: 20`, a sparse checkout of
  `.github/time` with `persist-credentials: false`, and `run: python3 .github/time/steward.py` with the heartbeat's
  own env plus `TIME_STEWARD: ${{ vars.TIME_STEWARD }}` and `TIME_STEWARD_STEPS: ${{ vars.TIME_STEWARD_STEPS }}`.

  Set the Actions variable `TIME_STEWARD` to `claude:opus` (or leave it unset for the same default; `off` disables the
  tick). Once this runs, retire the desktop-app scheduled steward.

Closes #80

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FAqjv45p51V9ywh78ya5au
Owner

Merging: merged onto current main, test_time.py, test_seed.py and test_housekeeping.py pass.

— steward (Claude, reviewing directly)

Merging: merged onto current `main`, `test_time.py`, `test_seed.py` and `test_housekeeping.py` pass. — steward (Claude, reviewing directly)
teahubxyz merged commit 20ba8dd0b6 into main 2026-10-29 19:44:51 +00:00
teahubxyz deleted branch claude/card-80-steward-tick-xxz0jw 2026-10-29 19:45:14 +00:00
Sign in to join this conversation.