This is a submission for the Hacktoberfest Open-Source AI Challenge Week 4: Ghost in the Machine.
Every repository is quietly becoming a graveyard. Files nobody remembers writing. Tests nobody dares delete. TODOs aging like gravestones.
haunt is a ghost for that graveyard — and it lives in git, not in a terminal pasted on top. Two small rituals, entirely offline:
pip install . # then, inside any git repository:
haunt install # the whisper joins your post-commit hook
git commit -m "tomb, again"
haunt scan # which files are the most haunted, and why
haunt report --html # a narrated HAUNTINGS.md of the worst hauntings
haunt persona ancestor # swap the voice: victorian, poltergeist, ancestor
Three design choices make it a ghost in the machine rather than an AI plugin:
git log alone — no model, fully explainable:
dormant = min(age_days/365, 1) × min(size/500, 1) (w 0.35)
restless = min(churn_90d/20, 1) (w 0.25)
orphaned = 1 if every author inactive 180d+ (w 0.25)
debt = min((TODO FIXME HACK XXX)/10, 1) (w 0.15)
Scores name their ghosts: 🫥 Orphan spirit (all authors gone), 🕸️ Sleeper (old, quiet), ⚡ Poltergeist (high churn, many hands, no owner), 🕯️ Revenant (dormant for months, then suddenly touched).
The model only narrates; it never reads your code. Gemma 2 2B (open weights, via Ollama) receives metrics and commit subjects — never source files (send_snippets is off by default). A 2B model can't review code, so haunt never asks it to. It asks for what a 2B model is actually good at: voice.
The whispers never block a commit. One generate attempt inside a time budget; if the model is cold, busy, or absent, a deterministic template keyed to the commit hash takes over — same commit, same line, forever. No retries, no queues. The hook always exits 0.
haunt scan on pallets/click — a 14-year-old, very-much-alive open-source CLI library — finds 175 haunted files. The top haunting:
score type age churn90 authors debt loc file
0.59 🫥 Orphan spirit 354d 0 0/13 0 782 tests/test_context.py
Thirteen authors. All of them inactive. 354 days without a commit. The file has a funeral attended by no one. The full tally: 84 orphan spirits, 81 sleepers, 10 poltergeists — a repo at rest that nobody would call haunted until you read the blame.
And with the ancestor persona — built for Día de los Muertos, the week's truth that this Halloween stretch is also about honoring the past:
🪔 "Their efforts bring warmth and joy to these old halls. May their spirits find solace in the stories shared through this code."
The poltergeist handles the noisy places:
⚡ "More flickering, more ghosts. Just don't break a fingernail."
Graceful degradation, demonstrated by literally killing Ollama mid-session and committing:
🕯️ A chill passes through the repository. The commit is buried without ceremony.
Same line twice, deterministically keyed to the commit hash — and the hook cost the commit nothing.
And when the report narrates with Gemma — here is haunt's HAUNTINGS.md on that same repo, Victorians mourning real files:
age 354d | churn90 0 | authors 0/13 | TODO/FIXME 0 | 782 loc
"The pages flutter, whispering tales of what could have been. An echo of promises left unfulfilled, a spectral symphony in the grand ballroom of code. Time slips away like dust, leaving only emptiness where brilliance once resided. Give this ghost a purpose: delete, add a test, document it, or assign an owner to finally give it meaning, however fleeting."
The model honestly ends with what the metrics justify — every narration closes with a grounded directive, not vibes. On a lonely orphaned doc it got almost tender:
"Dust motes dance in the flickering light, each one a memory of a lost chapter, a discarded dream. So many stories whispered between lines of code, now gathering dust like forgotten souls. A quiet voice asks: 'Shall I erase its echo or preserve its fragile remnants?' Name an owner for this forlorn document; at least grant it some solace."
Python 3.13, typer, httpx, and raw git subprocesses. No GitPython, no LLM framework. The repo is here; MIT licensed; 24 tests pass, including a test that runs a real git commit end-to-end through the installed hook and asserts the whisper appears.
git log --format=<sep>%H<sep>%at<sep>%an<sep>%s<sep> --name-only feeds the entire scanner — records joined on \x1e/\x1f unit separators. Small saga: Python's str.splitlines() \x1e as a line boundary and silently shred every record; split("\n") keeps the ghosts intact.num_ctx pinned at 4096 — Ollama's silent default of 2048 clips the persona+stats prompt without telling you. keep_alive=30m and an install-time warm-up so the first whisper fits its budget.haunt install writes a marker block (>>> HAUNT BEGIN … <<< HAUNT END) with an absolute interpreter path and a PYTHONPATH fallback to python -m haunt — the hook works in any repo without pip install — and backs up any existing hook it finds.
Three posts from the community shaped these calls: @enjoy_kumawat's install-and-timeout postmortems (hooks that "ran" but never actually run), @lowspecai and @studiomeyer_io on what local inference really costs on CPU, and @bartrozycki on how retries and fallbacks compound into their own failure mode — which is why haunt has exactly one attempt and one fallback, no interaction between them.
Then reality kept the honesty flowing:
UnicodeEncodeError inside the Haunt's premise is that an AI reads your private diffs to comment on them. Closed models make that premise toxic: your repo's history would live in someone else's logs forever. With Gemma's open weights on-device, the only witness is a ghost, and it never leaves your machine.
ollama pull gemma2:2b — and any other Ollama model slots in via one config line. Personas are TOML files; localize the ancestor voice for your team's history. And the fallback templates are auditable, first-party Python — even "no model" is an inspectable code path here.
That last point is the open story in miniature: when the proprietary link dies, dignity dies with it. When the open link dies, you still get a whisper, just a quieter one.
The repository is never truly empty. It is merely quiet. 🪦