Changelog
Newest first.
Versions follow semver: a major bump means a breaking change to the
lattice.json schema or the CLI surface. The running Lattice version is one of
the inputs hashed into every task’s cache key, so a version bump is a full cache
miss and the first run after an upgrade re-runs everything. Run
lattice version to see which version is installed. See
Upgrading and Caching.
Lattice 1.1.1 — 2026-09-10
- A task that leaves a process running behind it no longer holds the run open after the task itself has finished. The entry below has it
- Nothing in the
lattice.jsonschema or the CLI surface changed, so a 1.1.0 config loads unaltered. The running version is hashed into every task’s cache key, so the first run after upgrading re-runs everything - See Upgrading
A task that leaves a process running no longer hangs the run — 2026-09-10
- A task’s command runs in a shell whose output Lattice reads through a pipe.
Everything that shell starts inherits the same pipe, so a task leaving a
process behind — a Gradle daemon, an MSBuild node, an
esbuildservice, a watcher it backgrounded — left that process holding the pipe open. Lattice waited for the pipe to end before reporting the task, and the pipe ends only when the last thing holding it exits. A build that succeeded in a second against a daemon that never exits left the run waiting for good, with-vshowing the build’s own output and then nothing - Lattice now reads for half a second after the task’s own process exits and
then stops, the same grace a run already gave its final flush. The task’s
result, its exit code and its caching are unchanged, and output written
before the task exited is still reported.
-vnotesfinished leaving a process that still holds its output openwhen the grace runs out - The daemon is left running. Stopping it is not Lattice’s call
- A task’s reported duration is now measured when its process exits rather than
when its output is finished being read. A task that left a daemon behind used
to report the daemon’s lifetime as its own, and that figure is what the cache
stores and
lattice statslater adds up as time saved lattice setupread an installer’s output the same way and could hang for the same reason
Lattice 1.1 — 2026-09-01
lattice initnow writes the pipeline a repo already declares rather than a singlebuildtask, and a task can find the tools the project installed for itself. Both are below- Nothing in the
lattice.jsonschema or the CLI surface changed, so a 1.0.0 config loads unaltered. The running version is hashed into every task’s cache key, so the first run after upgrading re-runs everything - See Upgrading
lattice init imports the pipeline the repo already declares — 2026-09-01
- Init wrote exactly one task,
build. A repo whose task runner declared twelve tasks got a config naming one, and running any of the other eleven failed with a task that is not defined in thetasksmap. Init now reads the task list out of the file each selected workspace’s driver reads —turbo.json,nx.json,package.jsonscripts,deno.json,composer.json, ajustfile, aTaskfile.yml,pyproject.toml,Pipfile— and writes every task it finds - From a
turbo.json, each task’sdependsOn,inputs,outputs,env,persistentandcachecarry over, andglobalDependenciesandglobalEnvbecome the config’s. A!-negatedinputsglob becomes anignoreentry - A workspace whose driver takes the task name straight on the command line —
cargo,go,gradle,maven,dotnetand the rest — publishes no list of what it accepts, so it still gets the singlebuildtask - A
persistent: truetask in a workspace driven by a task runner now infers a command. Previously nothing was inferred for a persistent task unless the workspace declaredscripts, which left an importeddevtask with no command - See Adopting Lattice and
lattice init
A task finds the tools the project installed — 2026-09-01
- Lattice hands a task’s command to the shell, and put nothing on its
PATHbeyond the toolchainsenginesprovisions. A repo whoseturbo,eslint,pytestorphpstanis an ordinary dev dependency failed on the first run withcommand not found - Every project-local dependency binary directory that exists is now on the
task’s
PATH, nearest first, from the workspace directory up to the repo root:node_modules/.bin,vendor/bin,.venv/bin,.venv/Scripts,venv/bin,venv/Scripts - They go on after the pinned toolchain from
engines, so a pin still decides which copy of a tool runs. None of these paths is part of a task’s cache key - See Environment variables
The Windows installer builds — 2026-09-01
- The desktop app’s Windows
.msiand.exeinstallers had never been produced. The publisher string in the app’s config held a literal&, which reached the installer’s generated XML unescaped and stopped the Windows build. The publisher spells the ampersand out, and both Windows installers are on the release
Lattice 1.0 — 2026-08-31
- The first stable release. The
lattice.jsonschema and the CLI surface are under semver from here: a breaking change to either takes a major bump - The GitHub release is no longer marked a pre-release and the npm packages
publish under
latestrather thannext, so a bareinstall.sh, a barenpm install --save-dev @latticeandcompany/lattice, andlattice upgradewith no version all resolve to it - Nothing about the CLI, the config schema, or the cache key changed from
1.0.0-beta-3. The running version is hashed into every task’s key, so the first run after upgrading still re-runs everything
Ctrl-C ends a run holding persistent tasks — 2026-08-26
- A run whose persistent task left a process holding the task’s output open
outside the process group Lattice signalled would never exit. The run waits for
that output to end before it prints its summary, and it never ended.
tauri devstarts itsbeforeDevCommandin a process group of its own, which is enough to trigger it, solattice run devon a Tauri workspace hung on every interrupt - Lattice now waits half a second for that output, warns that a process was left running, and exits. Trailing output from a task can be cut short in that case; previously there was none at all, because the run never reached its summary
- A second
Ctrl-Cexits immediately with130. Watching for an interrupt replaces whatSIGINTandSIGTERMdo for the rest of the process’s life, so once the first interrupt had been consumed nothing butkill -9could end a run that wedged during teardown
Lattice installs from npm — 2026-08-26
- New package:
@latticeandcompany/lattice.npm install --save-dev @latticeandcompany/latticeputslatticeon a repo’s package scripts andnpx latticeon the command line.pnpm,yarn, andbuntake the same package name. Pre-1.0 releases publish under thenextdist-tag, so a bare install keeps resolving to the last stable one - The binary is not in that package. Six sibling packages carry one build each,
one per published target, and the wrapper depends on all six as
optionalDependencies. Each declares theos,cpu, andlibcit is for, so a package manager unpacks only the one that matches. The install downloads nothing beyond those packages and runs nopostinstallscript, so it works offline, from a lockfile, behind a proxy, and under--ignore-scripts - The binaries are the ones the release already publishes.
scripts/publish-npm.shdownloads a released tag’s archives, checks them against the checksums file published beside them, and repackages those exact bytes - An npm install does not follow a
latticeVersionpin, the one way it differs frominstall.sh. A binary under.lattice/binswitches to the pinned version. A binary innode_modulescannot, because the lockfile has already chosen the version. Lattice runs the installed version and prints the mismatch on stderr.settings.versionCheck,--no-version-check, andLATTICE_NO_VERSION_CHECKsilence that warning, and they silence the binary’s own version check the same way - The package exports
binaryPath(), the resolved path to the binary, so another tool can spawn Lattice directly instead of runningnpx. It exportsversionbeside it.importandrequire()both reach the two - Linux x86_64 gets both a glibc and a musl package. Package managers honor npm’s
libcfield inconsistently, so the wrapper also chooses between the two at runtime. Windows on ARM gets the x86_64 build, which is the fallback the installers already make. Lattice publishes no build for Linux, aarch64 (musl), and says so rather than guessing - See Installation
A cached run reports the time it saved, and lattice stats adds it up — 2026-08-23
- The run summary reports the task time the run’s cache hits skipped:
lattice: 10 tasks, 8 cached, 0 failed, 4.20s, 2m 51s saved, and❖ 10 tasks · 8 cached · 0 failed 4.20s · 2m 51s savedin the live display. It appears on any run with hits, not only a fully-cached one, and a run whose hits saved nothing measurable does not mention it - The figure is task time, not wall clock. Every cache entry records how long the
run that wrote it took, and a hit adds that recorded duration back. Four cached
one-minute tasks that would have run in parallel therefore read as
4m 00s saved, where the wall clock would only have shown about a minute - Under a minute the saved figure is written like the elapsed time beside it,
4.27s. Past a minute it reads4m 07s, and past an hour14h 22m - New command:
lattice stats. It reports the task time this repo’s cache has saved, the runs and hits behind that number, how much room the cache takes, and the last seven days. A repo that has never run says so and exits 0 - The record behind it is a ledger at
.lattice/cache/stats.jsonl, one JSON object appended per run. It is per-machine and already ignored by the.lattice/cache/linelattice initwrites. It lives with the cache so that it follows a relocatedcacheDir, which also means clearing the cache clears the history.lattice pruneand the leftover sweep leave it alone, and a line that no longer parses costs that run’s numbers and nothing else - A run appends nothing when it was told not to use the cache, or when it scheduled no task at all
- The banner under a fully-cached run reads
❖❖❖ FULL POWER. It was❖❖❖ FULL CACHE, which is the phrasing of a disk running out of room — the opposite of what a run that skipped all its work just did. The raw stream’s marker line changed with it, fromlattice: full cache, nothing to runtolattice: full power, nothing to run. A CI job grepping for the old string has to be updated. What triggers it has not changed: at least one task scheduled, no failures, and a hit for every one - The
cacheHitevent carriessavedMs, the recorded task time that hit skipped, and a run’s result carries the run’s total assavedMs. The desktop app’s run bar reports the same saved figure
See Caching for what a hit is worth and Output and logging for where the summary prints.
A failure names its exit code, and the live display drops its trace lines — 2026-08-23
- The live display no longer prints per-task trace lines. The dim
ui:build: hash 5341be256174fcacandui:build: cache miss: inputs changedlines above a running task are gone. A hit already prints its abbreviated key on the task’s own line,● ui:build cache hit [5341be25], and a miss shows up as the task running. The trace prints in the raw stream under-v, which is where it was always documented to live - Notes about the run as a whole still print dim in the live display: provisioning a toolchain, and pruning the cache
- The live display’s failed line carries the command’s exit code and how long the
task ran:
✗ ui:build FAILED (code 3) 1.02s - The raw stream’s failed line carries the same detail:
ui:build: FAILED (code 3) after 1.03s. It was the bare wordFAILED - A line drops the detail it does not have. A task a signal killed, and a task
stopped for overrunning its
timeout, have no exit code, so the line readsFAILED after 30.00s. A task that failed before its command ever ran has neither, so the line is the bare word. Its cache key would not compute, or its shell would not spawn, and reporting0.00sfor a task that never started would be a lie - A failing task’s captured output reads in arrival order. Both of a child’s pipes now append to one buffer as each line is read, so a compiler’s error lines stay next to the context they belong to. The whole of stdout used to print and then the whole of stderr, which left the error and its context pages apart. This is arrival order as Lattice reads it, so a task that dumps both streams in one burst can still print one stream before the other. Output that arrives spread over time is in the order it happened
- The failure block in the live display prints at normal brightness, with a
blank line above its
✗ ui:build outputheader. It was dim, which is the wrong treatment for the one thing on the screen worth reading - The
failedevent carriescodeanddurationMs, the two fieldspersistentExitedalready had. Both arenullfor a task that failed before its command ran, and adurationMsof0means a command that ran and failed inside a millisecond. The desktop app’s failed task row readsfailed (code 101) 1.84s
See Output and logging for both modes’ failure lines and Troubleshooting for reading a miss.
A task’s command is never invented, and a skipped task says so — 2026-08-23
- A workspace driven from a manifest now only ever runs a script that manifest
declares.
npm,pnpm,yarn, andbunreadscriptsinpackage.json, anddenoreadstasksindeno.jsonordeno.jsonc. A requested task the manifest does not hold drops out of the graph and the run carries on. Lattice used to fabricatenpm run <task>for it, and the fabricated command failed the build - The check could not tell four situations apart, and answered all of them the same way: a driver that takes the task name on its command line, a manifest that could not be read, a manifest that would not parse, and a manifest with no script section. Each is now handled separately
- A workspace whose manifest declares a script map that does not hold the task
being run is named in a warning, because a typo looks the same:
web declares scripts but no "build", so the task was skipped. Did you mean "biuld"?. With no near miss to offer, the warning endsDeclare it in the workspace's manifest, or under "scripts" in lattice.json, if the task should run there. Two or more such workspaces collapse into one line,some tasks were skipped: ..., and each keeps its own suggestion - The noun after
declaresis the manifest’s own word for the map, so a deno workspace saystasks. The warning appears once per run, on--dry-runas well as a real run, and covers only the tasks being run.lattice run buildsays nothing about a missinglint.--filterdoes not narrow the warning, so a manifest that should declarebuildand does not is reported even when this run does not select that workspace - A manifest with no script map at all stays silent, and so does a workspace with
auto: false. A package that declares nothing is a finished configuration - A manifest Lattice cannot read is now reported rather than worked around, one
warning per workspace:
web: package.json could not be parsed: <reason>, so every task it would have named was skipped. The other two reasons are a file that could not be read and ascriptsthat is not an object - Lattice strips
//and/* */comments before it parsesdeno.jsonanddeno.jsonc, so a commented Deno config resolves its tasks instead of counting as unparsable.package.jsonis still parsed as strict JSON, the way npm parses it
See What a driver can run for where a command comes from, and Errors for every shape the warning takes.
A pinned toolchain no longer records a version it did not read — 2026-08-23
- An engine whose version cannot be read, or that has no
versionCmdand no built-in rule, used to record0.0.0. That version was never installed, the constraint was never checked, and the fabricated number went intopins.jsonand into every cache key in the workspace - With a
versionconstraint it is now an error:engine 'alpes' has the version constraint '>=2' but no way to check what was installed. 'alpes' is not a well-known engine, so add a `versionCmd` to it. When the version command ran and printed no number, the error iscould not read a version from the output of `<cmd>` after install, so the constraint '<cons>' cannot be checked - With no constraint the recorded version is
unknown, and the install hash is what identifies the toolchain. The cache key’s toolchain component reads<name>=unknown@<hash>where it used to read<name>=0.0.0@<hash>, so those keys move once - A staging directory is now stamped with the process id, so two
lattice setupruns that provision one engine at the same time each get their own. They used to share one, clear it before use, and promote a tree assembled from both installs. Staging left behind by a run that was killed is deleted once it is 24 hours old - An engine’s
binhas to be relative and inside the toolchain install. It is checked when the config loads, and now also when a pin is read back out ofpins.json, so a hand-edited pin cannot put a directory Lattice never provisioned in front of every command - Failing to build the pinned
PATHis an error rather than a silent fall back to the host’s tool. All three places that build one report it: an engine’s version or install command,lattice setup’s dependency installer, and spawning a task. The message isthe pinned toolchain cannot be put on PATH, because a directory in it contains a character PATH cannot hold: <dirs>, and on a task it is reported as that task’s failure. Lattice used to drop that directory in silence, so the task ran against whatever version of the tool the machine had while the run still reported a provisioned toolchain - A workspace directory that resolves outside the repo through a symlink is
refused:
workspace path 'app' resolves to <path>, which is outside the repo root. A workspace directory has to be inside the repo. The directory a path resolves to is what bounds a task’s inputs and outputs. A symlink that stays inside the repo is still a workspace lattice initcan no longer propose two workspaces with the same name. The path-based fallback flatteneda/b-canda-b/cto the same string, and a config with two workspaces of one name does not load. The second now gets a-2suffix
See Engines and provisioning for the install layout, Cache internals for the toolchain component of the key, and Workspaces for the directory boundary.
A run ends on the first Ctrl-C, and a cancelled run reports no failures — 2026-08-23
Ctrl-Cnow ends a run that persistent tasks are holding open on the first press. It needed a second press before, because the wait started listening only after the graph had drained and so missed the signal that had already firedSIGTERM, which is what a CI runner sends when a job is cancelled, now ends the run. It previously hung until the runner force-killed it, so a cancelled job lost the rest of its timeout- A task failure now ends a run a dev server is holding open, instead of waiting for a signal only a person was going to send
- A task whose children ignore the stop signal is escalated to a force kill when
the grace period runs out. A
timeouton such a task therefore reports up to five seconds late. It previously never reported at all, because the run went on waiting on pipes nothing would close - A task’s output survives a byte that is not valid UTF-8, rendered as U+FFFD. Everything after such a byte used to be dropped for the rest of that task, and the line explaining a failure usually comes after the noise that caused it
- A task stopped as the run shuts down is no longer reported as a failure. The
events and the summary now agree: an interrupted run prints no
FAILEDline for a task the interrupt stopped, above a summary that already said0 failed. Read the exit code,130, rather than the summary
See Run dev servers, Persistent tasks, and Run Lattice in CI.
lattice setup notices a lockfile above the workspace — 2026-08-23
- A lockfile hoisted above a workspace now invalidates its install marker. Only
the workspace’s own directory was checked before, so in the everyday npm, pnpm,
or yarn workspace tree, where the only lockfile sits at the repo root,
lattice setupprinteddependencies up to dateforever and the next build failed on a dependency that had never been installed. Only--forcerecovered - The install marker moved out of the workspace directories. It is now one flat
file per workspace under
.lattice/setup, named for the workspace’s path relative to the repo root with/written%2Fand%written%25, soapps/webgets.lattice/setup/apps%2Fweb.marker,apps-webgets.lattice/setup/apps-web.marker, and the repo root as a workspace gets.lattice/setup/.marker. Encoding the separator rather than replacing it means two paths can never share a marker - A marker at the old
<workspace>/.lattice-setup-markerpath is still honored, so upgrading mid-project reinstalls nothing. Lattice deletes that marker once an install succeeds.lattice initwrites.lattice/setup/to.gitignoreand keeps the legacy.lattice-setup-markerline - Nothing under
.latticeis walked for a cache key, so the marker no longer moves the key of a task that declares noinputs - A marker Lattice cannot write produces a warning naming the real path:
web: dependencies installed, but .lattice/setup/apps%2Fweb.marker could not be written, so the next `lattice setup` will install again: <io error> lattice setup <name>with a name the config does not declare is refused. It used to select nothing and exit0, so a typo in a CI script looked like a clean run. The message isworkspace 'X' is not declared in the `workspaces` array in lattice.json. Declared workspaces: api, weblattice setupshows the installer’s output as it runs, with no verbosity flag needed- The installer no longer inherits the terminal’s stdin. An installer that stops for a password or a confirmation now fails immediately, and its own output says what it wanted. It used to block on a prompt nothing displayed until the run was killed. Supply the credential through the environment, or run that installer once by hand outside Lattice
latticeVersionis validated before it becomes a URL and a filename. A value that is not a version is refused by name instead of becoming a download of a release that cannot exist:lattice.json pins `latticeVersion` as "X", which is not a version. Write it like 0.2.0, or run `lattice upgrade <version>` to set it. A leadingvis accepted and stripped, so"v1.0.0-beta-3"and"1.0.0-beta-3"name one release, and either one naming the running build is a no-op rather than a failed download
See lattice setup for the marker layout
and Errors for the messages.
The desktop app shows output as it arrives — 2026-08-23
- Task output appears within about a tenth of a second. Output moved only when 256 lines had piled up or a task changed state, so a dev server that prints one line and then serves requests never showed that line at all
- The graph fills in during a run rather than only once it ends
- Closing a project, or switching to a different one, stops that project’s run and terminates its children. A run that kept going belonged to a project the window no longer showed, could not be stopped from the window, and reported task names from the wrong repo. Reopening the same project is a reload rather than a switch, and leaves the run alone
- Reloading the window reopens the project that was open and adopts a run still going in the backend, so the panes redraw and Stop comes back. It used to orphan that run
- A config save that failed no longer reports Saved.
useApp().saveConfigresolves to a boolean saying whether the write landed - A task the app stopped is no longer shown as a failure, because the runner no longer reports one
See The desktop app.
The cache no longer hides a change behind a symlink, a chmod, or an empty output directory — 2026-08-23
The first run after this upgrade re-runs everything. Two of the fixes below widen what a cache key covers, so every key that exists today moves.
inputspointing at a symlinked directory hashed zero files. The walk stopped at any symlink, soinputs: ["vendor/**"]against a symlinkedvendorproduced a key that could never change again, and the task hit cache forever after its first run. A symlinked directory is now descended- Re-pointing a symlinked file left the key unchanged. A link now contributes the
path it points at instead of the bytes on the other end, so switching
config/active.yamlfromproduction.yamltostaging.yamlmoves the key even when both files hold identical content - The two input walks treat a symlinked directory differently, on purpose. A
declared
inputsdescends one, because the pattern names the files behind it. A task with noinputsdoes not, because that walk covers the whole workspace and following the link would pull an arbitrary tree from elsewhere on the disk into the key. Neither walk tracks where it has been. A depth cap of 64 directories ends a cycle chmod +xon a hashed file did not move the key, so a hit restored the file without its executable bit. The artifact preserves file modes, so that bit is now hashed with every file’s contents. Only that bit is hashed. The rest of the mode is umask and platform noise that would make a key depend on which machine computed it. Windows has no executable bit and reports every file as non-executable- A task whose
outputsmatched only empty directories stored a valid artifact holding nothing. A bareoutputs: ["dist"]matchesdist/itself, so an emptydist/read as a result. Every later run then hit that entry, and the restore deleted whatever a real uncached run had produced. Lattice now refuses the store:outputs ["dist"] matched only empty directories, so nothing was cached. Check that the task writes its files where the patterns point. The existingno files matched outputs [...]message is unchanged - A cache hit left behind directories the cached run never produced. Clearing
before a restore skipped directories, so a
dist/chunks/from an earlier build survived a hit from a run that never created it. Clearing now removes directories too, deepest first and only when they are already empty, so a matched directory still holding a file no pattern names is left alone - Two
latticeprocesses sharing one checkout could delete each other’s cache writes. A store used to write its artifact before its metadata, and an artifact with no metadata is exactly what an interrupted store leaves behind, so the cleanup that runs at the end of every run undersettings.maxCacheSizecould not tell a live store from an abandoned one. A store now writes its metadata first, with no digest in it, and fills the digest in after the artifact lands - A leftover artifact, staging file, or entry with no digest is reclaimed only
once it has sat untouched for an hour. A store in progress and a store that
died halfway through leave the same files behind, so age is the only evidence
that separates them. Genuine debris now stays in the cache directory for up to
an hour longer than before, and counts against
settings.maxCacheSizewhile it does. A modification time in the future counts as recent, so a clock skewed between machines sharing a cache directory never causes a deletion - An entry whose metadata records no digest is never a hit, and is never an eviction candidate. Its bytes still count toward the size budget
See Caching for what a hit promises and Cache internals for the key’s components, the store order, and the grace period.
lattice.json rejects the values it used to accept and then ignore — 2026-08-23
Each of these was previously accepted and then silently did something other than what it said. Every one is now caught before any task runs.
- A key written twice in
tasks,engines, a workspace’sengines, or a workspace’sscriptswas last-wins and silent. In{"tasks": {"build": {"outputs": ["dist/**"]}, "build": {}}}theoutputsdeclaration disappeared, so the task cached something different and Lattice said nothing about it. The message isduplicate key `build` in tasks (lattice.json line 1, column 58), thenKeep one of them: the second replaces the first, so only the last would take effect. Only those four maps were affected. A repeated key anywhere else in the file was already rejected as a repeated field timeouthas a maximum of 365 days,31536000seconds. An oversized value used to saturate to the largest representable number of seconds. That deadline overflows rather than arriving, so asking for a very long timeout produced no timeout at all. The bundled JSON schema gained"maximum": 31536000to match- A
timeoutwritten as a fractional JSON number, such as1.5, is rejected rather than rounded up to2. The bundled schema has always declared that field an integer and the parser disagreed with it. The string form is unchanged:"1500ms"is still one second and"1.5m"is still ninety settings.cacheDiris validated. It has to be non-empty, relative, inside the repo, free of whitespace around a component, and not the repo root itself. It was unchecked before, so an absolute or../value put the cache outside the repo, and"cacheDir": "."pointedlattice pruneat the top of the repository, where it deleted the*.tar.gz,*.meta.json, and*.tmpfiles it found- An engine’s
binis validated on the same rules, against the toolchain install directory.{"bin": "/usr/bin"}previously replaced the install path outright and went on the front of every task’sPATH, while Lattice still reported a provisioned toolchain - A
scriptskey that names no declared task is an error, with aDid you meansuggestion. Ascriptsentry only ever supplies the command for the root task of the same name, so a typo likebiuldwas accepted, stored, and never used. The workspace ran the command Lattice detected for it instead of the override written underscripts - A workspace
pathwith whitespace around a component is rejected. Windows strips that whitespace and unix keeps it, soapps/webnamed two different directories depending on the platform - Lattice now judges path containment by the running platform’s own rules as well as by the text rules, so a spelling that resolves to a drive root or a filesystem root is caught even where the text rules do not enumerate it
- A path written with Windows separators, such as
apps\web, still passes validation and still fails when the directory is resolved. On unix that is one filename rather than two components, so it cannot be judged an escape at load time
The bundled JSON schema and the committed .lattice/schema.json also describe
the bin, timeout, and cacheDir rules, so an editor validating against the
schema flags the same values the parser rejects. See
Configuration for the field reference and
Errors for every message.
A persistent task that exits the moment it starts no longer hangs the run — 2026-08-21
persistent: true on a command that exits straight away could leave the run
waiting for a shutdown signal that was never coming. This is the typo case: a
dev server that dies on port already in use reported its exit, and then
nothing else happened until the caller gave up.
The runner counts persistent children to know when to stop holding the run open.
A child that dies instantly has its exit queued while its start is still
travelling back through the join handle, and tokio::select! takes whichever of
the two is ready without preferring either. When the exit won that race, the
counter was already at zero and clamped there. The start that arrived afterwards
raised it to one, and nothing ever brought it back down. The counter is signed
now, so both orders reach the same total. See
Persistent tasks.
Raw output is -v/--verbose, and -l/--loquacious is the hidden alias — 2026-08-21
--verbose and --loquacious swapped roles. -v/--verbose is now the
documented flag that prints raw workspace:task: lines instead of the live
display. It is also the only spelling lattice --help lists.
-l/--loquacious still parses and still does the same thing, but no help
output mentions it.
Nothing else changed. settings.loquacious keeps its name in lattice.json,
and the flag and the setting still combine the same way: either one on its own
turns raw output on. See Output and logging and
the CLI reference.
Windows installs from a one-liner — 2026-08-21
install.ps1 is a PowerShell installer, published next to install.sh:
irm https://latticeandcompany.github.io/lattice/install.ps1 | iex
It resolves a version the same way install.sh does — $env:LATTICE_VERSION,
then latticeVersion in lattice.json, then the newest release — verifies the
download against the release checksums, and writes
.lattice\bin\lattice-<version>.exe with a copy at .lattice\bin\lattice.exe.
The stable path is a copy rather than a symlink because Windows withholds the
privilege a symlink needs, which is what lattice upgrade has always done there.
It adds .lattice\bin to your user PATH, asking first when it has a terminal
to ask on, and skipping the edit when it does not unless you pass -AssumeYes.
install.sh no longer refuses Git Bash, MSYS2 and Cygwin. Those are POSIX shells
over a native Windows filesystem, so it now installs the Windows binary there
rather than turning you away. Under WSL2 nothing changes: uname reports Linux
and the Linux binary is what gets installed, which is right for a repo you build
from WSL2 and is not a Windows install. On Windows on ARM both installers take
the x64 build, which Windows runs under emulation, and say so while they do it.
See Installation.
Rust 1.88 is the floor for a build from source — 2026-08-21
Building Lattice from source now needs Rust 1.88, up from 1.86. Nothing in the code needed it: a dependency raised its own minimum, and cargo refuses to resolve a tree whose floor is below a dependency’s. Installing a released binary is unaffected — it needs no Rust at all. See Installation.
The CLI’s messages, the desktop app’s labels, and every doc — 2026-08-21
Two lines a script might grep for changed:
lattice: full cache — nothing to runis nowlattice: full cache, nothing to run- the run header pluralizes for real. Where it used to end
across 3 workspace(s), it now endsacross 3 workspaces, andacross 1 workspacefor one
Help text and error messages are reworded throughout. Each error now names what
failed, where, and the one thing to do next, and the semicolons that joined two
clauses are periods. The unknown-field error, the ambiguity error, and the
failed to <verb> <path> context layers are unchanged: they already read that
way, and 20 assertions depend on them.
The desktop app labels its controls with the words the docs and lattice.json
use. Where things can run is Workspaces, Files it reads is Inputs, Tool versions to carry over is Engines, and each carries one line underneath
explaining it. The 2026-08-08 entry below moved these labels the other way, to
plain English; a label that avoids the schema’s word teaches nothing a reader can
search for, so they now use the real term and explain it in place.
The app calls the thing you open a project, everywhere. It used to say repo in
one control and folder in the next, for the same thing. The word folder is
gone from the app.
Three claims the app made were false and are corrected. The --sequentially
switch was labeled One task at a time, which is --concurrency 1; the flag
runs each task’s graph to completion in turn, with normal parallelism inside each
graph. The engines help text said Lattice can fetch the tools it knows, which it
does only for an engine with an installCmd. The window’s duration formatter
disagreed with the terminal’s at three boundaries, so a 59.999s run read 60.00s
in the window and 1:00 in the terminal.
The docs, the README, the website, and the published skills/lattice skill are
rewritten. Corrections worth naming, because each documented behavior the code
does not have:
- Ctrl-C on a persistent task sends
SIGTERM, waits five seconds, thenSIGKILL, and the run exits130. The docs saidSIGKILLwith no grace period and an exit of0 settings.maxCacheSizetakes a string. The docs gave a bare-integer form that the parser rejects- driver detection takes the highest role rank across every candidate first, and
only then uses a declaration to break a tie. The docs and the skill described a
ladder that stops at the first rung, which told a reader that an
enginesentry always settles a driver conflict. Sometimes it does nothing - a task’s cache key includes the keys of the tasks it depends on, and a task
with no
inputshashes its whole workspace minus gitignored files. Several pages described a smaller key - the installer adds
.lattice/bin/to.gitignorewhen the repo has one, and aPATHline to your shell config unless you pass--no-modify-path. The README said it touched nothing else, so the uninstall it documented left that line behind - two error messages are built and then discarded, so no reader has ever seen
them.
RunFailurecarries the keep-going summary andRunInterruptedcarriesinterrupted — running tasks were stopped, andlattice_project::runtakes the run result off each one and drops the message. A--continuerun that fails prints its reporter summary and exits1. An interrupted run prints its summary and exits130. The docs quoted both messages as the run’s final error, so they now describe what a reader sees, and the absence of an error line is stated rather than left as a surprise
See CLI, Errors, and Desktop app.
The desktop app’s labels, accent, and repo switcher — 2026-08-08
The app labeled its controls with lattice.json keys and CLI vocabulary:
dependsOn, persistent, “Ignore cache”, “Concurrency: auto”, “no driver
resolved”. Each was accurate and meant nothing to a reader who had not read the
schema. The labels now read as plain English: “Waits for”, “Keeps running until
stopped”, “Skip the cache”, “as many at once as fits”, “nothing found to run it
with”. Every control in the config form still shows the lattice.json key it
writes.
Strings the CLI also prints are unchanged. A task’s state and a run’s summary line match the terminal’s output character for character.
The accent color is crimson rather than ink, on the buttons, the checkboxes, the
focus rings, and the active rail. The lockup in the sidebar now carries the word
desktop. Failure moved off crimson to amber, so a broken row and a Run button
never share a color. Amber is a status color only.
All thirteen ecosystems Lattice detects have artwork. Seven of them showed a two-letter monogram before. The marks are vector rather than pre-sized PNGs, and they sit on a fixed light plate, so a mark drawn for a white background is legible in dark mode without a second file per ecosystem.
Opening a repo, scanning one, saving a config, and a run in flight each show a spinner with a word beside it, in the accent color.
Switching repos was a two-glyph icon button in the bottom corner of the sidebar, and it did not say what it switched. The open repo is now a dropdown at the top of the rail, where its name already appeared. The dropdown lists every repo you have opened, marks the current one, and holds the actions for opening another repo and closing the current one.
See Desktop app.
A desktop app — 2026-08-07
Lattice now has a window. It lists the tasks each workspace can run and runs them,
draws the dependency graph, edits lattice.json, and scaffolds a config for a
repo that has none. The app links the same engine crates the CLI links, in
process, so the two cannot disagree about what a task is or whether it needs to
run.
The app shows what a terminal cannot hold on screen: which workspaces exist and what each one resolves to, the shape of the graph a task will run, and which of the ten cache key components moved when a task misses.
Run it with npm run app in apps/desktop. This change ships no installers.
See Desktop app and Architecture.
Cache entries live directly in the cache directory — 2026-08-07
Entries were written under a subdirectory named for an on-disk cache format
(.lattice/cache/v3/), and that name was hashed into every key, so a release
that changed what a key covered started a new group and left the old one for
lattice prune to reclaim. The running Lattice version already does that job. A
version bump moves every key, so the old entries are never asked for again. The
format directory bought only the directory sweep, which was the one part of prune
that ever called remove_dir_all on a path the user chose.
Entries now sit flat under settings.cacheDir, prune removes no directories at
all, and the first run after this upgrade re-runs everything. Anything left in a
v* directory from an earlier build is unreachable and safe to delete by hand.
See Cache internals.
A declared env name reaches the key even when it is unset — 2026-08-07
Only resolved (name, value) pairs were hashed, so a name in env or globalEnv
that the environment did not answer contributed nothing at all. Adding one
therefore hit the entry computed before it was declared, and went on hitting once
the variable was set, because the value had never been part of the key to begin
with. The name is now hashed whether or not it resolves, with a set value and an
unset marker as distinct cases.
See Caching and Configuration.
A task command with a quote in it works on Windows — 2026-08-07
A task’s command was handed to cmd as an ordinary argument. Rust quotes
arguments the way the MSVC runtime parses them, which escapes an embedded " as
\". cmd does not read \" as an escape, so any command containing a quote
arrived mangled. node -e "console.log(1)" was enough, as was any path
with a space in it. Task commands, installCmd, versionCmd and the setup
installers all went through the same door.
Each now passes /S /C "<command>" as a raw argument, which is the documented
way to reach cmd verbatim: with /S it strips the first and last quote of the
rest and takes what is between them as written.
See Installation.
The test suites run on Windows — 2026-08-07
Adding a Windows CI job showed that most of the suite could not run there, for a
reason that had nothing to do with what it covered: a test that drives a task
wrote its body as a POSIX shell script. cmd has no ; separator, its mkdir
takes no -p, echo hi > f writes a trailing space, and echo seed1>f reads
the trailing digit as a file descriptor. Those tests were not testing Lattice on
Windows; they were failing on shell grammar.
cargo test --workspace now passes on Windows as well as unix, and the Windows
job runs the whole suite rather than a subset. Three tests stay unix-only on
purpose: two cover symlink round-tripping and one covers process-group teardown,
which are the platform’s own mechanisms rather than something to emulate.
See Installation.
Shared files reach the cache key, and a run cleans up after itself — 2026-08-06
The first run after this upgrade re-runs everything: the key now covers more, so it is a new cache format. The previous group is retired by the next prune.
- A file shared above the workspaces could not be covered by anything, so editing
one served every task a stale artifact.
inputspatterns are relative to the workspace andtasksis shared across workspaces, which left a basetsconfig.json, a shared schema directory or a root.envwith no spelling that meant the same thing everywhere. Two root-level keys now cover them:globalDependencies(repo-root-relative globs) andglobalEnv(variable names), both hashed into every task’s key lattice prunedeleted every directory beside the cache that was not the current cache format. WithcacheDirpointing at a directory Lattice does not own outright, such as.lattice, that tooktoolchains/andbin/with it, including the installed binary. Only directories whose names have the shape of a cache format are reclaimed now- Ctrl-C left every running task’s children alive. Each task runs in its own
process group, which is what lets a task that shells out be cleaned up as a
unit, and the same call detaches it from the terminal’s Ctrl-C. The signal
reached Lattice, Lattice exited, and the compilers kept going. An interrupt now
sends
SIGTERMto every running group, waits up to five seconds, then kills what is left, and exits130rather than1, so a cancelled CI job does not report the same exit code as a build that failed - A
dependsOnthat named nothing was a silent no-op. A workspace depending on a misspelled workspace name, or a task depending on a task thetasksmap never defined, built no edge. The ordering the config was written to guarantee did not happen, and nothing was printed. Both are now rejected at load, with the nearest name offered - A workspace
pathcould be absolute or climb out of the repo with... The workspace directory bounds which files are hashed, which theoutputsglobs match, and which a cache hit clears before unpacking, so a path that left the repo put all three somewhere Lattice has no business writing. Rejected at load settings.maxCacheSizewas inert. It read as a budget but onlylattice pruneconsulted it, so a repo that set one still grew without limit. Every run now holds the cache to it. There is no default, so a cache with nosettings.maxCacheSizestill grows without limit- Toolchain provisioning could not work on Windows, which is a platform the
release matrix publishes a binary for. Engine version checks and
installCmdboth ran throughsh -cand joinedPATHwith:, so every engine in a config failed to resolve there. Both now use the platform shell and separator, the way the task runner already did, and CI builds and tests on Windows so it stays that way - A task that hung had nothing to stop it, in CI as much as locally. Tasks accept
a
timeout("90s","10m","1h", or seconds); an overrun stops the task’s whole process group and counts it as a failure. Ignored on apersistenttask, and not part of the cache key - A cache miss said only that it missed. The key is one hash, so it could not say
what moved. It is now built from named components:
inputs,env,globalEnv,globalDependencies,dependencies,manifests,toolchain,command,patterns, andenvironment. Each is recorded per workspace and task, so-lreportscache miss: inputs changedinstead of a bare miss
See Caching and Task graph.
The cache key covers what actually determines a task’s result — 2026-08-03
The first run after this upgrade re-runs everything. Entries are now grouped by
cache format on disk, and the previous group is retired by the next
lattice prune rather than read.
- Two workspaces could share one cache entry and restore each other’s artifacts. The key did not include the workspace, so a task running the same command in two places with nothing else to distinguish it resolved to one identity. The second workspace reported a hit and unpacked the first one’s build. The workspace name is now part of every key
- A change in a dependency did not reach the tasks that depend on it.
dependsOndecided the order and nothing else, so editing a library rebuilt the library and then served its consumers from cache, against code that no longer existed. Every task’s key now includes the resolved keys of its prerequisites. A workspace is one node, so a dependent re-runs when its dependency changes even if the specific files it reads did not - A task with no
inputshashed no source files at all, so it cached on its first run and never ran again however much the workspace changed. Such a task now hashes its whole workspace, minus what the applicable.gitignorefiles exclude and minus its ownoutputs. Declaringinputsis now an optimization rather than a correctness requirement - Only the invocation was hashed, never what the invocation resolves to.
npm run buildnames a script inpackage.jsonandmake testnames a target in aMakefile; rewriting that script left the key unchanged and served the old artifact. Manifests present in a workspace are now hashed - Lockfiles were only looked for beside the workspace, so every layout that hoists one to the top got no invalidation at all from a dependency bump. That covers pnpm, yarn and npm workspaces, and a Cargo virtual workspace. The repo root is now checked too
- The operating system, architecture and shell are in the key. A cache directory shared between runners, or between a host and a container, could answer one platform’s lookup with another platform’s artifacts
- A task’s own outputs no longer feed its key. Previously an
outputsglob inside theinputsset moved the key the run was about to store under, so the task could never hit its own entry; the workaround was to repeat every output inignore. That is no longer necessary - The
inputs,outputsandignorepatterns are hashed as declared. Wideningoutputsused to leave the key alone, so the next run hit an entry that had captured the narrower set and silently restored less than the run produced
Storage got the same treatment:
- Artifacts and metadata are written to a temporary name and renamed into place.
Nothing can read a half-written entry, and two
latticeprocesses storing the same key can no longer interleave into one broken one - A task that declares
outputsand produces none of them is no longer cached. It used to store an empty archive, which verified correctly forever after, so the task reported a hit, restored nothing, and never ran again - A restore clears what the entry’s
outputsmatch before unpacking. A file the task deleted stays deleted, and content-hashed names likeapp.a1b2.jsno longer pile up across builds - Symlinks are stored as symlinks and empty output directories survive. Symlinks were previously followed and flattened into copies of their targets
lattice prunecan see artifacts left without metadata by an interrupted run. It enumerated entries by metadata alone, so those bytes were invisible to it and could never be reclaimed, which mademaxCacheSizeunenforceable. It now also retires other formats’ directories, and one unreadable metadata file evicts that entry instead of aborting the prune- A directory symlink inside an input or output tree no longer recurses until the stack runs out
- An
outputspattern with no glob characters that names a directory, likedist, now captures that subtree. It previously matched nothing
--no-cache and --force are no longer the same flag. --no-cache neither
reads nor writes. --force skips the lookup but still stores, so it replaces a
suspect entry. It previously declined to write, so it could not.
See Caching and Cache internals.
lattice init reads the repo instead of asking about it — 2026-07-31
initopened by asking whether the repo needed Lattice as a build tool, a toolchain manager, or both. It then had you type every workspace name and path by hand and invent version constraints defaulting to>=0.0.0. It never looked at the directory it was scaffolding- It now scans first. Every directory holding a manifest Lattice recognizes is proposed as a workspace, with its detected driver shown next to it. The walk skips hidden directories, gitignored paths, and dependency and output trees
- Tool versions the repo already records become proposed engines, at the version
actually written down:
.tool-versions,.nvmrc,rust-toolchain.toml,.python-version,.ruby-version,.java-version,package.json(packageManagerandengines), andgo.mod’stoolchainline - The capability question is gone. On a terminal you get the two lists pre-checked and uncheck what’s wrong. A repo root that holds only a workspace declaration is offered alongside its members but starts unchecked
initno longer writes a config that does nothing: when the scan finds nothing, or you uncheck everything, it asks for at least one workspace or one engine first. Without a TTY there is no one to ask, so that case still writes the bare skeleton rather than failing a pipeline--yestakes the scan’s proposal rather than always writing the skeleton- A scanned
buildtask only claimsoutputs: ["dist/**"]when apackage.jsonworkspace was found. A Rust or Go repo no longer gets a JavaScript convention written into its config - A directory whose driver stays ambiguous is offered but starts unchecked, and
is named before
initexits. Declaring it would halt the next run, soinitproposes a config that runs and reports what it held back
See Getting started and CLI reference.
A persistent task that exits is reported — 2026-07-30
- Lattice spawned a dev server and never looked at it again. A
persistent: truetask whose command exited left the run reporting it as running until Ctrl-C, then printing0 failed. A port already taken, or a one-shot command marked persistent by mistake, was enough. Every persistent child is now waited on - An exit that isn’t a clean
0printsweb:dev: EXITED (code 1) after 1.09son stderr, counts in the run summary’s failed count, and exits non-zero. A signal readsEXITED (killed by signal) - An exit code of
0prints the same line lowercased on stdout and counts as nothing - A persistent task that has exited stops holding the run open. When the last one is gone the run prints its summary and exits instead of waiting for a Ctrl-C with nothing left to stop. Other persistent tasks still up are untouched, and the graph’s scheduling is unchanged: a persistent exit stops nothing
- A child Lattice kills at shutdown is not reported and never counts as a failure. The kill request and the child’s own exit can land in the same poll, so the shutdown flag decides that, not which one won
- On Unix, a persistent task that exits on its own now also takes down the rest of its process group, so a server the command backgrounded before quitting isn’t left holding a port
See Persistent tasks and Dev servers and watchers.
Breaking: an unknown key in lattice.json is an error — 2026-07-30
-
The bundled schema has always set
"additionalProperties": false, so an editor underlined a key Lattice does not define. The config types carried nodeny_unknown_fields, solattice runread the same file, ignored the key, and ran. The two now agree: every config type rejects a key it does not define, at every level -
This breaks any
lattice.jsoncarrying an extra key: a leftoverprojectsmap, asettings.loggingfrom before it was removed, aglobon a workspace entry, or a note parked under a key of your own. Delete it. There is no opt-out -
The two typos this catches were both silent.
outputforoutputsleft a task declaring nothing to capture, so a cache hit restored no files.inputforinputshashed no files, so the task hit the cache after its first run whatever you edited -
The message names the key, the object holding it, its position, the nearest valid field, and everything accepted there:
Error: unknown field `output` in tasks.build (lattice.json line 5, column 14) Did you mean `outputs`? Fields accepted here: dependsOn, inputs, outputs, ignore, env, persistent, cacheContainers read the way they do in the file:
tasks.build,workspaces[1],engines.node, orat the top level of lattice.json. The suggestion fires within one edit for short keys and two for longer ones, case-insensitively, soOutputsanddependOnare caught as well -
enginesis hand-deserialized rather than an untagged enum. An untagged enum reports only that no variant matched, which would have buried the unknown key inside an engine object. A value that is neither form now says so:invalid type: integer \20`, expected a version constraint string or an engine object` -
The pinned-version check still reads
latticeVersionandsettings.versionCheckstraight out of the JSON, so a config written for a newer release can still say which build is allowed to read it -
A test asserts the schema and the config types accept the same key set at every level, so the two cannot drift apart again. The shipped-config tests now cover every
lattice.jsonin the tree, examples included, rather than two of them
See Configuration and Errors.
A cache hit does not re-export the stored env — 2026-07-30
CacheStore::restore’s rustdoc claimed the caller re-exports the entry’s storedenv. Nothing ever did, and nothing can: a hit starts no process, so there is no environment to export into. The runner’s matching dead read is gone. It was anentry.env()assigned to_cached_env, under a comment that made it look deliberate- The stored
envstays. It is the record of the values the key was computed from, and since the key is a hash it is the only place they remain legible.cache-internals.mddescribes it that way instead of implying a hit re-applies it, and the page now states that restore overwrites the files at the output paths and touches nothing else - Tests pin both halves: the values round-trip through the entry and survive a
touch,restoreleaves the process environment alone, and a stored entry’s meta file records the resolved value that fed its key. The stress test asserts the same against a real.meta.json
See Cache internals.
--filter runs what the filtered workspaces depend on — 2026-07-30
- A filter used to be applied before the graph was built, so a
^buildedge into a workspace the pattern excluded resolved to nothing.lattice run build --filter lattice-runnerin this repo ran one task and reported success, having silently skipped the five workspaces it depends on - The matched workspaces are now the roots of the run: the graph adds their transitive dependency closure, deduplicated, and drops everything else. A prerequisite whose inputs haven’t changed comes back from cache, so the added cost is a cache lookup per node
- Nothing that depends on a match is included, so
--filterstill narrows a run to one part of the repo --dry-runtags each pulled-in node:→ dagger:build (dependency) cargo build- A workspace pulled in as a dependency is only asked for the tasks its
dependents need, so an
auto: falseworkspace outside the filter with no script for the task you named no longer halts the run. Toolchain provisioning and theacross N workspace(s)count now cover the workspaces in the graph rather than every workspace declared
See Selecting what runs.
The ambiguity error suggests a fix that works — 2026-07-30
- When a workspace had no ecosystem marker, the halt suggested
"engines": { "node": ">=0.0.0" }. Pasting that in reproduced the same error, because a runtime cannot drive named tasks. It now suggests"auto": false, "scripts": { "build": "<command>" }, which resolves it - The
nodefallback fired whenever the candidate list was empty, so the two workspaces most likely to hit it were the emptiest ones: a directory holding only a.nvmrc, and a directory with nothing Lattice recognizes at all - Where a candidate tool does exist, nothing changes. Every tool a generic
ecosystem marker maps to can drive, so a bare
package.jsonstill suggestspnpm. A test asserts that stays true as the marker table grows - The stress test now pastes the suggested fix back in and asserts the run succeeds
See Driver detection and Errors.
settings.logging is gone — 2026-07-30
- The field validated against the bundled schema and changed nothing. Nothing in the tree read it. It is removed from the config type and from the schema
- Output verbosity is
-l/--loquacious,settings.loquacious, andCI, which is what it always was - A
lattice.jsonstill carryingloggingkeeps loading, because an unknown setting is ignored rather than rejected, so nothing breaks on upgrade. Editors pointed at the refreshed.lattice/schema.jsonwill start flagging the key. Delete it .lattice/schema.jsonis only written when absent, so a repo initialized before this keeps its copy. Delete the file and run anylatticecommand to pick up the current one
See Configuration and Output and logging.
Flags for what used to be environment variables — 2026-07-29
--theme light|darkreplacesLATTICE_THEME, and picks the teal shade of the splash art. A value that is neither is now a parse error listing the two that work, rather than a silently ignored string. It is global, so it parses onlatticeitself and on every subcommand--release-base-url <URL>replacesLATTICE_RELEASE_BASE_URL. Also global, becauseupgradeis not the only thing that downloads. An invocation in a repo pinning a version that is not installed fetches it under whatever command you typed--release-latest-url <URL>and--release-list-url <URL>replaceLATTICE_RELEASE_LATEST_URLandLATTICE_RELEASE_LIST_URL. These sit onlattice upgrade, the only command that resolveslatest- Every one of those variables still works. The flag wins where both are given, and a blank value at either step falls through to the default rather than building an empty URL
LATTICE_SWITCHED_FROMstays a variable on purpose. It is read by the process a version switch hands the invocation to, and that process is a different build of Lattice. An older build would reject a flag it has never heard of and fail the handover. For the same reason, a repo pinning a version older than these flags should keep exportingLATTICE_RELEASE_BASE_URL: the handover passes the whole command line through, so a flag the pinned build does not know reaches it as an errorLATTICE_TOOLCHAIN_DIRis unchanged and gets no flag. It is what Lattice hands to an engine’sinstallCmd, not something you tell Lattice
See Environment variables and CLI reference.
One color per task in the plain stream — 2026-07-29
- The
workspace:tasklabel leading every line of the plain stream is now colored, one color per task, so the interleaved output of a parallel run can be followed by eye.web:build,web:test, andapi:buildare three different colors; the eight in the palette are one hue step apart at a fixed saturation, and none of them reads as the red aFAILEDmarker uses - Colors are handed out in the order labels are first seen, so the first eight distinct labels in a run never share one. Because tasks start in parallel, which color a task gets can differ between runs. Within a run it never changes
- The loquacious trace lines carry the same colored label, so
lattice: web:build: hash …andweb:build:’s own output read as one stream - Whether color is emitted now depends on stdout being a real terminal rather than
on which mode you got.
-lat a shell colors labels; the same run piped, redirected, or underCIemits no escapes at all and is byte-for-byte what it printed before.NO_COLORstill suppresses everything - Nothing but the label is styled, and
FAILEDis still the wordFAILED
See Output and logging.
A run that executes nothing says so — 2026-07-29
- When every task in a run comes back from cache, the summary is followed by
❖❖❖ FULL CACHE, painted across the teal ramp a character at a time. Plain output gets the same signal without color, aslattice: full cache — nothing to run, so a CI log can be grepped for it - It requires at least one scheduled task, zero failures, and zero tasks that
ran, so a filter that matched nothing stays quiet and a graph with a
persistent: truetask never qualifies
See Output and logging and Caching.
The toolchain table, filled in — 2026-07-29
- CocoaPods, pip, NuGet, and Kotlin are now fully wired rather than half-known.
podhad a driver row but no engine rule and no installer;piphad an installer that no driver could ever reach;nugetandkotlinwere absent. All four are drivers, well-known engines, and known tolattice setupnow denoandbunare runtimes as well as a task runner and a package manager, andmixis a package manager as well as a task runner. A driver declares every role it fills and competes with its highest-ranked one, so what drives a workspace is unchanged:denostill drives as a task runner andbunas a package manager. The table now lists every role a tool fills- The well-known engine list and the built-in version commands were two separate
tables that disagreed.
uv,poetry,just,turbo,nx,swift,dart,composer,mix,stack,cabal,pdm, andpipenvall had a version rule but were rejected in string form, so"engines": { "uv": ">=0.5" }failed to load for no reason. They are one table now, inlattice-config, and every driver is guaranteed a row in it "python3": ">=3.12"was checked by runningpython --version, which on many machines is a different interpreter, or Python 2. It runspython3 --versionlattice setupknows how to install dependencies for 11 more drivers:dotnet restore,nuget restore,pod install,swift package resolve,composer install,mix deps.get,dart pub get,pdm install,pipenv install,stack build --only-dependencies, andcabal build --only-download. A.csprojworkspace used to report “no known dependency installer” and skip- 13 more lockfiles feed cache keys, including
deno.lock,composer.lock,mix.lock,pubspec.lock,Package.resolved,Podfile.lock,packages.lock.json,pdm.lock,Pipfile.lock, andrequirements.txt. A dependency bump in those ecosystems used to hit a stale cache entry. The cache andsetupread one shared list, so they can’t drift apart npm-shrinkwrap.jsonis npm evidence, alongsidepackage-lock.json- An ambiguity error suggests better candidates. A bare
Cargo.toml,go.mod,composer.json,mix.exs,pubspec.yaml,Package.swift,stack.yaml,cabal.project, or.csprojused to produce an empty candidate list; each now names the tools that could plausibly have been meant - Two drivers have no fingerprint on purpose. A
requirements.txtis read by pip, uv, and pip-tools alike, and a Kotlin workspace is driven by gradle or maven, sopipandkotlinare selected by declaration rather than by guessing. For the same reasonpackages.lock.jsonis not nuget evidence: an SDK-style project can carry one and still be adotnetworkspace
See Toolchains and Driver detection.
Installing, upgrading, and running the version a repo pins — 2026-07-28
Lattice can now be installed without a Rust toolchain, and a repo’s
latticeVersion is enforced rather than merely announced.
curl | sh installs a target-matched binary into the repo
apps/web/public/install.shdetects the OS, architecture and libc, resolves a version, downloads the matching release archive, verifies its SHA256 against the release’s checksums file, and installs./.lattice/bin/lattice-<version>with./.lattice/bin/latticesymlinked to it. Nothing is written outside.lattice, sorm -rf .latticeis the uninstall- Version resolution, in order:
$LATTICE_VERSION, thenlatticeVersionfrom./lattice.json, then the newest release when the directory has no config at all. The pin is read by the installer because it has to be known before a binary exists to read it. Alattice.jsonthat exists but pins nothing is an error - It fails loudly, before installing anything, on an unsupported platform, a missing pin, a missing asset, a missing checksums entry, or a digest that does not match
- It sits in the docs site’s
public/, so the site serves it verbatim atlatticeandcompany.github.io/lattice/install.shwith no build step of its own. The release workflow publishes the same file as a release asset - Keeping versioned binaries on disk is what makes a branch switch cheap, so
.lattice/bin/is now in the.gitignorelineslattice initmaintains
Every invocation runs the version the repo pins
- A binary under
.lattice/binwhose version differs fromlatticeVersionnow prints one line naming both versions, installs the pinned version if it is not already on disk, repoints the symlink, and hands the invocation over to it with the arguments untouched. Switching between two branches that pin versions you already have is a symlink swap and touches no network - The pin is read straight out of the JSON rather than through the config loader. A config written against a newer schema has to be able to say which version can read it, so the handover happens before anything that could reject it
- A binary Lattice did not install is never replaced, whether it came from
cargo install, a distro package, orscripts/dev-link.sh. Those keep the advisory one-line nag from #45, which now prints a runnablelattice upgrade <version> --no-version-check,LATTICE_NO_VERSION_CHECKandsettings.versionCheck: falseeach skip the whole thing.upgrade,versionandcompletionsare never handed off: they answer for the binary that was invoked, and a completion script has to be the only thing on stdout- A pinned version that cannot be installed is a hard failure naming the version and the way past it, rather than a run on whichever binary happens to be present
lattice upgrade <version|latest>
- Installs the version, points
.lattice/bin/latticeat it, and rewriteslatticeVersion.latestresolves the newest release; a bare version pins it exactly, with or without a leadingv - The config is edited as text, so key order, indentation and the rest of the file survive a bump. A version that is not a version is rejected before it can reach a URL or a filename
- Re-running for a version already pinned and installed reports that and repoints the symlink, which is the one case where doing nothing would leave the repo on the wrong binary
Releases are built and published by tag
.github/workflows/release.ymlbuildsv*tags for six targets: macOS x86_64 and aarch64, Linux x86_64 gnu and musl, Linux aarch64, and Windows x86_64. It publisheslattice-<version>-<target>.tar.gzarchives carrying the binary, the license and completion scripts, plus onelattice-<version>-checksums.txtand the installer- Completions are generated once on a native runner, because a cross-compiled binary cannot be run to print its own
- The tag has to agree with the tree:
scripts/check-versions.sh <version>gates the build, and CI now runs it on every push, along withshellcheckover the installer
See Installation and Upgrading.
Four documented promises the code did not keep — 2026-07-28
Groundwork for the first tagged release. Each item here was a statement in the README, the docs or a manifest that the code contradicted.
The stated minimum Rust version was wrong by eleven minor versions
Cargo.tomldeclared norust-versionat all, while the README badge, the README Development section,CONTRIBUTING.mdandlattice.json’sengines.cargoall claimed 1.75. Resolving the lockfile against each dependency’s ownrust-version, the real floor is 1.86:clap,sha2andindexmapneed 1.85, and the ICU crates reached throughjsonschema→idna→idna_adapterneed 1.86. A build on 1.75 could not have workedrust-version = "1.86"is now set once in[workspace.package]and inherited by all seven crates, and the four prose copies are corrected to match.cargo +1.86 check --workspace --all-targets --lockedpasses- Declaring it turned on clippy’s
incompatible_msrvlint, which found one real violation:u64::is_multiple_ofinlattice-configis stable since 1.87. It is nowbytes % unit == 0, which is what the method is sugar for, so the floor stays where the dependency tree actually puts it jsonschemais a dev-dependency taken withdefault-features = false. Its default features pullreqwestin for remote$refresolution, which the schema tests never use. That dragged a whole TLS stack into the dev tree, againstCONTRIBUTING.md’s rule about network access. All 23 schema tests pass without it
version --json could not report a target triple
- The
targetfield wasstd::env::consts::ARCH, so it printedaarch64rather thanaarch64-apple-darwin.bug_report.ymlasks contributors for that output to identify a platform, and the installer needs the same vocabulary to pick a release asset - A
build.rsoncrates/latticenow emitsLATTICE_TARGETfrom cargo’sTARGET, and the bare architecture moves to its ownarchfield rather than being dropped - The stress test asserted only that a
targetkey existed, which a bare arch satisfied. It now also asserts the value looks like a triple, and a missingversionfield is a hard failure instead of silently falling back to a hardcoded0.1.0
A docs page described a command that does not exist
apps/web/src/content/docs/templates.mddocumented scaffolding new workspaces from a template. There is notemplatescommand incrates/lattice/src/commands/, andCONTRIBUTING.mdprohibits documenting features that do not ship. The page is deleted andnested-repos.mdmoves up to fill the gap it left in the Guides ordering
Windows was presented as merely untested
lattice-runnerhas a correctcmd /Cbranch, butlattice-workspace’s toolchain probe hardcodessh -cand joinsPATHwith:, so engine version checks and toolchain provisioning cannot work there. A Windows binary would launch, print a splash, and fail at the detection ladder- The README and the getting-started page now say macOS and Linux, and point Windows users at WSL2
See Installation and CLI reference.
Long durations print as a clock — 2026-07-28
- Task and run times over a minute now read
4:07and1:12:30instead of247.00sand4350.00s. Under a minute is unchanged (1.23s). Applies everywherelatticeprints a duration: per-task completion lines and the run summary, in both the interactive and CI reporters
See Output and logging.
Nested repos: docs, worked example, and tests — 2026-07-28
Docs
- A subtree that already has its own task runner is declared as a manual
workspace whose scripts shell out to that runner; this needed no feature work,
since ordering,
dependsOn, caching as one opaque unit, and validation all fall out of the existing mechanism - The nested-repos page covers the config, what each tool owns, and the
ignoreset that broadinputsrequire: dependency trees, the inner runner’s own cache, and output directories - Two limitations are documented. A manual workspace must declare any task invoked directly. A downstream workspace must not copy an upstream artifact at build time, because a cache key covers only the inputs its own workspace declares
Example & tests
examples/nested-repois a runnable repo with a real JS monorepo (npm workspaces, two packages, an inner dependency edge) as one Lattice node, plus a downstream servicee2e_passthrough.rsand a new stress-test section prove the nested repo runs in graph order, provisions no toolchains, caches and restores as one unit, re-runs on an inner source edit, and never reports a hit when the inner runner’s cache directory is left unignored
See Nested repos.
Docs site search — 2026-07-28
- Full-text search over the documentation, built on Pagefind; the index is
generated from the built HTML as the last step of
npm run buildand ships as static files data-pagefind-bodyon the docs article scopes the index to documentation prose, keeping the landing page and 404 out- Page title and section come from the collection frontmatter rather than being scraped out of headings
- The palette opens with
⌘K/Ctrl-Kor/, walks results with the arrow keys, and lists heading-level matches beneath each page so a long page points at the section that matched - With no index present the palette shows an explanatory notice; this is the
case under
astro dev, since search needsnpm run build && npm run preview
Persistent tasks stream their output by default — 2026-07-27
- A
lattice runthat pulls a persistent task into its closure now defaults to raw line-by-line output instead of the live TUI, so the process’s streaming output stays visible; this previously required-l(--loquacious) - Non-persistent runs on a terminal still get the interactive TUI
- A persistent task’s output always streams live even in raw mode, while other per-task output stays collapsed and is surfaced on failure
- Auto-detection no longer fabricates a command for a
persistenttask: a direct-invoke driver (cargo, go, …) used to invent a command for any task name, solattice run devpicked up every Rust and Go workspace ascargo devorgo deveven though no such task exists - A persistent task now runs only where the workspace declares it, through an
explicit
scriptsentry or a manifest script for the JS and Deno drivers; non-persistent tasks (build,test, …) still infer as before
See Persistent tasks and Output and logging.
Stacked commands and a self-healing editor schema — 2026-07-27
lattice runaccepts multiple tasks in one invocation (lattice run lint test build); the roots merge into a single dependency graph, so a dependency shared by several roots runs once and independent roots parallelize where the graph allows- All existing flags (
--filter,--concurrency,--continue,--dry-run,--no-cache) apply to the combined run, and an unknown task in the list fails fast and names the offender --sequentially/-sruns each task’s graph to completion in the order given before starting the next; fail-fast stops at the first failed phase, and--continueruns the remaining phases and still exits non-zerorun,setup, andprunewrite.lattice/schema.jsonwhen it is missing, as happens with a cleared cache directory or a clone where it was never committed, so an editor’s JSON language server can resolve the config’s$schema- An existing copy is left untouched to avoid churn, and the schema is committed to this repo so validation works before the first run
See Task graph and CLI reference.
The documented install command installed the wrong software — 2026-07-27
- The docs site told readers to run
cargo install lattice, butlatticeon crates.io is an unrelated markdown linter, so anyone following the getting-started page or the landing-page copy button got someone else’s tool - Both now show the repo-local bootstrap one-liner, with
cargo install --git … latticedocumented as the from-source path
See Installation.
License of record was inconsistent — 2026-07-27
LICENSEis ISC while the workspace manifest declaredlicense = "MIT"; the manifest now saysISC