Housekeeping checks that a GitHub repo is in good order. It's for someone who maintains a pile of public and private repos and keeps hitting the same problems: no branch protection, stale lockfiles, missing CI, READMEs that rotted, dead website links. You point it at one repo, it tells you what's out of order, and it helps you fix it. It's a setup/audit tool you run when you touch a repo — not a daemon, not a nightly nag.
Same split as Straitjacket: deterministic checks do the work, judgment lives at the edges. Every check is plain code — no model in the loop. The things that genuinely need taste (is this README actually good?) are Claude Code skills layered on top, and they consume the deterministic report rather than re-deriving it.
Second principle: check ≠ fix. Checking is always safe and read-only. Fixing is a separate, explicit invocation. Every fix explains what it's about to do and seeks confirmation before touching anything. For fixes that need commits, the operator can approve going all the way: branch → commit → push → open a PR. Nothing mutates without an explicit yes.
One Python project, managed with uv. The only shell lives in scripts/ —
scripts/dev.sh stands up the dev environment (uv sync + git hooks).
housekeeping/
pyproject.toml # uv-managed; entry point `housekeeper`
src/housekeeper/
cli.py # check / fix / report
context.py # RepoContext: gh wrapper, clone cache
languages.py # single source of truth for per-language/ecosystem facts
registry.py # @check registry, Result type
checks/
branch_protection.py
ci.py # ci-exists + ci-green
dependabot.py
lockfiles.py
straitjacket.py
readme.py
website.py
license.py
repo_meta.py
stale.py
skills/
housekeeping/SKILL.md # /housekeeping — run audit, interpret, drive fixes
tidy-up/SKILL.md # audit front door + README judgment pass
housekeeping.toml # defaults: severities, overrides
README.md
Runtime dependencies stay minimal: stdlib (argparse, tomllib, json,
subprocess) plus at most one or two conveniences (rich for the report
table). GitHub access goes through the gh CLI via subprocess — it already
holds auth, handles pagination, and means housekeeping never manages tokens.
Clones go through git. Install with uv tool install ., or run in-tree
with uv run housekeeper.
A check is a module in checks/ that registers itself:
@check("lockfiles", needs=["clone"], fixable=True)
def lockfiles(ctx: RepoContext) -> Result: ...RepoContextgives it everything:ctx.repo(owner/name),ctx.workdir(checkout path, populated only whenneeds=["clone"]),ctx.api(path)(gh api wrapper),ctx.ecosystems(shared detection),ctx.visibility,ctx.config(merged toml).Resultcarriesstatus(PASS | FAIL | SKIP),details, and an optionalnote— skips are first-class and always say why ("no homepage set and none expected", "branch protection unavailable on this plan").- A fixable check also provides a
fix(ctx)in the same module, so the check and its remedy never drift apart.
Everything a check needs to know that's specific to a package manager or a
language lives in languages.py, never hard-coded in a check:
Ecosystem(detected by manifest — cargo, bun, npm, uv, ruby, go, …): its lockfile, how to verify/regenerate it (lock_check/lock_regen/tool), its dependabot id, its.gitignorebuild-junk patterns, itslanguage, and a CI job template.ECOSYSTEMSis the registry;detect_ecosystems()returns the entries a repo uses. Detection is nested-aware: it emits oneEcosysteminstance per location, each carrying itsdir(relative to the repo root, "" for root), so a Rust workspace'scrates/*-nodenpm package,crates/*-pythonuv package, and so on are graded at their real paths — a consumer reads the lockfile atworkdir / eco.dir / eco.lockfile, not the root.Language(rust, js, python, ruby, go): thetest/lint/fmtregexes that prove it's exercised in CI. Several ecosystems share one language, so they're joined byEcosystem.language.TypedLanguage(typescript, python, clojure): the type-layer axis — detectionmarkers, the typecheckersignal, andguidance.
A check reads eco.gitignore or LANGUAGES[eco.language].test; adding a
language means one new registry entry, not edits scattered across six checks.
- API-side checks call
ctx.api()only — branch protection, repo metadata, workflow runs, Dependabot alert settings. Fast, no disk. - File-side checks get a checkout. Run inside an existing clone and
housekeeping uses your working tree; run against a repo you don't have
locally and it shallow-clones into
~/.cache/housekeeping/<owner>/<repo>.
One repo at a time:
housekeeper check # repo inferred from cwd's git remote
housekeeper check zmaril/entl # or named explicitly
housekeeper check --only lockfiles,branch-protection
housekeeper fix dependabot # explain, confirm, then act
housekeeper report # re-render the last check run
housekeeper detect # show detected ecosystems, artifacts, recommended setupcheck prints a human table and writes JSON results to
~/.cache/housekeeping/results/<owner>-<repo>.json so report and the
skills can consume them without re-running. Exit code is nonzero if any
non-skipped check failed.
housekeeper fix <check> for each failing check:
- Show what's wrong and exactly what the fix will do (settings diff, file diff, or PR plan).
- Ask for confirmation. No is always safe.
- Apply. API-side fixes (rulesets, repo settings) apply directly on yes.
File-side fixes write to a branch; if the operator says they want it
committed and pushed, housekeeping commits, pushes the branch, and opens
a PR via
gh pr create. It never pushes to the default branch.
| Check | Source | Pass criteria | Fix |
|---|---|---|---|
branch-protection |
API | Default branch has a ruleset (or classic protection): PRs required, status checks required, force-push and deletion blocked | Apply a standard ruleset via API |
strict-status-checks |
API | Default branch requires branches be up to date before merging (required_status_checks.strict, read from ruleset or classic protection) so CI reruns against the true merged state; recommends the repo-level allow_update_branch setting |
Set strict_required_status_checks_policy on the default-branch ruleset + PATCH allow_update_branch on (needs admin; required-checks must exist first) |
auto-update-pr-branches |
clone + API | Only when required_status_checks.strict is on: a workflow keeps open PR branches current with the default branch after each merge, updating the head of each selected open PR via the update-branch API on every push to the default branch. Skip when strict is off (nothing to keep current) or unreadable (needs admin) |
Ship .github/workflows/auto-update-pr-branches.yml |
ci-exists |
clone | Workflows trigger on PR + push, and every detected language (rust, js, python, ruby, go) has its own test, lint, and fmt signals in CI — combined tools (biome, rubocop) satisfy lint+fmt for their language | Scaffold a workflow from per-ecosystem templates |
ci-green |
API | Latest completed default-branch run of every repo workflow (GitHub's dynamic/ internals excluded) concluded success |
none — report only |
builds |
clone | Every build target runs in CI: package.json build* scripts per PR (transitive script resolution counts); tauri needs a per-PR compile check plus a full build on a scheduled workflow |
none — report only |
coverage |
clone | Presence-only, advisory: every detected language ecosystem (rust, js, python) has some coverage tool wired up — a config file (codecov.yml, .coveragerc), a CI step / task-runner target (cargo llvm-cov, tarpaulin, bun test --coverage, c8/nyc, pytest-cov), or a manifest dep. No %, no threshold, no patch coverage; each repo configures the specifics. Skip when no rust/js/python ecosystem |
none — report only |
artifacts-built |
clone | Every detected build artifact (napi/wheel/gem/tauri/site/binary) is built in some workflow; heavy artifacts on a scheduled one | - |
codegen-drift |
clone | Declared [[codegen]] regen commands run in CI followed by a zero-diff assertion (git diff --exit-code); wiring-only, skip when nothing declared |
none — declare and wire by hand |
typecheck |
clone | If the language supports typechecking, it runs in CI: TypeScript (tsc/vue-tsc/astro check), Python (mypy/pyright/ty), Clojure (clj-kondo/core.typed); untyped JavaScript fails with add-a-type-layer guidance; compiled ecosystems skip | TS: add a typecheck workflow (bun/npm) with preflight error count; other languages get guidance |
dependabot |
clone + API | .github/dependabot.yml exists and covers every detected ecosystem (cargo, bun/npm, pip/uv, actions, …); vulnerability alerts + security updates enabled |
Generate/extend the yml; enable settings via API |
secret-scanning |
API | Secret scanning + push protection enabled (skip-with-note on private repos without Advanced Security) | Enable via API |
workflow-permissions |
API | Default workflow GITHUB_TOKEN is read-only and cannot approve PRs |
Set via API |
lockfiles |
clone | For each manifest, the lockfile is committed and in sync: cargo metadata --locked, bun install --frozen-lockfile --dry-run, uv lock --check, npm ci --dry-run per ecosystem |
Regenerate lockfile on a branch |
pinned-versions |
clone | No floating version specifiers per ecosystem (npm/bun/python/ruby exact; actions SHA-pinned; cargo advisory) - [pinned-versions] configures scope |
- |
gitignore |
clone | .gitignore exists and covers each ecosystem's build junk (target/, node_modules/, .venv/, …) |
Append missing patterns |
straitjacket |
clone | A CI workflow step runs straitjacket — wiring only, findings are straitjacket's own business | Add the CI step from template |
readme |
clone | Deterministic floor: README exists, has a title + description, install and usage sections, License and Contributing section headings (word families: licensing, contributions, … count), ≥ ~150 words, no broken relative links | Escalates to the tidy-up skill's README quality pass |
action-badge |
clone + API | If the repo is public and publishes an action (action.yml), the README links its Marketplace listing (skip otherwise) |
Insert a badge under the README title, slug derived from the action's name |
website |
API + HTTP | Repo homepage URL is set and returns 200 (following ≤3 redirects, 10s timeout); README badge/doc links resolve | none — report only |
license |
clone + API | LICENSE file present and GitHub detects a license | Drop in MIT with current year |
changelog |
clone | A CHANGELOG file exists (CHANGELOG.md, CHANGES.md, HISTORY.md, …) — presence only; versions, dates, or freeform all fine | Scaffold a newest-first dated stub |
repo-meta |
clone + API | The README declares the description + topics via invisible <!-- housekeeper:description ... --> / <!-- housekeeper:topics ... --> markers (README = source of truth), and GitHub's actual values match them (topics validated: lowercase [a-z0-9-], ≤50 chars, ≤20); issues enabled |
Push the README-declared values to GitHub (needs admin); when a repo has no markers yet, seed them into the README from GitHub's current values |
stale |
API | No PRs idle >30 days; no merged-but-undeleted branches; delete_branch_on_merge enabled |
Enable the setting; delete merged branches (confirm each) |
allow-auto-merge |
API | GitHub allow_auto_merge matches the declared [allow-auto-merge] enabled preference (default off) |
PATCH the repo setting (needs admin) |
dependabot-automerge |
clone | If [allow-auto-merge] dependabot = true, a workflow auto-merges dependabot PRs once required checks pass (requires enabled = true) |
Write the workflow |
stray-files |
clone | One todo pile (default todo.txt), notes corralled in one directory (default notes/), conventional community files at root, nothing else; all paths configurable, keepers via [stray-files] allow |
none — needs judgment |
conventional-commits |
clone + API | Enforced in CI (PR-title check or commitlint) and mentioned in README/CONTRIBUTING; recent default-branch adherence reported as a note, never judged retroactively | Set squash-title-from-PR-title; add the PR-title workflow |
stylelint |
clone | If the repo has stylesheets (.css/.scss/.less) or an existing stylelint config, a config is present and stylelint runs in CI; skip when there are no stylesheets |
Scaffold .stylelintrc.json + a CI step |
vale |
clone | A .vale.ini (with its StylesPath) is present and vale runs in CI. Overlaps with straitjacket by design — straitjacket scans for slop, vale enforces house style and terminology; wiring only, findings are vale's own business |
Scaffold .vale.ini + styles/ + a CI step |
codespell |
clone | codespell runs in CI. The fleet's spell checker, split from vale on purpose: vale's dictionary spell-check false-positives on every unknown jargon word, so vale does style/terms and codespell does spelling — it flags only known misspellings, so it can't cry wolf on a term it's never seen. Wiring only | Scaffold .codespellrc + a CI step |
auto-update-pr-branches is the operational other half of strict-status-checks:
strict is the floor (CI reruns against the true merged state), but it also means
every merge invalidates the up-to-date status of every other open PR, so someone
has to press "Update branch" over and over to keep the queue flowing. The shipped
workflow does that clicking on every push to the default branch. It stays
loop-safe because updating a PR branch is not a push to the default branch, so it
never re-triggers itself — the only fan-out is one CI run per updated PR — and the
concurrency group coalesces a burst of merges into a single pass. For a very busy
repo GitHub's native merge queue is the heavier-duty alternative and subsumes the
workflow entirely; this check is the lightweight default for the fleet's volumes.
Ecosystem detection lives once in languages.py (re-exported through
context.py; look for Cargo.toml/Cargo.lock, package.json + which
lockfile, pyproject.toml, go.mod, .github/workflows) and is shared by
ci-exists, dependabot, lockfiles, gitignore, and coverage so they
never disagree about what the repo is. It is nested-aware — one instance per
location, each stamped with its directory — so those checks grade nested
packages (a workspace's crates/*-node, crates/*-python, crates/*-ruby) at
their real paths, not just the repo root. cargo anchors on Cargo.lock (a
workspace shares one lock; members aren't emitted separately); a shared
nested_manifests(workdir, filename) helper does the single tree-walk.
Global housekeeping.toml sets the default severity of each check
(required | recommended | off). A repo can carry .housekeeping.toml
at its root to override — skip website on a library, declare the expected
homepage URL explicitly, mark a sandbox exempt from branch-protection.
The defaults encode what good code looks like, public or private — but the
profiles differ. Private repos have no audience: the
audience-facing checks (website, license, changelog, readme) drop to
recommended, repo-meta turns off, and since full branch protection on
private repos needs a paid plan, that check reports skip-with-note
("unavailable on this plan") rather than failing. Engineering hygiene —
CI, lockfiles, dependabot, secret scanning — stays required everywhere.
Repos audit themselves (the action); the captain checks the auditors are on
duty — delegation, not duplication. A captain repo (zmaril/powderworks for
this fleet) carries housecaptain.toml: [[member]] entries plus optional
[policy.checks]. housekeeper captain is the API-only delegation check
that runs in the captain's CI: per member, a housekeeping workflow exists,
fires on pull_request + push + schedule, its latest default-branch run is
green, and the member's .housekeeping.toml doesn't contradict fleet policy.
Policy divergence is a surfaced conflict, never silently resolved — a fleet
policy that silently loses is theater, one that silently wins breaks the
repo-knows-itself principle. Fleets can also require files to exist
([[policy.required-file]] with path and scope = all/public/private —
e.g. every open source member carries notes/design.md). Policy escalates
from expectation to law via [policy] locked: members that declare their
fleet enforce locked keys in their own audits at PR time (self-excepting
diffs fail their own CI), and the captain backstops removal of the fleet
declaration itself — the one escape in-repo enforcement can't close.
Known adoption wrinkle: the manifest PR that introduces locks fails its own
captain check, since the captain reads member configs (including the captain
repo's own) from main, not from the proposing PR — merge it red and trust
the post-merge run. housekeeper fleet is the deep local audit
over the same manifest. Captain-driven fixes deliberately don't exist;
fixing stays one-repo and interactive.
Some checks pass on presence (a stylelint config exists, a .vale.ini
exists) but the content of that config is a fleet-wide decision — everyone
should lint CSS by the same rules, spell-check against the same vocabulary.
Rather than copy-paste the file into every repo and let the copies rot, the
captain owns the canonical config and pushes it outward.
Canonical files live in the captain repo under .fleet/ (dot-prefixed so it
reads as a tool directory, like .github/), declared in housecaptain.toml:
[[policy.managed-config]]
check = "stylelint"
scope = "all" # all | public | private
paths = { ".stylelintrc.json" = ".fleet/stylelintrc.json" } # member path -> captain source
[[policy.managed-config]]
check = "vale"
paths = { ".vale.ini" = ".fleet/vale/.vale.ini", "styles/" = ".fleet/vale/styles/" }A trailing-slash key is a directory sync (vale's whole vocab tree); a plain
key is a single file. Every source must live under .fleet/ — load_manifest
rejects the manifest otherwise, so the merge trigger below stays honest.
Distribution is a push, not a fix. This is a deliberate exception to
"captain-driven fixes don't exist," and the line is exact: the captain ships
the config artifact as its own isolated, reviewable PR on each member
(branch housekeeping/fleet-config-<check>, touching only the managed files,
titled chore(config): sync <check> config from fleet). It never touches
member code and never tries to make the resulting lint pass — merging the PR,
and cleaning up whatever new violations it surfaces, stays the member's own
one-repo, interactive job. The point is that a rules bump arrives as a
dedicated PR the member adopts at their own pace, instead of ambushing an
unrelated feature PR with a wall of new findings.
housekeeper captain --sync-configs performs the distribution: per member ×
managed-config in scope, it compares the member's current file(s) against the
captain's canonical copy and opens (or updates, idempotently) a sync PR only
where they differ. It needs a token with contents:write +
pull_requests:write on the members (a fleet PAT), where the read-only
captain check needs none of that.
Trigger. The captain repo carries .github/workflows/fleet-sync.yml:
on: push to main filtered by paths: ['.fleet/**'], so a merge that
changes a canonical config fans the update out to the fleet immediately; plus
a weekly schedule backstop (new members, sync PRs closed unmerged) and
workflow_dispatch. A concurrency group plus the command's idempotency keep
overlapping merges from opening duplicate PRs. No loop risk: sync PRs write
member paths (.stylelintrc.json, styles/), never .fleet/**, so even the
captain-as-its-own-member case can't re-fire the filter.
On the member side, drift is not a failure. The stylelint/vale checks
a member runs enforce only the standalone contract (config present + wired
into CI); they never compare content against fleet canonical, so a member
whose config lags behind an unmerged sync PR keeps a green self-audit. Content
canonicity is the captain's concern alone — surfaced on the captain output and
fleet dashboard as in sync / sync PR open / stale, never as a member-CI
red. That is what makes "adopt at your own pace" real.
One skill, tidy-up (installed via plugin as /housekeeping:tidy-up, or
via skills.sh into any agent): runs housekeeper check on the current repo,
reads the JSON results, explains failures in plain language, and offers to
drive fixes one at a time — the interactive front door; the Python is the
engine. It also carries the README judgment pass the deterministic readme
check can't do: read it as a newcomer (what is this, why would I want it, how
do I start — in 30 seconds?) and draft concrete edits rather than a critique.
Originally these were two skills, but they're one workflow with two phases.
- Multi-repo sweeps (
--all) and any scheduling. This is a run-it-when-you- touch-a-repo tool. The single-repo results format is designed so a sweep is a loop, not a redesign, if it's ever wanted. - Auto-merge, or any mutation without per-fix confirmation.
A GitHub Action distributed into each repo.Reversed:action.ymlat the repo root lets anyone runuses: zmaril/housekeeping@mainin their own CI. The original chicken-and-egg objection only applied to auditing your own fleet from outside; for a stranger's repo, the action runs inside it with their token. Checks the workflow token can't see (admin-level settings) skip with a note, andci-greennever grades the workflow it runs inside.- Org-level checks, other people's repos, GitHub Enterprise.
- Issue/PR template enforcement, CODEOWNERS, contributing guides — easy to
add later as new modules in
checks/, which is the point of the registry.