diff --git a/dev/changelog/mngr-donate-extra-quota-bio.md b/dev/changelog/mngr-donate-extra-quota-bio.md new file mode 100644 index 0000000000..ba2203cfdc --- /dev/null +++ b/dev/changelog/mngr-donate-extra-quota-bio.md @@ -0,0 +1 @@ +Registered the new `libs/mngr_donate` workspace member (root `pyproject.toml` coverage list + `uv.lock`). diff --git a/libs/mngr/changelog/mngr-donate-extra-quota-bio.md b/libs/mngr/changelog/mngr-donate-extra-quota-bio.md new file mode 100644 index 0000000000..da470cf075 --- /dev/null +++ b/libs/mngr/changelog/mngr-donate-extra-quota-bio.md @@ -0,0 +1 @@ +Registered the new `imbue-mngr-donate` plugin (`mngr donate`) in the plugin catalog as a default-installable DEPENDENT gated on `imbue-mngr-usage`. diff --git a/libs/mngr/docs/commands/secondary/donate.md b/libs/mngr/docs/commands/secondary/donate.md new file mode 100644 index 0000000000..0dd0725432 --- /dev/null +++ b/libs/mngr/docs/commands/secondary/donate.md @@ -0,0 +1,38 @@ + + + +# mngr donate +**Usage:** + +```text +mngr donate [OPTIONS] +``` +**Options:** + +## Common + +| Name | Type | Description | Default | +| ---- | ---- | ----------- | ------- | +| `--format` | text | Output format (human, json, jsonl, FORMAT): Output format for results. When a template is provided, fields use standard python templating like 'name: {agent.name}' See below for available fields. | `human` | +| `-q`, `--quiet` | boolean | Suppress all console output | `False` | +| `-v`, `--verbose` | integer range | Increase verbosity (default: BUILD); -v for DEBUG, -vv for TRACE | `0` | +| `--log-file` | path | Path to log file (overrides default ~/.mngr/events/logs/-.json) | None | +| `--log-commands`, `--no-log-commands` | boolean | Log commands that were executed | None | +| `--headless` | boolean | Disable all interactive behavior (prompts, TUI, editor). Also settable via MNGR_HEADLESS env var or 'headless' config key. | `False` | +| `--safe` | boolean | Always query all providers during discovery (disable event-stream optimization). Use this when interfacing with mngr from multiple machines. | `False` | +| `--plugin`, `--enable-plugin` | text | Enable a plugin [repeatable] | None | +| `--disable-plugin` | text | Disable a plugin [repeatable] | None | +| `-S`, `--setting` | text | Override a config setting for this invocation (KEY=VALUE, dot-separated paths; append __extend to the leaf key to extend list/dict/set fields) [repeatable] | None | + +## Other Options + +| Name | Type | Description | Default | +| ---- | ---- | ----------- | ------- | +| `--skill` | text | Donation skill to run (the subdir name under the host-dir skill cache). | `document-review` | +| `--skill-repo` | text | Upstream git repo the skill (code + prompts) is checked out from. | `https://gitlab.com/sinnott-armstrong-lab/elsi-checklist/credits-for-science/document-review-skill.git` | +| `--skill-ref` | text | Git ref to check out: a branch to track, or a pinned commit for a reviewed version. | `c2e9bbe799c20c9da3896c2205991164f10555fd` | +| `--agent-name` | text | Name for the created donation agent. | `donate-extra-quota-bio` | +| `--dry-run` | boolean | Report the spare-capacity decision without creating an agent. | `False` | +| `--start` | boolean | Schedule donate to run automatically (installs a launchd LaunchAgent; macOS only) and exit. | `False` | +| `--stop` | boolean | Remove the scheduled donate LaunchAgent and exit. | `False` | +| `--interval-minutes` | integer range | With --start: how often the scheduled donate runs. | `10` | diff --git a/libs/mngr/imbue/mngr/plugin_catalog.py b/libs/mngr/imbue/mngr/plugin_catalog.py index 8031e661f0..6a36acfca0 100644 --- a/libs/mngr/imbue/mngr/plugin_catalog.py +++ b/libs/mngr/imbue/mngr/plugin_catalog.py @@ -376,6 +376,14 @@ class CatalogEntry(FrozenModel): is_recommended=True, gate=RequiredPackagesGate(packages=("imbue-mngr-pi-coding", "imbue-mngr-usage")), ), + CatalogEntry( + entry_point_name="donate", + package_name="imbue-mngr-donate", + description="Spend spare Claude capacity on a donation skill (`mngr donate`)", + tier=PluginTier.DEPENDENT, + is_recommended=True, + gate=RequiredPackagesGate(packages=("imbue-mngr-usage",)), + ), # --- INDEPENDENT, no signal --- CatalogEntry( entry_point_name="usage", diff --git a/libs/mngr_donate/CHANGELOG.md b/libs/mngr_donate/CHANGELOG.md new file mode 100644 index 0000000000..b324dc0894 --- /dev/null +++ b/libs/mngr_donate/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog - mngr_donate + +A concise, human-friendly summary of changes for the `mngr_donate` library. Entries are categorized using the [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) categories: Added, Changed, Deprecated, Removed, Fixed, Security. + +For the full, unedited changelog entries, see [UNABRIDGED_CHANGELOG.md](UNABRIDGED_CHANGELOG.md). + +## [Unreleased] + +### Added + +- New `imbue-mngr-donate` plugin, extracted from `imbue-mngr-usage`: provides `mngr donate`, which spends spare Claude capacity on a donation skill (default: scientific `document-review`). Depends on `imbue-mngr-usage` for the spare-capacity snapshot. diff --git a/libs/mngr_donate/README.md b/libs/mngr_donate/README.md new file mode 100644 index 0000000000..604b6e8839 --- /dev/null +++ b/libs/mngr_donate/README.md @@ -0,0 +1,98 @@ +# imbue-mngr-donate + +`mngr donate` -- spend spare Claude capacity on a donation skill instead of letting +idle quota expire. + +One invocation is a single tick: read the account-level usage snapshot (the same one +`mngr usage` shows), and *if* there's spare capacity (5h window under budget **and** the +week under its pace line), launch a headless Claude agent that runs a donation skill +(default: scientific `document-review`) to completion, then auto-cleans up. When there's +no spare capacity -- or no usage data to judge from -- it does nothing and says so. + +This is a separate plugin from `imbue-mngr-usage` on purpose (measuring usage and donating +spare quota are orthogonal), but it depends on it: installing `imbue-mngr-donate` pulls in +`imbue-mngr-usage`, whose snapshot API + `usage` plugin config donate reads at runtime. + +## Run a donation + +```bash +# From inside a trusted git repo (the agent is sourced from the current dir): +mngr donate # one tick: donate now if there's spare capacity +mngr donate --dry-run # show the decision + numbers, launch nothing +mngr donate --skill my-skill # run a different skill (default: document-review) +``` + +## Schedule it (drain spare capacity over time) + +A single tick spends at most one skill run's worth of quota. To actually *drain* spare +capacity, schedule it -- the schedule, not any one tick, is what uses up the idle quota: + +```bash +mngr donate --start # install a launchd LaunchAgent (every 10 min by default) +mngr donate --start --interval-minutes 5 +mngr donate --stop # remove it +``` + +`--start`/`--stop` are **macOS-only** and install a **launchd LaunchAgent** +(`com.imbue.mngr.donate` in `~/Library/LaunchAgents/`). launchd -- not cron -- because the +agent must run inside your **login session** to reach the macOS keychain where Claude's +subscription token lives; a cron job runs outside it and every tick fails `Not logged in`. +launchd also catches up after sleep. On other platforms, schedule `mngr donate` yourself. + +## Authentication (read this when ticks start failing with 401) + +The donation agent is **headless**, and headless Claude authenticates differently from the +Claude app you use interactively -- this difference will bite you: + +- The desktop app's login popup (`/login`) renews **only the app's own session**. It does + not refresh the credentials headless `claude` reads. +- A plain web login yields a **short-lived (~8h) access token with no refresh token**. Any + scheduled donate setup that leans on it works for a few hours, then every tick fails with + `Failed to authenticate. API Error: 401 Invalid authentication credentials` -- typically + overnight, which makes it look like the schedule "randomly broke." + +The fix is a **long-lived (~1 year) token** minted for exactly this use case. One-time setup: + +```bash +# 1. Mint it (same login popup, but prints a long sk-ant-oat... token in the terminal -- +# copy ALL of it; it usually wraps across lines): +claude setup-token + +# 2. Stash it in the macOS keychain (prompts for a "password" -- paste the token there, +# so it never lands in a file or shell history). Add -U to overwrite an existing entry: +security add-generic-password -s mngr-donate-oauth -a "$USER" -w +``` + +At each tick, donate looks for that `mngr-donate-oauth` keychain entry and exports it to the +agent as `CLAUDE_CODE_OAUTH_TOKEN` (which takes precedence over the session token). If +`CLAUDE_CODE_OAUTH_TOKEN` is already set in the environment, or the entry doesn't exist, the +environment is inherited unchanged -- so without the stash, donate works exactly as long as +your current session token does. + +## Skills: pinned code, dynamic prompts + +The donation skill (its code **and** prompts) lives in the lab's own upstream git repo -- +the single source of truth. `mngr donate` **checks it out** into a host-dir cache +(`/donate-skills//`) and points the agent at it, so the lab can revise the +skill without an mngr release: + +- `--skill-repo` — the upstream repo (default: the `document-review` skill's GitLab repo). +- `--skill-ref` — the git ref to check out. A **branch** to *track* (each tick adopts the + latest — good for a fast-moving lab), or a **pinned commit** for a reviewed, reproducible + version that imbue bumps deliberately. + +**Pin the ref for anything unattended.** The donation agent runs with +`--dangerously-skip-permissions`, so whatever code is at `--skill-ref` executes on your +machine. Tracking a branch auto-adopts the lab's changes; pinning a reviewed commit is the +safe default for scheduled runs (bump the ref to adopt updates after review). + +## Notes + +- **Run it from a trusted git repo.** The donation agent is created from the current + directory; `--start` bakes that directory (and your `PATH`) into the LaunchAgent. +- **It needs usage data.** Spare capacity is judged from the account-level snapshot, which is + populated by mngr-managed Claude agents. With none recorded recently, `donate` reports + "can't tell" and skips rather than guessing. +- **Logs.** Each run's full event stream is tee'd to `/donate-logs/-.jsonl`, + and scheduled runs also append to `/donate-logs/schedule.log`, so a run survives the + agent's auto-destroy for later inspection. diff --git a/libs/mngr_donate/UNABRIDGED_CHANGELOG.md b/libs/mngr_donate/UNABRIDGED_CHANGELOG.md new file mode 100644 index 0000000000..6e4996d1c5 --- /dev/null +++ b/libs/mngr_donate/UNABRIDGED_CHANGELOG.md @@ -0,0 +1,5 @@ +# Unabridged Changelog - mngr_donate + +Full, unedited changelog entries consolidated nightly from individual files in `libs/mngr_donate/changelog/`. + +For a concise summary, see [CHANGELOG.md](CHANGELOG.md). diff --git a/libs/mngr_donate/changelog/.gitkeep b/libs/mngr_donate/changelog/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/libs/mngr_donate/changelog/mngr-donate-extra-quota-bio.md b/libs/mngr_donate/changelog/mngr-donate-extra-quota-bio.md new file mode 100644 index 0000000000..8f1da56317 --- /dev/null +++ b/libs/mngr_donate/changelog/mngr-donate-extra-quota-bio.md @@ -0,0 +1 @@ +New `imbue-mngr-donate` plugin: `mngr donate` spends spare Claude capacity (from the `imbue-mngr-usage` snapshot, a dependency) on a donation skill instead of letting idle quota expire. When the 5h window has budget and the week is under pace, it launches a headless Claude agent that runs the skill to completion, then auto-cleans up; `--start`/`--stop` install/remove a launchd LaunchAgent (macOS) so idle quota drains on a schedule (launchd, not cron, so it runs in the login session where Claude's keychain token lives). The donation skill (code + prompts) is owned by its upstream git repo -- `mngr donate` checks it out at `--skill-ref` (a branch to track, or a pinned commit for a reviewed version) into a host-dir cache and points the agent at it, so the skill updates without an mngr release. diff --git a/libs/mngr_donate/conftest.py b/libs/mngr_donate/conftest.py new file mode 100644 index 0000000000..41b27d7e72 --- /dev/null +++ b/libs/mngr_donate/conftest.py @@ -0,0 +1,9 @@ +"""Project-level conftest for mngr_donate.""" + +from imbue.imbue_common.conftest_hooks import register_conftest_hooks +from imbue.mngr.utils.logging import suppress_warnings +from imbue.mngr.utils.plugin_testing import register_plugin_test_fixtures + +suppress_warnings() +register_conftest_hooks(globals()) +register_plugin_test_fixtures(globals()) diff --git a/libs/mngr_donate/imbue/mngr_donate/__init__.py b/libs/mngr_donate/imbue/mngr_donate/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/libs/mngr_donate/imbue/mngr_donate/donate.py b/libs/mngr_donate/imbue/mngr_donate/donate.py new file mode 100644 index 0000000000..530dd6f6a9 --- /dev/null +++ b/libs/mngr_donate/imbue/mngr_donate/donate.py @@ -0,0 +1,810 @@ +"""``mngr donate`` -- spend spare Claude capacity on a donation skill. + +Companion to ``mngr usage`` (a separate plugin this one depends on for the +spare-capacity snapshot). One invocation is a single check-and-maybe-launch *tick*: + +1. Read the account-level usage snapshot (the same one ``mngr usage`` shows). +2. Decide whether there's *spare capacity* -- a Python port of the + ``spare-capacity.sh`` recipe (``mngr help usage_cron_recipes``): spare when + the rolling 5h window still has budget *and* the 7d window is under a + tapering pace line (:func:`evaluate_capacity`). Missing readings count as + "fully used", so a no-data tick never looks spare -- and that case is flagged + so the CLI can say "can't tell" rather than "maxed out". +3. If spare, launch a *headless* Claude agent (:data:`DONATE_AGENT_TYPE`) that + runs the donation skill (default ``document-review``) unattended and + auto-destroys when done; otherwise do nothing. The agent's stream is tee'd to + a durable per-run log under ``/donate-logs/``. + +A single tick spends at most one skill run's worth of quota. To drain spare +capacity over time, ``mngr donate --start`` installs a launchd LaunchAgent +(macOS) that re-runs the tick on an interval (``--stop`` removes it) -- the +schedule, not any one tick, is what actually uses up the idle quota. launchd +(not cron) because it runs in the login session, the only context that can reach +the keychain where Claude's subscription token lives. + +The launch mechanics (why headless, why the specific ``claude`` flags, why we +pre-clear stale agents/worktrees) are documented at each helper below. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +import time +from pathlib import Path +from typing import Any +from xml.sax.saxutils import escape + +import click +from pydantic import Field + +from imbue.imbue_common.frozen_model import FrozenModel +from imbue.imbue_common.pure import pure +from imbue.mngr.cli.common_opts import add_common_options +from imbue.mngr.cli.common_opts import setup_command_context +from imbue.mngr.cli.output_helpers import OperatorResultPart +from imbue.mngr.cli.output_helpers import emit_info +from imbue.mngr.cli.output_helpers import emit_operator_result +from imbue.mngr.config.data_types import CommonCliOptions +from imbue.mngr.config.host_dir import read_default_host_dir +from imbue.mngr.errors import MngrError +from imbue.mngr_usage.api import derive_elapsed +from imbue.mngr_usage.api import gather_usage_snapshots +from imbue.mngr_usage.data_types import UsagePluginConfig +from imbue.mngr_usage.data_types import UsageSnapshot + +# The Claude usage writer's source name and its fixed-window keys. These match +# what ``spare-capacity.sh`` selects (`.source == "claude"`, `.five_hour`, +# `.seven_day`) and what the Claude usage writer emits. +CLAUDE_SOURCE = "claude" +FIVE_HOUR_WINDOW = "five_hour" +SEVEN_DAY_WINDOW = "seven_day" + +DEFAULT_SKILL = "document-review" +DEFAULT_AGENT_NAME = "donate-extra-quota-bio" + +# The donation skill (code + prompts) lives in the lab's upstream git repo -- the +# single source of truth. donate checks it out at DEFAULT_SKILL_REF into a host-dir +# cache and runs it from there, so the lab can revise the skill without an mngr +# release. The ref is a branch to *track* (auto-adopt updates) or a pinned commit +# for a reviewed, reproducible version (imbue bumps the ref to adopt updates). +# Both overridable via --skill-repo / --skill-ref. +DEFAULT_SKILL_REPO = ( + "https://gitlab.com/sinnott-armstrong-lab/elsi-checklist/credits-for-science/document-review-skill.git" +) +# Pinned to a reviewed commit (NOT "main") so unattended/scheduled runs execute an +# audited version -- the agent runs this code with --dangerously-skip-permissions. +# Bump this SHA in a reviewed PR to adopt the lab's updates; pass `--skill-ref main` +# to track the branch instead. +DEFAULT_SKILL_REF = "c2e9bbe799c20c9da3896c2205991164f10555fd" + +# Optional macOS keychain entry holding a long-lived OAuth token from +# `claude setup-token`. Headless agents can't refresh Claude's short-lived (~8h) +# session token -- the desktop app refreshes only its own copy -- so without this, +# scheduled ticks start failing with `401 Invalid authentication credentials` as +# soon as the session token lapses. See README "Authentication". +OAUTH_KEYCHAIN_SERVICE = "mngr-donate-oauth" + +# `mngr donate --start` installs a scheduler that re-runs `mngr donate` on this +# interval; each firing re-checks spare capacity and does another batch, so the +# schedule -- not any one tick -- is what drains all the spare quota. macOS uses a +# launchd LaunchAgent (see build_launchd_plist); other platforms have no built-in +# scheduler here (see _require_macos) and must schedule `mngr donate` themselves. +DEFAULT_INTERVAL_MINUTES = 10 +# The LaunchAgent's label, and thus its plist filename in ~/Library/LaunchAgents. +LAUNCHD_LABEL = "com.imbue.mngr.donate" + +# Spare-capacity thresholds, mirroring spare-capacity.sh exactly: +# spare <=> five_hour.used% < 80 AND weekly.used% < pace_line(weekly.elapsed%) +# where pace_line starts ~30% under the plain used%==elapsed% line early in the +# 7-day cycle and tapers up to meet it by the cycle's end. +FIVE_HOUR_USED_CEILING = 80.0 +WEEKLY_EARLY_MARGIN = 0.30 + +# Missing readings default to "fully used" so a blank/no-data tick never looks +# like spare capacity -- the same defaults the jq recipe uses (`// 100`). +_ASSUME_USED_WHEN_UNKNOWN = 100.0 + + +class DonateCapacity(FrozenModel): + """The spare-capacity decision plus the numbers it was made from. + + Carrying the inputs (not just the boolean) lets the command explain itself + in both human and JSON output, and lets tests assert on the derived values. + """ + + has_spare: bool = Field(description="Whether there is capacity to spend on a donation agent this tick.") + has_usage_data: bool = Field( + description="Whether any Claude usage reading was found. When False the percentages below are the " + "conservative 'assume fully used' defaults, not real measurements -- so 'no spare' means 'can't " + "tell', not 'maxed out'." + ) + five_hour_used_percentage: float + weekly_used_percentage: float + weekly_elapsed_percentage: float + weekly_pace_line: float + + +@pure +def weekly_pace_line(weekly_elapsed_percentage: float) -> float: + """The weekly used-% ceiling for "under pace", given how far into the cycle we are. + + Ports ``$elw * (1 - 0.30 * (100 - $elw) / 100)``: a line ~30% under the plain + ``used% == elapsed%`` pace early in the cycle (when elapsed% is small the + margin is large) that tapers up to meet it as the cycle ends (elapsed% -> 100). + """ + return weekly_elapsed_percentage * (1 - WEEKLY_EARLY_MARGIN * (100 - weekly_elapsed_percentage) / 100) + + +@pure +def evaluate_capacity(snapshot: UsageSnapshot | None, now: int) -> DonateCapacity: + """Decide whether there is spare capacity, from the Claude usage snapshot. + + Mirrors ``spare-capacity.sh``: spare when the 5h window is under + ``FIVE_HOUR_USED_CEILING`` used *and* weekly usage is under + :func:`weekly_pace_line`. Absent windows/fields are treated as fully used + (conservative), so ``None``/blank data yields ``has_spare=False`` -- but + ``has_usage_data`` records whether that came from a real reading or the + default, so the caller can say "can't tell" instead of "maxed out". + """ + five_hour = snapshot.windows.get(FIVE_HOUR_WINDOW) if snapshot is not None else None + seven_day = snapshot.windows.get(SEVEN_DAY_WINDOW) if snapshot is not None else None + + five_hour_has_reading = five_hour is not None and five_hour.used_percentage is not None + weekly_has_reading = seven_day is not None and seven_day.used_percentage is not None + + five_hour_used = five_hour.used_percentage if five_hour_has_reading else _ASSUME_USED_WHEN_UNKNOWN + weekly_used = seven_day.used_percentage if weekly_has_reading else _ASSUME_USED_WHEN_UNKNOWN + weekly_elapsed = 0.0 + if seven_day is not None: + _, elapsed_percentage = derive_elapsed(seven_day, now) + if elapsed_percentage is not None: + weekly_elapsed = elapsed_percentage + + pace = weekly_pace_line(weekly_elapsed) + has_spare = five_hour_used < FIVE_HOUR_USED_CEILING and weekly_used < pace + return DonateCapacity( + has_spare=has_spare, + has_usage_data=five_hour_has_reading or weekly_has_reading, + five_hour_used_percentage=five_hour_used, + weekly_used_percentage=weekly_used, + weekly_elapsed_percentage=weekly_elapsed, + weekly_pace_line=pace, + ) + + +# The donation agent runs headless (``claude --print``) on purpose: a plain +# interactive ``claude`` agent blocks on the first tool-permission prompt, which +# hangs ``mngr create`` and spends none of the quota. A headless agent streams +# and auto-destroys after a successful pass, so a completed tick leaves nothing +# behind (a tick that dies mid-way can, which is what the pre-launch cleanup in +# `donate` handles). ``--dangerously-skip-permissions`` (below) is what lets it +# actually run the skill's commands unattended. +DONATE_AGENT_TYPE = "headless_claude" +# ``--output-format stream-json`` (which Claude requires ``--verbose`` to pair +# with ``--print``) is not optional here: mngr's headless runner reads the +# agent's stdout as stream-json, so a bare ``--print`` blob is seen as "no +# output" and the run is reported as failed even when the skill completed. It +# also turns the run into a per-event log -- every tool call and the skill's +# outbound HTTP -- which ``--foreground`` streams live and we tee to a file. +# ``--dangerously-skip-permissions`` lets the unattended agent run the skill's +# commands (in ``--print`` mode gated tools are denied, not prompted). +DONATE_AGENT_ARGS = ( + "--output-format", + "stream-json", + "--verbose", + "--include-partial-messages", + "--dangerously-skip-permissions", +) + + +@pure +def build_donation_message(skill_dir: str) -> str: + """The first message the donation agent receives. + + Points the agent at the assembled skill dir (pinned code + freshly-pulled + prompts; see :func:`prepare_skill_dir`) and tells it to run the skill from + there in manual mode, then stop. We pass an explicit path rather than relying + on Claude's skill auto-discovery because the skill lives in a host-dir cache, + not the agent's git worktree. + + The no-repo-changes paragraph is a guard against the *host repo's* hooks: the + agent works from a worktree of whatever repo donate ran in, so that repo's + stop hooks (e.g. a reviewer's "no stopping without a PR") apply and once + goaded a donation agent into opening a junk draft PR. The task touches no + repo files, so the agent can truthfully say so and stop. + """ + return ( + f"Follow the instructions in {skill_dir}/SKILL.md to review documents: run its client.py " + f"from {skill_dir} to lease a work item, review it against the active prompt yourself, and " + f"submit the result. Complete the work it leases, then stop.\n\n" + f"This task only talks to the skill's coordination server -- it makes no changes to the " + f"repository you are running in. Do not commit, push, or open pull requests. If a stop hook " + f"demands a PR or review, state that this session changed no repository files and stop." + ) + + +@pure +def build_create_argv(agent_name: str, skill_dir: str, mngr_path: str = "mngr") -> tuple[str, ...]: + """The ``mngr create`` invocation that launches a donation agent. + + Launches a **headless** claude agent so the donation runs unattended (see + :data:`DONATE_AGENT_TYPE`). ``--foreground`` is required for headless types + (it streams output and auto-destroys when done). The skill instruction (see + :func:`build_donation_message`, pointed at ``skill_dir``) is passed as the + agent's first message; ``--dangerously-skip-permissions`` is spliced in after + ``--`` so it reaches ``claude`` as an agent arg. Runs from the caller's cwd, so + invoke ``mngr donate`` from a trusted repo (like the recipes' ``cd``). + + ``mngr_path`` is the mngr executable to spawn. The command passes an absolute + path (:func:`_current_mngr_path`) so the launch never depends on ``mngr`` + being on ``$PATH`` in whatever environment donate happens to run from. + """ + return ( + mngr_path, + "create", + agent_name, + DONATE_AGENT_TYPE, + "--foreground", + # Source from the repo even with uncommitted changes: donate is meant to + # run unattended (incl. from a schedule), and the default clean-tree guard + # would fail every tick whenever the working repo has edits. + "--no-ensure-clean", + # Share the user's real Claude config/keychain instead of an isolated + # per-agent copy. Isolated copies of a subscription OAuth token go stale + # (~24h) and 401 -- fatal for an unattended donor whose owner may never open + # the interactive claude CLI to refresh them. Forced here so donate works + # out of the box regardless of the user's agent_types.headless_claude config. + "-S", + f"agent_types.{DONATE_AGENT_TYPE}.isolate_local_config_dir=false", + "--message", + build_donation_message(skill_dir), + "--", + *DONATE_AGENT_ARGS, + ) + + +@pure +def build_destroy_argv(agent_name: str, mngr_path: str = "mngr") -> tuple[str, ...]: + """The ``mngr destroy`` invocation that clears a stale donation agent. + + Run best-effort before :func:`build_create_argv` so a repeat tick never + collides on the fixed agent name. A headless agent only auto-destroys after + a *successful* pass, so a launch that failed part-way leaves the name taken; + ``--reuse`` isn't an option (headless agent types reject it). ``--force`` + skips confirmation and a no-op destroy of a missing agent is harmless. + ``mngr_path`` is the mngr executable (see :func:`build_create_argv`). + """ + return (mngr_path, "destroy", agent_name, "--force") + + +def _current_mngr_path() -> str: + """Absolute path to the mngr executable now running, for the LaunchAgent.""" + candidate = os.path.abspath(sys.argv[0]) + if os.path.isfile(candidate): + return candidate + return shutil.which("mngr") or candidate + + +def _schedule_log_path() -> Path: + """Stable log file the scheduled ticks append to (under the host dir).""" + return _donate_log_dir() / "schedule.log" + + +@pure +def build_launchd_plist( + mngr_path: str, + workdir: str, + skill: str, + skill_repo: str, + skill_ref: str, + agent_name: str, + log_path: str, + path_value: str, + interval_seconds: int, +) -> str: + """The LaunchAgent plist that schedules donate on macOS. + + Preferred over cron on macOS for one decisive reason: a LaunchAgent runs + inside the user's GUI login session, so it can reach the login **keychain** + where Claude stores the subscription token -- which cron cannot, making every + cron-launched tick fail "Not logged in". It also catches up after sleep. + + ``ProgramArguments`` runs mngr directly (no shell); ``WorkingDirectory`` gives + ``mngr create`` its git root, ``EnvironmentVariables.PATH`` lets it find + ``claude``/``git`` (launchd starts with a minimal PATH), and stdout/stderr are + captured to ``log_path``. ``StartInterval`` fires every ``interval_seconds``; + ``RunAtLoad`` is false so installing doesn't kick off a tick immediately. + + Every non-default skill/agent option the user passed to ``--start`` is carried + into the scheduled invocation (defaults are omitted to keep it minimal), so a + schedule installed with e.g. ``--skill-ref main`` actually tracks that ref on + every tick instead of silently reverting to the pinned default. + """ + args = [mngr_path, "donate"] + if skill != DEFAULT_SKILL: + args += ["--skill", skill] + if skill_repo != DEFAULT_SKILL_REPO: + args += ["--skill-repo", skill_repo] + if skill_ref != DEFAULT_SKILL_REF: + args += ["--skill-ref", skill_ref] + if agent_name != DEFAULT_AGENT_NAME: + args += ["--agent-name", agent_name] + program_args = "\n".join(f" {escape(a)}" for a in args) + return ( + '\n' + '\n' + '\n' + "\n" + f" Label\n {LAUNCHD_LABEL}\n" + f" ProgramArguments\n \n{program_args}\n \n" + f" WorkingDirectory\n {escape(workdir)}\n" + " EnvironmentVariables\n \n" + f" PATH\n {escape(path_value)}\n \n" + f" StartInterval\n {interval_seconds}\n" + f" StandardOutPath\n {escape(log_path)}\n" + f" StandardErrorPath\n {escape(log_path)}\n" + " RunAtLoad\n \n" + "\n\n" + ) + + +def _launchd_plist_path() -> Path: + """Path to the donate LaunchAgent plist, creating ~/Library/LaunchAgents if needed.""" + agents_dir = Path.home() / "Library" / "LaunchAgents" + agents_dir.mkdir(parents=True, exist_ok=True) + return agents_dir / f"{LAUNCHD_LABEL}.plist" + + +def _install_launchd(plist: str) -> None: + """Write the plist and (re)load it into the user's launchd GUI domain.""" + plist_path = _launchd_plist_path() + plist_path.write_text(plist) + domain = f"gui/{os.getuid()}" + # Boot out any prior instance (ignored if absent) before bootstrapping the new + # one. bootout is asynchronous, so the service can still be tearing down when + # bootstrap runs -- which surfaces as "Bootstrap failed: 5: Input/output + # error". Retry through that transient window before giving up. + subprocess.run(("launchctl", "bootout", f"{domain}/{LAUNCHD_LABEL}"), check=False, capture_output=True) + last_error = "" + for _attempt in range(5): + result = subprocess.run( + ("launchctl", "bootstrap", domain, str(plist_path)), check=False, capture_output=True, text=True + ) + if result.returncode == 0: + return + last_error = (result.stderr or result.stdout).strip() + time.sleep(0.5) + raise MngrError(f"launchctl bootstrap failed: {last_error}") + + +def _uninstall_launchd() -> bool: + """Boot out the LaunchAgent and delete its plist. Returns whether it existed.""" + subprocess.run(("launchctl", "bootout", f"gui/{os.getuid()}/{LAUNCHD_LABEL}"), check=False, capture_output=True) + plist_path = _launchd_plist_path() + existed = plist_path.exists() + plist_path.unlink(missing_ok=True) + return existed + + +def _require_macos() -> None: + """Guard ``--start``/``--stop``: scheduling is implemented via launchd (macOS only). + + A launchd LaunchAgent is the only scheduler that reaches the login keychain + where Claude's subscription token lives -- cron runs outside the login session + and every tick fails "Not logged in". Rather than ship a scheduler that + silently can't authenticate, we scope ``--start`` to macOS; elsewhere, run + ``mngr donate`` from your own scheduler (its env already has keychain access). + """ + if sys.platform != "darwin": + raise MngrError( + "`mngr donate --start`/`--stop` is macOS-only (it installs a launchd LaunchAgent). " + "On other platforms, schedule `mngr donate` yourself (e.g. a cron entry that runs it)." + ) + + +def _install_schedule(skill: str, skill_repo: str, skill_ref: str, agent_name: str, interval_minutes: int) -> str: + """Install the launchd LaunchAgent that runs donate on an interval.""" + _require_macos() + log_path = str(_schedule_log_path()) + plist = build_launchd_plist( + _current_mngr_path(), + os.getcwd(), + skill, + skill_repo, + skill_ref, + agent_name, + log_path, + os.environ.get("PATH", ""), + interval_minutes * 60, + ) + _install_launchd(plist) + return ( + f"Scheduled donate via launchd ({LAUNCHD_LABEL}) every {interval_minutes} min from {os.getcwd()}; " + f"logs -> {log_path}. It runs in your login session, so it can use your keychain login " + f"(unlike cron) and catches up after sleep." + ) + + +def _remove_schedule() -> str: + """Remove the donate LaunchAgent; returns a status message.""" + _require_macos() + removed = _uninstall_launchd() + return ( + "Unscheduled donate (removed the launchd agent)." + if removed + else "No scheduled donate found; nothing to remove." + ) + + +def _clear_stale_worktree(agent_name: str) -> None: + """Best-effort removal of a leftover ``mngr/`` git worktree + branch. + + mngr creates each agent in a git worktree on a branch named + ``mngr/``. A run that errors after the worktree is created (or an + agent that never auto-destroyed) leaves the worktree and branch behind, and + the next ``mngr create`` fails with "a branch named 'mngr/' already + exists" -- ``mngr destroy`` clears the tracked agent but not this orphan. + Runs ``git`` in the caller's cwd (the repo mngr sourced the agent from). All + steps are swallowed: a missing worktree/branch is the normal, healthy case. + """ + branch = f"mngr/{agent_name}" + listing = subprocess.run(("git", "worktree", "list", "--porcelain"), check=False, capture_output=True, text=True) + worktree_path: str | None = None + current_path: str | None = None + for line in listing.stdout.splitlines(): + if line.startswith("worktree "): + current_path = line[len("worktree ") :] + elif line.strip() == f"branch refs/heads/{branch}": + worktree_path = current_path + break + else: + continue + if worktree_path is not None: + subprocess.run(("git", "worktree", "remove", "--force", worktree_path), check=False, capture_output=True) + subprocess.run(("git", "worktree", "prune"), check=False, capture_output=True) + subprocess.run(("git", "branch", "-D", branch), check=False, capture_output=True) + + +def _donate_log_dir() -> Path: + """The dir donate writes its run logs to, created if missing. + + Lives under mngr's host dir via :func:`read_default_host_dir` -- the same + ``MNGR_HOST_DIR``-or-``~/.mngr`` resolution mngr uses everywhere else -- so we + don't re-derive (and drift from) that path here. + """ + log_dir = read_default_host_dir() / "donate-logs" + log_dir.mkdir(parents=True, exist_ok=True) + return log_dir + + +def _donate_skill_dir(skill_name: str) -> Path: + """The host-dir cache where donate checks out the skill repo. + + Under mngr's host dir (like the logs), so it persists across ticks -- the last + good checkout survives a later network hiccup. + """ + return read_default_host_dir() / "donate-skills" / skill_name + + +def prepare_skill_dir(skill_name: str, skill_repo: str, skill_ref: str) -> Path: + """Check out the donation skill (code + prompts) from its upstream repo. + + The whole skill is owned by the lab's repo (``skill_repo``) -- the single source + of truth. donate clones it into a host-dir cache and checks out ``skill_ref``: a + branch to *track* (adopt updates on the next tick) or a pinned commit for a + reviewed, reproducible version (imbue bumps the ref to adopt updates). The agent + runs the skill from the returned checkout. A network failure that leaves a usable + existing checkout reuses it; a first run with no usable checkout is fatal. + + donate never runs this repo's code itself -- it hands the path to the headless + agent -- but the agent runs with ``--dangerously-skip-permissions``, so pin + ``skill_ref`` to a reviewed commit for anything unattended/scheduled. + """ + cache = _donate_skill_dir(skill_name) + if (cache / ".git").is_dir(): + subprocess.run( + ("git", "-C", str(cache), "fetch", "--quiet", "--tags", "origin"), check=False, capture_output=True + ) + else: + shutil.rmtree(cache, ignore_errors=True) + subprocess.run(("git", "clone", "--quiet", skill_repo, str(cache)), check=False, capture_output=True) + if (cache / ".git").is_dir(): + subprocess.run(("git", "-C", str(cache), "checkout", "--quiet", skill_ref), check=False, capture_output=True) + # If skill_ref is a branch, advance to the fetched upstream tip; a harmless + # no-op when it's a pinned commit (no origin/ ref exists). + subprocess.run( + ("git", "-C", str(cache), "reset", "--hard", "--quiet", f"origin/{skill_ref}"), + check=False, + capture_output=True, + ) + if not (cache / "SKILL.md").is_file(): + raise MngrError( + f"Could not check out ref '{skill_ref}' of donation skill repo {skill_repo} (no SKILL.md in {cache})." + ) + return cache + + +def _donation_log_path(agent_name: str, now: int) -> Path: + """Where to persist one donation agent's streamed event log. + + A headless agent auto-destroys on success, taking its own ``stdout.jsonl`` + with it, and a donation you can't inspect afterwards is one you can't audit -- + so we keep a copy under the host dir. ``now`` (passed in, not read here) keeps + successive ticks from clobbering each other's logs. + """ + return _donate_log_dir() / f"{agent_name}-{now}.jsonl" + + +@pure +def build_agent_env(base_env: dict[str, str], stashed_token: str | None) -> dict[str, str] | None: + """The donation agent's environment: ``base_env`` plus the stashed OAuth token. + + Returns None (inherit unchanged) when the variable is already set -- an + explicit override wins -- or when there's no stashed token to add. + """ + if base_env.get("CLAUDE_CODE_OAUTH_TOKEN"): + return None + if stashed_token is None: + return None + return {**base_env, "CLAUDE_CODE_OAUTH_TOKEN": stashed_token} + + +def _read_stashed_oauth_token() -> str | None: + """The long-lived OAuth token from the macOS keychain, or None if not stashed. + + This is the year-long token from `claude setup-token`, stored under + OAUTH_KEYCHAIN_SERVICE (see README "Authentication"). Exporting it as + CLAUDE_CODE_OAUTH_TOKEN lets the headless agent outlive the ~8h session + token it can't refresh. + """ + if sys.platform != "darwin": + return None + result = subprocess.run( + ("security", "find-generic-password", "-s", OAUTH_KEYCHAIN_SERVICE, "-w"), + check=False, + capture_output=True, + text=True, + ) + token = result.stdout.strip() + if result.returncode != 0 or not token: + return None + return token + + +def _donation_agent_env() -> dict[str, str] | None: + """Environment for the donation agent, with the stashed token if one exists.""" + base_env = dict(os.environ) + if base_env.get("CLAUDE_CODE_OAUTH_TOKEN"): + return None + return build_agent_env(base_env, _read_stashed_oauth_token()) + + +def _run_and_tee(argv: tuple[str, ...], log_path: Path, env: dict[str, str] | None = None) -> int: + """Run ``argv``, streaming its combined output to both stdout and ``log_path``. + + The headless agent emits stream-json (one event per line: tool calls, + assistant text, the skill's outbound HTTP + submission), so teeing + line-by-line gives a live view *and* a durable record without holding the + whole run in memory. Returns the child's exit status. + """ + with open(log_path, "w", encoding="utf-8") as log_file: + # Header only in the file (not stdout) so it timestamps the per-run log + # without double-stamping schedule.log, which already gets the tick line above. + log_file.write(f"===== donate launch {time.strftime('%Y-%m-%d %H:%M:%S')} =====\n") + log_file.flush() + process = subprocess.Popen( + list(argv), stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1, env=env + ) + assert process.stdout is not None + for line in process.stdout: + sys.stdout.write(line) + sys.stdout.flush() + log_file.write(line) + log_file.flush() + return process.wait() + + +class DonateCliOptions(CommonCliOptions): + """Options for ``mngr donate`` (plus the common output/logging options).""" + + skill: str + skill_repo: str + skill_ref: str + agent_name: str + dry_run: bool + start: bool + stop: bool + interval_minutes: int + + +def _result_data(capacity: DonateCapacity, opts: DonateCliOptions) -> dict[str, Any]: + """Structured fields shared by every output branch (JSON + human).""" + return { + "has_spare": capacity.has_spare, + "has_usage_data": capacity.has_usage_data, + "five_hour_used_percentage": round(capacity.five_hour_used_percentage, 1), + "weekly_used_percentage": round(capacity.weekly_used_percentage, 1), + "weekly_elapsed_percentage": round(capacity.weekly_elapsed_percentage, 1), + "weekly_pace_line": round(capacity.weekly_pace_line, 1), + "skill": opts.skill, + "agent_name": opts.agent_name, + } + + +@click.command(name="donate") +@click.option( + "--skill", + default=DEFAULT_SKILL, + show_default=True, + help="Donation skill to run (the subdir name under the host-dir skill cache).", +) +@click.option( + "--skill-repo", + default=DEFAULT_SKILL_REPO, + show_default=True, + help="Upstream git repo the skill (code + prompts) is checked out from.", +) +@click.option( + "--skill-ref", + default=DEFAULT_SKILL_REF, + show_default=True, + help="Git ref to check out: a branch to track, or a pinned commit for a reviewed version.", +) +@click.option( + "--agent-name", + default=DEFAULT_AGENT_NAME, + show_default=True, + help="Name for the created donation agent.", +) +@click.option( + "--dry-run", + is_flag=True, + default=False, + help="Report the spare-capacity decision without creating an agent.", +) +@click.option( + "--start", + is_flag=True, + default=False, + help="Schedule donate to run automatically (installs a launchd LaunchAgent; macOS only) and exit.", +) +@click.option( + "--stop", + is_flag=True, + default=False, + help="Remove the scheduled donate LaunchAgent and exit.", +) +@click.option( + "--interval-minutes", + type=click.IntRange(min=1), + default=DEFAULT_INTERVAL_MINUTES, + show_default=True, + help="With --start: how often the scheduled donate runs.", +) +@add_common_options +@click.pass_context +def donate(ctx: click.Context, **kwargs: Any) -> None: + """Spend spare Claude capacity on a donation skill. + + Reads account-level usage (the same snapshot ``mngr usage`` shows): when the + 5h window still has budget and the week is under pace, create a + non-interactive agent that runs the donation skill; otherwise do nothing. + One tick per invocation -- use ``--start`` to install a launchd LaunchAgent + (macOS) that re-runs it on an interval (``--stop`` removes it), so spare quota + is drained over many ticks. Run it from a trusted git repo, since the created + agent is sourced from the current directory. + """ + mngr_ctx, output_opts, opts = setup_command_context( + ctx=ctx, + command_name="donate", + command_class=DonateCliOptions, + ) + + # --start / --stop manage the launchd schedule and exit; they don't do a tick, + # so they run before (and independently of) the spare-capacity check. + if opts.start and opts.stop: + raise MngrError("Pass only one of --start / --stop.") + if opts.start: + message = _install_schedule( + opts.skill, opts.skill_repo, opts.skill_ref, opts.agent_name, opts.interval_minutes + ) + emit_info(f"{message}\nStop it with `mngr donate --stop`.", output_opts.output_format) + return + if opts.stop: + emit_info(_remove_schedule(), output_opts.output_format) + return + + plugin_config = mngr_ctx.get_plugin_config("usage", UsagePluginConfig) + now = int(time.time()) + # Stamp every tick up front so an accumulating log (esp. schedule.log) shows when + # each run fired and what it decided -- printed for all branches (skip / no + # data / launch) since it lands before the capacity check. + emit_info( + f"===== donate tick {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(now))} =====", output_opts.output_format + ) + snapshots = gather_usage_snapshots( + mngr_ctx, + include_filters=(), + exclude_filters=(), + provider_names=None, + since_seconds=plugin_config.since_seconds, + now=now, + include_preserved=True, + ) + claude_snapshot = next((s for s in snapshots if s.source_name == CLAUDE_SOURCE), None) + capacity = evaluate_capacity(claude_snapshot, now) + data = _result_data(capacity, opts) + + if not capacity.has_spare: + if not capacity.has_usage_data: + since_hours = plugin_config.since_seconds // 3600 + message = ( + f"No Claude usage data in the last {since_hours}h, so spare capacity can't be judged -- " + f"skipping. (Usage is recorded by mngr-managed Claude agents; run one, e.g. " + f"`mngr create warmup claude`, to populate it.)" + ) + action = "no_data" + else: + message = ( + f"No spare capacity right now (5h used {data['five_hour_used_percentage']}%, " + f"weekly used {data['weekly_used_percentage']}% vs pace {data['weekly_pace_line']}%); skipping." + ) + action = "skipped" + emit_operator_result( + "donate", + [OperatorResultPart.shown(message, action=action, **data)], + output_opts.output_format, + ) + return + + if opts.dry_run: + emit_operator_result( + "donate", + [ + OperatorResultPart.shown( + f"Spare capacity available -- would create '{opts.agent_name}' to run the " + f"{opts.skill} skill (dry run).", + action="dry_run", + **data, + ) + ], + output_opts.output_format, + ) + return + + mngr_path = _current_mngr_path() + if shutil.which(mngr_path) is None and not Path(mngr_path).is_file(): + raise MngrError(f"Could not locate an mngr executable to launch the donation agent (tried {mngr_path!r}).") + # Assemble the skill dir (pinned code + freshly-pulled prompts) before launch, + # and point the agent at it. + skill_dir = prepare_skill_dir(opts.skill, opts.skill_repo, opts.skill_ref) + argv = build_create_argv(opts.agent_name, str(skill_dir), mngr_path) + log_path = _donation_log_path(opts.agent_name, now) + emit_info( + f"Spare capacity available -- launching '{opts.agent_name}' to run the {opts.skill} skill " + f"from {skill_dir}.\nStreaming its steps below; full event log at {log_path}", + output_opts.output_format, + ) + # Clear anything a prior failed tick left behind so the create below can't + # collide: first the tracked agent, then an orphaned git worktree/branch that + # `mngr destroy` doesn't cover. Both best-effort -- a missing agent/worktree + # is the normal case, and a cleanup failure shouldn't mask the create's own. + subprocess.run(list(build_destroy_argv(opts.agent_name, mngr_path)), check=False, capture_output=True) + _clear_stale_worktree(opts.agent_name) + returncode = _run_and_tee(argv, log_path, env=_donation_agent_env()) + if returncode != 0: + raise MngrError(f"`{' '.join(argv)}` exited with status {returncode} (see {log_path}).") + emit_operator_result( + "donate", + [ + OperatorResultPart.shown( + f"Created '{opts.agent_name}' to run the {opts.skill} skill.", + action="created", + **data, + ) + ], + output_opts.output_format, + ) diff --git a/libs/mngr_donate/imbue/mngr_donate/donate_test.py b/libs/mngr_donate/imbue/mngr_donate/donate_test.py new file mode 100644 index 0000000000..5cb061bce8 --- /dev/null +++ b/libs/mngr_donate/imbue/mngr_donate/donate_test.py @@ -0,0 +1,244 @@ +"""Unit tests for ``mngr_donate.donate`` -- the spare-capacity decision and argv builder. + +The decision logic is a pure function over a usage snapshot, so it's tested here +directly with hand-built snapshots (no config/host setup). The ``donate`` command +wiring itself (gather -> decide -> ``mngr create``) is exercised end-to-end in the +integration tests / by ``mngr donate --dry-run``. +""" + +from __future__ import annotations + +import pytest + +from imbue.mngr_donate.donate import CLAUDE_SOURCE +from imbue.mngr_donate.donate import DEFAULT_SKILL +from imbue.mngr_donate.donate import DEFAULT_SKILL_REF +from imbue.mngr_donate.donate import DEFAULT_SKILL_REPO +from imbue.mngr_donate.donate import FIVE_HOUR_WINDOW +from imbue.mngr_donate.donate import SEVEN_DAY_WINDOW +from imbue.mngr_donate.donate import build_agent_env +from imbue.mngr_donate.donate import build_create_argv +from imbue.mngr_donate.donate import build_destroy_argv +from imbue.mngr_donate.donate import build_donation_message +from imbue.mngr_donate.donate import build_launchd_plist +from imbue.mngr_donate.donate import evaluate_capacity +from imbue.mngr_donate.donate import weekly_pace_line +from imbue.mngr_usage.data_types import UsageSnapshot +from imbue.mngr_usage.data_types import WindowSnapshot + +# A fixed "now" and a 7-day window; resets_at is set relative to NOW so a chosen +# fraction of the window has elapsed. +_NOW = 1_000_000 +_WEEK_SECONDS = 7 * 24 * 60 * 60 + + +def _seven_day(*, used_percentage: float, elapsed_fraction: float) -> WindowSnapshot: + """A seven_day window whose derived elapsed% is ``elapsed_fraction * 100``.""" + seconds_until_reset = int(_WEEK_SECONDS * (1 - elapsed_fraction)) + return WindowSnapshot( + used_percentage=used_percentage, + window_seconds=_WEEK_SECONDS, + resets_at=_NOW + seconds_until_reset, + ) + + +def _snapshot(**windows: WindowSnapshot) -> UsageSnapshot: + return UsageSnapshot(source_name=CLAUDE_SOURCE, updated_at=_NOW, windows=windows) + + +def test_weekly_pace_line_starts_below_and_meets_the_plain_line() -> None: + # Early in the cycle the ceiling sits ~30% under the plain used==elapsed line... + assert weekly_pace_line(0.0) == pytest.approx(0.0) + # 50 * (1 - 0.30 * 0.5) == 42.5 + assert weekly_pace_line(50.0) == pytest.approx(42.5) + # ...and meets it exactly at the end of the cycle. + assert weekly_pace_line(100.0) == pytest.approx(100.0) + + +def test_spare_when_five_hour_has_budget_and_weekly_under_pace() -> None: + snap = _snapshot( + **{ + FIVE_HOUR_WINDOW: WindowSnapshot(used_percentage=10.0), + SEVEN_DAY_WINDOW: _seven_day(used_percentage=5.0, elapsed_fraction=0.5), + } + ) + decision = evaluate_capacity(snap, _NOW) + assert decision.has_spare is True + assert decision.has_usage_data is True + assert decision.five_hour_used_percentage == pytest.approx(10.0) + assert decision.weekly_elapsed_percentage == pytest.approx(50.0) + assert decision.weekly_pace_line == pytest.approx(42.5) + + +def test_no_spare_when_five_hour_window_is_near_exhausted() -> None: + snap = _snapshot( + **{ + FIVE_HOUR_WINDOW: WindowSnapshot(used_percentage=85.0), + SEVEN_DAY_WINDOW: _seven_day(used_percentage=1.0, elapsed_fraction=0.5), + } + ) + # 85 >= 80 ceiling -> no spare, even though the week is wide open. + assert evaluate_capacity(snap, _NOW).has_spare is False + + +def test_no_spare_when_weekly_usage_is_over_pace() -> None: + snap = _snapshot( + **{ + FIVE_HOUR_WINDOW: WindowSnapshot(used_percentage=10.0), + # elapsed 50% -> pace line 42.5; 45 is over it. + SEVEN_DAY_WINDOW: _seven_day(used_percentage=45.0, elapsed_fraction=0.5), + } + ) + assert evaluate_capacity(snap, _NOW).has_spare is False + + +def test_missing_snapshot_is_treated_as_fully_used_but_flagged_as_no_data() -> None: + decision = evaluate_capacity(None, _NOW) + assert decision.has_spare is False + # Conservative percentages, but flagged so the caller says "can't tell", not "maxed out". + assert decision.has_usage_data is False + assert decision.five_hour_used_percentage == pytest.approx(100.0) + assert decision.weekly_used_percentage == pytest.approx(100.0) + + +def test_partial_reading_counts_as_having_usage_data() -> None: + # Only the 5h window has a reading; that's still real data, not a blank tick. + snap = _snapshot(**{FIVE_HOUR_WINDOW: WindowSnapshot(used_percentage=90.0)}) + decision = evaluate_capacity(snap, _NOW) + assert decision.has_usage_data is True + # 90 >= 80 ceiling + assert decision.has_spare is False + + +def test_snapshot_without_windows_is_conservative() -> None: + decision = evaluate_capacity(_snapshot(), _NOW) + assert decision.has_spare is False + # A snapshot with no windows carries no readings -> treated as "no data". + assert decision.has_usage_data is False + assert decision.five_hour_used_percentage == pytest.approx(100.0) + assert decision.weekly_used_percentage == pytest.approx(100.0) + + +def test_window_without_derivable_elapsed_yields_zero_pace_and_no_spare() -> None: + # A seven_day window with no window_seconds -> elapsed% not derivable -> 0 -> + # pace line 0 -> weekly can never be "under pace", so never spare. + snap = _snapshot( + **{ + FIVE_HOUR_WINDOW: WindowSnapshot(used_percentage=1.0), + SEVEN_DAY_WINDOW: WindowSnapshot(used_percentage=0.0, resets_at=_NOW + 1000), + } + ) + decision = evaluate_capacity(snap, _NOW) + assert decision.weekly_elapsed_percentage == pytest.approx(0.0) + assert decision.has_spare is False + + +def test_build_create_argv_launches_a_headless_agent_that_skips_permissions() -> None: + argv = build_create_argv("donate-extra-quota-bio", "/host/donate-skills/document-review") + assert argv[:10] == ( + "mngr", + "create", + "donate-extra-quota-bio", + "headless_claude", + "--foreground", + "--no-ensure-clean", + # Force shared config so claude uses/refreshes the real keychain token. + "-S", + "agent_types.headless_claude.isolate_local_config_dir=false", + "--message", + build_donation_message("/host/donate-skills/document-review"), + ) + assert argv[10:] == ( + "--", + "--output-format", + "stream-json", + "--verbose", + "--include-partial-messages", + "--dangerously-skip-permissions", + ) + + +def test_build_donation_message_points_the_agent_at_the_skill_dir() -> None: + message = build_donation_message("/host/donate-skills/document-review") + # Points at the assembled cache dir (not Claude skill auto-discovery) and its SKILL.md. + assert "/host/donate-skills/document-review/SKILL.md" in message + assert "client.py" in message + + +def test_build_launchd_plist_embeds_program_env_and_interval() -> None: + plist = build_launchd_plist( + "/venv/bin/mngr", + "/repo", + DEFAULT_SKILL, + DEFAULT_SKILL_REPO, + DEFAULT_SKILL_REF, + "donate-extra-quota-bio", + "/logs/schedule.log", + "/usr/bin:/bin", + 600, + ) + # Runs mngr donate directly (no shell), in the repo, with the given PATH + interval. + assert "/venv/bin/mngr" in plist + assert "donate" in plist + # WorkingDirectory + assert "/repo" in plist + # EnvironmentVariables PATH + assert "/usr/bin:/bin" in plist + # StartInterval seconds (600s == 10 min) + assert "600" in plist + assert "/logs/schedule.log" in plist + # Defaults are omitted from ProgramArguments (kept minimal). + assert "--skill" not in plist + assert "--skill-repo" not in plist + assert "--skill-ref" not in plist + assert "--agent-name" not in plist + + +def test_build_launchd_plist_includes_non_default_options() -> None: + plist = build_launchd_plist( + "/venv/bin/mngr", + "/repo", + "other-skill", + "https://example.com/other-skill.git", + "some-branch", + "my-agent", + "/logs/schedule.log", + "/usr/bin", + 60, + ) + assert "--skill" in plist + assert "other-skill" in plist + # Non-default skill repo/ref are carried into the scheduled invocation, so a + # schedule installed with a custom ref actually runs against that ref. + assert "--skill-repo" in plist + assert "https://example.com/other-skill.git" in plist + assert "--skill-ref" in plist + assert "some-branch" in plist + assert "--agent-name" in plist + assert "my-agent" in plist + + +def test_build_destroy_argv_force_removes_a_stale_agent_by_name() -> None: + assert build_destroy_argv("donate-extra-quota-bio") == ( + "mngr", + "destroy", + "donate-extra-quota-bio", + "--force", + ) + + +def test_agent_env_inherits_unchanged_when_token_already_set() -> None: + # An explicit CLAUDE_CODE_OAUTH_TOKEN in the environment wins over the stash. + assert build_agent_env({"CLAUDE_CODE_OAUTH_TOKEN": "already-set"}, "stashed-token") is None + + +def test_agent_env_injects_stashed_keychain_token() -> None: + env = build_agent_env({"PATH": "/usr/bin"}, "stashed-token") + assert env is not None + assert env["CLAUDE_CODE_OAUTH_TOKEN"] == "stashed-token" + # The rest of the environment passes through. + assert env["PATH"] == "/usr/bin" + + +def test_agent_env_inherits_unchanged_when_no_token_is_stashed() -> None: + assert build_agent_env({"PATH": "/usr/bin"}, None) is None diff --git a/libs/mngr_donate/imbue/mngr_donate/plugin.py b/libs/mngr_donate/imbue/mngr_donate/plugin.py new file mode 100644 index 0000000000..56728972f4 --- /dev/null +++ b/libs/mngr_donate/imbue/mngr_donate/plugin.py @@ -0,0 +1,23 @@ +"""mngr_donate plugin entry point. + +Provides the ``mngr donate`` command: spend spare Claude capacity (as reported by +the ``mngr usage`` snapshot) on a donation skill. Kept separate from ``mngr_usage`` +because measuring usage and donating idle quota are orthogonal capabilities; this +plugin depends on ``imbue-mngr-usage`` (installing donate pulls usage in) and reads +its ``usage`` plugin config + snapshot API at runtime. +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import click + +from imbue.mngr import hookimpl +from imbue.mngr_donate.donate import donate + + +@hookimpl +def register_cli_commands() -> Sequence[click.Command] | None: + """Register the `mngr donate` command.""" + return [donate] diff --git a/libs/mngr_donate/imbue/mngr_donate/test_ratchets.py b/libs/mngr_donate/imbue/mngr_donate/test_ratchets.py new file mode 100644 index 0000000000..f0dcaa340f --- /dev/null +++ b/libs/mngr_donate/imbue/mngr_donate/test_ratchets.py @@ -0,0 +1,282 @@ +from pathlib import Path + +import pytest +from inline_snapshot import snapshot + +from imbue.imbue_common.ratchet_testing import standard_ratchet_checks as rc + +_DIR = Path(__file__).parent.parent.parent + +pytestmark = pytest.mark.xdist_group(name="ratchets") + + +# --- Code safety --- + + +def test_prevent_todos() -> None: + rc.check_todos(_DIR, snapshot(0)) + + +def test_prevent_exec() -> None: + rc.check_exec(_DIR, snapshot(0)) + + +def test_prevent_eval() -> None: + rc.check_eval(_DIR, snapshot(0)) + + +def test_prevent_while_true() -> None: + rc.check_while_true(_DIR, snapshot(0)) + + +def test_prevent_time_sleep() -> None: + rc.check_time_sleep(_DIR, snapshot(1)) + + +def test_prevent_global_keyword() -> None: + rc.check_global_keyword(_DIR, snapshot(0)) + + +def test_prevent_bare_print() -> None: + rc.check_bare_print(_DIR, snapshot(1)) + + +# --- Exception handling --- + + +def test_prevent_bare_except() -> None: + rc.check_bare_except(_DIR, snapshot(0)) + + +def test_prevent_broad_exception_catch() -> None: + rc.check_broad_exception_catch(_DIR, snapshot(0)) + + +def test_prevent_base_exception_catch() -> None: + rc.check_base_exception_catch(_DIR, snapshot(0)) + + +def test_prevent_builtin_exception_raises() -> None: + rc.check_builtin_exception_raises(_DIR, snapshot(0)) + + +def test_prevent_silent_decode_error_catches() -> None: + rc.check_silent_decode_error_catches(_DIR, snapshot(0)) + + +# --- Import style --- + + +def test_prevent_inline_imports() -> None: + rc.check_inline_imports(_DIR, snapshot(0)) + + +def test_prevent_relative_imports() -> None: + rc.check_relative_imports(_DIR, snapshot(0)) + + +def test_prevent_import_datetime() -> None: + rc.check_import_datetime(_DIR, snapshot(0)) + + +def test_prevent_importlib_import_module() -> None: + rc.check_importlib_import_module(_DIR, snapshot(0)) + + +def test_prevent_getattr() -> None: + rc.check_getattr(_DIR, snapshot(0)) + + +def test_prevent_setattr() -> None: + rc.check_setattr(_DIR, snapshot(0)) + + +# --- Banned libraries and patterns --- + + +def test_prevent_asyncio_import() -> None: + rc.check_asyncio_import(_DIR, snapshot(0)) + + +def test_prevent_pandas_import() -> None: + rc.check_pandas_import(_DIR, snapshot(0)) + + +def test_prevent_dataclasses_import() -> None: + rc.check_dataclasses_import(_DIR, snapshot(0)) + + +def test_prevent_namedtuple() -> None: + rc.check_namedtuple(_DIR, snapshot(0)) + + +def test_prevent_yaml_usage() -> None: + rc.check_yaml_usage(_DIR, snapshot(1)) + + +def test_prevent_functools_partial() -> None: + rc.check_functools_partial(_DIR, snapshot(0)) + + +def test_prevent_exit_stack() -> None: + rc.check_exit_stack(_DIR, snapshot(0)) + + +def test_prevent_async_await() -> None: + rc.check_async_await(_DIR, snapshot(0)) + + +# --- Hardcoded paths --- + + +def test_prevent_hardcoded_claude_dir() -> None: + rc.check_hardcoded_claude_dir(_DIR, snapshot(0)) + + +def test_prevent_hardcoded_guarded_binary() -> None: + rc.check_hardcoded_guarded_binary(_DIR, snapshot(0)) + + +# --- Naming conventions --- + + +def test_prevent_num_prefix() -> None: + rc.check_num_prefix(_DIR, snapshot(0)) + + +# --- Documentation --- + + +def test_prevent_trailing_comments() -> None: + rc.check_trailing_comments(_DIR, snapshot(0)) + + +def test_prevent_init_docstrings() -> None: + rc.check_init_docstrings(_DIR, snapshot(0)) + + +@pytest.mark.timeout(10) +def test_prevent_args_in_docstrings() -> None: + rc.check_args_in_docstrings(_DIR, snapshot(0)) + + +@pytest.mark.timeout(10) +def test_prevent_returns_in_docstrings() -> None: + rc.check_returns_in_docstrings(_DIR, snapshot(0)) + + +# --- Type safety --- + + +def test_prevent_literal_with_multiple_options() -> None: + rc.check_literal_with_multiple_options(_DIR, snapshot(0)) + + +def test_prevent_bare_generic_types() -> None: + rc.check_bare_generic_types(_DIR, snapshot(0)) + + +def test_prevent_typing_builtin_imports() -> None: + rc.check_typing_builtin_imports(_DIR, snapshot(0)) + + +def test_prevent_short_uuid_ids() -> None: + rc.check_short_uuid_ids(_DIR, snapshot(0)) + + +# --- Pydantic / models --- + + +def test_prevent_model_copy() -> None: + rc.check_model_copy(_DIR, snapshot(0)) + + +# --- Logging --- + + +def test_prevent_fstring_logging() -> None: + rc.check_fstring_logging(_DIR, snapshot(0)) + + +def test_prevent_click_echo() -> None: + rc.check_click_echo(_DIR, snapshot(0)) + + +def test_prevent_logger_exception() -> None: + rc.check_logger_exception(_DIR, snapshot(0)) + + +# --- Testing conventions --- + + +def test_prevent_unittest_mock_imports() -> None: + rc.check_unittest_mock_imports(_DIR, snapshot(0)) + + +def test_prevent_monkeypatch_setattr() -> None: + rc.check_monkeypatch_setattr(_DIR, snapshot(0)) + + +def test_prevent_test_container_classes() -> None: + rc.check_test_container_classes(_DIR, snapshot(0)) + + +def test_prevent_pytest_mark_integration() -> None: + rc.check_pytest_mark_integration(_DIR, snapshot(0)) + + +# --- Process management --- + + +def test_prevent_os_fork() -> None: + rc.check_os_fork(_DIR, snapshot(0)) + + +def test_prevent_bare_urwid_tty_signal_keys() -> None: + rc.check_bare_urwid_tty_signal_keys(_DIR, snapshot(0)) + + +def test_prevent_direct_subprocess() -> None: + rc.check_direct_subprocess(_DIR, snapshot(14)) + + +def test_prevent_bare_tmux_targets() -> None: + rc.check_bare_tmux_targets(_DIR, snapshot(0)) + + +# --- AST-based ratchets --- + + +def test_prevent_if_elif_without_else() -> None: + rc.check_if_elif_without_else(_DIR, snapshot(0)) + + +def test_prevent_inline_functions() -> None: + rc.check_inline_functions(_DIR, snapshot(0)) + + +def test_prevent_underscore_imports() -> None: + rc.check_underscore_imports(_DIR, snapshot(0)) + + +def test_prevent_init_methods_in_non_exception_classes() -> None: + rc.check_init_methods_in_non_exception_classes(_DIR, snapshot(0)) + + +def test_prevent_cast_usage() -> None: + rc.check_cast_usage(_DIR, snapshot(0)) + + +def test_prevent_assert_isinstance() -> None: + rc.check_assert_isinstance(_DIR, snapshot(0)) + + +def test_prevent_per_file_host_upload() -> None: + rc.check_per_file_host_upload(_DIR, snapshot(0)) + + +# --- Project-level checks --- + + +def test_prevent_code_in_init_files() -> None: + rc.check_code_in_init_files(_DIR, snapshot(0)) diff --git a/libs/mngr_donate/pyproject.toml b/libs/mngr_donate/pyproject.toml new file mode 100644 index 0000000000..829546a550 --- /dev/null +++ b/libs/mngr_donate/pyproject.toml @@ -0,0 +1,70 @@ +[project] +name = "imbue-mngr-donate" +version = "0.1.0" +description = "mngr donate: spend spare Claude capacity on a donation skill (e.g. scientific document review)" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "imbue-mngr==0.2.17", + "imbue-mngr-usage==0.1.6", + "click>=8.0", + "pydantic>=2.0", +] + +[project.entry-points.mngr] +donate = "imbue.mngr_donate.plugin" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["imbue"] +exclude = ["*_test.py", "test_*.py", "**/conftest.py", "**/testing.py"] + +[tool.uv.sources] +imbue-mngr = { workspace = true } +imbue-mngr-usage = { workspace = true } + +[tool.pytest.ini_options] +timeout_func_only = true +junit_family = "xunit1" +testpaths = ["."] +addopts = [ + "--cov=imbue.mngr_donate", + "--cov-report=term-missing", + "--cov-report=xml", + "--durations=20", + "--timeout=10", + "--timeout-method=signal", + "--slow-tests-to-file", + "--coverage-to-file", +] + +[tool.coverage.run] +parallel = true +concurrency = ["multiprocessing", "thread"] +omit = [ + "*_test.py", + "test_*.py", + "*/tests/*", + "*/conftest.py", + "*/utils/testing.py", +] + +[tool.inline-snapshot] +default-flags=["report"] +default-flags-tui=["report"] +default-flags-ide=["report"] +format-command="uv run ruff format --force-exclude --config pyproject.toml --stdin-filename {filename}" + +[tool.pyright] +venvPath = "../.." +venv = ".venv" +pythonVersion = "3.12" +strict = ["**/*.py"] + +[tool.coverage.report] +# donate is glue-heavy (launchd/subprocess/git IO that's verified end-to-end, not unit-tested); +# the pure decision logic + builders are fully covered. Raise this as IO gets more test seams. +fail_under = 25 diff --git a/pyproject.toml b/pyproject.toml index 44b7896fb8..edea2159f7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -87,6 +87,7 @@ addopts = [ "--cov=imbue.mngr_tutor", "--cov=imbue.mngr_kanpan", "--cov=imbue.mngr_usage", + "--cov=imbue.mngr_donate", "--cov=imbue.mngr_claude_usage", "--cov=imbue.mngr_opencode_usage", "--cov=imbue.mngr_pi_coding_usage", diff --git a/scripts/make_cli_docs.py b/scripts/make_cli_docs.py index 42bc9cc217..75bf5a687e 100644 --- a/scripts/make_cli_docs.py +++ b/scripts/make_cli_docs.py @@ -74,6 +74,7 @@ "chat", "cleanup", "config", + "donate", "event", "file", "forward", diff --git a/uv.lock b/uv.lock index f15b1d520e..22a067c695 100644 --- a/uv.lock +++ b/uv.lock @@ -24,6 +24,7 @@ members = [ "imbue-mngr-claude-usage", "imbue-mngr-codex", "imbue-mngr-codex-usage", + "imbue-mngr-donate", "imbue-mngr-file", "imbue-mngr-forward", "imbue-mngr-gcp", @@ -2362,6 +2363,25 @@ requires-dist = [ { name = "imbue-mngr-usage", editable = "libs/mngr_usage" }, ] +[[package]] +name = "imbue-mngr-donate" +version = "0.1.0" +source = { editable = "libs/mngr_donate" } +dependencies = [ + { name = "click" }, + { name = "imbue-mngr" }, + { name = "imbue-mngr-usage" }, + { name = "pydantic" }, +] + +[package.metadata] +requires-dist = [ + { name = "click", specifier = ">=8.0" }, + { name = "imbue-mngr", editable = "libs/mngr" }, + { name = "imbue-mngr-usage", editable = "libs/mngr_usage" }, + { name = "pydantic", specifier = ">=2.0" }, +] + [[package]] name = "imbue-mngr-file" version = "0.1.14"