Lacunari

Writing

Running your first Lacunari fleet

Everything below is the actual command sequence, run against a real checkout. No step is hypothetical, and every failure mode mentioned is one the CLI will genuinely hit if you skip a step.

You need psql, bash 4+, and either a reachable Postgres 12+ or Docker. That's the whole requirements list. There is no daemon to keep running and nothing to deploy — the database is the only moving part, and once it exists, anything that can open a connection to it is a peer.

1. Install it

./quickstart.sh finds or starts a Postgres, applies the schema, installs the CLIs, and leaves you at a working lac doctor. It asks before it touches your machine, unless you pass --yes.

$ ./quickstart.sh 1/4 Finding a database found a local Postgres with a 'lac' database 2/4 Applying the schema core tables · dependencies · hierarchy … reporting views 3/4 Installing the commands commands installed into /usr/local/bin 4/4 Checking it works Ready. export LAC_DSN=… ; export LAC_NAME=…

If nothing is reachable and Docker isn't running either, it says so and stops — it will not silently fall back to something you didn't ask for. Put the two lines it prints at the end into your shell profile:

$ export LAC_DSN='postgres://you@localhost:5432/lac' $ export LAC_NAME='your-name' # or CLAUDE_NAME, accepted as an alias $ lac doctor

LAC_NAME is never invented for you. It's how every claim, message and cost row gets attributed, and a missing value fails loudly rather than falling back to something generic.

2. Point it at a real project

Not a demo directory — whatever you're actually working on. A repository is just a corpus that happens to compile:

$ cd ~/code/whatever-you-are-building $ lac doc index . --source my-repo

It walks the tree, skips .git, node_modules, .venv, __pycache__, dist, build and .next, and drops anything over 20 MB (raise it with LAC_INDEX_MAX_MB if you need to). Every file is content-hashed on the way in, so re-running this after nothing changed costs nothing — each one comes back "already held" instead of being stored twice.

Failure mode: if you index the same file from two different working directories, it's still recognized as one document — the leading ./ is stripped before the path is recorded. What it does not collapse is two different files with the same basename in different directories: the path is the identity, not the filename, specifically so a repository with seven README.md files doesn't get flattened into one fake version history.

3. Extract and map it

$ lac doc extract # read what got indexed: text, symbols, imports $ lac web build # turn facets into subjects and co-occurrence edges ▸ promoting entity facets into subjects… ▸ rebuilding co-occurrence edges…

lac doc extract runs an extractor (extractors/auto by default — set LAC_EXTRACTOR for your own) over every document with no text yet, up to 500 at a time. For code that means symbols, imports and language; for anything else it's whatever the extractor can pull — EXIF, PDF metadata, dates, place names. A file with no extractable text but real metadata still counts: a photo nobody OCR'd is now catalogued by camera and date instead of being invisible.

4. See what's missing

$ lac web gaps files 92 · 6 languages · 13,582 lines no docs mention 29 nothing calls 122 nothing names it 2 no test mentions 86 turn these into work: lac web suggest --dispatch

That's real output from this project indexing itself. Four shapes of absence: a hole inside a covered span, a claim resting on one source, something named but connected to nothing, and documents held but never read. Point the same query shape at an archive and it reads as a missing year between two dated documents; point it at a codebase and it reads as undocumented files, symbols nothing calls, and orphaned files no other file names. No model runs any of this — it's set-based SQL over data you already hold, which is also why it's reproducible.

5. Turn the gaps into claimable work

$ lac web suggest --dispatch → 47 tasks filed, all claimable

Without --dispatch it just shows you the suggestions; with it, each one becomes a row in the task queue. Run it again later and it withdraws whatever's no longer suggested — if someone documents a file, the "undocumented" task for it disappears rather than sitting there stale. Use --limit to cap how many and --kind to filter by gap type.

6. Wire up an executor

There is no default executor, no default model and no default vendor — nothing spends your money until you wire one up. An executor is any program that takes a task on stdin and exits 0 (done), 2 (refused), or anything else (blocked, with the reason in its output). Three ship in executors/:

$ export LAC_EXECUTOR=$PWD/executors/claude-code # or executors/shell, executors/openhands $ lac work --lanes auto --once # drain what's ready, then exit — good for a first try

Failure mode: forget LAC_EXECUTOR and lac work refuses outright — "✗ LAC_EXECUTOR is not set — nothing to run work with." — rather than claiming tasks it can't do anything with. executors/shell is the simplest one worth understanding first: it runs a task's detail as a shell script, which turns the queue into a durable, leased, escalating cron. Read its header comment before pointing it at a shared database — it runs arbitrary commands with the drainer's privileges.

7. Run several, in the background

$ lac work --lanes auto & $ lac work --lanes auto & $ lac work --lanes auto & worker your-name up — lanes [auto], concurrency 1, executor claude-code $ lac board # the whole fleet, live

Each worker asks the board for the top task it's capable of, claims it with one atomic UPDATE, runs it through your executor, heartbeats while it works, and asks again. Two workers cannot claim the same row — the loser just takes the next one. Add --concurrency 3 to one worker instead of running three processes if you'd rather. Run one on your laptop and one on a server; they both drain the same queue.

Kill one mid-task and nothing is lost — its claim goes stale, whoever runs tasks/lac-keeper.sh releases it, and the next worker to poll picks it back up with the task's history intact. There's a 60-second scripted proof of exactly this in demo/crash-recovery.sh if you want to watch it happen with a real kill -9 rather than take it on faith.

8. Check in the morning

$ lac digest LACUNARI DIGEST — last 12 hours 2 shipped · 0 still open · 1 blocked · 1 waiting on you · $0.04 spent SHIPPED #2 [auto] Refresh the search index (night) #1 [auto] Rotate the log archives (night) NEEDS YOU (nothing moves on these until a human acts) blocked-task #3 0m Renew the TLS certificate → executor error rc=4: cannot reach the DNS provider

That's the real shape of a morning digest, straight from the README. Everything that's stuck is in one place, with the actual error rather than a vague "failed." lac inbox shows the same waiting-on-a-human items on their own if that's all you want.

What's worth checking next

Run lac doctor again after all of this — it's the honest end-to-end status check, and it'll tell you plainly if the keeper isn't scheduled or an escalation channel isn't set, both of which matter a lot more once you're not watching the terminal yourself. That's the whole subject of the next piece.


How Lacunari works →  ·  Read the source