Skip to content

Latest commit

 

History

History
327 lines (279 loc) · 22.4 KB

File metadata and controls

327 lines (279 loc) · 22.4 KB

Housekeeping — Design

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.

Philosophy

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.

Architecture

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.

Check contract

A check is a module in checks/ that registers itself:

@check("lockfiles", needs=["clone"], fixable=True)
def lockfiles(ctx: RepoContext) -> Result: ...
  • RepoContext gives it everything: ctx.repo (owner/name), ctx.workdir (checkout path, populated only when needs=["clone"]), ctx.api(path) (gh api wrapper), ctx.ecosystems (shared detection), ctx.visibility, ctx.config (merged toml).
  • Result carries status (PASS | FAIL | SKIP), details, and an optional note — 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.

Language registry

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 .gitignore build-junk patterns, its language, and a CI job template. ECOSYSTEMS is the registry; detect_ecosystems() returns the entries a repo uses. Detection is nested-aware: it emits one Ecosystem instance per location, each carrying its dir (relative to the repo root, "" for root), so a Rust workspace's crates/*-node npm package, crates/*-python uv package, and so on are graded at their real paths — a consumer reads the lockfile at workdir / eco.dir / eco.lockfile, not the root.
  • Language (rust, js, python, ruby, go): the test/lint/fmt regexes that prove it's exercised in CI. Several ecosystems share one language, so they're joined by Ecosystem.language.
  • TypedLanguage (typescript, python, clojure): the type-layer axis — detection markers, the typechecker signal, and guidance.

A check reads eco.gitignore or LANGUAGES[eco.language].test; adding a language means one new registry entry, not edits scattered across six checks.

Two data sources

  • 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>.

CLI

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 setup

check 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.

Fix flow

housekeeper fix <check> for each failing check:

  1. Show what's wrong and exactly what the fix will do (settings diff, file diff, or PR plan).
  2. Ask for confirmation. No is always safe.
  3. 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 catalog (v1)

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.

Severities and per-repo config

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.

Fleet captain

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.

Managed configs

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.

Skills layer

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.

Out of scope (v1)

  • 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.yml at the repo root lets anyone run uses: zmaril/housekeeping@main in 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, and ci-green never 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.