From ccfcb44a4506cc8eeeb62373fd8c8d32b449e19c Mon Sep 17 00:00:00 2001 From: Time Date: Thu, 22 Oct 2026 19:08:58 +0000 Subject: [PATCH 1/2] card #243: kept README.md, CHANGELOG.md, .claude/skills/time/SKILL.md, .github/time/time.py, .github/time/test_time.py (generated) --- .claude/skills/time/SKILL.md | 2 +- CHANGELOG.md | 3 +++ README.md | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.claude/skills/time/SKILL.md b/.claude/skills/time/SKILL.md index a612519..202ee01 100644 --- a/.claude/skills/time/SKILL.md +++ b/.claude/skills/time/SKILL.md @@ -89,7 +89,7 @@ Not a Time seat: `@cursor`. If Cursor's GitHub app is installed, `@cursor` in a 4. Human gate: PRs touching `TIME_PROTECTED` paths (default `.github/,deploy,infra/,.env,secret`) are never merged by Time; they get `needs-human`. So are PRs the reviewer objects to. 5. Specs: write an issue, label it `spec`. One card per line, `- title — DONE: what finished means`, or `### title` - sections that contain a `DONE:` line. Time creates `queued` cards that run one at a time (`TIME_PARALLEL`, default 1). + sections that contain a `DONE:` line. Time creates `queued` cards that run one at a time (`TIME_PARALLEL`, default 3). A spec that isn't card-shaped is split by a seat when `VENICE_API_KEY` is set (`TIME_PLANNER`, default kimi). ## Updating Time diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ecb244..9270f25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## [Unreleased] +### Changed +- `TIME_PARALLEL` defaults to 3 (was 1) and a bad value (not a positive integer) falls back to 3 (#243). + ### Security - A label filter is checked on every row. Gitea ignores `labels=` for a label the repo does not have and returns every issue: housekeeping's first live run listed all 59 open issues as parked cards (there is no `parked` label), and the same answer to `labels=spec` would have had Time split every open issue as a spec. `issues()` in time.py and the sweep's queries now keep only rows that carry the labels they asked for. - A commit status from one of Time's own workflows (`heartbeat /`, `mention /`, `venture /`, `astra /`, `doctor /`, `update /`, `steward /`) is not a check: it neither blocks nor earns a merge. A seat's review is a `pull_request_review` event that wakes `heartbeat.yml` during a heartbeat; the concurrency group cancels the run and Gitea records it as a failure on the PR head, which had stopped approved, CI-green PRs from landing. diff --git a/README.md b/README.md index 714f678..9077be4 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ A Gitea-native agent factory, running on Teahub prod (teahub.xyz). The Gitea repo is the ledger, Gitea Actions are the clock (Time), the worker is a Claude or Venice seat with hands or a Cursor cloud agent, and chat is where you plan. - **Cards**: an issue with a `DONE:` line, labelled `ready`. -- **Heartbeat** (`heartbeat.yml`, hourly and whenever a card is labelled `ready`): moves cards forward. In `dry-run` (default) it only says what it would do; with `TIME_MODE=live` it dispatches each card to the worker (`TIME_WORKER`: a seat such as `claude:sonnet` or `venice:kimi`, which edits the repo and opens the PR itself, or a Cursor cloud agent), which opens a PR that closes the card. An optional reviewer (`TIME_REVIEW`) checks the PR. At most `TIME_DAILY_CAP` launches a day. **Spend governor:** every model call is booked to `time/ledger/.jsonl` (tokens and dollars); once today's spend reaches `TIME_DAILY_USD` (default 5) or the month's reaches `TIME_MONTHLY_USD` (default 100), Time stops calling models, opens an `andon` issue, and a monthly breach also sets `TIME_MODE=dry-run`. No model decides whether to spend. +- **Heartbeat** (`heartbeat.yml`, hourly and whenever a card is labelled `ready`): moves cards forward. In `dry-run` (default) it only says what it would do; with `TIME_MODE=live` it dispatches each card to the worker (`TIME_WORKER`: a seat such as `claude:sonnet` or `venice:kimi`, which edits the repo and opens the PR itself, or a Cursor cloud agent), which opens a PR that closes the card. An optional reviewer (`TIME_REVIEW`) checks the PR. At most `TIME_DAILY_CAP` launches a day, and `TIME_PARALLEL` cards in flight at once (default 3; a non-positive or non-integer value falls back to 3). **Spend governor:** every model call is booked to `time/ledger/.jsonl` (tokens and dollars); once today's spend reaches `TIME_DAILY_USD` (default 5) or the month's reaches `TIME_MONTHLY_USD` (default 100), Time stops calling models, opens an `andon` issue, and a monthly breach also sets `TIME_MODE=dry-run`. No model decides whether to spend. - **Mentions** (`mention.yml`): `@sonnet`, `@opus`, `@kimi`, `@grok 4.7`, `@qwen max high` in any issue, PR or comment gets that seat's reply. - **Hands-off** (`TIME_AUTOMERGE=on`): label an issue `spec` and Time splits it into cards that run one at a time. A card's PR merges itself once the reviewer approves and checks are green, then the next card starts. Objections, red checks and protected paths (workflows, deploy, secrets) go to a human. - **Venture loop** (`venture.yml`, every 20 min once `TIME_VENTURE=on`): one venture card a day, or one right away from Actions → venture → Run workflow → "Start a new venture now". It scouts the repos that gained the most stars this week; seats pitch; the board votes (Borda, quorum of two); every board seat writes a launch plan (positioning, first users, pricing, channels, 7-day success and kill lines) and one seat synthesizes them into `LAUNCH.md`; the worker builds the product as the page on `gh-pages` (the tool fills the first screen under a slim header with the one signup button; pricing, limits and the why sit below); deterministic checks and headless Chrome (phone and desktop) gate it, with two fix rounds before an andon; GitHub Pages ships it at `/ventures//`. Signups go through an issue form (`.github/ISSUE_TEMPLATE/early-access.yml`), each one thanked on its issue; after 7 days the count of distinct people decides: build v2, kill, or watch a week more. The studio index lives at `/ventures/`. Launch posts are drafts for a human; nothing is posted outside the repo. -- 2.54.0 From eeb931a7eb548453a168a9bb56a251b7083914dc Mon Sep 17 00:00:00 2001 From: Time Date: Thu, 22 Oct 2026 19:09:11 +0000 Subject: [PATCH 2/2] card #243: kept .github/time/time.py, .github/time/test_time.py (generated) --- .github/time/test_time.py | 7 +++++++ .github/time/time.py | 11 ++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/.github/time/test_time.py b/.github/time/test_time.py index 16309c8..54863e5 100644 --- a/.github/time/test_time.py +++ b/.github/time/test_time.py @@ -1393,6 +1393,13 @@ assert not tm.moving(iss80(22, "parked again")) if par135 is None: os.environ.pop("TIME_PARALLEL") else: os.environ["TIME_PARALLEL"] = par135 tm.issues, tm.gh, tm.label, tm.unlabel = real80 +_pp = os.environ.pop("TIME_PARALLEL", None) +assert tm.parallel() == 3, "default" +for v, want in [("5", 5), ("1", 1), ("0", 3), ("-2", 3), ("abc", 3), ("2.5", 3), ("", 3)]: + os.environ["TIME_PARALLEL"] = v + assert tm.parallel() == want, (v, tm.parallel()) +os.environ.pop("TIME_PARALLEL") +if _pp is not None: os.environ["TIME_PARALLEL"] = _pp print("ALL OK 80") # 172 the router's outcomes: a live pick settles into one line (housekeeping writes it; test_housekeeping.py checks the diff --git a/.github/time/time.py b/.github/time/time.py index c3b2377..ee2ef0c 100644 --- a/.github/time/time.py +++ b/.github/time/time.py @@ -780,6 +780,14 @@ def moving(card: dict) -> bool: return not real or real[-1]["beat"] not in WAITING +def parallel() -> int: + """TIME_PARALLEL: cards in flight at once (default 3); anything but a positive integer falls back to 3.""" + try: + return max(0, int(os.environ.get("TIME_PARALLEL") or 3)) or 3 + except ValueError: + return 3 + + def promote_next() -> int | None: """Label the best `queued` card whose dependencies are closed `ready`: the router's live ranking first (Astra's latest), then the oldest; at most TIME_PARALLEL cards in flight. A `ready` card waiting on a person (an objection, @@ -788,7 +796,7 @@ def promote_next() -> int | None: ready = issues("state=open&labels=ready") busy = [c for c in ready if moving(c)] print(f"promote: ready={len(ready)} moving={len(busy)}") - if len(busy) >= int(os.environ.get("TIME_PARALLEL") or 1): + if len(busy) >= parallel(): return None queued = issues("state=open&labels=queued") pos = {n: k for k, n in enumerate(rank_order())} @@ -1811,6 +1819,7 @@ def cmd_doctor() -> int: row("TIME_WORKER", True, os.environ.get("TIME_WORKER") or "cursor (default); or claude: / venice: for a seat with hands") row("TIME_REVIEW", True, os.environ.get("TIME_REVIEW") or "off (default); a comma-separated list of seats, each votes") row("TIME_REVIEW_TIEBREAK", True, os.environ.get("TIME_REVIEW_TIEBREAK") or "none (default); a seat called only when the standing seats split") + row("TIME_PARALLEL", True, os.environ.get("TIME_PARALLEL") or "3 (default); cards in flight at once") row("TIME_REVIEW_QUORUM", True, os.environ.get("TIME_REVIEW_QUORUM") or "1 (default); approvals needed, net of objections") row("TIME_REVIEW_MAX_TOKENS", True, os.environ.get("TIME_REVIEW_MAX_TOKENS") or "8000 (default); a review cut off at 4000 is asked again with this") today, day_cap, month, month_cap = budget() -- 2.54.0