building vodou.

Nothing in your repo knows how many processes it starts

A pre-commit guard that fails any file starting a long-lived process no registry names, and why the first version flagged 35 files that were all fine.

Chad Priest / / 9 min read

Count the long-lived processes your project starts. Not the ones in your compose file. All of them: the nohup in the dev script somebody wrote in March, the launchd plist the installer registers, the detached: true in the desktop app’s backend manager, the worker a test fixture forgot to reap. Write the number down, then go grep. In my tree the written-down number was seven and the real number was thirteen, and nobody had ever decided to have thirteen.

The cost is not abstract. On 2026-08-29 two of my channel servers ran orphaned for fifteen hours while the gateway in front of them served eight-hour-old code. Every pid file on disk was accurate the entire time. The data was never wrong. The problem was that nothing asked.

Two registries were correct and neither one defended itself

I already had the inventory. One file lists which processes exist (daemon, worker, gateway, the browser-console child, the local llama server). A second file, stacks.toml, lists the eight ways those processes compose into a run: the web stack, headless, the MCP stack, the board worker, the lab, the single-binary mode, the desktop app, docker. For each stack it names the entrypoints, meaning every script or binary that starts it.

Both files were accurate the day I wrote them. Both were decaying from the moment I stopped looking, because nothing stopped the fourteenth nohup node thing.js & from landing in a script no registry mentioned. An inventory that is only true at the moment of writing is a snapshot, not a registry.

So I built the check. In Vodou, the AI operating system this blog runs on, it is scripts/entrypoint-guard.py, 298 lines, the sixth guard on the pre-commit hook. It reads the staged diff, finds added lines that start a long-lived process, and fails the commit if the file containing them is not named as an entrypoint by any stack.

no (comment, echo, prose)yesdeclaredundeclaredstaged diffadded lines onlylaunch verb in executableposition?named by a stack instacks.toml?commit failscommit proceedsThe registry is the contract. The guard is the only thing that makes it one.
Where the check sits

The first audit flagged 35 files and most of them were right to exist

My first version graded any file containing a launch verb. Run over the whole tree it returned 35 hits, and when I read them, most were legitimate. The console’s llama-server wrapper starts a process, yes, but the console itself is the entrypoint and the console is already declared in the web stack. That child process is governed by the other registry, the one that says which processes exist. A different file answering a different question.

Thirty-five findings, almost all wrong, means the rule was drawn wrong. Not the tree.

The redraw: an entrypoint is a file something executes. A shebang, or the executable bit, or a .sh extension. Module code living inside an already-declared process is not an entrypoint, no matter how many children it spawns. That one predicate took the audit from 35 to a handful, and the handful were real.

first rule (any launch verb)35 filesshipped rule (executable entrypoints)0 files
Whole-tree audit, same repo, two rules

The second thing I threw away was the obvious implementation. A guard for this is one git grep nohup away, and that guard is worthless. Before writing a line I counted: of the thirteen files in my tree containing the word nohup, four are prose. One echoes it as an instruction to a human. One has it in a Python docstring. Two mention it in comments. A guard that fails a commit because you documented a command gets switched off inside a week, and a switched-off guard enforces nothing at all. Precision here is not politeness. It is the difference between a rule that survives and a rule everyone has learned to bypass.

So the guard strips comment lines per language, and a launch verb inside something being echoed or logged is not a launch. Four of the eleven cases in scripts/test-entrypoint-guard.sh are must-not-fire controls lifted verbatim from those real prose lines.

Every sandbox case passed, and the guard could not see the registry

This is the part that actually cost me the afternoon.

The harness stages real content into a throwaway repo and asserts an exit code, so it never touches the working index. All eleven cases went green on the first run. That felt fast, so I checked why.

The guard resolves the repo root with git rev-parse --show-toplevel. Under the harness that pointed at the sandbox, and the sandbox has no stacks.toml. The guard has a deliberate fail-open branch: no declared entrypoints means no grading, print a warning, exit 0. Correct behaviour in production, where a missing registry must not block every commit in the repo. Under the harness it meant every case passed vacuously, including the three that were supposed to fail.

The fix was two roots instead of one. One root for where the staged files live, one for where the registry is read from. They are identical in a real commit and different under the harness, which runs the guard from the real repo while pointing git at the sandbox.

The general shape: a test that exercises a fail-open path is not testing anything, and it looks exactly like a test that passes. If your component degrades gracefully when a dependency is absent, your harness has to assert that the dependency was present, or the harness is measuring the degradation.

before any codecounted 13 nohup files, 4are prose: word greprejectedfirst audit35 flagged, mostlegitimate: rule redrawnfirst harness run11/11 green, guard neverfound the registry2026-08-30shipped: 4 undeclaredlaunchers found andadopted

Then the guard caught its own test fixtures on the first commit attempt, which is the guard working, and the reason scripts/test-*.sh is now an excluded naming convention rather than a special case.

What the finished audit found: four launchers that belonged to no stack. A detached session wrapper, the binary-swap script (it restarts the daemon with nohup), the docker gateway service, and the single-binary CLI launcher. Three more got adopted into the web stack the same day, including the console’s launchd installer and the brain console’s own entry module, a process that had been declared for weeks with no file naming it as its starter.

The invariant: a launch verb in executable position implies a registry entry

State it as a property of a codebase, not as advice, because then you can go and check it:

For every path P in the repository that contains a process-launch verb on an executable line, there exists exactly one registry entry naming P as an entrypoint. Commentary and echoed strings are not executable lines.

Either that is true of your repo or it is not. And the second clause carries as much weight as the first: a guard that counts documentation as a violation has a false-positive rate, and for a commit-blocking check the false-positive rate is part of correctness. Nobody reads the fifth false alarm.

Grep your own tree for launch verbs, then subtract the prose

Five minutes, on your stack, nothing of mine involved.

# 1. Candidate launch sites anywhere in the repo.
git grep -nE '(^|[;&|][[:space:]]*)nohup[[:space:]]|launchctl[[:space:]]+(load|bootstrap|kickstart)|setsid[[:space:]]|systemd-run|systemctl[[:space:]]+(start|enable)|docker[[:space:]]+run[^|]*[[:space:]]-d([[:space:]]|$)|detached[[:space:]]*:[[:space:]]*true' \
  -- ':!*.md' ':!docs/*' > /tmp/launch-sites.txt
wc -l < /tmp/launch-sites.txt
# 2. Drop the lines that only TALK about launching.
grep -vE '^[^:]+:[0-9]+:[[:space:]]*(#|//|\*|")' /tmp/launch-sites.txt \
  | grep -vE '(echo|printf|console\.(log|warn|error))' > /tmp/launch-real.txt
cut -d: -f1 /tmp/launch-real.txt | sort -u > /tmp/launchers.txt
echo "$(wc -l < /tmp/launch-sites.txt) candidates, $(wc -l < /tmp/launch-real.txt) executable"

The gap between those two numbers is your prose ratio. Mine was four in thirteen. If yours is anything like that, a naive word-grep CI check would have been firing on your README, and you would have muted it.

# 3. Type out, by hand, every file you BELIEVE starts a process.
$EDITOR /tmp/declared.txt   # one path per line, or generate it from
                            # your compose file / Procfile / unit files
comm -23 /tmp/launchers.txt <(sort -u /tmp/declared.txt)

Passing output is nothing at all. Every file that starts something is a file you knew about.

Failing output is a list of paths, and each one is a process that can run on your machines without any start script stopping it, any stop script reaping it, or any updater rebuilding it. Read the first one and ask when it last ran. On my tree the honest answer for two of them was “right now, for fifteen hours, orphaned.”

The guards on the shelf police runtime, and this defect is committed months earlier

There is good work on constraining what an agent does at execution time, and I looked at it before building. osteele/agent-command-guards wraps executables on PATH so that policy applies at the executable boundary, which catches a command however it was composed. mcp-guard sits as a Layer 7 proxy on MCP stdio traffic and can put a human in the loop before a call lands.

Both are the right shape for their question, and neither would have found my thirteenth launcher. A PATH wrapper sees a command when it runs, and the orphan case is precisely the one where nothing ran it today: launchd started it at boot from a plist an installer registered six weeks ago. A stdio proxy sees tool traffic, not the shell script that spawned the server the proxy is in front of. The defect was committed in March and detected in August. It needed a check that reads the repository, not the runtime.

The Anthropic engineering note on building effective agents makes the point I kept coming back to: prefer the simplest thing, add complexity only when it earns its place. A 298-line Python script on a pre-commit hook is about as much machinery as a process census deserves. The expensive part was not the code. It was measuring the tree first, for one afternoon, so the rule matched what was actually in it.

Still open: setsid, systemd and docker run -d walk straight through

The guard knows three launch verbs, because those are the three my tree uses. A systemd unit, a setsid, a docker run -d, or a library-level daemonize call would pass without a word. My reader check above greps for more verbs than my own guard enforces, deliberately, and closing that gap is future work rather than something I have shipped.

And it grades added lines in the staged diff. A launcher that predates the guard is only caught by the whole-tree audit, which is a command I have to remember to run. It reports clean today. The version of this that I trust will run that audit on a schedule and file the finding itself, because a check nobody types is not a check.

If your inventory of running processes is a document rather than a check, vodou.ai ships the registries and the commit-time guard that keeps them honest, along with the rest of the local-first stack they describe. The guard script and the registry format are Apache-2.0 and in the repo, so you can read both before deciding whether the idea is worth porting to your own tree.