diff --git a/skills/voyager/HANDOVER.md b/skills/voyager/HANDOVER.md index fe0b1b6..b86241d 100644 --- a/skills/voyager/HANDOVER.md +++ b/skills/voyager/HANDOVER.md @@ -24,16 +24,17 @@ again. When in doubt, skip; the result will still be true tomorrow. | thing | value | |---|---| | credentials | `~/.zuliprc` (bot email `Voyager-bot@leanprover.zulipchat.com` — capital V; never echo the key) | -| watermark | newest self-DM: `commit=02d13f9307fc… pr=1692` (DM id 614590157) | -| messages posted | 613614529 (first-run backlog, 2026-07-30, edited in place per channel feedback; now 9,473 codepoints — **do not let it grow**), 613886812, 614078696, 614179130, 614350642, 614590153 | +| watermark | newest self-DM: `commit=54d9297295d9… pr=1770` (DM id 614796848) — always re-read the DM; this row is a snapshot | +| messages posted | 613614529 (first-run backlog, 2026-07-30, edited in place per channel feedback; now 9,473 codepoints — **do not let it grow**), 613886812, 614078696, 614179130, 614350642, 614590153 (edited 2026-08-05 to note the `ModularForm.L` overlap Thomas Browning raised), 614796841 (seventh, 13 bullets — too many; see the curation rule below) | | cadence | daily, 16:03 UK; the window always ends at TauCeti's `docgen` branch | -| scheduling | a self-renewing session cron job in Chris's Claude session (currently `a22e2578`). Session-only: it dies if that session closes, and it fires late if the machine is asleep or the session busy at 16:03 — this has happened three times and is harmless (the watermark defines the window, not the clock). The sturdier long-term home is a GitHub Actions workflow in the TauCeti repo; Chris knows | +| scheduling | a macOS launchd agent, `com.tauceti.voyager`, fires a fresh **headless** run (`claude -p`, claude3 account) daily at 16:03 — see `launchd/README.md` beside this file. It replaced the self-renewing session cron on 2026-08-12 after two production lessons: a session cron dies with its session (the 2026-08-11 credits incident forced a takeover), and macOS App Nap delayed every idle-session firing by 20–40 minutes. launchd fires on the wall clock and logs to `~/Library/Logs/voyager.log`; a machine asleep at 16:03 runs once on wake. Any leftover session cron is a harmless backup — the freshness abort resolves double-fires | | first-run.md | dead weight — the backlog was posted once (2026-07-30) and must never be reposted; ignore that file entirely | Owner decisions already made, not yours to revisit: no `sorry` counts in the stats block (the no-`sorry` rule stays as an announcement gate); daily cadence at 16:03 UK; quiet windows with merged PRs still get a short check-in; only a genuinely empty window posts nothing; the -loop keeps running until Chris says otherwise. +bullet list is curated rather than exhaustive, and short is fine (2026-08-05 — see the +curation rule under House style); the loop keeps running until Chris says otherwise. ## The shape of a run @@ -73,6 +74,34 @@ back-reference to the earlier PR. The messages have earned trust by sounding like a mathematician telling colleagues what is now proved, not like release notes. Every rule below came from feedback or a near-miss. +**Curate, don't enumerate — and there is no target length.** Chris, 2026-08-05, after the +seventh update ran to thirteen bullets: *"don't feel obliged to make the list long, it's more +important to keep it interesting and mention significant results."* Every bullet in that +message had passed all three gates, which is exactly the trap — the gates decide what is +*allowed*, and you still have to decide what is *worth reading*. + +Chris sharpened this on 2026-08-09, after a message came in at five bullets and dropped an +interesting one to stay there: *"there is no rule to cut it to 5. the rule is that there doesn't +need to be a minimum or maximum, but you just need to make them be interesting."* Both failure +directions are real and the second is the sneakier: padding a thin window is obvious, whereas +cutting a good bullet to hit a self-imposed count looks like discipline. It isn't. Judge every +candidate on its own interest and keep exactly those that pass, whether that is one or ten. +Nothing is lost by leaving a dull one out — the dedupe keys on *cited* PR numbers, so it stays +announceable the day it matters. Message 614796841 (thirteen bullets, padded) and message +615474670 (five bullets, one good one cut for the count) are the two calibration points, one in +each direction. + +**Read every PR's contents — titles are not a filter.** Chris, 2026-08-10, after asking why +no elliptic-curve result had ever appeared: *"you can't rely on PR title, don't be lazy, this +is meant to be a slow methodical and careful check. you need to look at the contents of each +PR and from that make your list."* The record that prompted it: 105 bullets announced, zero +elliptic-curve results, while fifteen `feat(EllipticCurve)` PRs landed in two windows — +Silverman III.10 on Aut(E) for j ∉ {0, 1728} (TauCeti#2248), the quadratic twist and its +invariants (TauCeti#2254), and quadratic Galois descent (TauCeti#2268) among them, none +opened because the directory's titles read as bookkeeping. SKILL.md §2 now requires reading +every body in the window, and adds that the window bounds discovery, not eligibility: a +result found late is announced late, not never. + **The bullet is the unit.** Its anatomy, fixed: > - **[Standard name](verified docs anchor)** — one sentence, for a mathematician who does @@ -152,7 +181,7 @@ a bug. No sorry counts. **The quiet check-in**, when PRs merged but nothing survived the gates: -> **Voyager · Tau Ceti check-in** +> **Voyager · Tau Ceti check-in** *(AI-generated summary)* > > No notable named results landed in this window (as judged by the voyager AI bot). > @@ -227,6 +256,16 @@ message, not a silent rewrite of history. working (stats, anchors, sorry gate) while it runs. - The sorry gate greps *mentions*: read the hits; a docstring saying "the `sorry`-goal in Suggested.lean" is not a proof hole. +- Python's TLS can break independently of `curl`. On this machine the python.org 3.12 build + lost its CA file (`ssl.get_default_verify_paths().cafile` is `None`), so `zulip.py check` + died with `CERTIFICATE_VERIFY_FAILED` while `curl` kept working. The run path is curl-only + and was unaffected — but do not read a failing probe as a dead bot. Fix with + `export SSL_CERT_FILE=$(python3 -c 'import certifi;print(certifi.where())')`, or run the + installer's "Install Certificates.command". +- The state DM's SHA must be copied, never retyped or padded — see the watermark protocol in + SKILL.md, rule 5. +- A freshness abort is a *success*. If a human asked for the post early and the cron then + fires, the gate stops the double-post; report the abort and move on. - Never post from a fallback channel or invent output when credentials fail — report loudly and exit. diff --git a/skills/voyager/SKILL.md b/skills/voyager/SKILL.md index 37d3be8..1eb9298 100644 --- a/skills/voyager/SKILL.md +++ b/skills/voyager/SKILL.md @@ -41,6 +41,24 @@ Do **not** post: Mathlib is worse than useless; - a result whose proof still contains `sorry` in its dependency cone. +**Curate; do not enumerate** (owner decision, 2026-08-05, sharpened 2026-08-09). Passing the +bar above earns a result *eligibility*, not a bullet. The message is a digest, not a changelog: +keep the ones a mathematician outside the project would actually want to hear about — a named +theorem with content, a construction the subject is phrased in, something that closes a +milestone or unblocks upstream. + +**There is no target length: no minimum and no maximum.** Interest is the only test. Do not pad +a thin window to look productive, and — the failure that prompted this sharpening — do not drop +an interesting result to hit a number. A one-bullet day and a ten-bullet day are both correct if +that is what the window actually held. Any rule of thumb about "a handful" is a description of +past windows, never a quota to hit or a cap to respect; if you find yourself cutting a bullet +you would have been glad to read, the count is driving and it should not be. + +Incremental strengthenings, small computations, and infrastructure that happens to carry a name +are eligible but seldom interesting — drop those on their merits, not to make room. Nothing is +lost by omitting a genuinely dull result: the permanent dedupe keys on *cited* PR numbers, so +anything left out today stays announceable the day it matters. + ## Where Voyager posts Voyager is a **Zulip bot named `voyager`** on the **Lean Zulip** @@ -95,12 +113,14 @@ confirms channel subscription without posting. Run it first after setup. ## Prerequisites -Tools: `gh` CLI (authenticated), `git`, python3 stdlib only, the **chatgpt-math MCP** for -the significance gate, and a local Mathlib checkout for the novelty gate (the roadmap -repo's `.lake/packages/mathlib` is fine). +Tools: `gh` CLI (authenticated), `git`, python3 stdlib only, and a local Mathlib checkout +for the novelty gate (the roadmap repo's `.lake/packages/mathlib` is fine). The +**chatgpt-math MCP** drives the significance gate when it is reachable, but is **not** +required — see §4. -If credentials are missing, **stop and report** — do not post to a fallback channel and do -not invent a message. +If Zulip credentials or `gh` are missing, **stop and report** — do not post to a fallback +channel and do not invent a message. This rule is about *those* credentials: a missing or +quota-blocked chatgpt-math MCP is not a reason to stop, and never a reason to skip a post. ## Running it @@ -168,6 +188,12 @@ Each run: `pr=` to the merged-PR count at that time, write the DM, then proceed. 4. **After every successful channel post, send the updated state DM** — post first, DM second; if the DM write fails, retry it before ending the run. +5. **Never reconstruct the commit hash.** Put the *full* SHA in the DM exactly as + `git rev-parse origin/docgen` prints it. Do not pad an abbreviated hash you had on screen, + and do not retype it: a plausible-looking 40-character string that names no object makes + the next run's window unresolvable, and nothing downstream will notice. This happened + (2026-08-08) — the fix is a second DM superseding the first, since the protocol reads the + newest. Cheap check before sending: `git cat-file -t ` prints `commit`. ## Never double-post @@ -214,15 +240,34 @@ same tree the window ends at. ### 2. Extract candidates -**Work PR-by-PR — the merged PRs are the unit of discovery.** TauCeti squash-merges, so +**Work PR-by-PR — the merged PRs are the unit of discovery, and read every one of them** +(owner instruction, 2026-08-10: *"you can't rely on PR title, don't be lazy — this is meant +to be a slow, methodical and careful check. You need to look at the contents of each PR and +from that make your list."*). TauCeti squash-merges, so `git log --format='%s' ..HEAD` lists exactly the window's PRs, one per commit, -with the number in the subject (`feat: prove the double centralizer theorem (#1435)`). -Triage by title: `feat:` is where announcements live; `chore:`/`refactor:`/`fix:` almost -never are. Then, for each candidate, -`gh pr view --repo TauCetiProject/TauCeti --json title,body` — the body typically says -what was proved, names the result, and cites the roadmap and references, which is exactly -the raw material for the significance gate and for writing the one-sentence description. -The PR is also the attribution unit, so this hands you the link for free. +with the number in the subject. For **every** PR in the window — `feat:`, `chore:`, +`refactor:`, `fix:`, all of them — fetch the body and decide from **what it says was proved +or defined**, never from the title alone. Batch the fetches +(`gh pr list --repo TauCetiProject/TauCeti --state merged --search "merged:>=" --json +number,title,body`) rather than skipping any. The body names the result and cites the +roadmap and references — the raw material for the significance gate and the one-sentence +description — and the PR is the attribution unit, so this also hands you the link for free. + +**Titles are labels, not filters.** Title-tone triage is the lazy shortcut, and it produced +the worst miss so far: fifteen `feat(EllipticCurve)` PRs across two windows all read as +bookkeeping by title and none was opened, while 105 bullets went out with zero +elliptic-curve results among them. Reading the bodies later surfaced the automorphism group +of an elliptic curve with j ∉ {0, 1728} (TauCeti#2248 — Silverman III.10, uniform in the +characteristic), the quadratic twist of a Weierstrass curve with its invariant theory +(TauCeti#2254), and quadratic Galois descent for changes of variables and affine points +(TauCeti#2268). A `refactor:` body can likewise reveal a completed proof or a newly named +object. A body costs seconds to read; a miss is invisible until someone asks why a whole +subject never appeared. + +**The window bounds discovery, not eligibility.** The permanent dedupe keys on *cited* PR +numbers, so a result missed in an earlier window stays announceable the day it is found. +When a triage lapse comes to light, fold the missed items into the next run's candidates +and gate them normally. **Cross-check with the module-title sweep — a bland PR title can hide a named result.** Every Tau Ceti file opens with a `/-! # Title` naming what it contains, and named results are named @@ -320,8 +365,26 @@ docstring's *Main results* before believing any title. ### 4. Significance gate — the ChatGPT second opinion -Batch **all** surviving candidates into **one** `mcp__chatgpt-math__ask_chatgpt_math` -call. Operational facts learned the hard way: +**The gate is best-effort, not a precondition for posting.** Probe it with one cheap call +**before** reading the window: if it is going to be unavailable, that should cost seconds, +not a full read of every PR body. When it answers, its ranking drives bullet order and it +serves as the second opinion on how noteworthy each result is. When it does not — codex +quota exhausted, the account's plan refusing reasoning models, the MCP absent, a timeout — +**run without it**: select and order on your own judgement, post as normal, and record one +line in the run's final output naming why it was skipped. + +Do not hold the post, do not substitute a different model, and **do not mention the gate or +its absence in the Zulip message** — that belongs in the log, not in front of readers. +Holding is the worse failure: it takes a window that plainly contains notable results and +publishes nothing, which is indistinguishable to readers from a quiet week. + +This was learned on 2026-08-15, when both codex accounts were out (`~/.codex` on quota, +`~/.codex2` on quota *and* refusing `gpt-5.6-sol`/`gpt-5.4`/`gpt-5.3-codex` outright with +HTTP 400) and two separate runs read all 138 PRs in the window, assembled a slate, and then +declined to post because the gate was documented as mandatory. + +When the MCP *is* reachable: batch **all** surviving candidates into **one** +`mcp__chatgpt-math__ask_chatgpt_math` call. Operational facts learned the hard way: - use `reasoning_effort: "high"`. **`max` reliably times out** on long prompts (the MCP aborts after ~30 min of silence) — `high` has been reliable; @@ -344,7 +407,9 @@ worth announcing. For EACH numbered item below, answer with one of: For each ANNOUNCE, add: (a) the standard name of the result, (b) one sentence, for a mathematician who does not know it, saying what it asserts, (c) whether it has a -Wikipedia page, and (d) a standard reference if you know one. +Wikipedia page, and (d) a standard reference if you know one. Finally, rank the +strongest candidates in order of how noteworthy they are to a general mathematical +audience — this ranking, not the order of discovery, decides which result leads. Be conservative: if a result is only interesting inside its own proof, say SKIP. Do not be polite about it — a false ANNOUNCE is more costly than a false SKIP. @@ -352,11 +417,29 @@ be polite about it — a false ANNOUNCE is more costly than a false SKIP. ``` +**The gate is the second opinion on interest, not only on eligibility** (owner +instruction, 2026-08-10: get ChatGPT's view on *how interesting or noteworthy* each +result is, every pass). ANNOUNCE/SKIP feeds the cut; the per-item reasons and the +ranking feed the curation — which bullets run, in what order, and which eligible +results wait for a better day. Curation stays the operator's call (§What counts), but +it is made with the gate's reading in hand, never instead of it. + Take its verdicts as **advice, not authority**. It has been wrong before on this project — it misnumbered a Wedhorn theorem and mis-attributed a Mathlib file's authors. If a verdict looks wrong, check the primary source and use your judgement. Never post a description you have not sanity-checked against the actual Lean statement. +⚠ **The gate can only judge the summary you give it, so a summary that overstates a strand +gets you an ANNOUNCE for something unproved.** Describe each candidate in the words of the +file's own `## Main results`, not in the words of the roadmap milestone it serves. Worked +instance (2026-08-08): a batch of adic-spaces PRs was described to the gate as "the machinery +establishing that the valuation spectrum is a spectral space"; the gate duly said to announce +"Spv is spectral", and only extracting the anchor revealed that no `SpectralSpace` conclusion +exists anywhere in the tree — the patch criterion and the pro-constructible calculus had +landed, the endpoint had not. This is the Bochner trap arriving through the gate rather than +through the directory listing, so the same rule applies: find the theorem statement, or do +not announce it. + ### 5. Attribute each result to a PR Readers must be able to click through. TauCeti squash-merges, so the PR number is already in @@ -385,7 +468,7 @@ report the same PR, that is the bug. Check with `test -f .git/shallow` and Format (Zulip markdown): ```markdown -**Voyager · what's new in Tau Ceti** +**Voyager · what's new in Tau Ceti** *(AI-generated summary)* *Named results* - **[]()** — . (TauCeti#123) @@ -402,8 +485,11 @@ Format (Zulip markdown): …then send the updated state DM (see the watermark protocol). -Zulip-specific rules, each learned from reader feedback on the first message: +Zulip-specific rules, most learned from reader feedback on the first message: +- **The header carries the disclosure suffix `*(AI-generated summary)*`** (owner request, + 2026-08-19): every channel message — full update and quiet check-in alike — states in its + top line that an AI wrote it. The suffix survives any trimming done for the codepoint cap. - **One physical line per paragraph and per bullet.** Zulip keeps single newlines as line breaks, so hard-wrapped prose renders with ragged mid-sentence breaks. Never wrap. - **Use the realm linkifiers**: bare `TauCeti#NNN` for TauCeti PRs, `mathlib4#NNN` for @@ -453,11 +539,15 @@ the result; no marketing adjectives; no "exciting"/"major milestone". State what theorem says, not how impressive it is. If a result is a strengthening of Mathlib, say so in the sentence. +Length is neither a virtue nor a vice. The bullet list is **curated, not exhaustive** +(§What counts), and carries no target count in either direction — put the most interesting +result first, and let the message end when the interesting ones do, however many that is. + **Quiet runs still report.** If the window contains newly merged PRs but nothing survives the gates, post this short check-in instead of the full format: ```markdown -**Voyager · Tau Ceti check-in** +**Voyager · Tau Ceti check-in** *(AI-generated summary)* No notable named results landed in this window (as judged by the voyager AI bot). diff --git a/skills/voyager/launchd/README.md b/skills/voyager/launchd/README.md new file mode 100644 index 0000000..b0eee9d --- /dev/null +++ b/skills/voyager/launchd/README.md @@ -0,0 +1,87 @@ +# launchd scheduling for the daily Voyager run + +Since 2026-08-12 the daily 16:03 run is fired by macOS `launchd`, not by a session cron. +The session-cron era's two failure modes — the scheduler dying with its session (2026-08-11 +credits incident) and 20–40 minute App-Nap drift on an idle terminal — do not apply to +launchd: it fires on the wall clock while the machine is awake, and coalesces a missed +firing into one run on wake. + +Installed on the operator machine as: + +| file | installed at | +|---|---| +| `com.tauceti.voyager.plist` | `~/Library/LaunchAgents/com.tauceti.voyager.plist` | +| `run.sh` | `~/.claude3/voyager/run.sh` | +| `prompt.txt` | `~/.claude3/voyager/prompt.txt` | +| `smoke.txt` | `~/.claude3/voyager/smoke.txt` (read-only environment test: `run.sh smoke.txt`) | +| `freshness.py` | `~/.claude3/voyager/freshness.py` (read-only "has today's post landed?" check) | + +Each firing runs a **fresh headless Claude Code session** (`claude -p` on the preferred +model/account pair — see §Model preference and account fallback — +`--dangerously-skip-permissions`) with `prompt.txt` — the same daily +prompt the session cron carried, minus the cron-upkeep step (launchd has no 7-day expiry) +and with ask-Chris rerouted to the run log. All bot state stays in the Zulip self-DM, so +fresh sessions are the designed mode. Logs: `~/Library/Logs/voyager.log`. + +Operate it with: + +```bash +launchctl print gui/$(id -u)/com.tauceti.voyager # inspect, incl. last exit status +launchctl kickstart gui/$(id -u)/com.tauceti.voyager # fire a run now (freshness abort keeps it safe) +launchctl bootout gui/$(id -u)/com.tauceti.voyager # disable +tail -f ~/Library/Logs/voyager.log # watch a run +``` + +The wrapper pins PATH by hand (launchd's environment is minimal): `claude` and `uvx` from +`~/.local/bin`, node from the nvm install the chatgpt-math MCP needs, `gh` from Homebrew. +If node is upgraded via nvm, update the path in `run.sh`. The 2h freshness abort makes a +double-fire against any leftover session cron harmless — whichever runs second exits quietly. + +## Model preference and account fallback + +`run.sh` walks a two-level chain — **models × accounts** — and stops at the first pair that +completes the run. Models: **`claude-fable-5` → `claude-opus-5`** (owner request, 2026-08-19: +prefer Fable wherever it has credits; only when no account can run Fable does any attempt fall +back to Opus 5). Accounts: **`.claude3` → `.claude2` → `.claude4` → default** +(`CLAUDE_CONFIG_DIR` unset, `~/.claude.json`). `.claude5` is deliberately excluded — its +organisation has Claude Code subscription access disabled, so it can never serve; re-add it if +that changes. + +Before each attempt the pair is **probed**: one trivial `claude -p` completion on the exact +model. There is no headless credits query, so a one-line turn is the only true test of "this +account can run this model right now"; a missing Fable entitlement, an exhausted limit, and a +broken login all fail it identically and the chain moves on in seconds instead of burning a +full run (first live probe sweep, 2026-08-19: `.claude3` and `.claude2` passed on Fable, +`.claude4` was weekly-limited, the default account was out of Fable usage credits). On a +normal day the whole run spends exactly one probe. + +**Exit status cannot drive the fallback.** `claude -p` exits **0** even when it never ran a +turn: an exhausted weekly limit and an org-disabled subscription each print one line and exit +cleanly (both observed on this machine, 2026-08-14). Past the probe, the chain therefore keys +off two positive signals: + +1. **The `VOYAGER_RESULT:` sentinel**, required by `prompt.txt` as the last line of every + terminal outcome — `posted-`, `quiet-checkin-`, `empty-window`, `freshness-abort`, or + `failed-`. A *missing* sentinel means the model never got to run, so the chain moves + to the next account. Known limit/auth strings are matched first, purely so the log says why. +2. **`freshness.py`**, run before every attempt: a read-only check for a Voyager post or state + DM inside the 2-hour window. If one exists the chain stops immediately, which covers the case + where an attempt posted successfully but died before printing its sentinel. + +Retrying is safe by construction: the skill's own freshness abort makes a second attempt exit +quietly rather than double-post, and the permanent `TauCeti#NNN` dedupe backs that up. If every +account fails, the log says so loudly and nothing is posted — the watermark is untouched, so the +next day's run covers the missed window too. + +Test the chain without spending a real run by overriding it and using the read-only smoke prompt: + +```bash +VOYAGER_ACCOUNTS="$HOME/.claude5 $HOME/.claude2" ~/.claude3/voyager/run.sh \ + ~/.claude3/voyager/smoke.txt # .claude5 fails fast, .claude2 answers: proves the fallback +VOYAGER_MODELS="claude-opus-5" ~/.claude3/voyager/run.sh \ + ~/.claude3/voyager/smoke.txt # pin a single model tier the same way +``` + +Every account in the chain needs the chatgpt-math MCP for the significance gate; all four have +it. Zulip credentials (`~/.zuliprc`) and the `gh` keychain login are shared, so they are +account-independent. diff --git a/skills/voyager/launchd/com.tauceti.voyager.plist b/skills/voyager/launchd/com.tauceti.voyager.plist new file mode 100644 index 0000000..d578030 --- /dev/null +++ b/skills/voyager/launchd/com.tauceti.voyager.plist @@ -0,0 +1,24 @@ + + + + + Label + com.tauceti.voyager + ProgramArguments + + /bin/zsh + /Users/mcu22seu/.claude3/voyager/run.sh + + StartCalendarInterval + + Hour + 16 + Minute + 3 + + StandardOutPath + /Users/mcu22seu/Library/Logs/voyager.log + StandardErrorPath + /Users/mcu22seu/Library/Logs/voyager.log + + diff --git a/skills/voyager/launchd/freshness.py b/skills/voyager/launchd/freshness.py new file mode 100755 index 0000000..3cc9d13 --- /dev/null +++ b/skills/voyager/launchd/freshness.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""Exit 0 if a Voyager post/state-DM landed within the freshness window, else 1. + +Used by run.sh as the authoritative "did the work already get done" test, because +`claude -p` exits 0 even when it never ran (rate limit, org restriction). Read-only; +never posts. Never prints the API key. +""" +import datetime +import json +import os +import subprocess +import sys + +WINDOW_HOURS = 2.0 + + +def zuliprc(): + cfg = {} + with open(os.path.expanduser("~/.zuliprc")) as fh: + for line in fh: + if "=" in line: + k, _, v = line.partition("=") + cfg[k.strip()] = v.strip() + return cfg["site"], cfg["email"], cfg["key"] + + +def newest_age_hours(site, email, key, narrow): + out = subprocess.run( + ["curl", "-sS", "--max-time", "30", "-u", f"{email}:{key}", "-G", + f"{site}/api/v1/messages", + "--data-urlencode", "anchor=newest", + "--data-urlencode", "num_before=1", + "--data-urlencode", "num_after=0", + "--data-urlencode", "apply_markdown=false", + "--data-urlencode", f"narrow={json.dumps(narrow)}"], + capture_output=True, text=True) + msgs = json.loads(out.stdout).get("messages") or [] + if not msgs: + return None + ts = datetime.datetime.fromtimestamp(msgs[0]["timestamp"], datetime.timezone.utc) + return (datetime.datetime.now(datetime.timezone.utc) - ts).total_seconds() / 3600.0 + + +def main(): + try: + site, email, key = zuliprc() + except Exception as exc: # noqa: BLE001 + print(f"freshness: cannot read ~/.zuliprc ({exc.__class__.__name__})") + return 1 # unknown -> let the run proceed + ages = [] + for narrow in ( + [{"operator": "channel", "operand": "Tau Ceti"}, + {"operator": "topic", "operand": "new results"}, + {"operator": "sender", "operand": email}], + [{"operator": "dm", "operand": email}], + ): + try: + age = newest_age_hours(site, email, key, narrow) + except Exception: # noqa: BLE001 + age = None + if age is not None: + ages.append(age) + if not ages: + print("freshness: no Zulip history readable") + return 1 + youngest = min(ages) + if youngest < WINDOW_HOURS: + print(f"freshness: newest Voyager activity {youngest:.2f}h old — today is done") + return 0 + print(f"freshness: newest Voyager activity {youngest:.1f}h old — a run is needed") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/voyager/launchd/prompt.txt b/skills/voyager/launchd/prompt.txt new file mode 100644 index 0000000..5a948ca --- /dev/null +++ b/skills/voyager/launchd/prompt.txt @@ -0,0 +1,15 @@ +Daily Voyager run (headless, launched by launchd — no interactive user; anything that would normally be a question for Chris must instead be recorded in your final output, which lands in ~/Library/Logs/voyager.log). + +First run `git -C ~/Documents/GitHub/mathlib-quality fetch origin`, then invoke the mathlib-quality:voyager skill and carry out one full run following skills/voyager/SKILL.md and skills/voyager/HANDOVER.md at origin/main of that repo (the repo copies are canonical and win over any older plugin copy; if the checkout lags origin/main, read via `git show origin/main:`). Where origin/voyager/curate-not-enumerate is ahead of origin/main, its copies of those two files carry Chris's 2026-08-09/10 corrections and win; once that branch is merged or gone, origin/main is current. + +Chris's standing rules apply regardless of doc state: (1) read the contents/body of EVERY PR in the window — slow, methodical, careful; titles may order the reading but never exclude a PR; (2) no minimum or maximum bullet count — interest is the only test; never pad, never trim to a number; (3) the ChatGPT significance gate (gpt-5.6-sol via mcp__chatgpt-math__ask_chatgpt_math, one batched call, reasoning_effort "high", never "max") is BEST-EFFORT and is NOT a precondition for posting. Probe it with one cheap call BEFORE reading the window, so an unavailable gate costs seconds instead of a 138-PR read. If it answers, its ranking drives bullet order and doubles as the second opinion on how noteworthy each result is. If it is unavailable for any reason — codex quota exhausted, the account's plan refusing reasoning models, MCP absent, timeout — then proceed without it: select and order on your own judgement, post as normal, and record one line in your final output naming why it was skipped. Do NOT hold the post, do NOT substitute a different model, and do NOT mention the gate or its absence in the Zulip message itself — that belongs in the log, not in front of readers. This clause overrides SKILL.md wherever that file makes the gate a prerequisite: its "stop and report if credentials are missing" rule governs the Zulip credentials and `gh`, never the chatgpt-math MCP, whose absence is now a normal condition to run through. As of 2026-08-15 both codex accounts are out (~/.codex quota until 2026-08-20; ~/.codex2 quota until 2026-09-14 and its plan rejects gpt-5.6-sol, gpt-5.4 and gpt-5.3-codex outright), so expect the gate to be unavailable for now — that is a normal run, not a failure. (4) Every Zulip channel message — the full update and the quiet check-in alike — carries the italic suffix *(AI-generated summary)* at the end of its bold header line, so AI authorship is stated at the top of the post (owner request, 2026-08-19); the suffix survives any trimming for the codepoint cap. + +All other decision logic lives in those two files — follow them exactly. The freshness abort is mandatory and is a successful outcome: if the newest Voyager channel message or state DM is under 2 hours old, another run already happened today — end quietly. Quiet check-ins and silent exits are normal successful outcomes. Never echo the Zulip API key. There is no cron-upkeep step in this mode: scheduling is owned by launchd (com.tauceti.voyager), not by session crons. + +Any judgment call the docs don't settle: skip the item and state the question clearly in your final output so Chris can pick it up from the log. + +MANDATORY LAST LINE. End your final message with exactly one line of the form + + VOYAGER_RESULT: + +where is one of: `posted-` (a full update went out), `quiet-checkin-` (the short check-in format went out), `empty-window` (docgen equalled the watermark, so nothing was posted and nothing was due), `freshness-abort` (a run inside the last 2 hours had already covered today), or `failed-` (anything else). The wrapper script reads this line to decide whether the day's run is done or whether it should retry on a different Claude account, so it must be present on every terminal outcome including the quiet ones — a missing sentinel is read as a failed run and triggers a retry elsewhere. Print it once, as the very last line, and nowhere else in the output. diff --git a/skills/voyager/launchd/run.sh b/skills/voyager/launchd/run.sh new file mode 100755 index 0000000..0be8ff2 --- /dev/null +++ b/skills/voyager/launchd/run.sh @@ -0,0 +1,138 @@ +#!/bin/zsh +# Daily Voyager run — launched by launchd (com.tauceti.voyager) at 16:03 local. +# Runs a fresh headless Claude Code session, preferring the Fable model (owner +# request, 2026-08-19): the chain is models × accounts, so every account is probed +# for claude-fable-5 before any attempt falls back to claude-opus-5. +# Optional $1: alternative prompt file (used for smoke tests). +# +# Why the fallback cannot key off the exit status: `claude -p` exits 0 even when it +# never ran a turn — an exhausted weekly limit and an org-disabled subscription both +# print a one-line notice and exit 0. The authoritative signals are therefore +# (a) a cheap entitlement probe before each attempt: a one-line completion on the +# exact model, which is the only true test of "this account can run this model +# right now" (there is no headless credits query), +# (b) the VOYAGER_RESULT sentinel the prompt requires on every terminal outcome, and +# (c) freshness.py, a read-only check for a Voyager post/DM inside the 2h window. +# Re-running after a partial failure is safe: the skill's own freshness abort makes a +# second attempt exit quietly rather than double-post. + +set -u + +export PATH="/Users/mcu22seu/.local/bin:/Users/mcu22seu/.nvm/versions/node/v22.9.0/bin:/opt/homebrew/bin:/Users/mcu22seu/miniforge3/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin" +export HOME="/Users/mcu22seu" + +VOYAGER_DIR="$HOME/.claude3/voyager" +PROMPT_FILE="${1:-$VOYAGER_DIR/prompt.txt}" + +# Model preference, outermost loop: try Fable on EVERY account before Opus on any, +# so a Fable-entitled account always beats an Opus-only one. +MODELS=("claude-fable-5" "claude-opus-5") +# Tests may override: VOYAGER_MODELS="claude-opus-5" run.sh smoke.txt +if [[ -n "${VOYAGER_MODELS:-}" ]]; then + MODELS=(${=VOYAGER_MODELS}) + echo "── model chain overridden: ${MODELS[*]}" +fi + +# Ordered fallback chain. "DEFAULT" means: run with CLAUDE_CONFIG_DIR unset (~/.claude.json). +# .claude5 is deliberately absent — its org has Claude Code subscription access disabled, +# so it can never serve; re-add it if that changes. +ACCOUNTS=("$HOME/.claude3" "$HOME/.claude2" "$HOME/.claude4" "DEFAULT") +# Tests may override the chain: VOYAGER_ACCOUNTS="/path/a /path/b" run.sh smoke.txt +if [[ -n "${VOYAGER_ACCOUNTS:-}" ]]; then + ACCOUNTS=(${=VOYAGER_ACCOUNTS}) + echo "── account chain overridden: ${ACCOUNTS[*]}" +fi + +echo "" +echo "══════════════════════════════════════════════════════════" +echo "voyager run: $(date '+%Y-%m-%d %H:%M:%S %Z') prompt=$PROMPT_FILE" +echo "══════════════════════════════════════════════════════════" + +PROMPT="$(cat "$PROMPT_FILE")" +overall=1 + +# probe : succeeds iff the account completes a one-line +# turn on that exact model. A missing entitlement, an exhausted limit, and a broken +# login all fail the same way — no VOYAGER_PROBE_OK in the output. Costs one trivial +# completion; on a normal day the whole run spends exactly one probe. +probe() { + local po + if [[ "$1" == "DEFAULT" ]]; then + po="$(env -u CLAUDE_CONFIG_DIR claude -p 'Reply with exactly: VOYAGER_PROBE_OK' --model "$2" 2>&1)" + else + po="$(CLAUDE_CONFIG_DIR="$1" claude -p 'Reply with exactly: VOYAGER_PROBE_OK' --model "$2" 2>&1)" + fi + [[ "$po" == *VOYAGER_PROBE_OK* ]] +} + +for model in "${MODELS[@]}"; do + for acct in "${ACCOUNTS[@]}"; do + label="${acct##*/}"; [[ "$acct" == "DEFAULT" ]] && label="default" + + # Never start an attempt if the day's work already landed — covers the case where + # a previous attempt posted but died before printing its sentinel. + if python3 "$VOYAGER_DIR/freshness.py"; then + echo "── stopping before $label: today's update is already out" + overall=0 + break 2 + fi + + if ! probe "$acct" "$model"; then + echo "── $label cannot run $model right now (probe failed) — next" + continue + fi + + echo "── attempt: $label model=$model ($(date '+%H:%M:%S'))" + out="$(mktemp)" + if [[ "$acct" == "DEFAULT" ]]; then + env -u CLAUDE_CONFIG_DIR claude -p "$PROMPT" --model "$model" --dangerously-skip-permissions 2>&1 | tee "$out" + else + CLAUDE_CONFIG_DIR="$acct" claude -p "$PROMPT" --model "$model" --dangerously-skip-permissions 2>&1 | tee "$out" + fi + + result="$(grep -o 'VOYAGER_RESULT:[^\"]*' "$out" | tail -1)" + + # Account health is decided by the SENTINEL FIRST, never by scanning the whole + # transcript. A run that completed always prints VOYAGER_RESULT; a Claude account + # that is rate-limited or unauthenticated never gets far enough to print one. The + # limit-phrase grep therefore only applies when no sentinel came back — otherwise + # it matches the agent's own prose about *other* services' limits and mislabels a + # perfectly healthy account. That is exactly what happened on 2026-08-15: .claude2 + # and .claude4 both ran the full window and reported the codex quota in their + # summaries, and the chain marked them rate-limited and burned two more accounts. + if [[ -z "$result" ]]; then + if grep -qiE 'weekly limit|usage limit|rate limit|disabled Claude subscription|Invalid API key|please run /login' "$out"; then + echo "── $label unavailable (limit or auth) — falling through" + else + echo "── $label produced no VOYAGER_RESULT sentinel — treating as a failed run, falling through" + fi + rm -f "$out"; continue + fi + rm -f "$out" + + case "$result" in + *posted*|*quiet-checkin*|*empty-window*|*freshness-abort*|*smoke-ok*) + echo "── $label succeeded — $result" + echo "── served by $label on $model" + overall=0; break 2 ;; + *failed-chatgpt*) + # Machine-level blocker (codex quota / model entitlement), not an account one. + # Every remaining account would fail identically, so stop rather than churn. + echo "── $label reported $result — machine-level blocker, not account-level; stopping instead of retrying elsewhere" + break 2 ;; + *failed*) + echo "── $label reported failure — $result — falling through" ;; + esac + done + + if (( overall != 0 )); then + echo "── no account could serve model=$model — trying the next model tier (if any)" + fi +done + +if (( overall != 0 )); then + echo "── ALL ACCOUNTS FAILED. No Voyager update today; the watermark is untouched, so tomorrow's run covers this window too." +fi + +echo "── voyager run finished: $(date '+%Y-%m-%d %H:%M:%S %Z') status=$overall" +exit $overall diff --git a/skills/voyager/launchd/smoke.txt b/skills/voyager/launchd/smoke.txt new file mode 100644 index 0000000..89cdb13 --- /dev/null +++ b/skills/voyager/launchd/smoke.txt @@ -0,0 +1,18 @@ +SMOKE TEST — read-only, post nothing, edit nothing. You are verifying that this headless environment can support the daily Voyager run. Perform exactly these checks and then print a one-line-per-check report: + +1. `git -C ~/Documents/GitHub/mathlib-quality fetch origin` succeeds. +2. `gh auth status` shows a logged-in account (keychain access works headless). +3. ~/.zuliprc exists; using its credentials, GET the newest message from the Zulip channel "Tau Ceti" topic "new results" (read-only) and report its id and age in hours. NEVER print the API key. +4. `git -C ~/Documents/GitHub/TauCeti fetch origin '+refs/heads/docgen:refs/remotes/origin/docgen'` succeeds; report the docgen SHA. +5. Confirm the tools mcp__chatgpt-math__ask_chatgpt_math and the lean-lsp MCP tools are available to this session (listed as available or deferred tools — do NOT call them). +6. `uvx --version`, `node --version`, `python3 --version` all run. + +Also report which Claude account is running you: print the value of the CLAUDE_CONFIG_DIR environment variable (or "default (unset)" if it is not set). + +Do not post to Zulip, do not write files, do not run the voyager skill. + +MANDATORY LAST LINE. End your final message with exactly one line of the form + + VOYAGER_RESULT: + +where is `smoke-ok` if every check passed, or `failed-` otherwise. The wrapper script reads this line, so it must be the very last line and must appear nowhere else in your output. diff --git a/skills/voyager/scheduler/README.md b/skills/voyager/scheduler/README.md new file mode 100644 index 0000000..916a92f --- /dev/null +++ b/skills/voyager/scheduler/README.md @@ -0,0 +1,43 @@ +# Voyager scheduler + +The daily run is owned by **launchd** (`com.tauceti.voyager`), not by a session cron. These +are the files it executes. They were untracked until 2026-08-15 — living only in +`~/.claude3/voyager/` on one machine, with no review trail and no backup. + +**`~/.claude3/voyager/` remains the deployed copy.** These are the reviewable source of +truth; edits here do nothing until they are copied across: + +```sh +cp skills/voyager/scheduler/{prompt.txt,run.sh,freshness.py,status.py,smoke.txt} \ + ~/.claude3/voyager/ +``` + +| file | role | +|---|---| +| `prompt.txt` | what launchd hands to `claude -p`. Carries the standing rules, which **override `SKILL.md`** where they conflict. | +| `run.sh` | the account-fallback wrapper: tries each Claude account in turn until one returns a terminal `VOYAGER_RESULT` sentinel. | +| `freshness.py` | asks Zulip directly whether today's update is already out. Exit 0 = already posted, stop. Run before every attempt, so a partial run cannot double-post. | +| `status.py` | backs the `voyager-status` alias: schedule loaded, last run, whether today posted, account chain. | +| `smoke.txt` | a read-only prompt that verifies the headless environment (git, gh, Zulip read) without posting. | + +## Two lessons the code now encodes + +**Account health is decided by the sentinel, never by scanning the transcript.** `run.sh` +used to grep the whole output for `usage limit|weekly limit|…` to decide whether an account +was rate-limited. On 2026-08-15 two healthy accounts described *codex's* quota in their own +reports, matched that grep, and were logged as rate-limited Claude accounts — so the chain +burned every remaining account on a blocker no account switch could fix, and `voyager-status` +reported four dead accounts when two were fine. A completed run always prints +`VOYAGER_RESULT`; a rate-limited account never gets far enough to. So the sentinel is checked +first, and the limit-phrase grep applies only when no sentinel came back. + +**A machine-level blocker stops the chain.** `failed-chatgpt-*` means the significance gate +could not run, which is a property of the machine's codex credentials, not of the Claude +account. Retrying elsewhere fails identically, so the chain stops rather than churning. + +## The significance gate is best-effort + +`prompt.txt` clause (3) and `SKILL.md` §4 both say so, and they must stay in agreement. A run +that cannot reach the chatgpt-math MCP posts anyway, on its own judgement, and notes the +reason in the log — never in the Zulip message. Holding the post was the failure mode on +2026-08-15: two runs read all 138 PRs in the window, assembled a slate, and published nothing. diff --git a/skills/voyager/scheduler/freshness.py b/skills/voyager/scheduler/freshness.py new file mode 100755 index 0000000..3cc9d13 --- /dev/null +++ b/skills/voyager/scheduler/freshness.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""Exit 0 if a Voyager post/state-DM landed within the freshness window, else 1. + +Used by run.sh as the authoritative "did the work already get done" test, because +`claude -p` exits 0 even when it never ran (rate limit, org restriction). Read-only; +never posts. Never prints the API key. +""" +import datetime +import json +import os +import subprocess +import sys + +WINDOW_HOURS = 2.0 + + +def zuliprc(): + cfg = {} + with open(os.path.expanduser("~/.zuliprc")) as fh: + for line in fh: + if "=" in line: + k, _, v = line.partition("=") + cfg[k.strip()] = v.strip() + return cfg["site"], cfg["email"], cfg["key"] + + +def newest_age_hours(site, email, key, narrow): + out = subprocess.run( + ["curl", "-sS", "--max-time", "30", "-u", f"{email}:{key}", "-G", + f"{site}/api/v1/messages", + "--data-urlencode", "anchor=newest", + "--data-urlencode", "num_before=1", + "--data-urlencode", "num_after=0", + "--data-urlencode", "apply_markdown=false", + "--data-urlencode", f"narrow={json.dumps(narrow)}"], + capture_output=True, text=True) + msgs = json.loads(out.stdout).get("messages") or [] + if not msgs: + return None + ts = datetime.datetime.fromtimestamp(msgs[0]["timestamp"], datetime.timezone.utc) + return (datetime.datetime.now(datetime.timezone.utc) - ts).total_seconds() / 3600.0 + + +def main(): + try: + site, email, key = zuliprc() + except Exception as exc: # noqa: BLE001 + print(f"freshness: cannot read ~/.zuliprc ({exc.__class__.__name__})") + return 1 # unknown -> let the run proceed + ages = [] + for narrow in ( + [{"operator": "channel", "operand": "Tau Ceti"}, + {"operator": "topic", "operand": "new results"}, + {"operator": "sender", "operand": email}], + [{"operator": "dm", "operand": email}], + ): + try: + age = newest_age_hours(site, email, key, narrow) + except Exception: # noqa: BLE001 + age = None + if age is not None: + ages.append(age) + if not ages: + print("freshness: no Zulip history readable") + return 1 + youngest = min(ages) + if youngest < WINDOW_HOURS: + print(f"freshness: newest Voyager activity {youngest:.2f}h old — today is done") + return 0 + print(f"freshness: newest Voyager activity {youngest:.1f}h old — a run is needed") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/voyager/scheduler/prompt.txt b/skills/voyager/scheduler/prompt.txt new file mode 100644 index 0000000..54ccc69 --- /dev/null +++ b/skills/voyager/scheduler/prompt.txt @@ -0,0 +1,15 @@ +Daily Voyager run (headless, launched by launchd — no interactive user; anything that would normally be a question for Chris must instead be recorded in your final output, which lands in ~/Library/Logs/voyager.log). + +First run `git -C ~/Documents/GitHub/mathlib-quality fetch origin`, then invoke the mathlib-quality:voyager skill and carry out one full run following skills/voyager/SKILL.md and skills/voyager/HANDOVER.md at origin/main of that repo (the repo copies are canonical and win over any older plugin copy; if the checkout lags origin/main, read via `git show origin/main:`). Where origin/voyager/curate-not-enumerate is ahead of origin/main, its copies of those two files carry Chris's 2026-08-09/10 corrections and win; once that branch is merged or gone, origin/main is current. + +Chris's standing rules apply regardless of doc state: (1) read the contents/body of EVERY PR in the window — slow, methodical, careful; titles may order the reading but never exclude a PR; (2) no minimum or maximum bullet count — interest is the only test; never pad, never trim to a number; (3) the ChatGPT significance gate (gpt-5.6-sol via mcp__chatgpt-math__ask_chatgpt_math, one batched call, reasoning_effort "high", never "max") is BEST-EFFORT and is NOT a precondition for posting. Probe it with one cheap call BEFORE reading the window, so an unavailable gate costs seconds instead of a 138-PR read. If it answers, its ranking drives bullet order and doubles as the second opinion on how noteworthy each result is. If it is unavailable for any reason — codex quota exhausted, the account's plan refusing reasoning models, MCP absent, timeout — then proceed without it: select and order on your own judgement, post as normal, and record one line in your final output naming why it was skipped. Do NOT hold the post, do NOT substitute a different model, and do NOT mention the gate or its absence in the Zulip message itself — that belongs in the log, not in front of readers. This clause overrides SKILL.md wherever that file makes the gate a prerequisite: its "stop and report if credentials are missing" rule governs the Zulip credentials and `gh`, never the chatgpt-math MCP, whose absence is now a normal condition to run through. As of 2026-08-15 both codex accounts are out (~/.codex quota until 2026-08-20; ~/.codex2 quota until 2026-09-14 and its plan rejects gpt-5.6-sol, gpt-5.4 and gpt-5.3-codex outright), so expect the gate to be unavailable for now — that is a normal run, not a failure. + +All other decision logic lives in those two files — follow them exactly. The freshness abort is mandatory and is a successful outcome: if the newest Voyager channel message or state DM is under 2 hours old, another run already happened today — end quietly. Quiet check-ins and silent exits are normal successful outcomes. Never echo the Zulip API key. There is no cron-upkeep step in this mode: scheduling is owned by launchd (com.tauceti.voyager), not by session crons. + +Any judgment call the docs don't settle: skip the item and state the question clearly in your final output so Chris can pick it up from the log. + +MANDATORY LAST LINE. End your final message with exactly one line of the form + + VOYAGER_RESULT: + +where is one of: `posted-` (a full update went out), `quiet-checkin-` (the short check-in format went out), `empty-window` (docgen equalled the watermark, so nothing was posted and nothing was due), `freshness-abort` (a run inside the last 2 hours had already covered today), or `failed-` (anything else). The wrapper script reads this line to decide whether the day's run is done or whether it should retry on a different Claude account, so it must be present on every terminal outcome including the quiet ones — a missing sentinel is read as a failed run and triggers a retry elsewhere. Print it once, as the very last line, and nowhere else in the output. diff --git a/skills/voyager/scheduler/smoke.txt b/skills/voyager/scheduler/smoke.txt new file mode 100644 index 0000000..89cdb13 --- /dev/null +++ b/skills/voyager/scheduler/smoke.txt @@ -0,0 +1,18 @@ +SMOKE TEST — read-only, post nothing, edit nothing. You are verifying that this headless environment can support the daily Voyager run. Perform exactly these checks and then print a one-line-per-check report: + +1. `git -C ~/Documents/GitHub/mathlib-quality fetch origin` succeeds. +2. `gh auth status` shows a logged-in account (keychain access works headless). +3. ~/.zuliprc exists; using its credentials, GET the newest message from the Zulip channel "Tau Ceti" topic "new results" (read-only) and report its id and age in hours. NEVER print the API key. +4. `git -C ~/Documents/GitHub/TauCeti fetch origin '+refs/heads/docgen:refs/remotes/origin/docgen'` succeeds; report the docgen SHA. +5. Confirm the tools mcp__chatgpt-math__ask_chatgpt_math and the lean-lsp MCP tools are available to this session (listed as available or deferred tools — do NOT call them). +6. `uvx --version`, `node --version`, `python3 --version` all run. + +Also report which Claude account is running you: print the value of the CLAUDE_CONFIG_DIR environment variable (or "default (unset)" if it is not set). + +Do not post to Zulip, do not write files, do not run the voyager skill. + +MANDATORY LAST LINE. End your final message with exactly one line of the form + + VOYAGER_RESULT: + +where is `smoke-ok` if every check passed, or `failed-` otherwise. The wrapper script reads this line, so it must be the very last line and must appear nowhere else in your output. diff --git a/skills/voyager/scheduler/status.py b/skills/voyager/scheduler/status.py new file mode 100755 index 0000000..8a8dfb7 --- /dev/null +++ b/skills/voyager/scheduler/status.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""One-screen answer to "is Voyager working?". + +Reports: whether the launchd agent is loaded and when it next fires, what the last +run did (which account served it, what it decided, how long it took), whether a run +is in flight, and whether today's update is actually out on Zulip. + +Read-only. Never posts, never prints the API key. Run: voyager-status +""" +import datetime +import os +import re +import subprocess +import sys + +LOG = os.path.expanduser("~/Library/Logs/voyager.log") +VOYAGER_DIR = os.path.expanduser("~/.claude3/voyager") +LABEL = "com.tauceti.voyager" +GREEN, RED, YELL, DIM, OFF = "\033[32m", "\033[31m", "\033[33m", "\033[2m", "\033[0m" + + +def agent_state(): + uid = os.getuid() + out = subprocess.run(["launchctl", "print", f"gui/{uid}/{LABEL}"], + capture_output=True, text=True) + if out.returncode != 0: + return None, None + state = re.search(r"^\s*state = (.+?)\s*$", out.stdout, re.M) + last = re.search(r"last exit code = (\S+)", out.stdout) + return (state.group(1) if state else "?"), (last.group(1) if last else None) + + +def next_fire(hour=16, minute=3): + now = datetime.datetime.now() + fire = now.replace(hour=hour, minute=minute, second=0, microsecond=0) + if fire <= now: + fire += datetime.timedelta(days=1) + delta = fire - now + hrs, rem = divmod(int(delta.total_seconds()), 3600) + return fire, f"{hrs}h {rem // 60}m" + + +def last_run(): + if not os.path.exists(LOG): + return None + text = open(LOG, errors="replace").read() + blocks = re.split(r"^voyager run: ", text, flags=re.M)[1:] + if not blocks: + return None + b = blocks[-1] + started = b.split("\n")[0].split(" prompt=")[0].strip() + attempts = re.findall(r"── attempt: (\S+)", b) + served = re.search(r"── (\S+) succeeded — VOYAGER_RESULT: (\S+)", b) + sentinel = re.search(r"VOYAGER_RESULT: (\S+)", b) + finished = re.search(r"── voyager run finished: (.+?) (?:status|exit)=(\d+)", b) + allfail = "ALL ACCOUNTS FAILED" in b + skipped = re.findall(r"── (\S+) unavailable \(limit or auth\)", b) + return dict(started=started, attempts=attempts, served=served, sentinel=sentinel, + finished=finished, allfail=allfail, skipped=skipped, n_blocks=len(blocks)) + + +def main(): + print(f"\n{DIM}Voyager status — {datetime.datetime.now():%Y-%m-%d %H:%M:%S %Z}{OFF}") + + state, last_exit = agent_state() + fire, until = next_fire() + if state is None: + print(f" schedule {RED}NOT LOADED{OFF} — launchd agent {LABEL} is not registered") + else: + print(f" schedule {GREEN}loaded{OFF} ({state}), next fire {fire:%a %H:%M} (in {until})") + + r = last_run() + if not r: + print(f" last run {YELL}none logged yet{OFF}") + else: + if r["finished"]: + when, status = r["finished"].group(1), r["finished"].group(2) + if r["allfail"]: + verdict = f"{RED}ALL ACCOUNTS FAILED{OFF}" + elif r["served"]: + verdict = (f"{GREEN}{r['served'].group(1)}{OFF} → " + f"{r['served'].group(2)}") + elif r["sentinel"]: + verdict = f"{GREEN}{r['sentinel'].group(1)}{OFF}" + else: + verdict = f"{YELL}no sentinel (predates the fallback system){OFF}" + print(f" last run started {r['started']}") + print(f" finished {when} (status {status}) — {verdict}") + else: + print(f" last run {YELL}IN FLIGHT{OFF} — started {r['started']}, " + f"on {r['attempts'][-1] if r['attempts'] else '?'}") + if r["skipped"]: + print(f" fell through: {', '.join(r['skipped'])}") + + fr = subprocess.run([sys.executable, f"{VOYAGER_DIR}/freshness.py"], + capture_output=True, text=True) + msg = (fr.stdout or "").strip().replace("freshness: ", "") + print(f" today {(GREEN + 'posted' + OFF) if fr.returncode == 0 else (YELL + 'not yet' + OFF)} — {msg}") + print(f" chain .claude3 → .claude2 → .claude4 → default{DIM} (.claude5 org-disabled){OFF}") + print(f"{DIM} log: {LOG}{OFF}\n") + + +if __name__ == "__main__": + main()