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.
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.
| Command | Use 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 demo | You 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
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 process | haunt run exits |
|---|---|
exits 0 | 0 — and nothing is narrated, because nothing died |
exits 3 | 3 |
exits 130 | 125 — capped, so it never collides with shell conventions |
is killed by SIGSEGV | 11 — the signal number |
| could not be started | 127 |
| was never named on the command line | 2 |
--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.
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.
| Option | Commands | Meaning |
|---|---|---|
--model, -m | run, analyze, demo | Any Ollama model tag. Overrides the configured model for this run. |
--output, -o | run, analyze, demo | Where to write the report. -o "" writes nothing. |
--no-narrate | run, analyze, demo | Skip the model. The evidence is still collected and still written to -o. |
--exit-zero | run | Always exit 0, even if the process failed. |
--help | all | Usage 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.
| Section | What it argues from |
|---|---|
| The Crash Site | the command, the working directory, the branch, how long it survived |
| Last Words | the exit code, any signal, the tail of stdout and stderr |
| Prime Suspects | the traceback, the recent commits, the files you changed but never committed |
| The Epitaph | one line, quoted back from the evidence |
| How to Prevent the Haunting | one 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.
| Field | What it holds |
|---|---|
command | the argv you passed; for analyze, ["log:<path>"] |
cwd | the working directory |
timestamp | UTC, ISO 8601 — when the evidence was gathered |
exit_code | the process status; negative when a signal killed it (-11), null for analyze |
signal | POSIX signal number — 11 for SIGSEGV; null on a normal exit |
duration_s | seconds from a monotonic clock, three decimals; 0.0 for analyze |
stderr_tail | the last stderr_lines lines — 100 by default |
stdout_tail | the last stderr_lines lines — 100 by default |
traceback | stderr from the Traceback marker onward; null when there is none, which is normal for native crashes |
git.branch | git rev-parse; null outside a repository |
git.recent_commits | git log --oneline -n 10 |
git.diff_stat | git diff --stat HEAD |
git.uncommitted | git status --porcelain — the footprints of whoever touched it last |
env | the running interpreter’s python, platform and cwd; never the environment variables |
recent_files | files 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 |
source | run 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.
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>"
| Key | Default | Environment |
|---|---|---|
model | gemma2:9b | HAUNT_MODEL |
fallback_model | gemma2:2b | HAUNT_FALLBACK_MODEL |
ollama_host | http://localhost:11434 | HAUNT_OLLAMA_HOST |
temperature | 0.8 | HAUNT_TEMPERATURE |
stderr_lines | 100 | HAUNT_STDERR_LINES |
output | GHOST_REPORT.md | HAUNT_OUTPUT |
sentry_dsn | unset | SENTRY_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 readreturn 1 / candlesas “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, whateverollama listshows.--modeltakes 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:
| Span | Covers |
|---|---|
haunt.capture | running the wrapped process |
haunt.gather | the traceback, git, environment, recent files |
haunt.narrate | the streaming chat call; the http.client span appears underneath it |
haunt.render | writing the report |
Sampling is 1.0 and send_default_pii is False:
the trace records timing and operation names, not the evidence itself.
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.
| Symptom | Cause | Fix |
|---|---|---|
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.pyruns the process, recording the exit code, signal, duration and both output streams.gather.pyfills in the traceback, the git context, the environment fingerprint and the recently modified files.prompt.pyserializes the evidence as JSON around the Ghost Narrator system prompt: five fixed headings, no invented facts, the word “unknown” allowed.narrator.pystreams the model from Ollama, with the Sentry spans and the fallback model.render.pyparses 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
hauntthan 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.
analyzeis 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.