English | Русский
greenkeep is a scheduled, gated maintainer for your own Python repositories. Deterministic tools find the problems, an LLM writes one patch per finding, and the fix PR auto-merges only when the full test suite passes and every changed line was already covered by tests. It's for a developer with a pile of pet projects who wants them to keep getting small, safe fixes without reviewing each one by hand.
Every fix PR resolves one finding, and its commit message names the model and the prompt hash that produced the patch.
A real dry run on a small demo repo with a mutable default argument (ruff B006). The patch came from a free-tier model through OmniRoute; the whole run took about 10 seconds:
$ greenkeep ~/dev/tidy --keep
tidy: B006: Do not use mutable data structures for argument defaults (src/tidy/tags.py)
merged B006: Do not use mutable data structures for argument defaults (src/tidy/tags.py) via gemini-3.8-flash-tiered fix: B006: Do not use mutable data structures for argument defaults (src
Finding: `B006` at `src/tidy/tags.py:1`: Do not use mutable data structures for argument defaults
Patched by: `gemini-3.8-flash-tiered` (prompt `cbd0ba095834`)
Gate: full test suite green; every changed line was covered before the change; no new lint findings.
-def add_tag(tag, tags=[]):
+def add_tag(tag, tags=None):
+ if tags is None:
+ tags = []
tags.append(tag.strip().lower())
return tagsIn a dry run, "merged" means merged into a throwaway clone. With --push the same change becomes a plan issue and a fix PR on GitHub, which is squash-merged once the required CI checks pass.
The first week on the author's 19 tended repos (2026-10-15 to 2026-10-22), from greenkeep's state database and the PRs themselves:
| Merged | How | |
|---|---|---|
| Vulnerable dependencies | 17 | uv lock --upgrade-package, lockfile only: certifi PYSEC-2026-4320 in 9 repos on one day, Pillow, werkzeug, aiohttp… |
| Outdated dependencies | 2 | same, same major only (alembic) |
| Lint findings | 12 | 6 by ruff --fix, 6 patched by the LLM, e.g. a file handle that was never closed (finance-insights#6) |
| Characterization tests | 1 | repo-scout#10 |
32 PRs in 16 repos. A separate review of all 32 (Opus, reading every diff) judged 23 good, 5 acceptable and 4 bad:
- repo-scout#10: 559 lines of tests for a vendored copy of another repo's module, from the first version that had no reviewer and no line budget. Vendored code now gets no findings.
- whisper-gpu#2:
ruff --fixfor unusednoqacomments also deleted the explanations written after them. That rule now goes to the patcher, which keeps them. - linux-coding#23 and claude-cli#4: correct but worse code — a logger added to a test only to please
S110, a string reflowed to column 0. The patcher and reviewer prompts now ask for the rule's idiomatic fix.
What it did not manage: 20 findings were parked after three attempts, 16 of them characterization tests (wrong guesses about behaviour, new lint, a reviewer rejection, tests put where the repo does not keep them). Writing good tests for untested code is the hardest job it has, and the one it fails most.
- Clone: each repo you pass is cloned into
~/.cache/greenkeep; your checkout is never touched. If its tests are red before greenkeep changes anything, the repo is skipped. - Find: findings come from deterministic tools only: pip-audit (vulnerable dependencies), ruff (security, bug and pyflakes rules first), coverage (poorly tested modules), outdated dependencies and readiness checks. An opt-in bug hunt adds model-suspected bugs, each fixed test first.
- Patch: for each finding the LLM gets the finding and its code and returns SEARCH/REPLACE edits. For an untested module it writes characterization tests that pin down what the code does today.
- Gate: the change is kept only if the full suite passes, every changed line was covered before the change (for a new test file: module coverage grew), and ruff reports nothing new.
- Merge or park: a passing change is committed (and with
--push, opened as a PR and merged on green). A finding gets three attempts; after that it is parked and labelledneeds-human.
Why deterministic findings and not a free-roaming coding agent: ADR 0001. Terms used here: CONTEXT.md.
Linux and macOS:
curl -LsSf https://raw.githubusercontent.com/octrow/greenkeep/main/install.sh | shWindows (PowerShell):
powershell -ExecutionPolicy ByPass -c "irm https://raw.githubusercontent.com/octrow/greenkeep/main/install.ps1 | iex"The script installs uv if it's missing, then the wheel from the latest release. Run it again to update; ... | sh -s -- --uninstall removes greenkeep. To read the script before running it, download install.sh and run sh install.sh. Or install with uv directly:
uv tool install git+https://github.com/octrow/greenkeepYou also need:
- git, and the GitHub CLI logged in with
gh auth login(only for--push); - Claude Code logged in, called through claude-subprocess as plain
claude -p, so the account Claude Code is logged in to pays: both run inside the clone with read-only tools: Sonnet writes the patches, Opus reviews them.
greenkeep ~/dev/repo-a ~/dev/repo-b # dry run: nothing leaves the machine except LLM calls
greenkeep ~/dev/repo-a --push --budget 3 # open issues and PRs, merge on green, at most 3 merges a day| Option | Default | What it does |
|---|---|---|
--push |
off | Open a plan issue and a fix PR, wait for the required checks, squash-merge |
--budget N |
3 | Merged fix PRs per day, across all repos |
--model NAME |
claude-sonnet-5-5 |
Claude Code model that writes the patches |
--review-model NAME |
claude-opus-5-5 |
Claude Code model that reviews each patch inside the clone |
--keep |
off | Keep the throwaway clones in ~/.cache/greenkeep for inspection |
A run makes at most one fix per repo, so it's meant to run on a schedule. A daily cron line is enough:
0 6 * * * $HOME/.local/bin/greenkeep $HOME/dev/repo-a $HOME/dev/repo-b --push >> $HOME/.local/state/greenkeep.log 2>&1State (attempts per finding, merges per day) lives in ~/.local/share/greenkeep/state.db. A dry run never writes it.
- Python, managed with uv: a
pyproject.tomlat the root (uv syncmust work). - Tests that pytest can run and that pass today. A repo with no tests at all is fine: greenkeep starts by writing characterization tests.
- For
--push: a GitHub remote you can push to and merge into.
- Dependabot, Renovate: they bump dependencies, with a PR per bump. greenkeep only bumps a dependency for a known vulnerability or a same-major update, and only merges it on a green suite; most of its work is code findings and tests. Both can run on the same repo.
- ruff
--fix, pre-commit.ci autofix: they apply only the fixes ruff can make mechanically. greenkeep uses ruff's safe fix when there is one and calls the LLM only for findings ruff can't fix, likeB006above; both kinds go through the same gate and get their own PR. - Coding agents (Copilot coding agent, OpenHands, Sweep): an agent decides what to change and how much. Here a deterministic tool picks the problem, the patch is limited to one finding, and nothing merges unless tests already covered every line it touched. The agents can do more; greenkeep aims to be boring and repeatable.
- patchwork: closest in spirit (AutoFix flows). It doesn't own the schedule, the daily budget, the coverage gate or the issue → PR → merge loop, and greenkeep is built around those.
In daily use on the author's repos since 2026-10-16. What is done and what comes next: Status and roadmap.
See CONTRIBUTING.md and SECURITY.md. Changes: CHANGELOG.md.
- Clone each tended repo into a throwaway directory (the local checkout is only read).
- Baseline:
uv sync, then the full test suite under coverage. Red baseline → skip the repo. - Findings, in work order: known vulnerabilities (pip-audit), ruff bugs, modules with little or no test coverage, outdated dependencies (same major), ruff style.
- Per finding, up to 3 attempts: a deterministic ruff fix or an LLM patch (Sonnet), then the
gate — only allowed files touched, changed lines covered before the change, no new lint, no
noqa-style suppressions, full suite green — then a review by Opus in Claude Code, which reads the repo, not just the diff. - A plan issue, a PR that closes it, squash merge once required CI checks pass.
- A digest of the run goes to Telegram.
At most one merge per repo per run and a daily budget across all repos.
uv run greenkeep ~/dev/some-repo # dry run: commits in a throwaway clone, no state
uv run greenkeep --push # the real thing, over the config's reposConfig ~/.config/greenkeep/config.toml:
budget = 3 # merged PRs per weekday
weekend_budget = 1
boost_until = 2026-10-30T03:00:00+05:00 # optional: until then, a run every hour
boost_budget = 15 # and this budget; every repo bug-hunts and lints
# for complexity, SIM, PERF, UP and RUF too
# and Opus plans each hard change before Sonnet writes it
repos = [
"~/dev/my-repo",
"owner/name-without-local-checkout",
{ path = "~/dev/monorepo", tests = "app" }, # the suite runs from app/, not the root
{ path = "~/dev/lib", hunt = true }, # bug hunt once nothing else is left to do
]
focus = ["~/dev/my-repo"] # optional: these run first every runTelegram digest: GREENKEEP_TG_BOT_TOKEN and GREENKEEP_TG_CHAT_ID in
~/.config/greenkeep/env (without them: desktop notification). Full log:
~/.local/state/greenkeep/greenkeep.log. Pause: touch ~/.config/greenkeep/PAUSE.
Dashboard (findings, pause/resume, dry run with live log, recent events):
uv run greenkeep-ui # http://127.0.0.1:8095If the port belongs to another app it moves to the next free one; if a greenkeep dashboard already runs there it asks whether to restart it (default No) and prints its link.
Once a day: the timer fires hourly and greenkeep exits at once if today's digest already went out, so a run cut short by a shutdown starts over after the next boot. During a boost every hourly firing is a run. A run where Claude does not answer (usage limit, logged out, API down) stops there without counting an attempt and leaves the day open, so the next hourly firing tries again.
cp contrib/systemd/greenkeep.* ~/.config/systemd/user/
systemctl --user daemon-reload && systemctl --user enable --now greenkeep.timerDone: ruff, coverage and dependency findings (vulnerabilities neither need nor spend the daily budget;
upgrades need 60% coverage), gate, Sonnet patcher + Opus reviewer, issue → PR → merge,
config, daily timer, digest (with a one-line Quiet: summary), pause, cool-down, dashboard, auto-pause (a reverted greenkeep merge stops all
runs until you delete the pause file, which says which merge it was), opt-in bug hunt
(Hunter → Skeptic, then a test-first fix whose test must fail without it), readiness checks
(pyproject description/readme/urls, README install and usage sections, CI badge; a missing
LICENSE or README is left to you, once, in the digest).
Next, roughly in this order:
- Bug hunt in uncovered code: today it only hunts modules at 50%+ coverage.
- JS/TS as a second language plugin.
Design: CONTEXT.md (glossary), docs/adr (decisions).