Lacunari

Writing

Goal decomposition: how lac goal add and lac goal plan actually work

Not a model asked to write a 400-item plan in one shot. A goal is expanded one level at a time, and every level is shown the real state of your project rather than asked to imagine one.

Everything else in Lacunari runs bottom-up: you index something, the web finds gaps, gaps become tasks. Goals run the other way — you state an objective, and something has to turn it into a tree of work an agent can actually claim. The naive version of that asks a model to decompose the whole thing up front and files the result. It fails for the reason every waterfall plan fails: the decomposition at depth 4 depends on what depth 2 learned, and at the moment of planning nothing has been learned yet.

State the objective once

$ lac goal add "Ship multi-tenant support" \ --criteria "every query scoped to a tenant, with tests" \ --depth 3 --budget 40.00 --kind software goal #1 created (software), root task #1 expand it: lac goal plan 1

--depth clamps to 1–8 — a hard ceiling, not a suggestion. --budget is a dollar figure, checked before each expansion rather than after. --kind picks the default frontier a planner falls back on once a node has nothing more specific to work from: research (scope → gather → extract → corroborate → synthesise), software (reproduce → isolate → fix → verify → regression-guard), or ops (diagnose → plan → execute → validate → document). Get it wrong at creation time and lac goal plan <id> --kind software corrects it in place before planning — it's stored on the goal row, so every later round sees it without the flag being repeated.

Skip --criteria entirely and the CLI warns you outright: "! no --criteria given: nothing will be able to tell when this is done." A goal with no success criteria gets expanded until the budget runs out, because nothing else will ever stop it.

There's no separate goals table sitting apart from the task queue. The root task created here is the goal — everything that already existed for tasks (tree-path rollups, subtree budgets, capability routing, dependencies, the keeper reaping dead claims, lac replay) applies to a goal tree with zero special cases, because a planned subtask is just an ordinary task with a parent.

Expand one level

$ lac goal plan 1 # expand ONE level, grounded in the code ○ Ship multi-tenant support ○ Add tenant_id to the 9 tables that lack it work ○ Scope the 41 queries in core/ that ignore it work ○ Decide the isolation boundary: RLS or app-level needs a human ○ Backfill fixtures for the 12 untested paths work 4 task(s) added across 1 round(s)

lac goal plan <id> pulls the current frontier — every task in this goal's tree that hasn't been expanded yet — ordered by depth, then priority, then id, and expands up to --max of them (default 6) per round. Default is one round; pass --rounds N to keep going, capped at 20. Budget is checked before every round starts, not after: a planner that discovers it's over budget by having already filed 400 tasks hasn't actually been stopped by anything.

For each frontier node it builds a context pack, pipes it to a planner, and reads back JSON. What comes back determines what happens next: exit 1 means the planner is declaring this node atomic — real, unbreakable work, marked so and never revisited. Zero subtasks with exit 0 means the same thing by a different route. Anything else nonzero, or empty output, is logged as a planner failure and nothing gets filed — a silent failure here would look exactly like a goal with nothing left to do, which is worse than an obvious one.

What comes back that is usable subtasks gets filtered hard before it touches the database: at most 12 per response, titles truncated to 180 characters, priority clamped to 1–5, malformed entries dropped outright. A planner that files garbage costs more than one that files nothing, because garbage gets claimed and wastes someone's time before anyone notices it was garbage.

The context pack — grounding is the point

$ lac goal context 3

A planner is shown a slice, never the database. Run lac goal context yourself on any task id and you'll see exactly what it sees:

  • goal — title, statement, criteria, kind
  • node — the specific task being expanded, its title, detail, priority
  • depth / max_depth — how far down the tree this node sits, and the ceiling
  • ancestors — the titles of every node above it, so a depth-3 subtask isn't a restatement of the root goal
  • siblings — what else came out of this same expansion, with state
  • findings — everything recorded so far for this goal and anything it was forked from, newest first
  • budget — whether spending is still allowed, spent-so-far, and the ceiling
  • web — real timeline gaps, single-source claims, hotspots, unread documents and near-duplicates, straight from the same views lac web gaps reads
  • code — present only when the corpus actually holds code: gap stats and the top suggestions from lac_v_code_suggestions

That last split matters more than it looks. The code section only appears when to_regclass('public.lac_v_code_gaps') resolves to something — so a research archive is never handed code statistics and told to go write unit tests, and a software goal is never shown timeline gaps and asked to corroborate a claim from a second source. Both corpora feed the same mechanism; neither planner sees the other's shape of missing.

The prompt handed to an LLM planner says the point plainly: a gap in the context is a task to go and look, not a license to guess. That's the entire mechanism that stops a model inventing a source it would like to exist — it's shown what's actually in front of it and nothing else.

Planners are programs, not prompts

A planner follows the same contract as an executor or an observer: JSON context pack on stdin, {"atomic": bool, "subtasks": [...]} on stdout, exit code carrying the verdict. Two ship with the project. planners/offline needs no model and no network at all — it derives the decomposition straight from the web: timeline gaps become "find material for year N," single-source claims become "corroborate this," unread documents become "read this." It exists specifically to prove the architecture doesn't depend on having an LLM in the loop. planners/llm pipes the same context pack to whatever you already run —

$ export LAC_LLM_CMD='claude -p' $ export LAC_LLM_CMD='ollama run llama3' $ export LAC_LLM_CMD='my-company-gateway --model gpt-4o'

— no SDK, no vendor lock, no key handling anywhere in this project. Whatever gateway you already trust already solved that part.

Findings compound, and disagreement is recorded, not resolved

$ lac goal finding 1 "the tenant boundary can't be RLS — three queries span tenants intentionally" recorded as finding #7 — the next expansion of goal #1 will be shown this

Without this loop the fleet re-asks the same question forever. With it, every later expansion of the same goal — and anything forked from it — sees what earlier branches already learned. Two branches occasionally learn opposite things, and that's worth catching on purpose rather than by accident:

$ lac goal finding 1 "actually RLS covers it fine, cross-tenant reads were a bug" --contradicts 7

lac goal status lists that pair under unresolved contradictions until someone calls it — lac goal refute <id> or lac goal corroborate <id>, which is also the only way a finding's confidence ever changes after it's recorded. There's no separate "resolved" flag to remember to clear: once either side of a pair is refuted or corroborated, the same query that lists contradictions stops returning it.

What actually stops a runaway

This is the part that matters before pointing it at something large:

  • Depth cap. 8 levels, hard, verified by test.
  • Nodes per round. 6 by default — one round is bounded work, not an unbounded burst.
  • Budget checked before expanding, not after the fact.
  • Malformed output is dropped, not filed. Clamped priorities, discarded junk, no exceptions.
  • A planner failure is recorded and files nothing — never silently treated as "nothing to do."
  • Atomic is permanent. Once a node is marked real work, it's never re-decomposed.
  • Abandoning actually stops the work. lac goal abandon <id> closes every open and blocked task under it, not just the goal row — otherwise agents keep draining a tree nobody wants anymore.

A second attempt without duplicating the first

$ lac goal fork 1 "Multi-tenant, app-level isolation instead of RLS" goal #1 forked as #6 inherits 7 finding(s), no tasks — plan it fresh: lac goal plan 6 lac goal compare 1

A fork carries the parent's findings forward — otherwise it would re-ask every question the original already answered — but copies zero tasks, because copying the tree would just be two teams doing the same work twice. lac goal compare lines attempts up side by side: tasks, done, blocked, findings, depth reached, dollars and human minutes spent. It hands you the numbers and stops there — "better" depends on criteria no query can weigh, so the tool doesn't pick a winner.

What the progress number doesn't mean

pct_of_known_work in lac goal status is progress over the work that exists right now, not over the objective — nothing in this system can know how much of a real-world objective remains, and a progress bar that claimed to is a lie a budget holder would act on. lac_goal_settle() reports when all known work is done. It does not decide whether the criteria you wrote are actually met — no query can judge that, and closing the goal with lac goal close <id> stays a human call.

What this doesn't claim

None of this makes a model good at the underlying problem — multi-tenant isolation is still a hard design question after the planner runs, and nothing here makes that easier. What it changes is the shape the model's output lands in: bounded by a depth cap and a budget, grounded in what your corpus actually contains instead of what a prompt imagines, auditable through lac_plans — which planner, what it was shown, what it returned — and cheap to abandon if a branch turns out to be wrong. That's a real difference from a wall of text a model wrote once and nobody can check, and it's the honest size of the difference.


How Lacunari works →  ·  Read the source