Taleshaunt, a post-mortem narrator

the manual · v0.1

How to use Tales

Tales is the project. haunt is the command it installs: it runs a process, records what happened when it died, and asks a local open-weight model to narrate the death. This page is the whole of it — every flag, every field it collects, what it refuses to do, and how to check the ghost’s work.

01 · install

Python, Ollama, and one entry point.

You need Python 3.11 or newer and Ollama, running as a separate daemon. The package itself does not start the model server.

install  — installs the haunt command
$ git clone https://github.com/Ewan-Dkhar/Tales
$ cd Tales
$ uv venv
$ uv pip install -e .
$ ollama serve
$ ollama pull gemma2:2b
# then, to see the whole pipeline once:
$ .venv/bin/haunt demo

uv pip install -e . puts a haunt script in .venv/bin/. Call it by path, or activate the environment and type haunt. With plain pip it is the same inside any virtual environment:

$ python -m venv .venv && source .venv/bin/activate
$ pip install -e .
$ haunt --help

Three commands tell you whether the parts agree with each other:

  • haunt config — the resolved configuration, as JSON.
  • haunt demo — a bundled crash, narrated end to end.
  • haunt run -- python -c "raise SystemExit(1)" — the smallest real thing you can wrap.

02 · three ways in

The process has not died yet, or it already has.

CommandUse it when
haunt run -- <cmd>The process has not died yet and you want to watch it happen.
haunt analyze <log>It already died, and you kept the log.
haunt demoYou want to see the tool work without breaking anything of your own.

All three funnel into the same contract — haunt.models.Evidence — so the panels, the story and the report have the same shape whichever way you arrived.

03 · haunt run

Wrap a process and wait for the death.

usage

haunt run [OPTIONS] -- COMMAND [ARGS...]

haunt starts COMMAND, waits for it to exit, and gathers the evidence if the process did not survive. Options before -- belong to haunt; everything after it belongs to your program.

examples

$ haunt run -- python app.py                 # narrate the failure
$ haunt run -- pytest -q tests/              # flags pass through after --
$ haunt run -m gemma2:2b -- ./bin/worker     # another model, one run
$ haunt run -o reports/last-crash.md -- ./job.sh
$ haunt run --no-narrate -- ./job.sh         # evidence only
$ haunt run --exit-zero -- ./job.sh          # always exit 0
always use the separator

Without --, haunt parses the first arguments itself. A wrapped program’s -o, -m, --model, --output, --no-narrate, --exit-zero or --help would be consumed by haunt instead of reaching it; anything else fails loudly with No such option. The separator removes the ambiguity entirely.

What happens to the process’s output

stdout and stderr are captured, not streamed. Your program’s output goes into a pipe for the evidence, so it does not appear live in the terminal, and the child does not see a TTY: progress bars, colour and line-buffering behave the way they do when output is piped. The last stderr_lines lines of each stream — 100 by default — end up in the report.

Exit codes

haunt run is a wrapper, so it exits the way your process did.

The wrapped processhaunt run exits
exits 00 — and nothing is narrated, because nothing died
exits 33
exits 130125 — capped, so it never collides with shell conventions
is killed by SIGSEGV11 — the signal number
could not be started127
was never named on the command line2

--exit-zero overrides all of it and forces 0, which is what haunt demo uses so a demonstration never fails your shell.

A process that survives

If the command exits cleanly, haunt prints The process survived. Nothing to bury. and writes no story and no report. There is no death to narrate, and inventing one would be exactly the failure mode this tool exists to avoid.

04 · haunt analyze

A log is a partial witness.

usage

haunt analyze [OPTIONS] LOGFILE

Narrates a death that already happened, from a file you kept — a container log, a CI run, a machine you do not want to re-crash.

examples

$ docker logs haunted-app > crash.log
$ haunt analyze crash.log
$ haunt analyze crash.log -m gemma2:9b -o postmortem.md
$ haunt analyze crash.log --no-narrate -o evidence.md

The file is the only witness, so exit_code and signal are null, duration_s is 0.0, and recent_files is empty. It still extracts the Python traceback if there is one, and it still reads the git context of your current directory, which is usually what makes the narration worth reading.

A missing file exits 2. Otherwise analyze exits 0: it did not run anything, so it has no status to report. --exit-zero does not exist here.

05 · haunt demo

Bury the bundled program.

usage

$ haunt demo                  # the default model
$ haunt demo -m gemma2:2b     # the fast one
$ haunt demo -o ""            # panels only, no file

It runs haunt/demo_crash.py with the interpreter that is running haunt, and narrates the resulting ZeroDivisionError. The demo program crashing is the point; it is not a bug to fix. demo always exits 0.

06 · haunt config

What a run will actually use.

haunt config prints the resolved configuration as JSON — defaults merged with ~/.haunt/config.toml and the environment.

careful with the output

It includes sentry_dsn when one is configured. Do not paste it into an issue. voice also appears; it is a placeholder and is not implemented.

07 · options and exit codes

Five flags, in total.

OptionCommandsMeaning
--model, -mrun, analyze, demoAny Ollama model tag. Overrides the configured model for this run.
--output, -orun, analyze, demoWhere to write the report. -o "" writes nothing.
--no-narraterun, analyze, demoSkip the model. The evidence is still collected and still written to -o.
--exit-zerorunAlways exit 0, even if the process failed.
--helpallUsage for that command.

Command-line flags beat environment variables, which beat ~/.haunt/config.toml, which beats the built-in defaults.

08 · the report

A story with a receipt stapled to it.

The report is written to GHOST_REPORT.md in the current directory unless -o says otherwise — and it is written whenever an output path is set, including when narration was skipped or the model could not be reached. In that case the story is replaced by one line:

a report with no story

# 👻 Ghost Report

_Summoned by haunt at 2026-10-29T09:26:55.060260+00:00_

_The ghost did not speak. The evidence it was given is recorded below, unchanged._

---

## Raw Evidence

A full report is five sections the prompt fixes, then the appendix.

SectionWhat it argues from
The Crash Sitethe command, the working directory, the branch, how long it survived
Last Wordsthe exit code, any signal, the tail of stdout and stderr
Prime Suspectsthe traceback, the recent commits, the files you changed but never committed
The Epitaphone line, quoted back from the evidence
How to Prevent the Hauntingone concrete change the evidence supports

Here is a real report from a crash with no traceback at all — ollama serve run while the daemon was already listening. The narration is verbatim; the JSON is abridged for width, but the report always carries the whole block.

“Its final words echoed through the silent servers: ‘Error: listen tcp 127.0.0.1:11434: bind: address already in use.’ The Program had been born of a dream to give life to the tales of the fallen.”

Last Words, from a real haunt run -- ollama serve.

Raw Evidence · abridged

{
  "command": ["ollama", "serve"],
  "exit_code": 1,
  "signal": null,
  "duration_s": 0.015,
  "stderr_tail": "Error: listen tcp 127.0.0.1:11434: bind: address already in use",
  "traceback": null,
  "git": { "branch": "main", "uncommitted": [] },
  "env": { "python": "3.12.5" }
}

The prose is a performance. The JSON is testimony. If a sentence in the story is not supported by the block underneath it, the block is what you trust — and the 2B model does get details wrong, which is precisely why the block is there. -o "" skips the file entirely when you only want the panels on screen.

09 · what gets collected

Nothing else is recorded.

Everything in this table is written into the report and sent to the model. Nothing outside it is collected.

FieldWhat it holds
commandthe argv you passed; for analyze, ["log:<path>"]
cwdthe working directory
timestampUTC, ISO 8601 — when the evidence was gathered
exit_codethe process status; negative when a signal killed it (-11), null for analyze
signalPOSIX signal number — 11 for SIGSEGV; null on a normal exit
duration_sseconds from a monotonic clock, three decimals; 0.0 for analyze
stderr_tailthe last stderr_lines lines — 100 by default
stdout_tailthe last stderr_lines lines — 100 by default
tracebackstderr from the Traceback marker onward; null when there is none, which is normal for native crashes
git.branchgit rev-parse; null outside a repository
git.recent_commitsgit log --oneline -n 10
git.diff_statgit diff --stat HEAD
git.uncommittedgit status --porcelain — the footprints of whoever touched it last
envthe running interpreter’s python, platform and cwd; never the environment variables
recent_filesfiles under the working directory modified in the last hour: at most 20, three levels deep, skipping .git, .venv, venv, node_modules, __pycache__, .mypy_cache; empty for analyze
sourcerun or analyze

The git commands run with a five-second timeout and never raise. Outside a repository the whole git block is null, and the narration simply has less to work with.

privacy

The evidence goes to a model running on localhost through Ollama. No crash data leaves the machine unless you configure a Sentry DSN, and even then what is sent is the trace — operation names and timings — plus any unhandled exception, with PII disabled.

The report is written to your disk in plain text. Treat GHOST_REPORT.md like a log file: it contains file paths, a git diff summary, and the last hundred lines your process printed.

10 · configuration

One file, or one environment.

~/.haunt/config.toml

model = "gemma2:9b"
fallback_model = "gemma2:2b"
ollama_host = "http://localhost:11434"
temperature = 0.8
stderr_lines = 100
output = "GHOST_REPORT.md"
# sentry_dsn = "https://<key>@<org>.ingest.sentry.io/<project>"
KeyDefaultEnvironment
modelgemma2:9bHAUNT_MODEL
fallback_modelgemma2:2bHAUNT_FALLBACK_MODEL
ollama_hosthttp://localhost:11434HAUNT_OLLAMA_HOST
temperature0.8HAUNT_TEMPERATURE
stderr_lines100HAUNT_STDERR_LINES
outputGHOST_REPORT.mdHAUNT_OUTPUT
sentry_dsnunsetSENTRY_DSN

model and fallback_model take any Ollama tag; an empty fallback_model disables the fallback. temperature is 0.0–2.0 and lower is more literal. stderr_lines must be 10 or more. An empty output writes nothing. ollama_host may point at another machine if you run Ollama remotely.

Out-of-range values are rejected rather than silently clamped: stderr_lines = 5 fails with an error naming the field. For a single project, skip the file and set the environment:

$ HAUNT_MODEL=gemma2:2b HAUNT_OUTPUT=crash-report.md haunt run -- python app.py

11 · choosing a model

The narrator is a config value, not a dependency.

  • gemma2:2b — 1.6 GB, fast enough to feel interactive on a laptop, occasionally wrong about details. It once read return 1 / candles as “a candle’s value was being divided by one.” Good for demos and CI.
  • gemma2:9b — the default. Slower, better prose, better at staying inside the evidence.
  • Anything else — llama3.1, qwen2.5, a fine-tune, whatever ollama list shows. --model takes the tag verbatim and nothing else in the tool changes.

fallback_model is only tried when the primary model fails before emitting any text. If it fails halfway through, the error propagates instead of splicing two different stories together.

Because the evidence is serialized before it reaches the model, two models narrating the same crash produce different prose over an identical evidence block — apart from the timestamp. The diff comes back with one line changed, and it is the clock:

$ haunt analyze crash.log -m gemma2:2b -o fast.md
$ haunt analyze crash.log -m gemma2:9b -o careful.md
$ diff fast.md careful.md     # the story differs; the JSON does not

12 · sentry tracing

Off unless you name a DSN.

The sentry-sdk dependency is installed either way; nothing is sent without a DSN.

$ export SENTRY_DSN="https://<key>@<org>.ingest.sentry.io/<project>"
$ haunt run -- python app.py

One transaction per invocation — haunt.pipeline, named after the command it wrapped — with these spans:

SpanCovers
haunt.capturerunning the wrapped process
haunt.gatherthe traceback, git, environment, recent files
haunt.narratethe streaming chat call; the http.client span appears underneath it
haunt.renderwriting the report

Sampling is 1.0 and send_default_pii is False: the trace records timing and operation names, not the evidence itself.

the one ordering rule

init_sentry() must run before the transaction opens. Initializing it afterwards yields a transaction nobody samples, which looks exactly like “tracing is broken.” flush_sentry() runs before the CLI exits, because a short-lived process would otherwise drop buffered spans.

13 · recipes

Things worth doing with it.

Wrap a failing script and keep the report

$ haunt run -- python app.py
# the report lands in ./GHOST_REPORT.md

Record evidence in CI, with no model on the runner

The evidence is still written, the exit code is still propagated, and nothing is narrated.

$ haunt run --no-narrate -o crash-evidence.md -- ./run-tests.sh

Reproduce a native crash

$ cat > segv.py <<'PY'
import os, signal
os.kill(os.getpid(), signal.SIGSEGV)
PY
$ haunt run -- python segv.py; echo "exit=$?"
exit=11

There is no traceback in that report. The narration is built from the exit code, the signal and the last thing the process said — which is the whole point of capturing more than a stack trace.

Narrate a log from somewhere else

$ kubectl logs crashy-pod --previous > crash.log
$ haunt analyze crash.log -o postmortem.md

Keep a shell’s exit status while reading the story

$ haunt run --exit-zero -- ./job.sh

14 · troubleshooting

The ghost is silent. Why.

SymptomCauseFix
The ghost did not answer. Ollama is not running, or the model is not pulled. ollama serve, then ollama pull gemma2:2b. The evidence is still written to -o.
Could not start that command., exit 127 The binary does not exist or is not executable. Check the path; haunt uses the shell’s exit code for this.
No such option: -c The wrapped command’s flags were parsed as haunt’s. Put -- before the command.
No output from the wrapped program Output is captured for evidence, not streamed. Read the tails in the report, or run the program directly and haunt analyze its log.
No report where you expected one -o "" was passed, or the process exited 0. A surviving process writes nothing. Name the path explicitly.
GHOST_REPORT.md keeps being overwritten That is the default path. -o per run, or -o "" for panels only.
A long log is truncated in the report Only the last stderr_lines lines are kept. Raise stderr_lines in the config.
Nothing appears in Sentry SENTRY_DSN unset, or Sentry initialized after the transaction opened. Set the DSN; keep init before the transaction.
A validation error on startup temperature outside 0–2, or stderr_lines below 10. Fix ~/.haunt/config.toml; the message names the field.
haunt demo is slow gemma2:9b on a small machine. haunt demo -m gemma2:2b.

15 · how it works

Five small modules, one contract.

the pipeline

capture.run_command → gather.build_evidence → prompt.build_messages
                    → narrator.NarratorEngine → render.render_story / write_report
  • capture.py runs the process, recording the exit code, signal, duration and both output streams.
  • gather.py fills in the traceback, the git context, the environment fingerprint and the recently modified files.
  • prompt.py serializes the evidence as JSON around the Ghost Narrator system prompt: five fixed headings, no invented facts, the word “unknown” allowed.
  • narrator.py streams the model from Ollama, with the Sentry spans and the fallback model.
  • render.py parses the Markdown into Rich panels and writes the report with the evidence appendix.

Every stage passes the same Evidence object along, which is why the pipeline is testable without a model: 21 tests, no network.

16 · limits

What it will not do.

  • One process, not a tree. A parent that spawns children and exits leaves the children unobserved.
  • Captured output, not a terminal. Programs that need a TTY behave differently under haunt than under your shell.
  • POSIX exit status. Signal numbers assume a POSIX system; the project is developed and tested on Linux.
  • The model can still be wrong. The prompt forbids invented facts and the evidence is attached, but a small model can misread a line. The appendix is the part you trust.
  • analyze is blind to exit codes and durations. A log is a partial witness.
  • It does not fix your bug. It points at the line, quotes the evidence, and gets out of the way.