Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

qk

qk, short for quick, is a standalone task runner written in Rust. It reads Nx-compatible workspace configuration and executes tasks with dependency ordering, affected selection and a cache shared by Git worktrees.

Start with Getting started to inspect and run the self-contained example workspace.

This book documents the current implementation. The design and implementation plan describes the longer-term plan; planned behavior should not be treated as supported functionality.

Read Running tasks for execution and dependency ordering, Affected selection for change detection, and Local cache for inputs and output restoration.

qk also implements S3-compatible remote caching, warm state and run history.

Use Workspace configuration, Target configuration, and the CLI reference for options and defaults. Nx compatibility explains the supported subset.

Getting started

Install a current stable Rust toolchain, then run from this repository. On Windows, building also needs clang-cl, from LLVM, as the C compiler for the MSVC target: set CC_x86_64_pc_windows_msvc=clang-cl.

cargo run -p qk-cli -- --workspace examples/basic show projects
cargo run -p qk-cli -- --workspace examples/basic show project web --json
cargo run -p qk-cli -- --workspace examples/basic show projects --projects 'tag:scope:*' --json
cargo run -p qk-cli -- --workspace examples/basic graph --file -
cargo run -p qk-cli -- --workspace examples/basic run codegen:smoke

Inspection and the codegen:smoke task are self-contained; no Node installation, package installation, Nx process is required. Other example targets demonstrate package scripts and require pnpm when executed.

To install the binary locally:

cargo install --path crates/qk-cli --locked
qk --help
qk --workspace /path/to/your/workspace show projects --json

Inside a workspace, omit --workspace. qk searches ancestors for nx.json, pnpm-workspace.yaml or a root package.json with workspaces. A standalone project.json, or a package with an nx object, is also supported when no parent workspace marker exists.

Running tasks

Inspect the normalized project before running it:

qk show project web
qk run web:build --dry-run
qk run web:build
qk run-many -t build,test -p 'web,tag:library' --parallel 4

The dry run exposes dependency expansion without starting a process. Use qk --help or qk <command> --help for command details.

Selecting tasks and run options

run requires an existing target; run-many skips projects without the requested targets and errors when nothing matches. run, run-many and affected take Nx’s run options:

  • -c/--configuration, and --prod for -c production.
  • --parallel: a number, a percentage of the cores such as 50%, or false for one task at a time. Without it, NX_PARALLEL, then nx.json parallel, then 3; --parallel alone means NX_PARALLEL, else 3, as in Nx.
  • --skip-nx-cache (--skip-cache, --disable-nx-cache, or NX_SKIP_NX_CACHE=true), and --skip-remote-cache (--disable-remote-cache, NX_SKIP_REMOTE_CACHE=true or NX_DISABLE_REMOTE_CACHE=true) to keep to the local cache. Tasks see NX_SKIP_NX_CACHE=true when the cache is skipped.
  • --nx-bail (or NX_BAIL=true, which --nx-bail=false overrides): after a task fails, nothing else starts; what is running finishes.
  • --exclude-task-dependencies: only the requested tasks run.
  • --nx-ignore-cycles (or NX_IGNORE_CYCLES=true): a task dependency cycle is broken, as Nx does, by dropping the dependency that closes it, with a warning; otherwise it is an error.
  • --graph=<file> or --graph=stdout (--graph alone prints too, where Nx opens its viewer): writes the project graph and the task graph in the shape Nx’s --graph writes, without running anything. Nx’s taskPlans, its hashing plan, is left out.
  • --verbose sets NX_VERBOSE_LOGGING=true for tasks; without it they see NX_VERBOSE_LOGGING=false, unless it is already true.
  • --output-style and --dry-run, which prints qk’s own task graph.
  • Any other option, as in Nx, is forwarded to the requested tasks; see Forwarding arguments.
  • Nx’s own options (--runner, --batch, --skip-sync, --cloud, --dte, --agents, --tui, --tui-auto-exit) are accepted and have no effect.

--output-style takes Nx’s names, and qk’s own quiet:

  • dynamic, tui and dynamic-legacy show a live panel on stderr: how many tasks are done, cached, running, queued and failed, and each running task with its time. Successful tasks’ output stays hidden; a failed task’s output is printed above the panel. For run, the panel counts the requested task’s dependencies, prints warnings as they come, and once they are done gives way to one line saying how they went and the task’s own output, streamed under > qk run <task>; continuous dependencies stream behind their project prefix. A task without dependencies streams without the line or the header. Without a terminal, under NX_TASKS_RUNNER_DYNAMIC_OUTPUT=false or with verbose logging they fall back to static, as in Nx.
  • quiet prints nothing while tasks run except a failed task’s output, under ✖ qk run <task> failed, with escape sequences removed when the output is not a terminal and FORCE_COLOR is not set. It is meant for agents and scripts.
  • stream prefixes each non-empty line with the task’s project, in Nx’s colour for it; stream-without-prefixes passes output through untouched.
  • static holds each task’s output until it ends and prints it under > qk run <task>, as Nx does under > nx run <task>, with the target in quotes when its name has a colon and the task’s arguments after it, marked [local cache] for a hit, and in GitHub Actions folds each task into a log group unless NX_SKIP_LOG_GROUPING=true. As in Nx, the run opens with a banner naming what it runs and ends with one saying whether it succeeded, listing failed tasks and those not run because of them. For run, static follows nx run: the requested task streams under its header as it runs, and its dependencies are held, with only the header shown for a cache hit.
  • static-failures-only is static, except that a task that did not fail is one line, ✔ qk run <task>, and the run ends saying how many tasks’ output was left out. For run, the tasks of the requested task’s project still print in full. With --verbose, every task prints in full, as in Nx.
  • summary prints no task output. The run ends with how many tasks succeeded, came from the cache, failed and were skipped, then each failed task with the file holding its output, kept in the worktree’s state directory until qk reset, and how to see the logs inline.

Without the option, NX_DEFAULT_OUTPUT_STYLE applies when it names a style; otherwise every command uses static in CI; every command uses the live panel on a terminal, except a run whose output is piped; otherwise run passes output through, and run-many and affected use static on a terminal and quiet otherwise. These defaults are qk’s; Nx’s is static-failures-only where it does not use its terminal UI. stream and stream-without-prefixes print qk: status lines on stderr; static shows each task’s status in its header instead, and the panel and quiet collect warnings for the summary. Continuous tasks stream under the static styles, since their output would otherwise never appear. As in Nx, when no static style was named, as with the CI default, a long-running task streams too: one that runs with --watch, or whose target is serve, dev, start or ends in -watch or :watch. NX_STREAM_OUTPUT=true streams every task and NX_STREAM_OUTPUT=false none but continuous ones. As in Nx, output streamed under a style other than stream carries the project prefix only with NX_PREFIX_OUTPUT=true. Colour follows picocolors: off with NO_COLOR or FORCE_COLOR=0, on with FORCE_COLOR, in CI or on a terminal.

Runs of several tasks, and any run under static, the panel or quiet, end with a summary on stderr: how many tasks succeeded and came from cache, or which failed and which were skipped because of them; the critical path with its three longest tasks, in the order they ran; the most memory the tasks used together and the tasks that used the most; and whether --parallel held the run back. For that, qk records how long each task waited for a free slot after its dependencies finished, and samples the machine’s CPU use through the run. When tasks on the critical path waited while the machine had capacity to spare, it says how much sooner a higher --parallel could finish; when the machine was busy while they waited, it says more parallelism would not help.

Of the tasks ready to start, those with the longest expected path to the end of the run start first: how long the task took in its recent runs that executed it, rather than restoring it from the cache, and the longest chain of tasks depending on it. A task without such runs is expected to take no time, so a workspace’s first run starts tasks in the order of their ids.

Terminals and input

As in Nx, when qk’s stdout is a terminal, on Unix, a command whose output qk captures runs under a pseudo-terminal, so tools that check for a terminal draw as they would on one. It is the command’s controlling terminal, as large as qk’s, and both its stdout and stderr, so the output is recorded and replayed as one stream on stdout. Commands keep pipes for a target with usePty: false, for a command entry with a prefix, for several commands run side by side, and for every nx:run-commands target under NX_NATIVE_COMMAND_RUNNER=false. Output qk passes straight through already reaches the terminal.

Only the task a run is for reads qk’s stdin, when it has a single command whose output is shown as it runs; other tasks read nothing. Run directly, it has the terminal itself. Under a pseudo-terminal, it reads input that is not a terminal, such as a pipe, directly; from a terminal, qk copies its input in and puts the terminal in raw input mode while the task runs, so each key arrives as typed and Ctrl-C interrupts the task; tty: false keeps the terminal’s own line editing. What the task echoes of the input is part of its output.

Waiting for memory

Tasks that each fit in memory can exhaust it together, and a machine out of memory freezes rather than failing one task. So a task starts only once the machine has the memory it is expected to use free. Free memory is the machine’s, so other runs, worktrees and programs count. What is free, less a tenth of the machine’s memory kept for everything else and what the run’s running tasks are still expected to grow by, must cover what the task is expected to use. A task that waits leaves its slot to the next task that fits, and starts regardless once nothing else of the run is running, so a run cannot stall. The summary names the tasks that waited, and does not count their wait for memory as a wait for a --parallel slot.

A task is expected to use the most memory it used in its recent runs that executed it, as run history records. A task without such runs is expected to use the median of the run’s other tasks of its target, so a type-check of a new project is expected to use what the others do. A task of a target none of which has been measured starts without waiting.

Dependencies and configurations

The planner expands dependsOn before execution: local targets, ^target on project dependencies, project:target, and objects with target, projects, dependencies, params and options. Target names may contain colons: a:b is project a’s target b when a is a project with that target, and otherwise the task’s own target a:b. A target may be a glob, such as lint-* or ^build-*: as in Nx, it stands for every target name in the workspace it matches. options: "forward" passes the target’s options, its configuration’s included, to the dependency as its own options, which they override; as in Nx that includes a run-commands target’s own command, so it suits targets of the same executor. params: "forward" passes the run’s own arguments, however the dependency is reached. projects takes project selectors. As in Nx 24, projects: "self" and projects: "dependencies" fail: leave projects out for the task’s own project, or set dependencies: true. As in Nx, a project dependency without the target is looked through: the task’s dependsOn applies again from that project, so ^tsc reaches the nearest dependencies that have tsc. Other missing dependency targets are skipped; unknown explicit projects and task cycles fail. Shared tasks run once. When different requested configurations reach the same task, its dependencies include the selections from every request. Requested configurations propagate to dependencies that define the same name; otherwise the dependency’s default configuration applies. The requested name continues through that dependency to its dependencies. Without a requested configuration, each task chooses its own default. run-many -c treats the targets it selects the same way. As in Nx, a shared task reached with different forwarded arguments or options keeps those it was first reached with.

Executors

See Executors for supported executors and options.

Environment

See Environment variables for precedence, dotenv loading and task variables.

Forwarding arguments

Forward task arguments after --, or, as in Nx, give options qk does not know directly:

qk run app:build -- --mode=production 'two words'
qk run app:package -c release -- --arch=arm64
qk test app --coverage --max-workers 2

The options qk and Nx know, such as --configuration or --nx-bail, are read as theirs; everything else, and any positional after the task, reaches the requested tasks in the order given, before what follows --.

A run-commands command, as Nx’s, puts the task’s arguments in one of three ways:

  • {args.name} stands for one value: a --name=value or --name value argument, a flag without a value as true, --no-name as false, a repeated flag as its values joined with commas, a name the args option sets (also in camel case), or another option of the target. The value goes in as it is, unquoted, for the shell to read, so quote the placeholder where the command needs one word: echo "{args.message}". A missing value interpolates as nothing. As in Nx 24, ?? lists fallbacks, and the first that is set and not empty applies: another args.name, env.NAME from the task’s environment, or a final literal, as in {args.port ?? env.PORT ?? 4200}. 0 and false are values.
  • {args} stands for everything: the options run-commands does not know, as --name=value, the arguments, and then the args option.
  • Without either, the same are appended, unless forwardAllArgs is false.

A command cannot use both {args} and {args.name}. Forwarded options and arguments the shell would split or read are put in double quotes, as Nx puts them, so $NAME in one still expands. Arguments that set a run-commands option, such as --cwd=dir, are not forwarded. Path tokens such as {projectRoot} in options resolve as the workspace loads; see options. Dependencies receive no arguments unless their dependency object sets params: "forward".

Failures and cancellation

All selected tasks are prepared before any command starts, so unsupported executors and options fail before dependencies run. A failed task skips its dependents while independent tasks continue. As in Nx, the CLI exits with 1 when any task failed or was skipped, whatever the task’s own exit code. Ctrl-C and, on Unix, SIGTERM and SIGHUP cancel the run, which exits with 128 plus the signal’s number, as Nx does: 130, 143 or 129. Cancelled commands receive SIGTERM on Unix and are killed with their process group if they have not exited within NX_PROCESS_KILL_GRACE_PERIOD milliseconds, five seconds by default; on Windows they are killed directly. Parallel commands within a failed task are terminated immediately, and as in Nx the task then fails with exit code 1; commands run in sequence stop at the first failure. Under --nx-bail, a run that left tasks unstarted exits with 1, where Nx exits with 130 as if interrupted. Commands must not detach themselves into separate sessions or launch external services; those processes are outside the managed group.

As Nx does, a run refuses to run a task that one of the runs it was started from is running with the same arguments, such as a task whose command runs qk run for itself, and names the chain of tasks.

Continuous tasks and readiness

Targets with continuous: true, such as dev servers and watchers, run until they exit or are stopped. A task that depends on a continuous task starts as soon as that task has started, not when it finishes, unless its dependsOn entry sets waitFor: "ready". Continuous tasks do not count towards --parallel. A requested continuous task runs until it exits or the run is cancelled. A continuous task that only other tasks depend on is stopped, like a cancelled command, once all of them have finished; that stop counts as success. A continuous task that exits by itself reports its exit status as usual. Continuous tasks are never cached. As in Nx, a cacheable task depending on one is keyed with the continuous task’s inputs as its own, unless every dependsOn entry naming it sets inputs: false.

With readyWhen, a string or an array of strings, a task is ready once every string has appeared in its output, on stdout or stderr: as printed, or as a terminal shows it, so text that terminal control sequences break up, such as a port printed in bold, matches too. Its dependents start then, while its commands keep running, and qk stops it, as a success, once nothing still to run needs it. A requested task with readyWhen is done once ready, as in Nx, unless it is also continuous. Such a task holds a --parallel slot until it is ready, and a continuous one uses readyWhen to hold its dependents back until then. A task whose commands exit before it is ready ends with their status, and fails, as in Nx 24.0.2, when they all succeed without printing it. With commands, readyWhen requires parallel. These tasks are not cached, since their commands outlive any result.

A continuous target’s own readyWhen, an object, is a check for the tasks that depend on it with waitFor: "ready", as in Nx 24: they start once a url answers, a port accepts a connection, a command succeeds, text appears in the output or the task signals that it is ready. A task that waited gets the address of the one server it waited for that reported one, in NX_READY_URL, and its own check, resolved once those servers are ready, can read it too. Waiting holds no --parallel slot. qk checks only while a task in the run waits. A check that times out fails the task, and the tasks waiting for it are skipped. A task that exits before its check passes keeps its own status, and the tasks waiting for it fail, naming it, as in Nx. An invalid check fails the task only when something waits for it; otherwise qk warns and runs it unchecked. A port of 0 or auto is one the server picks, so there is nothing to check and the task is ready once started. A request to this machine skips proxies and accepts a self-signed certificate.

A request to another host goes through the proxy that HTTP_PROXY or HTTPS_PROXY, by the url’s scheme, or else ALL_PROXY names, unless NO_PROXY covers the host. As in Nx 24.0.3, a NO_PROXY entry is * for every host, a domain that also covers its subdomains (example.com or .example.com), *.example.com for the subdomains only, an IP address, or a CIDR range such as 10.0.0.0/8. Any entry except a range can end in a port, such as staging.internal:8080 or [fd00::1]:8080, to match only that port. The request trusts the certificates in the operating system’s store, which SSL_CERT_FILE or SSL_CERT_DIR replaces, and in the file that NODE_EXTRA_CA_CERTS names. A file qk can’t read adds nothing.

A target with parallelism: false runs alone, as in Nx: it waits until no other task runs, continuous ones included, and nothing starts while it runs. Like Nx, qk rejects a graph where such a task depends on a continuous task, or where a continuous task other tasks depend on has it.

--dry-run only plans and prints the task graph: it does not load dotenv, validate executor support, execute runtime inputs, or run commands.

Running affected tasks

For an opt-in consumer-specific comparison of shared sources, see Project change projections, and for leaving out projects whose entry points do not import a change, see Import reachability. Ordinary selection and task cache keys retain the behavior described here.

qk show projects --affected --base main
qk show affected web --base main
qk affected -t build,test --base main
qk affected -t build,test --granularity project --base main

As in Nx, changed files are those between the merge base of --base and --head, or with no head, between the merge base and the working tree, including uncommitted and untracked files. The base defaults to NX_BASE, then nx.json defaultBase, then main; the head to NX_HEAD. --files, --uncommitted and --untracked replace the comparison. A file named with --files counts as changed whole: its revisions are not compared, so a JSON or lockfile change is not narrowed to what changed inside it. Files matching the root .gitignore or .nxignore are left out. Untracked files are those Git lists as untracked and not ignored, so an .nxignore negation does not bring back one .gitignore excludes, though it does for a file named with --files. As in Nx, a base or head starting with - is refused, since git would read it as an option.

Unlike Nx, when the base is a branch with an upstream, such as main following origin/main, and the head left the upstream later than the local branch, the base is that later point. Otherwise a local main that has not been updated would count everything that landed on origin/main since as changes. When the compared commits still include some already on the default branch’s upstream, as with an explicit --base older than where the head branched, affected and show affected warn. On the default branch itself the range is its own history, and nothing is flagged.

Tasks or projects

As in Nx 24, qk affected selects the tasks a change reaches, by the rules in Selecting affected tasks by inputs, and runs them with the tasks they depend on. Project selection, Nx’s behavior before 24, runs the targets of every affected project instead, by the rules in How changes reach projects. The first of these that is set decides:

  1. --granularity task or --granularity project;
  2. NX_LEGACY_AFFECTED: true selects projects and false tasks; as in Nx, other values are ignored;
  3. nx.json affected.legacy: true selects projects;
  4. otherwise, tasks are selected.

To keep selecting projects, as Nx’s migration to 24 does for an existing workspace, set affected.legacy in nx.json:

{
  "affected": { "legacy": true }
}

show projects --affected -t <targets> decides the same way, as in Nx 24: under task selection it lists the projects owning an affected task of the targets, and under project selection the affected projects with one of the targets. Without -t it lists the affected projects, and with -p, unless given --granularity task, it selects projects, as Nx does.

Change projections apply to project selection only (see Project change projections). Under an affected profile with projections, qk affected and show projects --affected -t select projects where they would select tasks, and say so on stderr; with --granularity task they fail instead.

How changes reach projects

A changed file touches the project whose root most specifically contains it. A file named by a {workspaceRoot} input touches the projects declaring it; an input without glob syntax, such as {workspaceRoot}/generated, names that file or everything under it.

A change to nx.json, the file it extends or nx.local.json touches projects by what it changes. qk reads the configuration at both revisions, merged as qk loads it, and compares the values, so a change to comments or formatting touches nothing. A change to a setting every task’s cache key holds, such as plugins, pluginsConfig or workspaceLayout, touches every project (see What changes the key). A change to targetDefaults or namedInputs touches the projects whose targets or named inputs it resolves differently: qk resolves every project with the base revision’s configuration and the head’s manifests, and compares. A change only to settings that change no task’s work, such as defaultBase, parallel or release, touches nothing. Where a revision cannot be read or parsed, or the files were named with --files, every project is touched.

A changed project.json or project.local.json that a project has at both revisions touches its project as any file does when an input of the project’s targets selects it, as default does unless the workspace leaves the files out of it (see What changes the key). It also touches projects by what it changes. qk reads the changed manifests at the base revision, resolves the workspace with them and the head’s other files, and compares every project’s configuration, since a tag or a name decides what other projects’ inputs and dependencies select. A project is touched where its targets, named inputs or other keys differ, where its dependencies do, or where an input of it naming projects, such as tag:shared, selects others. Where no input selects the file, a change to comments or formatting touches nothing. A manifest the change added, or one that cannot be read at the base revision or was named with --files, touches its project as any other file does.

A deleted project.json or package.json that removes a project touches the projects that depended on it. Nx touches every project instead, having no way of knowing what depended on it; qk reads the removed project’s manifests at the base revision and looks for what still declares it. qk’s graph is made of manifests alone, and a project whose files the change leaves alone has the same manifests at both revisions, so what depended on the removed project is what the change touches already and what still names it: a package.json dependency that linked to it, or an implicitDependencies entry or pattern selecting it. Their dependents follow as for any other touched project. A deletion can remove a package and leave its project, when a project.json still makes it, or move a project under the same name. Either way a project whose dependency on the package the head graph no longer has is touched, as when a file: path names where the package was; one whose link survives, such as a workspace: range to the moved package, depends on it as before, and one whose implicitDependencies exclude it was never linked to it. Every project is still touched when qk cannot tell: for a deleted root lockfile, for files named with --files, which are not compared with a base revision, or when the removed project’s manifests cannot be read there.

Path mappings changed in the root tsconfig touch the projects they point into, and any other change to it every project.

A change to the root lockfile (pnpm-lock.yaml, pnpm-lock.yml, package-lock.json, yarn.lock, bun.lock or bun.lockb) touches projects by nx.json’s pluginsConfig["@nx/js"].projectsAffectedByDependencyUpdates: every project under "all", Nx’s default, or the projects a list of selectors names. Under "auto", a pnpm-lock.yaml change touches the projects whose importer installs something different, by the same installed sets the cache keys use; another lockfile, or one qk cannot compare, touches every project.

In the root package.json, a changed dependency touches the projects installing that package, or the workspace project it names, and an @types/ package also the package it types. A changed override, under overrides, resolutions or pnpm.overrides, touches the projects installing the package its selector names: the last key, past a pnpm parent> and without a @range. A removed dependency, a change to a global package (nx and the plugins nx.json loads), an override naming no installed package, or a file that cannot be compared touches every project. Installed packages are read from pnpm-lock.yaml; without it, a changed dependency touches every project. Every project depending on a touched project is affected too.

Some rules deliberately differ from Nx:

  • Under "auto", the installed sets leave out projects that install exactly what they did before, which Nx reports, and catch transitive changes such as a dependency’s dependency moving version, which Nx can miss.
  • A list of selectors touches its projects only when a lockfile changed. Nx touches them on every change, lockfile or not, which reads as a bug; its task selection checks for a lockfile change, as qk does.
  • A change to nx.json touches only what it changes, by the rule above. Nx hashes nx.json whole into every task, so any change to it, even a comment, touches every project.
  • A change to the file nx.json extends counts as a change to nx.json does. Nx does not look at that file, although it configures the workspace as much as nx.json does.
  • A change to a project’s project.json that no input of the project selects touches what it changes, by the rule above. Nx touches the project whatever changed.

show projects --affected prints projects in graph order; Nx prints its traversal order, so compare the two as sets.

Explaining the result

qk show affected takes the same change options and explains the result. Without a project it lists every affected project with the first reason it is touched, or the dependency it is affected through. With a project it prints a shortest dependency path to a touched project, the project’s other affected dependencies, and every reason the touched project is touched. For a lockfile reason it lists what the importer installs differently, by package: versions that moved first, then packages added or removed, then those that kept their version but resolved different peers or patch, then those whose dependencies changed as a consequence. After what was compared, it says which granularity qk affected selects by and what decided it (see Tasks or projects).

$ qk show affected web --base HEAD^ --head HEAD
2 changed files between 3f1c0a9e2b7d and HEAD.
The base is where HEAD left HEAD^, 1 commit back.
Granularity: task (the default).
web is affected because it depends on ui:
  web -> ui (static)
ui is touched:
  what packages/ui installs changed in pnpm-lock.yaml (3 packages)
      react (direct): 19.0.0 -> 19.1.0
      react-dom (direct): 19.0.0: peers or patch changed
      scheduler: dependencies or integrity changed

Selecting affected tasks by inputs

Under task selection, a task is affected when Nx 24 would select it, or when its cache key changed, except that qk narrows a lockfile change, as below, and a deleted manifest, an nx.json change and a project.json change, as in How changes reach projects. As in Nx, that is when:

  • a changed file is one of its resolved inputs, a declared output included: a tracked output is a file like any other;
  • a file projects are created from changed in a project whose inputs it reads, its own or one its ^ and projects inputs reach, or such a file was deleted and removed a project that one of those depended on, by the rule above, which falls back on every task where it does. A project.json or project.local.json compared at both revisions counts by its inputs and the rule below instead;
  • the root tsconfig changed outside compilerOptions.paths, or, with selectivelyHashTsConfig, in path mappings into a project whose inputs it reads (see What changes the key);
  • the root lockfile changed and projectsAffectedByDependencyUpdates is "all", Nx’s default, and the task hashes installed packages: it declares no externalDependencies, or names some. Under a list of selectors, the tasks of the projects it names are affected;
  • the root package.json moved an installed package the task hashes, by the rules above, or named the task’s project as a dependency.

Where Nx selects every task on any nx.json change, qk selects a task when the change moves its key: when it changed a setting every key holds, or when, resolved with the base revision’s targetDefaults and namedInputs, the task’s definition differs, or what its inputs select does, its own or a dependency’s through ^ inputs. A named input that selects the same files leaves the task alone. A task declaring {workspaceRoot}/nx.json as an input is affected by any change to the file, as its key holds it whole.

A changed project.json or project.local.json selects every task whose inputs select it, as any input does, its dependents’ through ^ inputs included; with default holding {projectRoot}/**/*, that is every task reading the project’s files, as in Nx. For a task whose inputs leave it out, it counts as nx.json does: the task is selected when, resolved with the manifests the base revision had, its definition differs, or what its key holds of its project’s configuration, such as its root and output paths, or what its inputs select, or the dependency manifests and importers qk adds for it. Editing one target then selects that target’s tasks, and a tag the tasks whose inputs select projects by it.

Beyond those, qk’s key also reads what its lockfile importers or externalDependencies install, pnpm-workspace.yaml outside its resolution keys, and each dependency project’s package.json in the fields its key reads, so a task is affected when any of these changed, or a dependency’s version moved into or out of the range the task’s project declares for it. Under "auto", these installed sets are what a lockfile change is judged by. Nx instead selects, on any lockfile change, every task it keys by the whole lockfile, such as those of a project that depends on no package, and every task that hashes a package whose lockfile entry moved, even where what the task’s importers install stayed the same. qk keys neither, so it selects neither.

A task is also affected when it reads the declared outputs of an affected task, through dependentTasksOutputFiles or a fileset that selects them. As in Nx, depending on a task without reading its outputs carries nothing, so a task ordered after a build but reading only sources is not affected by the build’s changes. A file the task’s inputs select counts by any change to it, as in its key. An unaffected task would therefore be a cache hit, except that env and runtime inputs count as unchanged, since the base revision’s environment is unknowable. An affected one can still be a hit where Nx selects more than qk keys, such as for a changed declared output or a lockfile change under "all". A test-only change then reaches the test tasks and not the builds whose production inputs exclude tests. A continuous task a task depends on, such as the dev server an e2e task runs against, counts its inputs as the task’s own instead, and so do those of the continuous tasks it depends on in turn. A dependsOn entry with inputs: false leaves them out, unless another entry names the same task without it. qk show tasks -t <targets> --affected lists the affected tasks with their first reason, or the whole analysis with --json, whatever the granularity.

--json gives the whole analysis, or for one project its path with the typed reasons, including the snapshot keys a lockfile change added, removed or changed. Both carry granularity, task or project, and granularityFrom: --granularity, NX_LEGACY_AFFECTED, affected.legacy, projections or default.

Project selectors

Project selectors are read as Nx’s findMatchingProjects reads them, wherever Nx reads them: --projects and --exclude, implicitDependencies, dependsOn and inputs projects, targetDefaults filter.projects and a projectsAffectedByDependencyUpdates array.

  • A project name selects that project. A glob selects the names it matches; globs are minimatch globs, so * and ? do not cross a / (@scope/*, not @scope*, selects @scope/ui) and ** does.
  • tag:<pattern> selects by tag, directory:<glob> by project root and name:<pattern> by name only. A pattern without a label is tried as a name, then as a root when no name matched, so libs/* selects the projects directly under libs.
  • A plain pattern that is no project name and no glob selects the names containing it as a word, ignoring case: ui selects ui, @scope/ui and ui_kit, but not ui-e2e, as - and @ join words.
  • Patterns apply in order, each adding what it matches or, prefixed with !, removing it: '!tag:e2e,app-e2e' keeps app-e2e. When the first pattern excludes, selection starts with every project.
  • --exclude removes what its patterns match among the projects already selected.
  • Empty patterns and nx-cloud: references to other workspaces are skipped. A selection matching nothing is empty. As in Nx, show project takes a name that is no project as a pattern, which must match exactly one.

The projects of an input are read by Nx’s native hasher, which differs: a first ! pattern starts from nothing, and a plain name matches only that name.

Quote globs so the shell does not expand them. Commas delimit CLI selectors, so brace globs containing commas are not supported on the command line.

Project change projections

A schema or another shared source can change without changing what its consumers use. An affected profile lets a workspace adapter compare a consumer-specific representation at two revisions. qk replaces the declared source changes with changed artifact paths before selecting projects and following their dependencies.

For example, a runtime profile can compare generated JavaScript rather than all generated TypeScript. A type-only change can then leave preview deployments unselected while ordinary affected selection still selects type checking. Changes to runtime values, such as generated operations or possible-type maps, still count when the adapter includes them in its snapshot.

Profiles are opt-in and apply to project selection. They do not change task cache keys, cached results, or task-level affected selection.

qk show projects --affected --affected-profile runtime --base main --head HEAD --json
qk show affected web --affected-profile runtime --base main --head HEAD

--profile remains the performance-timing flag. --affected-profile selects a named change projection.

Configure a profile

Add qk:affectedProfiles to nx.json.

For example:

{
  "qk:affectedProfiles": {
    "runtime": {
      "projections": [
        {
          "name": "generated-runtime",
          "command": ["node", "tools/runtime-snapshot.mjs"],
          "sources": ["schemas/**", "operations/**", "generator.config.json"],
          "outputs": ["apps/*/generated/**", "packages/*/generated/**"],
          "timeoutSeconds": 60
        }
      ]
    }
  }
}

Each projection has these settings:

SettingMeaning
nameNonempty name, unique within the profile; used in explanations
commandNonempty executable-and-arguments array; qk does not invoke a shell
sourcesNonempty array of workspace-relative globs naming source changes this adapter may replace
outputsNonempty array of workspace-relative globs permitting artifact paths in the manifest
timeoutSecondsPer adapter invocation and per worktree checkout, integer from 1 to 3600; defaults to 60

Patterns use /, with * confined to one path segment and ** spanning segments. Absolute paths, backslashes, . and .. segments are rejected. Colons and negation patterns are rejected too. {workspaceRoot} tokens are not supported here. Unknown settings and overlapping ownership of a changed source are configuration errors. They stop selection rather than silently applying a different profile.

List every source whose change needs this interpretation, including operations and generator configuration. Sources not covered by a projection keep their normal affected behavior. A projection is skipped when none of its sources changed. When it runs, it must return the complete artifact manifest, not only artifacts it believes changed.

Adapter protocol, version 1

qk resolves base/head to commits and runs head’s adapter for both snapshots. Each invocation gets a fresh detached head worktree for adapter code and a separate fresh worktree for revision data. The command runs in the head adapter’s workspace directory. Read inputs and generate outputs under revisionRoot, rather than assuming the current directory contains revision data. Changing the adapter without changing nx.json therefore still uses the same committed adapter to interpret both revisions. The original checkout, branch, dirty files and generated output directories are left alone. Worktree registration is temporary; qk removes the worktrees after comparison or failure. Each projection gets fresh checkouts, so another adapter’s writes do not become its inputs.

The command receives one JSON object on stdin:

{
  "version": 1,
  "workspaceRoot": "/workspace/project",
  "revisionRoot": "/tmp/qk-revision/checkout",
  "adapterRoot": "/tmp/qk-adapter/checkout",
  "revision": "0123456789abcdef0123456789abcdef01234567"
}

workspaceRoot is the original workspace, useful for locating a pinned tool installation. revisionRoot is the isolated workspace containing data at the requested commit. adapterRoot is the fresh head workspace and the command’s working directory. For a workspace nested inside a Git repository, all three roots name the workspace subdirectory. Relative executable paths and arguments are interpreted from adapterRoot; bare executable names are resolved through PATH. An adapter imported by a Node script also comes from head.

qk expands {workspaceRoot}, {revisionRoot} and {adapterRoot} inside each command argument, without shell parsing. For example, a wrapper can use ["node", "tools/snapshot.mjs", "--input-root", "{revisionRoot}"]. Use {adapterRoot} for committed adapter code and {workspaceRoot} for tools installed in the original workspace. Pointing adapter code at {workspaceRoot} explicitly opts into the original checkout’s code, including dirty edits. These tokens apply to command, not sources or outputs. qk supplies no task metadata or task sandbox; the adapter inherits the runner environment and permissions. Git repository-local environment variables, such as GIT_DIR, GIT_INDEX_FILE and per-repository config overrides, are removed from qk’s Git and adapter subprocesses so hook invocation cannot redirect them to another checkout.

Write exactly one manifest to stdout. Write progress and diagnostics to stderr, which qk forwards without mixing them into selection JSON:

{
  "version": 1,
  "artifacts": {
    "apps/web/generated/operations.js": "sha256:035ad...",
    "packages/client/generated/possible-types.js": "sha256:cb900..."
  }
}

Artifact names are normalized workspace-relative paths. They must match outputs and cannot name workspace or project metadata such as nx.json, package.json, project.json or lockfiles. Empty segments, . or .., backslashes, colons and NUL characters are rejected. Fingerprints are opaque, nonempty strings of at most 1024 bytes; a content digest is a good choice. Fingerprints must be deterministic across checkouts: exclude timestamps and absolute temporary paths, and normalize ordering when it has no meaning in the representation. Duplicate artifact names, unknown response fields, unsupported protocol versions, and manifests larger than 1 MiB are rejected.

Artifacts can be virtual: the named file need not exist in the original checkout, and it can be Git-ignored generated output. qk maps each changed artifact path to the most specific containing project, then propagates through normal project dependencies. Choose artifact names in the consumer’s project when that consumer is the unit of change. Naming a shared package artifact instead affects that package and its dependents.

qk compares the union of both manifests. A changed fingerprint, newly present artifact, or removed artifact contributes a changed path. The adapter must include shared runtime artifacts as well as per-consumer artifacts; leaving an artifact out of both snapshots cannot establish that it is unchanged.

Tools and generated code

Disposable checkouts contain revision files and Git metadata. qk does not install packages, initialize submodules, copy node_modules, or run checkout hooks. Put external tools on PATH or let the adapter explicitly locate a pinned installation through workspaceRoot. For a Node adapter, dependencies can be resolved with createRequire:

import { createRequire } from 'node:module'
import { join } from 'node:path'
const requireTool = createRequire(join(request.workspaceRoot, 'package.json'))
const ts = requireTool('typescript')

Use the same chosen tool installation to interpret both revisions. qk does not reproduce historical installations. Installation metadata changing in the comparison causes conservative fallback, and there is no persisted projection cache in this version.

For a generator adapter:

  1. Read the revision’s generator configuration from revisionRoot.
  2. Run the pinned generator there. Send its stdout to stderr so it cannot corrupt the manifest. Clean its declared generated output directories first if tracked generated files could otherwise survive a deletion.
  3. Read every relevant generated output. For a JavaScript-runtime profile, erase TypeScript types using the chosen compiler and fingerprint the resulting JavaScript; keep non-TypeScript runtime files in the manifest too.
  4. Emit workspace-relative output names and fingerprints.

The representation defines what the profile promises. Equal generated JavaScript is not proof that types remain valid, that validation passes, or that a complete bundle is equivalent. Keep validation and type checking on ordinary affected selection. A broader deployment profile must account for all other deployment inputs through normal changes or additional projections.

Adapters are trusted executable workspace tooling. Selecting a profile can execute the adapter code committed at head with runner permissions. Use an appropriate CI execution environment when reviewing untrusted changes. The adapter must write generated files only in revisionRoot; qk’s disposable worktrees are isolation of files, not a permission sandbox.

Conservative fallback and explanations

qk retains all original changes for the profile if any active adapter fails, times out, emits invalid output, conflicts with another adapter’s artifacts, or emits an artifact overlapping a replaced source. This prevents a partial successful projection from hiding changes after another projection failed. Adapter process groups are stopped on exit and on failure, including timeout; worktrees are then removed. Each worktree checkout also has its own timeoutSeconds deadline; a stalled checkout is a comparison fallback, and its process group and any registered worktree are cleaned up. Each active projection creates four full checkouts (base and head data, plus fresh head code for each invocation), so checkout cost can be significant in large repositories. Adapter roots are writable and are not reused between runs. An adapter that deliberately escapes its process group is outside this cleanup contract.

Projection also falls back when:

  • The comparison has no explicit committed head (--head or NX_HEAD).
  • --files, --stdin, --uncommitted or --untracked replaces the revision diff.
  • Workspace configuration or root installation metadata differs between commits.
  • The current workspace metadata differs from the committed head.
  • A declared source change would replace protected workspace/project metadata.
  • A revision cannot be checked out, including an absent workspace subdirectory.

An untracked or Git-ignored nx.local.json selects the same merged profile for both snapshots and is exempt from the metadata check. It is not copied into revision worktrees. If an adapter needs local settings, read the common original workspace’s override through workspaceRoot. Tracked overrides remain protected: changes between commits or dirty edits trigger fallback.

Fallback retains all original changes and prints a warning to stderr by default. For preview CI, use --fail-on-projection-fallback=adapter to fail only when an adapter exits unsuccessfully, cannot start, times out, emits invalid output or undeclared artifacts, or conflicts with another adapter’s artifacts:

qk show projects --affected --affected-profile runtime --base main --head HEAD \
  --fail-on-projection-fallback=adapter --json

Configuration or installation changes remain successful conservative comparisons in this mode, including a commit first introducing a profile. They print a one-line notice to stderr naming the projection and the reason, so the job log explains why conservative selection built more previews. Explanations also retain the reason. This lets normal dependency updates create previews while output drift, such as a new generated project outside outputs, fails CI.

The bare --fail-on-projection-fallback flag and --fail-on-projection-fallback=all fail on every fallback condition, including configuration changes and checkout failures. Both modes require --affected-profile and a committed head via --head or NX_HEAD. Missing head, --files, --stdin, --uncommitted or --untracked are invocation errors in either strict mode, even when no declared sources changed. Default mode keeps conservative fallback with a diagnostic for unsupported comparisons. A valid comparison with no changed sources still skips the adapter successfully. Invalid profile configuration always fails selection.

qk show affected --json includes projections with each adapter’s status (applied, skipped or fallback), replaced source paths, changed artifact paths and any fallback detail. Fallback reports also include fallbackKind: adapter for adapter execution or manifest failures, and comparison for configuration, installation, unsupported comparison or checkout conditions. Text explanations show both the original changed-file count (after ignore rules) and the count after projection, followed by the changed artifacts. Explanation JSON includes originalFiles and files when a profile is selected, including when explaining a single project. files contains the paths used for selection. Project-list JSON stays a plain array.

A profile with projections is rejected by task-level affected selection: qk affected --granularity task and show tasks --affected. Without --granularity, where qk affected would select tasks, as it does by default, it selects projects under such a profile and says so on stderr (see Tasks or projects). Task dependency propagation and cache inputs keep their ordinary meanings.

Try the example

The examples/affected-projections workspace uses only Node’s standard library. Its adapter fingerprints each consumer’s runtime object in a JSON contract and ignores its types object. It does not run a real code generator; the small representation makes the protocol and project propagation visible.

Copy examples/affected-projections outside the qk repository, then:

git init -b main
git add .
git commit -m 'Initial contract'

Edit schemas/contract.json, change only types.id, and commit it. Ordinary selection includes schema and web; the runtime profile returns an empty array:

qk show projects --affected --base HEAD^ --head HEAD --json
qk show projects --affected --affected-profile runtime --base HEAD^ --head HEAD --json

Next change runtime.operation and commit it. The runtime profile includes web, because apps/web/generated/contract.js has a different fingerprint. qk show affected web with the same profile and revisions explains why.

Import reachability

A task is affected when one of its inputs changes, whether or not its code uses what changed. An affected profile with reachability leaves out a task whose entry points do not import the change, so a change to a corner of a shared package that only one app uses does not select every app’s builds. Imports are followed by fallout.

qk show projects --affected --affected-profile reach --base main --head HEAD
qk show tasks -t build test --affected --affected-profile reach --base main --head HEAD

The profile is opt-in. It decides tasks, and a project by its tasks: a project is left out only when every task of it a change affects is. A target can also name cases it runs separately. Ordinary selection and cache keys do not change. Use it where a wrongly skipped task is recovered cheaply, such as deciding which app builds a pull request runs, and keep ordinary selection for checks and for the default branch.

Configure it

Name the settings tasks are decided by under qk:reachability in nx.json, and enable reachability in a profile. A profile can also contain projections, which are applied first.

{
  "qk:reachability": {
    "app": {
      "anchors": ["{projectRoot}/src/main.tsx", "{projectRoot}/vite.config.ts"],
      "sources": ["{projectRoot}/src/**/*.{ts,tsx}", "{workspaceRoot}/packages/*/src/**/*.{ts,tsx}"]
    },
    "unit": {
      "anchors": ["{projectRoot}/test/setup.ts"],
      "cases": ["{projectRoot}/src/**/*.test.{ts,tsx}"],
      "sources": ["{projectRoot}/src/**/*.{ts,tsx}", "{projectRoot}/test/**/*.ts", "{workspaceRoot}/packages/*/src/**/*.{ts,tsx}"]
    }
  },
  "qk:affectedProfiles": {
    "reach": { "reachability": { "default": "app" } }
  }
}

A target takes part through its qk:reachability, in its project’s configuration or from targetDefaults:

ValueMeaning
absentThe profile’s default config, or none without one
a nameThat config from nx.json
falseNone, even with a default: ordinary selection decides it
an objectSettings of its own, as a named config declares them
{
  "name": "app",
  "targets": {
    "build": {},
    "test": { "qk:reachability": "unit" },
    "lint": { "qk:reachability": false }
  }
}

"reachability": true enables the profile without a default, so only targets that name a config or declare one take part.

SettingMeaning
anchorsGlobs naming the files every run of the task depends on through their imports: entry points, build configuration, a test harness
casesGlobs naming files the task runs separately, each decided on its own. See Cases
sourcesNonempty globs naming the files whose changes matter only through imports

A config needs anchors, cases or both, and sources. Paths take {projectRoot} and {workspaceRoot}, expanded for the task’s project, so one config serves every project laid out alike. They take ! exclusions: a file matches when an inclusion matches it and no exclusion does, in any order. A package.json is never a source, since it decides what imports resolve to.

Anchor what each task runs, not only what the app ships. An app’s build reads its entry points; its unit tests also read their harness and helpers, which the app never imports. A test target narrowed by the app’s anchors would be left out when a helper changes.

List code in sources, not every file under a directory. A GraphQL schema, a translation catalogue or a tool’s configuration reaches a build through a generator or a tool rather than an import, so as sources they would let a change that matters leave the task out. Leave test files in: a changed test reaches the task only if an anchor or case imports it, while a file left out of sources keeps the task whenever it changes.

"sources": [
  "{projectRoot}/src/**/*.{ts,tsx,js,jsx}",
  "{workspaceRoot}/packages/**/*.{ts,tsx,js,jsx}",
  "!{workspaceRoot}/packages/**/*.config.{ts,js}"
]

An end-to-end target that drives an app rather than importing it can name the app’s anchors with {workspaceRoot}, so the two are decided alike. Every target’s settings are read whenever the profile is selected, so a mistake is reported before a change needs it. qk:reachability on a project rather than a target is refused.

When a task is left out

Under the profile, a task with settings that ordinary task selection affects is decided by its changed inputs:

  1. A changed input that is not a source runs the whole task, as do lockfile installs, a lockfile or root package.json change Nx selects the task for, a changed root tsconfig, a deleted manifest and an affected task whose outputs it reads. A continuous task it depends on is not one of these: its inputs count as the task’s own, so the profile decides them by imports like any other changed input. That holds whatever else touches the task, and for a dependency the profile keeps for any reason, such as an anchor importing a change; one it leaves out does not count. The task’s ordinary inputs are what make this sound: declare its harness, its workflow and anything else that changes how it runs as inputs, outside sources. A dependency’s package.json whose fields dependents read are unchanged, such as one with only a new version or scripts, is the exception: it does not count, though it changes a project configuration Nx reads. A new or moved dependency does.
  2. An anchor that imports a change runs the whole task. fallout searches from each anchor through the imports, at file granularity, comparing each changed file with its base version. A change made only of TypeScript types does not count, since it does not change what a bundler emits.
  3. An import the search cannot place runs the whole task. If fallout cannot resolve an import the repository answers for, such as a relative path or a workspace package that is not linked, the change may be behind it.
  4. Otherwise the task is left out, and with it any task affected only through it, or narrowed to its cases that import a change.

Anything that prevents an answer runs the whole task and says why: anchors or cases that match no file, a fallout.toml that cannot be read, or a head revision that is not the checkout.

Project selection

Under the profile, project selection decides each affected project by its tasks: every target of it, with the tasks they depend on. A project is left out when the profile left out every task of it that a change affects. One with a task that still runs is kept, as is one none of whose affected tasks the profile decides. Its dependents are decided by their own tasks.

A project with many targets is left out rarely, since any of its tasks without settings keeps it. To ask about the targets a job runs, such as which apps’ builds a pull request needs, select projects by those targets instead:

qk show projects --affected -t build,export --affected-profile reach --json

Under task selection, the default, this lists the projects where a task of one of the targets is affected, by its inputs and then by the profile. --affected-targets in place of -t does so under project selection too. A profile with projections selects projects instead (see Tasks or projects), and cannot be used with --affected-targets, since task selection reads declared inputs.

Cases

A target can name cases: files it runs separately, such as the cases of a visual regression suite or a project’s test files, each decided on its own.

{
  "targets": {
    "visual": {
      "inputs": ["default", "^default", "{workspaceRoot}/.github/workflows/visual.yml"],
      "qk:reachability": {
        "anchors": ["{projectRoot}/visual/shell.tsx"],
        "cases": ["{projectRoot}/visual/cases/**/*.tsx"],
        "sources": ["{projectRoot}/src/**/*", "{projectRoot}/visual/**/*.tsx", "{workspaceRoot}/packages/*/src/**/*"]
      }
    }
  }
}

Anchors are what every case runs inside, such as a suite’s shell, so an anchor that imports a change runs every case. Otherwise the cases that import a change, or a changed case itself, are selected. With none, the task is left out.

qk show tasks -t <targets> --affected --affected-profile <name> lists each selected case with the change it imports, and --json adds a reachability object with each task’s decision.

Running only the selected cases

qk affected -t <targets> --affected-profile <name> runs the affected tasks, under task selection, the default. A task narrowed to some of its cases receives QK_AFFECTED_CASES, the absolute path of a temporary file naming those cases one per line, each relative to the workspace root. Read the file at that path as it is, from any working directory. The variable is absent when the whole task runs, so a task that does not read it runs every case, which is always safe.

if [ -n "$QK_AFFECTED_CASES" ]; then
  suite --only "$(paste -sd, "$QK_AFFECTED_CASES")"
else
  suite
fi

Such a task runs without the cache, reads and writes both: its result covers the selected cases alone, so it must not stand for a run of every case. A suite’s own rules, such as running the cases that show translated copy when a translation catalogue changes, stay in the suite, which can add cases to the list but should not drop them.

Requirements

  • The head must be the checkout. fallout reads files from disk, so with --head naming another commit, or with uncommitted changes to tracked files, every task runs. Run selection in the checkout of the revision being tested, as CI does.
  • Install workspace packages first. Imports between workspace packages resolve through node_modules. Without them, those imports are gaps and nothing is left out: the profile is safe and saves nothing.
  • Only static imports are followed. What a bundler adds through its configuration, native code and requests built at run time are not seen, which is why only changes to sources can leave a task out.
  • Bundler settings come from fallout.toml. Which exports conditions and package.json fields an app’s bundler reads, and its import aliases, are declared in a fallout.toml beside the app. See fallout’s documentation.

Explaining the result

qk show affected lists the projects the profile left out after the affected ones, and notes for each kept project with a narrowed task which task runs and why: the anchor that imports a change, the import that could not be resolved, or the change imports do not carry. With a project, it shows what the profile decided for each of its affected tasks, or the chain of imports that reached one. --json adds a reachability object with each decision. qk show tasks --affected does the same for tasks.

$ qk show affected --affected-profile reach --base main --head HEAD
1 changed file between 3f1c0a9e2b7d and HEAD.
Granularity: task (the default).
other  depends on lib
lib    touched: libs/lib/src/unused.ts changed
Left out by import reachability:
  app-e2e  left out: none of its 1 affected task runs
  app      left out: none of its 1 affected task runs

A workspace can record this beside ordinary selection without acting on it, and compare what it would have left out with what later broke on the default branch, before letting it skip anything.

Local task cache

qk cache path
qk run web:build
qk run web:build
qk run web:build --skip-cache
qk cache prune --max-size 1GB

With a cacheable target and unchanged inputs, the second run restores outputs and replays logs. Run history explains why a key changed.

Targets with cache: true are cached locally. Inside a Git repository the cache lives by default in <git common dir>/qk/cache/v1, so every linked worktree of the repository shares one cache. What belongs to one worktree (file digests, records of the outputs it holds, restores in progress) lives in its own git directory, $(git rev-parse --git-dir)/qk, so nothing of qk’s appears in the working tree. Outside Git both live in .qk at the workspace root, the cache in .qk/cache/v1. qk cache path prints the cache’s location. --skip-cache (also --skip-nx-cache and --skipNxCache) bypasses result-cache reads and writes. Otherwise cacheable executions still record their keys and bounded stdout/stderr in run history, so repeated forced runs can reveal flaky outcomes.

What changes the key

A task’s key covers its ID, forwarded arguments, resolved target definition, declared inputs, the package manager, qk’s version and the platform. As in Nx, a dependency task counts only through the outputs the task reads: files its dependentTasksOutputFiles inputs select from the dependency’s declared outputs, or that its filesets select. A run that reproduces those outputs leaves the task cached however the dependency’s own inputs changed, and a task that reads none of them is keyed without them. A task that reads a dependency’s outputs without declaring it can therefore be restored stale; declare such reads as inputs. Files are keyed by workspace-relative path, content and mode. Branch names and checkout locations are not part of the key, so identical sources in two worktrees share entries.

A key has two kinds of input. What the task’s inputs select counts as they say: a file by its whole content and a json input by its fields, whatever the file is. Beside them, qk adds workspace configuration and package-manager state to every task’s key on its own, each by the rule below. Where an input selects a whole file qk would add, the file counts whole, as the input says. A json input on such a file adds its fields beside qk’s copy rather than narrowing it.

Root workspace files (.gitignore, .nxignore, pnpm-workspace.yaml, lockfiles) and the package.json of the task’s project are always included, its scripts and dependency declarations with it. The root tsconfig (tsconfig.base.json, or else tsconfig.json) is included as Nx hashes it: without compilerOptions.paths, so adding or moving a path mapping leaves every key as it was. With selectivelyHashTsConfig: true in the options of nx.json’s tasksRunnerOptions runner (the one --runner names, else the one NX_TASKS_RUNNER names, else default), the key also holds the mappings whose targets lie in the projects whose inputs the task reads: its own, and those its ^ and projects inputs or a continuous task serving it reach. A root tsconfig that cannot be read as JSON with comments is keyed whole. The root package.json is not, as in Nx: what the root importer installs counts for every task through the lockfile, as below, and the rest of it is the root project’s own manifest, so editing a root script misses only the root project’s tasks. Dotenv files are not, as in Nx: they hold per-machine values and credentials, so keying them would keep machines from sharing entries; env inputs key the variables a task declares.

nx.json counts through what it changes rather than by its content, unlike in Nx, which hashes it into every task. Merged with the file it extends and nx.local.json, it reaches a task through the task’s resolved configuration, which the key holds already: targetDefaults through its definition, and namedInputs through what its inputs select, the files, env and runtime inputs, externalDependencies and dependency outputs they name, a dependency’s through ^ inputs included. Beside that, the key holds the merged settings without targetDefaults and namedInputs, and without those that change no task’s work: $schema, extends, defaultBase, affected, parallel, tui, cli other than cli.packageManager, generators, release, sync, s3, nxCloudId, nxCloudAccessToken, nxCloudUrl, nxCloudEncryptionKey, neverConnectToCloud, defaultProject, cacheDirectory, maxCacheSize, qk:storeServer, qk:affectedProfiles, qk:reachability, and the workspace-wide implicitDependencies, which Nx no longer supports. Every other setting, such as plugins, pluginsConfig, workspaceLayout, tasksRunnerOptions, which can set selectivelyHashTsConfig, or one qk does not know, changes every task’s key. Comments and formatting change none, and neither does a named input edited to select the same files. A tool that reads nx.json itself declares {workspaceRoot}/nx.json as an input, which keys the file whole.

A project’s project.json and project.local.json are not added to its tasks’ keys either. An input that selects one, such as {projectRoot}/**/*, which default holds unless the workspace names it otherwise, keys it as the input says, as for any file and as in Nx: whole for a fileset, by its fields for a json input. Where no input selects them, they count through what they change. Their targets reach a task through its definition, and their named inputs through what its inputs select. Tags, the project’s name and implicitDependencies reach a task through which projects its inputs and dependencies select, and sourceRoot through the output paths {project.sourceRoot} names: the key holds each task’s resolved output paths. Beside those, a task’s key holds what is left of its own project’s configuration without name, targets, namedInputs, tags, implicitDependencies, sourceRoot, metadata, projectType, $schema, generators, release and qk:reachability, such as its root, and all of it when a forwarded argument holds a {project.*} token.

To have tasks keyed by their resolved configuration rather than by the files, leave the files out of the inputs that select them, as default does here:

{
  "namedInputs": {
    "default": [
      "{projectRoot}/**/*",
      "!{projectRoot}/project.json",
      "!{projectRoot}/project.local.json"
    ],
    "production": ["default", "!{projectRoot}/**/*.spec.ts"]
  }
}

A comment, or an edit to one target, then misses only the tasks whose keys it changes. The negations hold for every named input built on default, such as production, and for a dependency’s files read through ^production. As in Nx, a negation leaves the file out of all of the task’s filesets, so a target that also names {projectRoot}/project.json does not read it back; one whose inputs do not build on default names it to key it whole.

The package.json of each project the task’s project depends on, directly or not, counts by what it decides for its dependents, such as where their imports of it resolve, even when the task’s inputs leave the file out. Its version, scripts, description, keywords, author, contributors, maintainers, license, homepage, repository, bugs, funding, private and publishConfig are left out, since none of them changes what a dependent reads or runs. A version that moves into or out of the range a dependent declares for the package adds or removes that dependency instead, which changes the dependent’s key through what it depends on. With a pnpm lockfile qk can read, its dependencies, devDependencies, peerDependencies, peerDependenciesMeta, optionalDependencies and dependenciesMeta are left out too: what they install is keyed through the lockfile, as below. A manifest the task’s inputs select, as ^default selects every file of each dependency, counts whole. A dependency releasing a new version within its dependents’ ranges therefore leaves them cached, while a change to its exports reaches them.

A pnpm v9 pnpm-lock.yaml is keyed by what it installs rather than by its content: for the root importer and the importers of the task’s project and its transitive project dependencies, every package installation they reach, by snapshot key (version, peers and patch), integrity and dependencies. The root importer counts for every task because its packages resolve from every package, unless the task names its external dependencies: as in Nx, a task with an externalDependencies input, even an empty one, hashes the packages it names in place of every installed one, so only the root project’s own tasks then count the root importer. Name in it every root-installed tool the task runs; the sandbox’s audit and input analysis report a package a task reads but its key leaves out. A lockfile change therefore invalidates only the tasks whose projects install something that changed, plus every task when the lockfile version, pnpm’s settings or the lock of pnpm itself changes. A lockfile qk cannot read, or one the task’s inputs select, is keyed by its whole content.

With such a lockfile, pnpm-workspace.yaml is keyed without the keys that configure resolution (catalog, catalogs, overrides, patchedDependencies, peerDependencyRules and the like): their effect lands in the lockfile, so a catalog bump invalidates only the tasks whose installs change. A task whose inputs select the file keys it whole, as they say. That includes a root project’s own files and a task that reads the catalog itself, such as a catalog-consistency lint. A task that reads only packages or other keys outside resolution need not select it: the copy qk adds already covers them, while selecting it reruns the task on every catalog change. A task that reads only some resolution keys, such as catalog, can add them with a json input, which reads the file as YAML. Affected selection applies the same rules.

qk show hash <task> prints a task’s key and what it is computed from without running it, and --against compares it with another task’s key or with the task’s key in a recorded run; see Key inspection.

Inputs

Candidate input files are tracked and untracked-but-not-ignored files, minus planned tasks’ declared outputs. Nx keys a tracked declared output like any other file; qk leaves it out, so that a task regenerating a tracked file does not change keys, its own included. Source filesets also select other tasks’ declared outputs after dependencies complete, including newly generated files, while respecting source ignore rules and excluding the consumer’s own outputs. These files are keyed independently of selective dependentTasksOutputFiles inputs. The source list is taken once per run, so a file that a task creates without declaring it as an output is seen from the next run. File contents are re-read whenever their metadata changes; their digests persist between runs in the worktree’s state, keyed by path, size, modification time and, on Unix, change time, inode and mode, so a warm run reads only files whose metadata changed. Without inputs, a task uses default and ^default; default is {projectRoot}/**/* unless a named input overrides it. As in Nx, a project’s filesets select only the files it owns, leaving out those of projects nested in its root.

Every input declaration of Nx 24 is supported, always read as absent:

  • filesets, as strings or {"fileset": "…"}, with ! exclusions; a path with no glob pattern names a file or a whole directory, and \ escapes a glob character. With "includeIgnored": true a fileset reads the disk, gitignored files included, and a file it reads is keyed by the content it points to, wherever that is;
  • named inputs, also as {"input": "name"};
  • ^name and {"input": "name", "dependencies": true} (or "projects": "dependencies"), which as in Nx cover every project the project depends on, directly or not, and ^{projectRoot}/…, ^{workspaceRoot}/… or {"fileset": "…", "dependencies": true}, a fileset of each of them;
  • {"input": "name", "projects": […]}, the named input of the projects the project selectors select;
  • env, runtime, dependentTasksOutputFiles with transitive, and externalDependencies, which adds every installation of the named packages in the pnpm lockfile, whichever importer installs them;
  • {"workingDirectory": "relative" | "absolute"}, the directory qk was run from;
  • {"json": "path", "fields": […], "excludeFields": […]}, only the selected dotted fields of a JSON file, or of each file a glob selects. Affected selection counts any change to the file, even when source ignore rules exclude its path.

As in Nx, a named input cannot use dependencies or projects. With a package manager other than pnpm, lockfiles are always keyed by content. Generated artifacts stay out of source filesets. A consumer’s filesets can include outputs from its dependency tasks, including transitive dependencies. Outputs in git-ignored directories are read only by dependentTasksOutputFiles and by filesets with includeIgnored. Outputs from downstream or unrelated tasks do not feed back into its cache key.

runtime commands share one evaluation per run when their effective runtime environments match, including when tasks request them concurrently. That environment includes dotenv, explicit settings and local binary lookup. As in Nx, runtime inputs are evaluated before task metadata such as NX_TASK_TARGET_TARGET is injected. Runner color defaults apply to task execution; an explicitly configured FORCE_COLOR remains visible to runtime commands. dependentTasksOutputFiles keys the selected artifacts directly; qk checks their listings and contents again after execution before saving a result. . and .. segments in paths are resolved within the workspace. A symlinked input is keyed by its target text and the content it resolves to, including the files below a linked directory, even when source ignore rules exclude the target. Nested links are followed; directory cycles and links outside the workspace run uncached. Task-level affected selection follows changes to symlink targets, including deletions, even when the target lies outside the source glob that selected the link.

See Inputs and outputs for glob expansion rules and the affected-selection differences from Nx for bare parenthesized extension groups.

Outputs

Outputs resolve as in Nx 24. {projectRoot} and {projectName} name the project, {options.name} reads the target’s options, with the task’s --name=value arguments applied, and the legacy {project.<path>} reads the project’s configuration, such as {project.sourceRoot}. A placeholder can fall back, left to right, to others and finally to a literal: {options.outputPath ?? options.outDir ?? {projectRoot}/dist}. A value is written as JavaScript writes it, so false is false, 0 is 0 and an array is its items joined with ,; only a missing option, null and "" have no value. Option values have {projectRoot}, {projectName} and a leading {workspaceRoot} resolved first, as Nx does when it builds the project graph. Braces that do not start with a token are glob text, so {projectRoot}/dist/**/*.{js,map} is one output, and so is {env.NAME}: environment variables are no placeholder in outputs.

As in Nx 24, every output starts with a token, after its ! if negated: {projectRoot}/dist or {workspaceRoot}/dist, not dist, which nx repair rewrites. An output whose {options.…} placeholder, or a {projectRoot} or {workspaceRoot} left as text, has no value is left out. {workspaceRoot} may only start an output, and an output from it must name a directory before any pattern: {workspaceRoot}/dist/**, not {workspaceRoot}/**. Building the task graph fails on an output that breaks either rule. An absolute output from an option is read relative to the workspace, and one outside the workspace is an error. ! negates an output: what it matches is neither cached nor removed when an entry is restored. A target without outputs takes options.outputPath, and a build or prepare target dist/<root>, <root>/dist, <root>/build and <root>/public. Those defaults are cached and restored, but because they are guesses they are not declared outputs: they stay source files that other tasks’ filesets can select, and dependentTasksOutputFiles selects none of them.

A negated group such as src/**/!(*.test).ts or dist/!(cache)/** expands as Nx expands it, into a glob with the group widened and one naming the group’s items that excludes, so both tools select the same files, including where Nx’s expansion is surprising: *.!(ts) keeps only files ending in ., as in Nx. Nx applies such exclusions to every pattern of a project at once; qk applies each to the pattern it comes from, which can only add inputs.

A task with any other input declaration, or an output without a fixed directory prefix, runs uncached and reports why.

Saving and restoring results

On a miss, the task runs with stdout and stderr streamed through a pipe while they are recorded, or, when qk’s output is a terminal, through a pseudo-terminal, which records them as one stream that replays on stdout. Runtime inputs always use pipes, so a key does not depend on where qk ran. The entry is saved only when the task succeeds and its inputs are unchanged afterwards. Declared dependency outputs are rechecked too. Cache keys use a new schema so entries saved before these artifact checks are not reused. On a hit, qk removes existing files matching the declared outputs, copies the cached outputs into place and replays the recorded stdout and stderr. As in Nx, outputs a worktree already holds for the key are left as they are: after each restore or save qk records, in the worktree’s state, every output path with its size, times, inode and mode, and a hit that finds exactly those still in place only replays the log, marked [existing outputs match the cache, left as is]. Restores are staged in the worktree’s state. Saving and restoring copy files, which clones them on copy-on-write filesystems such as APFS when the cache and the checkout share a volume. Files are never hardlinked, so editing a restored file never changes the cache. Every file is verified against its content hash; an unreadable or corrupt entry is a miss. A per-key lock makes concurrent runs of the same task, including runs in different worktrees, wait for each other and reuse the result.

Restored files take the time their restore started as their modification time. A task restores only after its dependencies finish, and its sources were written before the run, so restored outputs are newer than the sources they were built from and newer than the outputs of the tasks they depend on. Tools that decide what to rebuild by comparing modification times, such as tsc --build, then find restored outputs up to date. Outputs a worktree already holds are stamped again when a dependency’s outputs changed in the run, so they stay the newer. Symbolic links and directories keep their times.

Entries unused for a week are evicted after each run, as in Nx, and the cache stays under a size limit. See remote caching for size limits and cache directory overrides.

Remote cache and storage limits

nx.json’s s3 key, as @nx/s3-cache reads it, adds a remote store on S3-compatible storage: bucket, region, endpoint, forcePathStyle, cacheKeyPrefix, accessKeyId and secretAccessKey. Credentials otherwise come from AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and AWS_SESSION_TOKEN, including from the workspace’s .env files. localMode applies outside CI and ciMode when CI is set; each is read-write (the default), read (also spelled read-only) or no-cache, and NX_POWERPACK_CACHE_MODE overrides both. encryptionKey and SSO profiles are not supported; a store that cannot be used is reported and left out, and the local cache carries on.

Entries are stored under <cacheKeyPrefix>qk/v4/, so a bucket shared with Nx never mixes the two. Result entries and portable warm state use streaming Zstandard level 3 compression with a frame checksum. Each entry is one object holding its manifest and its small outputs, so that a lookup or restore of an entry whose outputs are all small is one request however many files the task wrote. A local miss fetches it, keeps the outputs the local cache lacks, verifying each against its hash, and shows [remote cache]; any failure is a miss. The path sets of tasks with qk:ultracache are stored as pathsets/<weak>. A store server takes at most 16 MiB of them for one task, so a larger record is shared without its oldest sets; one whose newest set alone is larger is not shared, and its upload fails.

What tasks are expected to take, which orders them and sizes their shares of the cores, is stored as expectations/<name>, one record per branch and platform. It holds each task’s last five runs that executed it: their time, the cores they held and the memory they used. A run that executed tasks adds them to the record of its branch, starting from the default branch’s when its branch has none, and saves it for its branch; the newest save replaces the last, and a task no run has executed for 30 days is dropped. A run whose local history lacks a task reads the record of its branch, else of the default branch, so that a machine without history, such as a CI job on a fresh clone, still knows which tasks take long. Local history wins for a task it has. A record that cannot be read leaves the run without it, and is not replaced. The record is fetched while the run gets ready to start its tasks, and waited for only when local history lacks one of them.

Expectations are read and written as the store’s mode allows. QK_REMOTE_EXPECTATIONS narrows that: read (also spelled read-only) reads them without adding to them, off neither reads nor writes them, and read-write, the default, changes nothing.

An output whose compressed size reaches 1 MiB is stored apart, as blobs/<hash>, uploaded once for every entry that has it and only when the store lacks it. A machine fetches it only when its local cache lacks it, so a machine that already holds a build’s large files receives little more than the manifest. The index names each entry’s large outputs, so a machine lacking them fetches them beside the entry, a few at a time, rather than after it. QK_REMOTE_BLOB_THRESHOLD sets another size, such as 256KB, or off to keep every output in its entry. Large outputs are shared between entries, so do not expire blobs/ with a storage lifecycle rule: an entry whose output is gone is a miss, never a wrong result, but every entry citing it misses until it runs again. After a task is saved it is uploaded in the background, and a run waits for its uploads before it ends. Uploads report their failures without failing the run; an entry larger than 5 GiB, the most one S3 upload may hold, is not uploaded.

A request may take ten minutes, which allows for the largest transfers, but one that receives nothing from the store for 30 seconds fails: a store that stops answering costs a lookup a miss and an upload its failure, rather than holding every task that asks it. Once the store has stalled twice, the run asks it nothing more: each later lookup misses without asking, each later upload fails, and qk says so once. Until then, when the connection fails an upload, by a reset, a broken pipe or a stall, qk sends it twice more, after two and then four seconds, and stops trying once the store is left out. An upload the store refuses, or one that runs out its ten minutes, fails at once.

For local runs, qk can hand queued uploads to a detached process after local cache saves finish. Set s3.uploadMode to "background" to opt in:

{
  "s3": {
    "bucket": "task-cache",
    "region": "us-east-1",
    "uploadMode": "background"
  }
}

"wait" is the default. QK_REMOTE_UPLOAD_MODE=wait overrides the setting for a run; use it in CI so uploads finish before the runner is torn down. It waits for that run’s uploads, not workers started by earlier runs. Background mode starts uploads at the end of the run, whereas the default mode overlaps uploads with task execution and waits for them at the end.

The detached process receives a private snapshot of result manifests and warm records, with hard links to immutable cache blobs (copies when hard links are unavailable). It can finish after qk exits, even if the local cache is pruned or reset. Snapshots and private failure logs live beside the cache directory; qk prints the log path on handoff. Credentials go through a pipe, not command arguments or a request file. If handoff fails, qk waits for the uploads instead.

The worker removes its snapshot when it finishes and writes a completion line with a failure count to the log. This is best-effort background work, not a durable retry queue: shutdown, worker termination, or CI job cleanup can interrupt it. Later background runs reclaim snapshots older than one day when no worker holds their lease, and logs older than one day. Pending snapshots can retain disk space outside the cache size limit until cleanup.

The remote format uses a clean cutover from qk/v3: new clients neither fetch nor overwrite older remote objects. Upgrading may cause one-time remote misses while the compressed cache fills. Existing local entries are unchanged, and older clients can continue using their own remote namespace. There is no legacy lookup after a miss. Downloads limit the decoder window to 128 MiB and the expanded pack to 5 GiB; malformed, truncated or oversized packs are misses. The local content-addressed blob store remains uncompressed.

Every request to the store waits a round trip, so qk keeps an index of the entries it holds and does not ask for one the index leaves out. Each run that writes to the store adds a listing under index/ of the entries it found or put there. Each entry upload also writes a listing before publishing the entry, so an interrupted run or a failed final listing cannot leave a stored entry undiscoverable. After the final listing succeeds, the run removes its replaced announcements in batches. If that first listing fails, the entry is not uploaded; if the entry upload fails, its listed key simply returns a miss. A run lists index/ as it starts, reads the listings it has not seen and keeps them in the local cache. If that fails, including a listing removed by concurrent compaction, it looks up each entry as it needs it. Paginated indexes also use direct lookups because pages cannot establish a complete snapshot during concurrent compaction. Listing payloads are bounded at 8 MiB each and 32 MiB across a synchronization; exceeding either limit also uses direct lookups without retaining an incomplete index. An entry uploaded after a run started is not found by that run. Once there are 16 or more listings, a writing run merges them into its own and deletes them, so the store’s credentials need permission to delete objects. Entries whose timestamps have not been renewed for 30 days drop out of the index. Read-write local cache hits renew entries an available remote index already lists; unuploaded local results are not advertised.

A listing also records its run: the entries it used, in the order it used them. Merging keeps the most recent runs, at most 32, none older than 14 days. A cached chain of tasks otherwise fetches one entry per level, each waiting for the level below. Once a task’s entry is a remote hit, qk fetches the other entries of the recent runs that used it in the background, nearest to it first, so a dependent usually finds its entry already local; a task whose entry is still being fetched waits for that fetch. Prefetching stops when fewer than half of the run’s entries match the runs it follows, after a gigabyte or 5,000 entries, or when the run ends, without waiting for it. A prefetched entry is shown as [remote cache], and listed, only once a task uses it. QK_REMOTE_PREFETCH=off turns it off.

Store server

nx.json’s qk:storeServer key sends the remote cache to a store server instead of S3, with its token in QK_STORE_SERVER_TOKEN:

{
  "qk:storeServer": { "url": "https://cache.example.com" }
}

A store server keeps the same entries, but each run first tells it which outputs the local cache holds, and it leaves those out of every entry it sends. A run whose request to tell it fails says why, and receives entries whole, as does, silently, one whose local cache holds no outputs or more than two million. It keeps the index itself and commits each entry once, so runs neither write announcements nor merge listings. localMode, ciMode, uploadMode and NX_POWERPACK_CACHE_MODE apply as they do for s3. A read token fetches; a write token also uploads.

Nx does not read qk:storeServer, so a workspace that still runs some tasks with Nx can keep s3 for Nx’s cache plugin: qk uses the store server and ignores s3. tools/store-server in qk’s repository is a reference server, a Cloudflare Worker over R2, and documents the protocol in its PROTOCOL.md.

Storage limits

The cache stays under a size limit: NX_MAX_CACHE_SIZE, else nx.json maxCacheSize, else a tenth of the disk holding it (100GB when the disk’s size cannot be read), as in Nx’s code; its documentation’s 10GB cap is not in the code. Sizes are a number of bytes with an optional KB, MB or GB, in powers of 1024; 0 means unlimited. After each run, and on qk cache prune [--max-size <size>], qk evicts the entries unused for a week, whatever the limit, and when the cache is over the limit the least recently used entries until it holds at most 90% of it, as Nx does. A hit counts as a use. Outputs shared between entries are stored once and deleted only with the last entry citing them. Stored outputs no entry cites, scratch files and locks of evicted entries are removed once they are an hour old, since a concurrent run may still be writing them; outputs a remote fetch in progress relies on are kept whatever their age. A run under the limit only sums the cache’s size; the full pass runs at most hourly unless the cache is over its limit.

NX_CACHE_DIRECTORY, else nx.json cacheDirectory, moves the cache, as in Nx: a path relative to the workspace root, with qk’s entries in qk/v1 inside it so they never mix with Nx’s, and so CI steps that save and restore that directory keep working. A relative path gives each worktree its own cache; the run history and each worktree’s state stay in the git directory.

Reusing warm state

A target can keep scratch state that makes it faster to rerun, without that state ever being part of a result, with a qk:warm key at target level (beside inputs and outputs, not in options; Nx ignores it):

"tsc": { "qk:warm": { "outputs": true } },
"build-android": {
  "qk:warm": {
    "paths": ["{projectRoot}/node_modules/.cache/babel"],
    "env": { "METRO_CACHE_DIR": "{warm}/metro" },
    "maxSize": "2GB"
  }
}
  • outputs: true restores the task’s previous outputs before it runs, so an incremental tool finds its last build, such as tsc --build its tsbuildinfo.
  • paths are workspace scratch paths, kept beside the task’s entries. They are never inputs. Like outputs, they may be globs with a fixed directory prefix, and a path starting with ! excludes what it matches from the saves, such as packaged artifacts that every build rewrites; excluded files are not inputs either.
  • env sets variables for the task, with absolute directory tokens {projectRoot}, {workspaceRoot} and {warm}, a directory qk keeps for the task in the worktree’s state, outside the working tree. Under Nx these variables are not set, so tools that only cache when told to keep their default behaviour there.

Warm state is restored before the task runs, on a cache miss and for targets that are not cacheable, and never on a hit. A group already present on disk is left alone, since it is the newest for that checkout; otherwise it comes from the worktree’s own save. The task’s paths count as one group unless they are restored one by one. With portable: true, it can also come from the store linked worktrees share or from the remote store, and with portable: "path" from the remote store when saved in a checkout at the same path. Each worktree keeps its own save, and a restore takes the worktree’s own before the most recent other worktree’s; the eight most recent saves of a task are kept. With sharing enabled and no suitable local save, it comes from the remote store: the current branch’s save, else the default branch’s (nx.json defaultBase, else main). A local save made at a commit HEAD does not descend from, as on another branch, counts as suitable only when the remote store has none: a CI runner that builds every branch in one directory keeps one save there, the last branch’s, which is the least like the checkout it is restored into. The branch comes from GITHUB_HEAD_REF or GITHUB_REF_NAME in CI, else from git; a checkout with no branch reads the default branch’s state but saves none remotely. remote: false keeps a target’s warm state local. It is saved after successful runs only; files unchanged since the last save or restore are recognised by their metadata and not read again. New warm files are copied with bounded concurrency. Cache writes are left to the operating system to flush to disk, for both warm state and result entries. A power loss may discard or damage a cache entry; blobs are verified before restoration, invalid result entries are misses, and a failed warm restore does not prevent the target from running. The timestamp and path policies below distinguish reusable tool state from verified result outputs.

Replacing a bad save

A save holding state a tool cannot recover from is restored by every run that misses, saved again once the run succeeds, and shared through the remote store with every machine on the branch, or on every branch that falls back to the default branch’s. Removing the warm paths by hand, or qk reset, clears only this machine’s copy, and the next run restores the save again.

--cold, or QK_COLD=true, runs each task from scratch: it restores no result, empties the task’s warm paths rather than restore them, and saves what the task gives as usual. Its save replaces the bad one, here and in the remote store, for the branch it ran on, so a bad save on the default branch is replaced by a cold run on that branch:

qk run app:build --cold

A group tasks share is emptied once a run, so a task sharing it with one that ran before it takes what that one left. A group qk cannot empty, as when a file in it cannot be removed, is not saved, so the run cannot keep what it was to replace; qk says so.

A task’s result is saved too, replacing the local entry under its key, and, for a task with Ultracache recordings, the entries under the keys its recordings give, which a later run looks up first; the remote store keeps an entry it already holds. --cold cannot be combined with --skip-cache, which saves nothing; QK_COLD gives way to a run that skips the cache, as an input analysis does.

Result hits and warm state

A result-cache hit means qk has already verified the task’s inputs and can restore its outputs without running it. Restored result files receive the time restoration started, after the dependencies finished. This lets tools such as tsc --build see valid outputs newer than their inputs. Outputs already held unchanged on disk retain their timestamps unless dependency outputs were renewed.

Warm state helps when the task must run. It can belong to an older input set, so qk dates restored files to the Unix epoch by default and lets the tool validate them. mtimes: "preserve" applies only to the worktree that saved the state. Another worktree’s or a remote save still receives epoch timestamps; this setting never promises an up-to-date build in a different checkout. Preserving times also preserves the tool’s timestamp assumptions: Make 3.81 can miss a rapid source edit within its output timestamp’s one-second resolution. Validate source changes as well as unchanged builds before using preserved times for that tool.

Choosing state for a tool

Separate final deliverables from the tool’s build history. Restoring an executable or library does not restore Cargo’s fingerprints, Ninja’s log, or Xcode’s build database. Those tools can rebuild even when the final artifact has a fresh timestamp.

The following guidance comes from local restoration and relocation checks with TypeScript 6.0, Make 3.81, Ninja 1.10, CMake 4.4, Xcode 27, Cargo 1.98, Metro 0.84, Gradle 9.3, Jest 30.5 with babel-jest, and Vitest 5.0.2. Tool versions, plugins, generated rules and compiler flags can change whether state relocates. The configurations are starting points to verify on your project.

Tool and stateSame-worktree configurationDifferent checkout path
TypeScript build outputs and .tsbuildinfooutputs: true; keep default epoch times; opt into portable: true after relocation checksRelative build-info paths relocated and sources were revalidated. Custom generators or absolute paths need their own checks.
Make objectsComplete object/output paths, mtimes: "preserve", portable: falseRelative rules rebuilt correctly, but epoch objects did not skip compilation.
Ninja objects and logsComplete build directory, mtimes: "preserve", portable: falseRelative rules rebuilt correctly. Absolute paths in generated rules can bind the state to the old checkout.
CMake build directoryComplete build directory, mtimes: "preserve", portable: falseCMakeCache.txt retained the original source and build paths; relocation failed. Configure a new build directory.
Xcode build intermediatesExplicit intermediate/DerivedData paths, mtimes: "preserve", portable: falseA relative-source project rebuilt correctly but compiled again. CMake-generated projects also retained CMake’s absolute paths.
Cargo target directoryActual target directory, mtimes: "preserve", portable: falseA simple crate rebuilt correctly, but was Dirty rather than Fresh. Build scripts and generated absolute paths need separate checks.
Metro transform cacheportable: true after validation; default epoch times; configure the tool’s cache storeReused transforms at a different project root with the same transformer/configuration. Its file-map cache is separate.
Jest transform cacheConfigure cacheDirectory; default epoch times worked; portable: falsebabel-jest transformed modules again because the configuration/cache identity included checkout paths.
Vitest filesystem module cacheEnable fsModuleCache, configure fsModuleCachePath; default epoch times worked; portable: falseModule-cache keys included absolute module IDs and the Vite root; transforms ran again.
Gradle project history and build outputsComplete local state, mtimes: "preserve", portable: falseA Java project rebuilt correctly; copied history alone did not make compilation up to date. Native/CMake state can impose stricter path requirements.

Jest and Vitest

Warm transform caches avoid repeating compilation while the test runner still executes tests. In controlled two-module fixtures, Jest/babel-jest and Vitest with filesystem module caching each performed two transforms on a cold run, zero after restoring the same checkout’s cache, and one after changing a source module. Both epoch and preserved timestamps worked; preserving times provided no extra transform hits. Every run executed the test and asserted the current source value.

Linked-worktree and remote restores at a different checkout path each performed both transforms again. These caches therefore use portable: false in the recipes below. Remote checks used an isolated local S3-protocol store on the same platform with empty local cache directories; they do not establish portability across machines or operating systems.

For Jest, configure its cache directory explicitly rather than trying to capture the operating system’s temporary directory:

// jest.config.cjs
module.exports = {
  cacheDirectory: '<rootDir>/.cache/jest',
};
"test": {
  "qk:warm": {
    "paths": ["{projectRoot}/.cache/jest"],
    "portable": false
  }
}

For Vitest, a Vite cache directory alone did not persist source transforms between processes in the tested installation. Enable the filesystem module cache explicitly; defaults and option locations vary by version. Vitest 5 uses test.fsModuleCache and test.fsModuleCachePath; versions that expose them under test.experimental need that nesting instead.

// vitest.config.mjs
export default {
  test: {
    fsModuleCache: true,
    fsModuleCachePath: '.cache/vitest-modules',
  },
};
"test": {
  "qk:warm": {
    "paths": ["{projectRoot}/.cache/vitest-modules"],
    "portable": false
  }
}

Ignore these scratch directories in Git. In multi-project configurations, use the actual Jest root or Vitest cache root when declaring the warm path. Vitest plugins that read external files or options may need a cache key generator or an opt-out to invalidate transforms correctly. Include those inputs in qk’s task inputs too: task-result invalidation and transform-cache invalidation each need to cover them.

For example, keep Cargo’s local target directory with the worktree:

"compile-rust": {
  "executor": "nx:run-commands",
  "options": { "command": "cargo build --target-dir target" },
  "qk:warm": {
    "paths": ["{workspaceRoot}/target"],
    "mtimes": "preserve",
    "portable": false,
    "key": ["{workspaceRoot}/rust-toolchain.toml", "{workspaceRoot}/Cargo.lock"]
  }
}

Use the directory the command actually writes; for a task with a project working directory, target may need {projectRoot} instead. Include every intermediate and history file needed by the tool. Excluding final artifacts from warm paths is reasonable only when the tool can regenerate them while reusing the remaining state.

survive keeps local state across a dependency that deletes its directory, without resetting its timestamps. Combine it with local-only state for native prebuilds. Use a key for the relevant toolchain and configuration; do not use restoreKeys to relax a compatibility boundary that the tool cannot validate.

Sharing state across worktrees and CI

portable: false is the default: warm state is saved and restored only in its own worktree. Set portable: true to permit another worktree’s save and, when remote is enabled, remote exchange. It does not rewrite absolute paths inside files or make incompatible tools accept them. remote: false disables remote exchange while still permitting other worktrees when portable is true.

State that records where it was made, as a CMake build directory, Xcode’s intermediates or Gradle’s project state do, cannot follow a checkout to another path, but suits another machine whose checkout is at the same path, as CI runners checking out in one place are. portable: "path" exchanges it through the remote store, and restores a save only when the checkout that made it was at the same path, resolved through links: one runner’s save reaches the next runner, while a developer’s checkout elsewhere keeps its own state, as with false. Saves from each path are stored apart, so a checkout elsewhere that can write to the store never replaces the save the runners restore. Linked worktrees, each at a path of its own, do not share it. What else such state depends on, as the toolchain’s location or version, qk does not check: keep it the same where the path is, or name it in key.

Migration: configurations that previously relied on implicit cross-worktree or remote warm-state sharing must now set portable: true explicitly. Validate the tool’s relocation behavior before opting in. Same-worktree warm restores and local/remote task-result caching keep their existing behavior.

Prefer a tool’s content-addressed compiler/transform/task cache when the full build directory is tied to a checkout. ccache and Gradle’s task-output cache are separate from the local build history in the table above. A group can keep a shared compiler cache, but cannot also declare paths or outputs. When one task needs both, with different portability, list them as several entries.

Before enabling shared warm state:

  1. Build in one worktree and remove the intended warm paths.
  2. Verify an own-worktree restore, including which compiler/transform steps the tool actually runs rather than qk’s replayed log.
  3. Restore into a worktree at another absolute path. Change a source there and verify the resulting artifact uses that source, not the first checkout’s files.
  4. Repeat with an empty local qk store and remote warm state. Match the operating system, architecture, toolchain and relevant configuration, then vary whichever dimensions the cache is intended to support.

Controlled same-platform S3-protocol checks restored TypeScript build information, Metro transforms and Cargo state to another path. Metro reused its transforms; TypeScript revalidated sources and Cargo compiled again. Separate ccache and Gradle task-cache checks produced compiler/task cache hits after both linked-worktree and remote restores. The Gradle task-cache check used an isolated cache directory, so it did not rely on a preexisting user-wide cache.

Transporting state successfully is not evidence of an incremental hit or of cross-platform portability. A production store, credentials and tool configuration still need their own verification.

Fetching remote state ahead

A task restores its warm state once its result has missed, so fetching a remote save adds a round trip to the store, and the time to receive what the local store lacks, before the task starts. prefetch: true starts that fetch as the run starts instead, while the task’s dependencies run, and the restore takes what it fetched:

"qk:warm": {
  "portable": true,
  "prefetch": true,
  "env": {"TOOL_CACHE": "{warm}"}
}

Nothing is fetched ahead when the warm paths are already on disk, when a save already in the local store would be restored first, or in a cold run, which restores nothing. Whether the task’s result misses is not known until its dependencies are done, so a task whose result comes from the cache never uses the save fetched for it: set prefetch on a target that usually runs, as one whose inputs change in most pull requests.

Two saves are fetched at a time, those of the tasks with the fewest dependencies to wait for first, and a group shared by several tasks is fetched once. A task that starts while its save is still waiting its turn fetches it itself; one that starts while it is being fetched waits for that fetch. A save fetched for a key the task no longer has when it runs, as when a dependency rewrites a file the key names, is not used, and the task fetches its own without waiting. Once the run’s tasks are done, saves still waiting their turn are not fetched, and those being fetched are waited for, unless the run was cancelled.

Portable transform and task caches

For Metro, configure its transform cache explicitly:

"bundle": {
  "qk:warm": {
    "portable": true,
    "env": { "METRO_CACHE_DIR": "{warm}/metro-transform" },
    "maxSize": "1GB"
  }
}

Metro does not automatically read that variable. In the Metro configuration, pass it to FileStore, retaining a default when running without qk:

const os = require('node:os')
const path = require('node:path')
const { FileStore } = require('metro-cache')

const cacheStores = [
  new FileStore({
    root: process.env.METRO_CACHE_DIR ?? path.join(os.tmpdir(), 'metro-cache'),
  }),
]

Use cacheStores in the existing Metro configuration. File-map state is a separate cache; sharing transform results does not promise that Metro will skip discovery in a new checkout. Match the transformer, configuration and dependencies, and test plugins that use absolute source paths.

For Gradle’s task-output cache, keep its content-addressed objects in a shared group rather than copying the project’s whole .gradle directory:

"compile-java": {
  "executor": "nx:run-commands",
  "options": { "command": "./gradlew compileJava --build-cache" },
  "qk:warm": {
    "group": "gradle-task-cache",
    "portable": true,
    "env": { "GRADLE_BUILD_CACHE_DIR": "{warm}/task-cache" },
    "maxSize": "1GB"
  }
}

The Gradle settings must consume this variable. For example, in settings.gradle:

def warmBuildCache = System.getenv('GRADLE_BUILD_CACHE_DIR')
buildCache {
    local {
        if (warmBuildCache != null) {
            directory = file(warmBuildCache)
        }
    }
}

A task must support Gradle caching and declare its inputs correctly. Its path-sensitivity rules and toolchain decide whether another checkout can reuse the entry. This recipe does not make the project’s execution history, configuration cache, or native CMake directories portable.

Finding local state

Native builds write to places that are hard to guess. qk warm suggest <project:target> runs the task, and what it depends on, in the sandbox’s audit mode and lists the directories it wrote outside its declared outputs, with how much each holds. Each written path is shown under its topmost directory that holds no source file, tracked or not ignored, since that is a directory the build keeps apart from the checkout; writes beside sources are left out. It takes the options qk run does and needs macOS, where the sandbox can report rather than refuse.

Keeping state across destructive dependencies

A step that regenerates a directory can take build state with it, as expo prebuild --clean deletes an Android project together with Gradle’s build directories. survive names such dependencies:

"android": {
  "dependsOn": ["prebuild-android"],
  "qk:warm": {
    "paths": [
      "{projectRoot}/android/.gradle",
      "{projectRoot}/android/build",
      "{projectRoot}/android/app/.cxx",
      "{projectRoot}/android/app/build"
    ],
    "survive": ["prebuild-android"],
    "mtimes": "preserve",
    "portable": false
  }
}

Before a named dependency runs, or is restored from the cache, qk moves the task’s paths into the worktree’s state, and after it finishes, whether it succeeded or not, moves them back over whatever it left there. They keep their files and modification times without being stored or copied. Dependencies named this way that run at once share the move: the first moves the paths, the last moves them back. A path is only moved back where its parents are directories inside the workspace; when the dependency put a link or a file in their place, the path stays aside, and the next run moves it back after that run’s dependency, keeping the task’s other paths across that dependency as usual. A run that is stopped leaves its paths aside the same way. An entry names a target in the task’s project, or project:target, and applies to the dependency in any configuration. What the dependency writes beside the paths stays.

Stash ownership records are replaced atomically after syncing the new file. Unreadable, invalid or missing metadata leaves saved contents in place and reports a recovery warning. This protects stopped-process recovery; it does not promise durability across power loss.

Restoring paths one by one

All of a task’s paths are restored together, and only when none of them is on disk. Then one directory left behind, such as a native library’s CMake state in node_modules, keeps the rest from coming back when another is deleted. restore: "each-path" restores every path that is missing, from the save, and leaves those on disk as they are:

"android": {
  "qk:warm": {
    "paths": [
      "{projectRoot}/android/app/build",
      "{workspaceRoot}/node_modules/.pnpm/*/node_modules/*/android/.cxx"
    ],
    "restore": "each-path"
  }
}

A restore can then put an older save beside newer state on disk. Use it only when every path is state a tool validates on its own, as Gradle and CMake check their outputs against their inputs. Keep state that one tool writes and reads together, such as an index and the files it describes, under one path, or leave the default, "whole".

Several entries

qk:warm can be an array of entries, each restored, saved and shared under its own settings. An Android build can then share Metro’s transform cache, which relocates, while keeping Gradle’s build history to the worktree that wrote it:

"android": {
  "dependsOn": ["prebuild-android"],
  "qk:warm": [
    {
      "group": "metro",
      "portable": true,
      "env": { "METRO_CACHE_DIR": "{warm}/metro" }
    },
    {
      "paths": ["{projectRoot}/android/.gradle", "{projectRoot}/android/app/build"],
      "survive": ["prebuild-android"],
      "mtimes": "preserve",
      "portable": false
    }
  ]
}

At most one entry goes without a group: it is the task’s own and the only one that can hold outputs and paths. No group may be named twice, and no variable set by two entries. Each entry’s portable, remote, key, maxSize, mtimes and save apply to that entry alone, and each restore is reported with the group it belongs to.

Matching toolchains and configurations

A key restricts restores to saves that suit the checkout, such as those built with the same toolchain:

"qk:warm": {
  "paths": ["{projectRoot}/android/app/.cxx"],
  "key": ["{workspaceRoot}/pnpm-lock.yaml", { "env": "ANDROID_NDK_VERSION" }],
  "restoreKeys": 1
}

Each part is a file, by its content, or an environment variable, by its value as the task receives it, including the target’s options.env. A save is restored when its key matches whole, or, with restoreKeys, when it matches in that many leading parts; one that matches whole is preferred. Among saves that suit equally, the worktree’s own comes first, then, with portable: true, the one made at the commit nearest behind HEAD in Git history, then the newest, so a worktree cut from main starts from main’s state rather than the last feature branch’s.

Shared compiler caches

A group shares one {warm} directory, and its saves, between every target that names it, as one compiler cache serves several builds:

"qk:warm": {
  "group": "ccache",
  "portable": true,
  "env": { "CCACHE_DIR": "{warm}/ccache", "CCACHE_BASEDIR": "{workspaceRoot}" }
}

The ccache example supplies absolute directories. Relative compiler inputs and CCACHE_BASEDIR allowed cache hits across tested checkout paths. Debug information and flags containing absolute paths may need compiler path remapping; test that the reused object has the intended paths and contents.

A group keeps no outputs or paths, which belong to one task. Its targets may run at once and use the directory together; restoring and saving it are serialized, and a save holds everything the directory held.

save: "background" saves after the task has reported, so its dependents start without waiting for a large group to be stored; the run waits for the save before it ends. Other tasks may change the paths meanwhile, so the save can hold files from after the task ran, and files removed before it reads them are left out.

Groups over their maxSize are not saved. Warm state counts toward the cache’s size limit and is evicted with it. --skip-cache neither restores nor saves it, and leaves the variables unset, so it cannot replace a bad save either: see Replacing a bad save. Two tasks in one run cannot keep the same path. A tool must validate its own cache, as Metro, tsc and Next do: qk only guarantees that warm state never changes a key or a hit.

Keying tasks by what they read

{
  "targetDefaults": {
    "build": { "cache": true, "qk:ultracache": {} }
  }
}
qk run-many -t build --record     # on Linux, in CI
qk run-many -t build              # anywhere, later

A target’s declared inputs are usually wider than what its task reads, so an edit the task never reads still misses the cache, or narrower, so a hit can be stale. With qk:ultracache, a task is keyed by what it actually read the last time it was recorded, as Nx keys tasks from Nx Cloud’s Ultracache recordings. qk records tasks itself, and keeps the recordings in its cache.

Recording

A run with --record, or with QK_RECORD=true, records each task with qk:ultracache that it executes. Recording needs Linux, with eBPF or strace as Recorders describes. On other systems, and on Linux where no recorder works, --record records nothing and says why, and the run goes on: a recording only keys what a later run may take, so CI can set QK_RECORD on any runner. A QK_RECORDER naming no recorder, or naming one that cannot record here, fails the run. A task taken from the cache is not recorded, since nothing runs.

A recording keeps a path set: what the task did to each path in the workspace.

  • A file it read is keyed by its content.
  • A directory it listed is keyed by its entries’ names and kinds, so a new file there, such as another .svg in a directory a build bundles whole, changes the key even though no recording read it.
  • A path it looked up is keyed by whether it exists, and as what, and a file it found by its content: a tool that compares timestamps, as tsc --build does, decides by files it never reads. A file created where a resolver once found nothing changes the key; so does deleting or changing one it found.

Left out are paths outside the workspace, node_modules, which the lockfile keys, .git, qk’s own state, the task’s outputs and warm paths, files it wrote before reading them, its scratch directories, and what its ignoreReads names, which the sandbox’s audit leaves out too. A glob there starting with ^ names files in the projects the task’s project depends on, so which projects those are is part of its weak key. A directory’s listing leaves out the task’s outputs and what it wrote there, which are there after it runs and need not be before, and the declared outputs of every other target in the workspace, which other tasks write whether or not this one reads them, with the directories between an output and its project’s root, which hold it: .cache for an output in .cache/build. The outputs of the targets it depends on are its inputs, and stay in its listings. A directory listings leave out has nothing in them to key.

Keying

A task with qk:ultracache has a weak key: its key without its declared file inputs. It keeps the command, arguments, environment and runtime inputs, externalDependencies, the lockfile and what qk keys for every task, such as the nx.json settings and its project’s configuration and package.json, and its inputs marked "always": true, which count whether it read them or not. Up to eight path sets are kept for a weak key, the newest first. A task that reads differently from run to run keeps a set for each way.

For each path set, qk keys the paths as they are now, and restores a result stored under that key. When none has one, a task whose declared inputs cover every one of its path sets is looked up by its declared key, as without qk:ultracache; any other runs. Recorded, its new path set and result are kept; not recorded, its result is kept under its declared key only, which the next run finds when its declared inputs cover its path sets.

The declared inputs cover a path set when the declared key changes with everything the task could notice through it:

  • each file it read or found is keyed, by the declared inputs or by what qk keys for every task, or a directory it found holds a keyed file;
  • a file created where it looked for one and found none would be selected, and is neither gitignored nor a declared output, so that, say, a missing .env.local keeps it on its recordings;
  • every entry a directory it listed could gain or lose would be selected, gitignored entries aside: a fileset takes all of what is under the directory, as {projectRoot}/**/* takes a project’s directories, no exclusion of that fileset reaches into it, and the directory is no declared output.

A file qk keys by part of its content, such as a dependency’s package.json or a json input, counts as keyed, as it does for every task’s key: a result taken by the declared key is as sound as one without qk:ultracache.

Otherwise its declared inputs are what a recording found wanting, and the task is keyed by its path sets alone. Coverage is judged when the task is looked up, by the declared inputs it is looked up with, so that a declaration changed since a path set was recorded, such as a narrowed named input, is what counts.

A task without path sets is keyed by its declared inputs, as without qk:ultracache, until a run records it. Its results are also kept under its declared key when recorded, so runs without its recordings find them.

The run summary names the tasks taken from the cache by what they read, those taken by their declared key in place of their recordings, those recorded, those that missed what they read when recorded, and those with no recording yet. --report writes which of these each task’s recordings did as recorded, and the run history keeps it, so qk show run --json prints it too.

Recorders

qk records a task on Linux in one of two ways, and a recording is kept or refused alike whichever made it.

  • eBPF, by default. A program in the kernel notes the file calls of the task’s processes and passes them to qk, so a recorded task runs at close to its usual speed. Loading the program takes root, so qk starts a helper, qk __trace-helper: itself when it runs as root, otherwise through sudo -n, which never asks for a password. It needs Linux 5.8 or later built with BTF, as distributions build it, on x86-64 or arm64; in a container, it also needs the CAP_BPF and CAP_PERFMON capabilities.
  • strace, on PATH, where eBPF cannot load. strace stops the task at each call it records, so a task that opens many files takes noticeably longer. It filters the calls it records in the kernel with seccomp where it can, which strace 5.3 and later do, so that other calls run at full speed.

QK_RECORDER picks one: auto, the default, records with eBPF where it loads and with strace otherwise; ebpf or strace records only with that one, and a run that cannot record with it fails.

What a recording cannot see

  • Reads by processes outside the task, such as a build daemon started before it or shared between tasks: keep such a target off.
  • Environment variables, which are no file reads: declare them as inputs.
  • A compiled program in the workspace that the task runs directly. A script it runs is read by its interpreter, and so recorded.
  • Files opened before the task started, and reads through io_uring.
  • A recording a task’s trace was too large for, that could not be decoded wholly, or that lost calls the recorder had no room for, is not kept, and the summary says so. Neither is one that names no path in the workspace, which would match any workspace, nor one whose task changed a file it read or found while it ran: keyed by the file as the task left it, it would be keyed by the task’s own write. Nor is one where an entry in a directory it listed was added, renamed or removed while it ran, other than what its listings leave out, and the summary names the entry that changed where it can be told; what another task writes inside one of its entries, or a file it rewrites in place, leaves the listing as it was. Where the task wrote into the directory, any file there that changed counts, rewritten or not, and a subdirectory counts if it was created while the task ran, which a file system that keeps no creation time cannot tell. An entry another process removed there, or a subdirectory it moved in, cannot be told from the task’s own writes, and goes unnoticed.

Sharing recordings

With a remote cache, recordings are shared. A recorded run uploads a task’s path sets for its weak key, as pathsets/<weak>, and its result under the key of what it read, beside its result under its declared key. A task whose local path sets all miss fetches the path sets the store holds for its weak key, adds the new ones to its own, and fetches a result for any of their keys the store’s index lists. A machine that never records, such as one on macOS, is keyed by the recordings CI made.

A local hit makes no request. A local miss makes one for the path sets, and fetches only results the store holds. The newest upload of a weak key’s path sets replaces the last, after merging what the store held when the task was looked up; two machines recording one weak key at once can lose one’s path set, which costs a miss, never a wrong result.

Trying it out

"qk:ultracache": { "mode": "record" }

With "record", a task is keyed by its declared inputs, as without the field, while a run with --record records it and keeps and shares its recordings as with "on". Every run also looks the task up by its recordings without restoring anything, and says what they would have done: --report writes it as shadow, wouldHit with the key that had a result, wouldMiss, or unrecorded without path sets, and the run history keeps it, so runs can be counted afterwards: a task its declared inputs missed with wouldHit is a run its recordings would have saved. The run summary names the tasks where the two disagree:

  • a task its declared inputs missed where its recordings had a result: a change it never read, which "on" would have taken from the cache;
  • a task its declared inputs took from the cache where its recordings had none: a change to what it read that its declared inputs leave out, a hit that may be stale, or a recorded result never shared.

Two more things tell whether "on" would be right:

  • Whether a recorded result is the one a run gives. A task that runs where its recordings had a result is compared with that result: shadow carries check, with matched true when the task succeeded with the same output files, and otherwise the differences, such as failed (exit 1) or dist/main.js (differs). Its terminal output is not compared, as timings and the like change from run to run. The run summary names each task that gave another result: a recording that left out something the task read, or a task that does not give the same result twice.
  • What changed that the declared inputs did not see. Where the declared inputs took a task from the cache and its recordings had no result, shadow carries changed: the paths of its newest path set that are not as they were when it was recorded, with what the task did to each, such as package.json (read), and declared where the declared inputs cover the path. A change they do not cover either matters, so their hits may be stale, or does not, and ignoreReads keeps the recording from keying it. For a path set recorded by a qk that did not keep how its paths were, uncovered names instead what it holds that the declared inputs do not cover, one of which changed.

The recordings also show what a task read beyond its declared inputs at all: QK_RECORD=audit holds each task that runs against its declarations, as --sandbox does, from the recording it already made. See Auditing recorded runs.

Looking a task up by its recordings costs the request for its path sets whenever it has none of its own, even when its declared inputs hit the local cache: that is where a stale hit would show. Switching to "on" keys it by them.

Running the tests

tools/ultracache/fixture is a workspace whose build reads, lists and looks for files, and crates/qk-cli/tests/ultracache.rs follows it through edits. The tests need Linux and strace; tools/ultracache/podman.sh runs them, or any other cargo test arguments, in a Linux container from another system. With QK_RECORDER=ebpf it runs them with the eBPF recorder, in a container with the capabilities it needs.

The kernel program is crates/qk-trace/bpf/trace.bpf.c, and the objects built from it are committed beside it, so that building qk needs no BPF toolchain. After changing it, rebuild them with tools/trace/build.sh, which needs Podman; CI checks that they match.

Sharing cores between tasks

--parallel bounds how many tasks run; tools such as Vitest and Jest then start a worker per core each, so tasks running side by side oversubscribe the machine. qk also keeps a core budget, --cores (or QK_CORES, nx.json qk:cores, else the cores available), and a target with qk:threads is given a share of it:

"build": {
  "qk:threads": {
    "env": { "CARGO_BUILD_JOBS": "{threads}", "RAYON_NUM_THREADS": "{threads}" },
    "min": 2,
    "max": 8
  }
},
"test": { "qk:threads": true }

The task gets its share as QK_THREADS and through each env variable naming {threads}, times its scale if it has one (see below); "qk:threads": true sets only QK_THREADS. A tool without such a variable reads it in its configuration, as Vitest’s maxWorkers: Number(process.env.QK_THREADS) || undefined does. Under Nx neither is set, so the tool keeps its default there. Every other task holds one core. Continuous tasks are outside the core budget: qk:threads does not allocate a share or set these environment variables for them.

A share is fixed when the task starts, since a tool cannot give up workers it has started. Threaded tasks without an expectation, from earlier runs, split the free cores evenly, after a core for each task slot other pending tasks could take, so two get half each and three a third, and one starting alone leaves room for what follows.

A task’s expectation is its time in its recent runs, those that executed it, on the cores it held then: from local history, which lives beside the cache, or for a task local history lacks, from the remote store, which shares what other machines ran. A task with an expectation is given the part of the cores that its work is of the work that can run beside it: its own, and the largest of what the other task slots can hold of the tasks still to finish, leaving out those that depend on it, which wait for it to end. A running task counts what it has still to do, its expected core-milliseconds less what it has done on the cores it holds. A short suite thus takes a few cores, finishes, and leaves the rest free for a long one still waiting for its dependencies, rather than holding an even split of them when the long one starts; and a long task is given most of the cores, so that it does not keep running once the rest is done. Either share leaves a core for each other task slot, including tasks without history, and the minimum of other ready threaded tasks when the budget can accommodate them.

Cores come back as tasks finish and go to the next to start. A share stays within min (default 1) and max (default the budget); a task waits until min cores are free, unless nothing else runs. The thread count is not part of the cache key, and neither is qk:threads. The summary lists what each threaded task was given, and qk show task records it. When none of the threaded tasks that ran had an expectation, the summary says so below them: every share was then an even split, as on a CI job with neither local history nor a remote store to read one from.

Threads that wait

A share counts cores, one thread to each. Test runners’ workers spend much of their time waiting, on the file system, a database or timers, so one worker per core leaves the cores idle. scale tells the task to start more threads than the cores it holds:

"test": {
  "qk:threads": {
    "scale": 2,
    "env": { "VITEST_MAX_WORKERS": "{threads}" }
  }
}

The task holds its share of the budget as without scale, and is told ceil(share × scale) threads: a task holding 3 cores starts 6 workers, and one running alone on 16 starts 32. Rounding up adds less than one thread per task, so the threads every task is told stay within scale times the budget plus one for each task running: eight tasks holding a core each with scale: 1.1 are told 2 threads each. A test’s wall-clock budgets, such as a waitFor or a test timeout, are no less trustworthy than that bound makes them, however many tasks run at once. Keep scale to targets whose threads wait; for a compiler, whose threads keep their cores busy, it only adds contention. Vitest reads VITEST_MAX_WORKERS itself, and a variable a script sets for Nx keeps applying there, since qk sets nothing under Nx.

How busy a task kept the cores

While a task runs, qk samples the CPU time of its processes, as it samples their memory. The summary follows each threaded task’s threads with the cores it kept busy on average, app:test 6 on 3 (2.4 busy), and qk show task says it, with the cores it held, for a task that ran for two seconds or more and whose processes were sampled using CPU time: one whose processes all started and exited between samples has no figure. A task busy on fewer cores than it held is waiting, and can take a higher scale; one busy on more is starting threads it was not told about, such as a runtime’s own, and holds less than it uses. A process that starts and exits between two samples, a quarter of a second apart, is not counted, so the figure is a lower bound, close for a task whose work runs in processes that last.

Checking inputs and outputs in a sandbox

--sandbox checks that each task declares what it reads and writes in the workspace. On macOS it runs the task under Seatbelt, through sandbox-exec. On Linux, audit records the task, as Ultracache does, and enforce runs it under Landlock. qk is not a hermetic build system: outside the workspace everything stays open, including system files, the home directory, temporary files and tool caches.

Inside the workspace, a task may:

  • read the files its cache key holds whole, such as its declared inputs, its project’s package.json and the root .gitignore, any node_modules, its own outputs, its dependencies’ outputs and its warm paths, though audit reports reading an installed package its key leaves out;
  • list any directory;
  • write its outputs and warm paths.

A file the key holds only by what it changes or resolves to is not one of them, unless the task’s inputs select it: nx.json, the file it extends and nx.local.json, the task’s project’s project.json and project.local.json, a pnpm lockfile qk reads, pnpm-workspace.yaml, and the root package.json outside the root project. A tool that reads one, such as a package manager running a script or a nested qk, could act on what the key leaves out, so the task declares the file: as a file input, which keys it whole, or as a json input selecting the fields the tool reads. default’s {projectRoot}/**/* selects the project’s own project.json and project.local.json, and a root project’s nx.json.

Structured JSON inputs permit reading their declared JSON file, including its symlink target. Declared directory symlinks permit reading the whole linked tree and its nested link targets, matching the content in the cache key. These read permissions do not allow writing input files.

A directory whose every tracked file is an input is allowed whole, so untracked files in it can be read too, unless it holds one of the configuration files above that the task does not declare, such as an ignored project.local.json: Landlock cannot allow a directory less a file in it, so its inputs are then allowed one by one.

Audit

--sandbox (or --sandbox=audit) lets a task do everything, and reports what went beyond its declarations:

With a build target that declares src as input and dist as output, but also reads other/notes.txt and .env and writes stray.txt:

$ qk run-many -t build,clean --sandbox
…
Sandbox audit: 1 of 2 tasks went beyond what they declare
  app:build
    1 read outside its inputs:
      other/notes.txt
    1 written outside its outputs:
      stray.txt
  read, which the sandbox allows, but not in their keys: .env (1 task)

For each task it lists the files read outside its inputs, the TypeScript configuration (tsconfig*.json) it read outside them, which is likely an input, and what it wrote outside its outputs. The count of tasks that went beyond what they declare leaves out what enforce allows: the .git and dotenv files tasks read, what they wrote in .git, and the installed packages their keys leave out, listed below. The .git and dotenv files are not listed for each task either: the summary ends with each, once, and how many tasks read it, then how many wrote in .git, and --sandbox-report lists them for each task, as unkeyedReads and among strayWrites. A task that reads one its inputs do not declare can be restored from the cache unchanged when it differs, as one stamping the commit it was built from. A file it writes there and reads, such as a version stamp it keeps, is listed once, as written: declaring it an output covers both. Creating the directory that holds a declared output is not listed: a tool writing dist/app.js creates dist. Another file written there, such as dist/other.txt, still is. The directories an output lies in that do not exist yet are created as the task starts, in audit as in enforce, and removed again if the task leaves them empty: a tool that makes one by renaming a directory it built beside it, as cargo makes target for an output in target/release, then finds it there rather than writing outside its outputs. The output itself is not created, as its path may name a file, except by enforce on Linux, which takes a missing output without an extension for a directory (see Linux). Reads of workspace packages’ package.json, which package managers and bundlers make to resolve them, and of version and package managers’ configuration, such as .tool-versions, are counted once at the top rather than listed for each task; --sandbox-report lists them as manifestReads and toolchainReads. Other paths read by many tasks, at least three and at least a quarter of those that went beyond what they declare, are listed once at the top, under read by many tasks, likely by the tools their commands start through.

Files read in a project the task’s project depends on, directly or not, are grouped by that project, as dependencyReads in --sandbox-report, with how many each in the summary. They are what running a dependency’s code, or a tool following imports into it, reads. Inputs prefixed with ^, such as ^production, key them. Whether one changes the task’s result depends on the tool: a test running a dependency’s code depends on it, while a lint rule that only follows imports to look for a cycle through the linted file cannot find one through a project that cannot import back.

A task whose dependencies’ files cannot change its result names them in qk:ultracache.ignoreReads (see ignored reads) with a leading ^, the glob after it matched from the root of each project its project depends on, directly or not: ^** matches every file in them, ^src/** their sources. A file in a project nested in a dependency is that project’s, and matches only if the task depends on it too.

Installed packages outside the key

With a pnpm v9 lockfile qk reads, a task’s key counts the packages its importers install, and those its externalDependencies name. A task that names its external dependencies, even none, leaves the root importer out (see the cache), so a tool only the root package.json installs, such as a linter, is in its key only if the task names it. Audit reports reading an installed package the key leaves out, by name:

$ qk run-many -t lint --sandbox
…
Sandbox audit: 3 tasks kept to what they declare
  app:lint
    1 installed package read, which the sandbox allows, but not in its key; name it in its externalDependencies:
      fallow

Enforce allows the read, so it does not count the task as going beyond what it declares, but no key holds the package: name it in the task’s externalDependencies input, beside any it names already; its installations and everything they reach then count. A package read by many tasks, as for paths, is listed once at the top with how many tasks read it.

A task without externalDependencies keys every package its importers install and everything those depend on. A package it still reads outside that set is no dependency of any package its importers install: some package imports it without declaring it, and finds it through pnpm’s hoisting, which links every package installed anywhere in the workspace where any of them can resolve it. What the task reads then changes with what another project installs. Audit lists such a package apart, as hoistedPackages in --sandbox-report:

  app:build
    1 installed package read that nothing its importers install depends on, which the sandbox allows, found through pnpm's hoisting; declare it as a dependency of the package importing it:
      phantom

Naming it in externalDependencies would key it, but in place of every other installed package. Declaring it as a dependency of the package importing it, with pnpm’s packageExtensions, keys it with the rest. Narrowing pnpm’s hoist-pattern instead makes the import fail rather than find it, which shows where it is missing but keys nothing.

Audit finds a package by a read of its package.json, which Node and bundlers make for every package they load, rather than by each of the thousands of files a tool may read: reports of those would crowd the system log, which drops reports when many arrive at once. A tool reading a package without its package.json goes unreported; input analysis, which records every read, finds it.

A read is placed by the directory of pnpm’s store it lies in, node_modules/.pnpm/<directory>, which pnpm names after the installation’s snapshot key in the lockfile; a read through an importer’s node_modules/<name> link counts as a read of the directory it points to. What lies elsewhere in node_modules, such as .bin, .modules.yaml or a tool’s .cache, and a store directory no snapshot in the lockfile names, such as one left from an earlier install, belong to no package and are never reported. Without a pnpm lockfile qk reads, with one a task’s inputs select, or with pnpm’s node-linker=hoisted, which has no such store, nothing is reported.

Enforce

--sandbox=enforce refuses those reads instead, so a task that depends on what it does not declare fails, and only its declared outputs can change in the workspace. A write outside them is refused as it happens on macOS, and on Linux is discarded when the task ends, and listed (see Linux). Reading and writing .git and reading dotenv files stay allowed, and so do reading and writing qk’s own state for the worktree, where warm state lives.

Reading an installed package the key leaves out stays allowed too, and is not listed: Landlock grants a directory with everything beneath it, so on Linux a store directory could not be refused while node_modules stays open for what tools create there as they run, and enforce would refuse on macOS what it lets through on Linux. Audit, or input analysis, finds such reads.

Scratch directories and ignored reads

A tool may keep a cache in the project that nothing declares, as a linter’s results by file. Declared as warm state, qk keeps and restores it, which is safe only for a tool that checks each entry against what it read. For any other, name the directory as scratch on its target:

"lint": { "qk:sandbox": { "scratch": ["{projectRoot}/.tool-cache"] } }

qk empties a scratch directory before the task runs, with or without --sandbox, so nothing an earlier run left there can change a result, whatever the tool does with it. The cache neither keeps nor restores it, a recording for Ultracache leaves it out, and the sandbox lets the task read and write it, in audit and enforce alike. A scratch directory lies in the workspace, outside .git, .qk and node_modules however their names are cased, holds no project and neither qk’s cache nor its state, and neither is nor lies under a symbolic link, which emptying it could follow out of the workspace; qk doctor reports one that breaks these rules. The links are checked again as each directory is emptied, in case an earlier task made one. Two tasks of a run cannot share a scratch directory, nor have one inside the other’s, since each empties it before it runs: such a run fails before any task starts.

A file a task reads that cannot change its result, as a tool reading the tsconfig only to print a hint, is named in its qk:ultracache.ignoreReads, whether or not the task is recorded. That it cannot is a claim qk cannot check, which a scratch directory does not need. The globs that leave it out of recordings also leave it out of the audit’s findings, and enforce lets the task read the files they match, Git-ignored ones included.

Landlock and Seatbelt hold paths, so enforce allows the files that exist as the run starts, found by walking each glob from the directory its leading literal parts name. A glob ending in /**, as **/.cache/**, allows each directory it names whole, so a profile stays small however many files the directories hold, and a file made in one during the run may be read too. Any other glob, or one with a negated group such as !(private), which can leave out a path deeper down, allows the files it matches one by one. A path named whole, with no pattern in it, is allowed as the task starts though it did not exist as the run started, so a file a task it depends on makes, as code it generates, may be read. The walk never enters .git, .qk or node_modules, and a directory holding one of them is not allowed whole: the directories beside it are. A symbolic link is left out, since allowing it would allow whatever it leads to. Seatbelt matches the path a link resolves to, so a link put in place of one of these during the run reaches only what is allowed anyway. Landlock follows links as it allows a path, so on Linux each is allowed only while no link stands anywhere on the way to it from the workspace root: one put there by an earlier task of the run gets no rule.

A glob that covers a nested workspace, a directory with a pnpm-lock.yaml importer of its own such as a project’s end-to-end tests, covers what it installs too. Reads of those packages reach their pnpm store directory, not a path under the ignored directory, so the audit leaves out a read of a package that the nested workspace installs, directly or not, unless the task’s key covers it. The same goes for the workspace projects it links to, and what they install, unless the task’s key counts them: enforce lets the task read their manifests, and the audit leaves those reads out. The root importer, and an importer the task’s key counts, is never covered this way.

Notes

  • A sandboxed run keeps the cache. A task restored from it did not run, so it is not checked; the summary counts it apart, and --sandbox-report lists none of its findings. --skip-cache runs every task to check it. Enforce caches what a task produced held to its declarations, which a run without the sandbox then restores, as it restores any result of the same key.
  • --sandbox-report <path> writes every finding as JSON.
  • On macOS, qk reads the sandbox’s reports from the system log while the run lasts; each report is tagged with its task. The log can drop reports when many arrive at once, so audit lists what was reported rather than proving nothing else happened. A task that makes more than 100000 reports, as a package manager installing thousands of files outside its outputs, is listed as not audited, with how many it made, and the other tasks keep theirs. On Linux, audit’s reports come from each task’s recording, and a recording that lost events or could not be decoded leaves its task listed as not audited.
  • A task the sandbox cannot be built for, as one whose inputs cannot be resolved, one whose profile Seatbelt rejects on macOS, or one whose rules Landlock cannot take on Linux, runs unsandboxed under audit and is listed under unsandboxed with the reason. Enforce does not run it unconfined: it fails, is listed under refused in --sandbox-report, and says to set "qk:sandbox": false on its target to run it unsandboxed. A task taken from the cache runs nothing, so it is not refused.
  • On macOS, Seatbelt caps the paths a profile with reports may name at 64 KiB together. A task’s profile names the shorter list of pnpm’s store directories, those to report or those not to, which fits stores of a few thousand directories. A task whose profile still does not fit is retried without reporting installed packages: if Seatbelt takes that profile, the task runs sandboxed and is listed under packagesUnchecked in --sandbox-report; if not, it is handled as above. Only audit profiles carry reports, so the cap is an audit’s: an enforce profile can name far more. Only tasks that started are listed.
  • On macOS, Seatbelt refuses to apply a sandbox inside one that restricts anything, as every task’s profile does. A task that applies its own, as Xcode does to run Swift macros, fails with sandbox_apply: Operation not permitted. Audit runs it again unsandboxed and lists it with the reason, without the findings of its first attempt, which ended early; enforce leaves it failed. "qk:sandbox": false on its target runs it unsandboxed from the start, in either mode. For Swift, -disable-sandbox in its Swift flags instead leaves out only the compiler’s own sandbox, so the task can be checked.
  • On Windows, qk has no sandbox.

Linux

Audit records each task with the recorder Ultracache uses, eBPF where it loads and strace otherwise, as QK_RECORDER picks, and reports what the recording shows the task did beyond what it declares. A read is a file the task opened for reading, and a write one it opened for writing, created, renamed or removed. A task whose recording is incomplete is listed as unaudited, with why, rather than with part of its findings.

Enforce runs each of a task’s commands in a user and mount namespace of its own, over an overlay of the workspace, as ADR 0002 lays out. What a command writes in the workspace lands in an upper layer of the overlay, which qk reads as the task ends: what lies within its outputs, warm paths and scratch directories, written, created or removed, is applied to the workspace before the cache stores its outputs and before any task depending on it starts; anything else is listed as written outside its outputs and not kept. Nothing is applied through a symbolic link in the workspace: a directory a command made where another left a link replaces the link. Writes are judged by what the task leaves, not as they happen: a tool may write a temporary file anywhere, or make a directory beside its output and rename it into place. Commands run one after another share an upper layer, and ones run side by side each have their own. A continuous task’s writes are applied when it stops. .git, qk’s state, every node_modules and the task’s scratch directories are bound through the overlay, so what a task writes there reaches the workspace at once, as without enforce. Reads are held by Landlock, built in each command’s namespace once the overlay is mounted; a refused read fails the task with Permission denied.

The overlay needs unprivileged user namespaces, which Linux 5.11 and later offer to any process where the system allows them; some distributions keep them from unprivileged processes, as Ubuntu does through AppArmor from 24.04. Where a command cannot run over an overlay, enforce holds tasks by Landlock alone, in Linux 5.13 and later, and the summary says why; so does QK_SANDBOX_OVERLAY=0. Landlock refuses a write outside the outputs as it happens, failing the task.

An overlay keeps a task from seeing what another task running beside it writes until that task ends. A directory another task replaces whole while it runs, as a restore from the cache does, may still look to it as it was if it looked at it before; a task does not depend on what a task running beside it writes, so this matters only for what it reads undeclared. Renaming a directory that was there as the task started, rather than one it made, fails with Invalid cross-device link under an unprivileged overlay; mv copies instead, and a tool calling rename must too.

Auditing recorded runs

With --record, --sandbox audits a task the cache records from that recording rather than recording it twice. QK_RECORD=audit does the same as --record --sandbox, for CI, which sets it once for every run:

$ QK_RECORD=audit qk run-many -t build
…
Sandbox audit: 1 of 4 tasks went beyond what they declare
  app:build
    1 read outside its inputs:
      other/notes.txt
  12 tasks restored from the cache, so not audited

Where no recorder works, such a run goes on unrecorded and unaudited, and says so, when QK_RECORDER is unset or auto. A QK_RECORDER naming a recorder that cannot record here, or naming none, fails the run.

A task without qk:ultracache is recorded for the audit alone, as without --record. Only --record saves what a recording read for the cache: without it, an audit’s recordings save nothing, though recordings saved before still key results. An audit costs reading each recorded task’s trace a second time, as the audit reads installed packages’ manifests that a path set leaves out. --report gives each task audited its findings as audit, by the kinds --sandbox-report names, those with nothing left out: {} for a task that kept to its declarations. A task not audited has no audit, and the run history keeps it with the rest of the task.

Enforce cannot be combined with --record yet.

Landlock holds paths that exist, so each task’s rules are built as it starts, after its dependencies have written their outputs. Held by Landlock alone, an output that does not exist yet and has no extension is taken for a directory and created then too, and removed again if the task leaves it empty, and a file output that does not exist yet cannot be created. A task may write beneath its outputs, but not remove or rename an output directory itself, which would need rights over its parent: a tool should clear an output directory rather than delete and recreate it. Outside the workspace, Landlock allows what exists when the task starts; an entry created later directly in a directory above the workspace, such as a new file in the home directory when the workspace is below it, is refused.

Recording task inputs

qk inputs analyze web:build --report inputs.json --suggestions inputs-review.json
qk inputs analyze web:build --report next.json --previous inputs.json
qk inputs analyze web:build -c production --report production.json -- --verbose

inputs analyze executes a finite task and its task dependencies, records file accesses by their commands and descendants, and compares those accesses with qk’s effective file-input coverage. The terminal summary goes to stderr; task output keeps its usual streams. --report writes a versioned input-analysis JSON report instead of the normal run report. Without it, analysis prints only the summary. Paths to reports are relative to the invocation directory.

Analysis bypasses result-cache reads and writes, remote caching, and warm-state restoration/publication for every task in the graph. Existing output and warm files remain on disk: this is not a clean build. Use a clean checkout when you want to observe a tool’s cold behavior. Normal run history still records the run.

Reading the report

Each task retains its arguments, configuration, outcome, backend and coverage limitations. Events contain a process ID, operation, path, result and category. Workspace paths are relative; recorded external paths are absolute. Accesses retain descriptor targets as resolvedPath when available. Declared symlinks captured before execution can identify a covering input as keyedPath, including nested links in declared directory inputs and structured JSON links. Target definitions are represented by a digest, not their environment values. Reports contain filenames and forwarded arguments, so review them before sharing.

Categories distinguish keyed source files, mandatory workspace/package configuration, structured JSON inputs, dependency outputs, own outputs, warm state, installed packages, resolution metadata, Git state, discovery and external accesses. Discovery includes directory accesses and missing-path checks; these deserve review independently of content inputs.

A content read in node_modules is unkeyedPackage, with the package named as package, when it lies in an installation the task’s key leaves out, as the sandbox places it: a task naming its externalDependencies, even none, keys only what its projects’ importers install and the packages it names, so a tool only the root installs must be named. unkeyedPackages lists each such package with how many of its files were read, and the terminal names them with the fix: add each to the target’s externalDependencies. A task without an externalDependencies input keys every package its importers install and what those depend on, so a package it still leaves out is one nothing they install depends on, found through pnpm’s hoisting; namesExternalDependencies tells the two apart, and the terminal says to declare such a package as a dependency of the package importing it. Other node_modules accesses are installedPackage: in an installation the key covers, outside pnpm’s store, or in a store directory no lockfile snapshot names.

uncoveredAccesses lists workspace paths accessed outside those categories. observedInputs lists keyed file inputs accessed in this execution. Successful recordings contribute to successfulObservations; unobservedInputs lists current declared files absent from that union. Mandatory configuration files are excluded from narrowing suggestions because qk keys them regardless of the target’s filesets.

candidateFilesets contains exact observed file patterns. Suggestions replace only broad {projectRoot}/**/* and {projectRoot}/** filesets. Existing specific filesets and named references that do not contain broad project filesets stay intact, even when some of their files were not observed. A task already using specific inputs receives a note that no replacement is suggested.

For broad project filesets, globSuggestions groups active top-level source trees into recursive patterns such as {projectRoot}/src/**/*.{ts,tsx}. The extension set includes all currently declared file types in that tree, retaining files that a partial trace missed. Successful directory enumeration retains whole trees, such as {projectRoot}/public/**/*, so new assets and nested directories stay covered. Directory evidence is unioned across compatible successful recordings. Root files remain literal or are grouped by extension. Each glob reports its observed count, additional declared files retained, and the reason for the scope. These are review suggestions; discovery at the project root, empty directories, absent-path probes and new file types outside enumerated trees need further review.

configurationFragment is a review-only project.json fragment replacing the target’s inputs. Only named inputs containing broad project filesets are expanded. Specific file patterns, other named references, exclusions, environment, runtime, structured JSON, working-directory, package and dependency declarations remain in their original order. Mandatory configuration files stay keyed by qk. No fragment is produced without usable successful observations or when the existing declarations need no replacement. Earlier version 1 reports remain compatible; their successful directory events can still contribute evidence.

accessReview separates uncovered potential content reads, workspace package manifest reads, toolchain configuration reads, TypeScript configuration reads (tsconfig*.json, likely inputs), and metadata-only checks. The sandbox’s audit groups reads the same way. These groups help distinguish likely source dependencies from package-manager scans; none proves an access is harmless. Raw events and uncoveredAccesses remain available in full. The terminal shows content reads first and limits examples from the other groups.

The terminal prints the fragment. --suggestions inputs-review.json exports fragments grouped by task ID, with a reviews entry for every task, including no-change notes, uncovered-access groups and unkeyed packages; this wrapper is not itself a project configuration. Copy a reviewed fragment into the corresponding project’s configuration. Suggestions apply to the whole target, even when the recording used -c production: validate every configuration before accepting them. Expanding broad named inputs also means future edits to those expanded definitions will no longer flow through the copied declaration. Specific named references stay live. Review uncovered accesses and directory or missing-file discovery separately; they are not automatically added. Evaluated environment values are excluded, but input declarations can contain runtime commands and reports retain arguments and paths, so review exports before sharing. No configuration or cache key is changed automatically.

Not observed does not mean unnecessary. A tool can use other inputs with different arguments, environment values, configurations, file existence or warm state. Recording coverage is partial on both platforms. Validate proposed changes across representative runs and clean builds.

Combining recordings

Repeat --previous <path> to union observations from several reports. Only compatible task definitions, effective input configuration, arguments and platforms are merged. Run IDs prevent counting an observation twice. Failed, cancelled, skipped or interrupted recordings do not add evidence for narrowing; their access events remain useful for diagnosing missing declarations. A union can itself be saved and passed to a later run.

Backends and limits

On macOS, qk uses Seatbelt through sandbox-exec and reads its reports from the system log. It probes the log before starting tasks. Workspace file-access authorization checks are observed, including subprocesses; external files and syscall results are not. Failed lookups may never produce an event. The log can coalesce or lose events, and directory reads do not establish an exact listing. Directory type is inspected after execution, so renamed or removed directories can be ambiguous. A machine that does not expose these logs refuses analysis before task execution. Symlink accesses can be reported by target path rather than link path.

On Linux, qk records with eBPF where it can load its recorder and with strace on PATH otherwise, as Ultracache’s recorders describes, and QK_RECORDER picks one. qk checks it can record before starting tasks. The backend records path operations and directory enumeration, follows descendants, tracks working-directory changes, and records syscall results such as ENOENT. Readable opens are potential content dependencies; write-capable opens do not prove a write, and the recorder does not inspect read buffers. Symlink targets, inherited descriptors, io_uring, external services and processes outside the task tree are not fully reconstructed. Undecodable or truncated records are reported as collection issues.

Neither backend traces qk’s own command preparation, dotenv loading or runtime input evaluation. Raw Linux trace files are temporary and removed after collection. Read/write buffers and exec argument/environment arrays are excluded from tracing.

--dry-run, --graph, --sandbox, continuous tasks and tasks with readyWhen are not supported by analysis. A run with a failed task exits with 1, as any run does, and still writes a report. A cancelled run exits as the signal that cancelled it, 130 for Ctrl-C. Collection issues appear in the report and prevent that execution contributing narrowing evidence. Windows has no recorder backend.

Run history and reports

qk show runs
qk show run --json
qk show task web:build
qk run web:build --report run.json

Every run is recorded in a SQLite database beside the cache (<git common dir>/qk/history.db, or .qk/history.db outside Git), so linked worktrees share it. Each run records the worktree it ran in: qk show runs, qk show run without an id, and qk show flaky read the current worktree’s runs, and --all-worktrees reads every worktree’s, naming each run’s. Runs recorded before qk kept the worktree appear only with --all-worktrees. Each task’s record holds its status, cache result (local-hit, remote-hit, miss or uncached), key, timing and cause: what differs from the previous key recorded for the task, grouped as files (with the paths added, removed and changed), configuration (the files qk keys for every task, such as .gitignore and the task’s own package.json, listed with files, the nx.json settings every key holds, by key, what the key holds of the task’s project’s configuration, such as its root and output paths, and dependency manifests and pnpm-workspace.yaml as dependents read them), env, runtime, dependencies (the dependency outputs dependentTasksOutputFiles reads, by task and pattern), lockfile (with the importers and packages), inputs, definition (with each field that differs and both of its values), args and tooling, or first, unchanged and unknown when the previous inputs are no longer kept. For targets with warm state it also holds, for each entry that restored any, its group when it has one, where that state came from (local, worktree <root> or remote <branch>) and how many files and bytes were restored; and, once for the task, which groups were on disk already and how long saving took, or that saving happened in the background. The run summary names the tasks that started from warm state, and qk show task compares how long the task took from warm state with how long it took without, over its successful runs that executed. A task that executed also records the most memory it used: that of its commands and every process under them, summed, sampled every 250 ms while it runs, so a task too brief to be sampled records none. On macOS a process’s memory is its physical footprint, the figure Activity Monitor shows, and a task’s peak is at least the most its largest process used in its life, which the system keeps, so a spike between samples is not missed; elsewhere it is its resident memory. The scheduler waits for memory from these peaks. The run summary gives the most the run’s tasks used together and the three tasks that used the most, and qk show task what the task used in each run. The newest 200 runs are kept. The schema is versioned in schema_version.

qk show hash <task> --against <run-id> compares the key a task would have now with its key in a recorded run, without running it; see Key inspection.

--report <path> (or NX_RUN_REPORT) on run, run-many and affected writes the run as JSON: the command, commit, exit code, each task with its record, and the critical path, the dependency chain with the longest total duration. qk show run --json prints the same for a recorded run.

$ qk show task web:build
web:build, most recent first:
  run 1790687500772-75771  2 min ago  success  miss  4.1s
      key changed since run 1790687500464-74260: dependencies, files
        changed packages/ui/src/index.ts
        dependency ui:build:**/*.d.ts
      used 2.1 GB of memory at most

Execution logs and mixed outcomes

qk show flaky
qk show flaky web:test --json
qk show log <run-id> web:test

Successful and failed cacheable executions retain stdout and stderr separately, with their observed chunk order preserved. show log replays each chunk to its original stream. Cache hits replay a previous success and do not create another execution log. Otherwise cacheable tasks run with --skip-nx-cache (or its aliases or NX_SKIP_NX_CACHE=true) retain their keys and execution logs while always executing, without restoring or publishing results or warm state. Finite tasks that run without the cache, under inputs analyze, or with the cases qk affected narrowed them to, retain their execution logs too, cacheable or not, within the same budget, as do uncacheable tasks run under --sandbox. Their inputs are not rechecked, so show flaky does not count them. Otherwise, uncacheable tasks and tasks whose inputs cannot be keyed do not retain execution logs. Failed executions never publish reusable cache entries.

show flaky [task] lists task keys with both successful and failed executions in retained history. --limit defaults to 20 groups; --json includes the exact run IDs, outcomes and whether each log is still available. It excludes cache hits, cancelled executions and executions whose declared inputs changed while running. These are mixed outcomes for identical declared inputs, which can also indicate undeclared inputs, network dependencies or changing external state. To collect more actual executions while keeping existing results, run the task repeatedly with --skip-nx-cache, then use qk show flaky and qk show log to inspect the outcomes. qk reset --only-cache also clears result entries while keeping history.

Each log retains at most its first 4 MiB of framed output and reports truncation when replayed. History keeps at most 64 MiB of log payload across linked worktrees, evicting older payloads while keeping their outcome observations. The normal 200-run retention also removes those runs’ logs and observations. These limits apply to retained payload, not SQLite metadata, free pages or result-cache logs. Logs stay local in history.db; remote result caching is unchanged. Logs can contain anything the command prints, including credentials.

Schema version 4 adds execution observations and logs. Older histories upgrade in place; existing runs remain readable but have no execution logs and are not included in mixed-outcome detection.

Schema version 5 adds the memory each task used. Older histories upgrade in place, and runs recorded before have none.

Schema version 6 adds the worktree each run ran in. Older histories upgrade in place, and runs recorded before have none. Linked worktrees share the history, so once one worktree’s qk upgrades it, a qk that reads only an earlier version cannot read or record runs in any of them.

CLI reference

CommandResult
qk inputs analyze <task> [--report <path>] [--previous <path>] [--suggestions <path>]Execute tasks without caching, record file accesses and review input coverage; macOS, or Linux with eBPF or strace. See Recording task inputs
qk show projects [--json]Project names in Nx graph order, one per line or as a compact JSON array
qk show projects -p 'web,tag:library' --exclude 'experimental-*'Matching names, globs, tags and roots, minus exclusions
qk show projects --with-target e2e --type app [--sep ,]Projects with one of the targets, of a type (app, lib or e2e, as Nx types them), joined by a separator
qk show projects --affected -t build,exportProjects owning an affected task of one of the targets, or under project selection the affected projects with one of them, as qk affected selects them
qk show projects --affected --affected-targets build,exportAffected projects where a task of one of the targets is affected, as task selection decides it
qk show project [name] [--json]A project’s graph node, as Nx’s summary or one line of JSON; the current directory’s project by default, and a name that is no project matches as a pattern, as in Nx
qk graph --file <path>.jsonWorkspace project graph in a { "graph": { "nodes": ..., "dependencies": ... } } envelope
qk graph, qk graph --file stdout or --file -The same graph on stdout
qk graph --externalThe graph with the packages the pnpm lockfile installs as externalNodes
qk graph --focus web --exclude 'tag:e2e'Only web, what it depends on and what depends on it, minus exclusions
qk run <project>:<target>[:<configuration>], qk run -p <project> -t <target>Execute a task and its dependencies
qk <target> [project], qk <project>:<target>, qk <project>Nx-style shorthand for qk run
qk run-many -t build,test -p 'web,core' --parallel 4Execute matching targets with bounded task concurrency
qk exec -p web -- <command>Run a command in projects, or as a package script’s target, as nx exec does
qk run web:build --dry-runPrepare every planned task without running it, and print the task graph as JSON
qk show projects --affected [--base <rev>] [--head <rev>]Projects affected by the changes, in graph order
qk affected -t build,test [--base <rev>] [--head <rev>]Execute the affected tasks of the targets, and the tasks they depend on
qk show affected [project] [--json]Why projects are affected, or why one project is, and the granularity qk affected selects by
qk affected -t build --granularity projectExecute the targets of the affected projects, as affected.legacy does
qk show tasks -t build,test --affected [--json]The affected tasks and why
qk show runs, qk show run [id]Recent runs; one run’s tasks, cache results and critical path
qk show flaky [task]Mixed successful/failed executions for identical declared inputs
qk show log <run> <task>Replay retained stdout/stderr from an actual cacheable execution
qk show task <project:target>A task’s recent runs and why its cache key changed
qk show hash <project:target> [--against <task|run>]A task’s cache key and what it covers, or where it differs from another key, without running the task
qk cache pathPrint the local cache directory without creating it
qk cache prune [--max-size 1GB]Evict entries unused for a week, then least recently used entries until the cache fits
qk warm suggest <project:target>Run a task in the sandbox and list directories it wrote outside its outputs, as candidate warm paths (macOS)
qk reset [--only-cache | --only-workspace-data]Remove the cache and this worktree’s state, as nx reset does

As in Nx, qk build web and qk web:build mean qk run web:build, and take the same options. qk run reads its task as Nx’s run does: a project:target[:configuration], else -t/--target (for a target whose name has a colon) with -p/--project or the positional naming the project, else a project with a run target, which qk tool and qk run tool run, else a target. The project may be a name or a pattern matching exactly one project; a pattern matching several fails with Nx’s “Multiple projects matched”. As in Nx, b, e, l, s and t name the build, e2e, lint, serve and test targets. Without a project, qk build and qk run build use the project whose root most specifically contains the current directory, as Nx does: in the root project, NX_DEFAULT_PROJECT picks another, and where no project contains the directory, NX_DEFAULT_PROJECT, then nx.json cli.defaultProjectName, then defaultProject. As in Nx, --base, --head, --files, --uncommitted and --untracked imply --affected for show projects. qk’s own subcommands, and Nx commands qk does not implement such as format and release, are never read as targets; use qk run for a target with one of those names.

As in nx graph, --focus keeps the project and every project connected to it through dependencies in either direction, and --exclude takes project selectors. The kept projects’ edges are unchanged, so they can name projects left out. --print and --file stdout write to stdout. qk has no browser view, so nx graph’s --affected, --view and server options do not apply; for a task graph, use qk run-many -t <targets> --graph.

Options are read as Nx’s yargs configuration reads them. They may be spelled in camelCase as well as kebab-case, so Nx’s --outputStyle works as --output-style. A boolean option takes a value or a negation: --nx-bail=false, --skip-nx-cache true and --no-nx-bail all work, and any value other than true means false. An explicit --nx-bail=false or --verbose=false overrides NX_BAIL and NX_VERBOSE_LOGGING.

On the commands that run tasks, run and its shorthand, run-many and affected, an option qk does not know, and a positional beyond the command’s own, is forwarded to the requested tasks, as Nx’s unknown-options-as-args forwards it: qk test web --coverage runs web:test with --coverage. They are forwarded in order and as spelled, followed by any arguments after --, which are forwarded unchanged. Nx drops the unknown options when a -- is also given; qk keeps them. The other commands reject unknown options.

As in Nx, the hidden affected:build, affected:test, affected:lint and affected:e2e run affected for that target, and affected:graph and print-affected, which Nx removed, fail with Nx’s message.

--workspace <path> works before or after the subcommand. As in Nx, a graph --file path is relative to the workspace root, its directories are created, and it must end in .json; qk has no HTML graph, so a .html file fails. JSON output has no progress messages mixed into stdout. Errors go to stderr and return a nonzero exit code. Unknown commands fail explicitly.

exec

qk exec -- <command> runs a command as nx exec does. Each argument is wrapped in double quotes and the result runs in the shell tasks use, so $NAME expands; NX_PROJECT_NAME and NX_PROJECT_ROOT_PATH name the project.

  • Inside a task, where NX_TASK_TARGET_PROJECT is set, the command runs in the current directory for that project.
  • From a package script, where the package manager sets npm_lifecycle_event, the current directory’s project runs the script’s target instead, with the run options and any arguments beyond the script’s own. That task runs the script again, which then takes the first case, so a script such as "build": "qk exec -- tsc" is cached. Nx runs nx run for it without the run options, and reads the extra arguments as its own options; qk passes the options to the run and the arguments to the task.
  • Otherwise it runs in each project -p and --exclude select, all by default, and in every project they depend on, dependencies first. The first failure stops it. --exclude-task-dependencies runs only the selected projects.

The command is what follows --; as in Nx, other options exec does not know are ignored. A failing command exits with 1, as in Nx. Only the package-script case runs a task, so the others refuse --sandbox, --dry-run and --graph rather than run the command without them.

Shared run options

These options apply to run, run-many and affected:

OptionBehavior / default
-c, --configuration <name>Select named options; otherwise defaultConfiguration
--prodSelect production
--parallel [value]Number, percentage, or false; NX_PARALLEL, then workspace parallel, then 3
--cores <count>Positive core budget; QK_CORES, then qk:cores, then available cores
--skip-cacheBypass all result caching and warm state
--coldRun each task from scratch: restore no result and empty its warm paths rather than restore them, then save both as usual; QK_COLD=true too. See Replacing a bad save
--skip-remote-cacheKeep local caching only
--nx-bailStop starting tasks after the first failure; NX_BAIL=true too
--nx-ignore-cyclesDrop cycle-closing dependency edges with a warning
--exclude-task-dependenciesRun only requested tasks
--graph[=<file>]Write Nx-shaped project/task graph without execution; default stdout
--dry-runPrint qk’s task graph; no dotenv, runtime inputs or executor preflight
--output-style <style>Select output behavior; see Running tasks
--verboseSet NX_VERBOSE_LOGGING=true for tasks, which otherwise see false unless it is set to true; print every task in full under static-failures-only
--sandbox[=audit|enforce]Report (audit) or refuse (enforce) what tasks read and write in the workspace beyond their declarations. A task restored from the cache is not checked; add --skip-cache to check every one. See Checking inputs in a sandbox
--sandbox-report <path>Write every sandbox finding as JSON
--recordRecord what each task with qk:ultracache reads when it executes, and key its result by that; Linux only. With --sandbox, audit what it records. See Keying tasks by what they read and Auditing recorded runs
--report <path>Save JSON run report; defaults to NX_RUN_REPORT when set
--profilePrint cache input and restore phase timings to stderr; see Profiling cache operations
-- <args>Forward arguments to requested tasks, after any unknown options

--parallel alone uses NX_PARALLEL, otherwise 3. Percentages are rounded down with a minimum of one task. --skip-nx-cache and --disable-nx-cache alias --skip-cache, as NX_SKIP_NX_CACHE=true and NX_DISABLE_NX_CACHE=true do; --disable-remote-cache aliases --skip-remote-cache. The corresponding environment flags are documented in Environment variables.

Profiling cache operations

Run qk run web:build --profile (or set QK_PROFILE_CACHE=1) to print lines such as qk profile: task=web:build stage=local_restore ms=3.197. Profiling is disabled by default and reads no clocks while disabled.

Timings cover dependency inputs, declared inputs, runtime commands, remote fetches, local restore phases, restoring and saving warm state, the command itself (execute), the input recheck after it (inputs_recheck) and storing its result (publish). Input resolution also times listing the workspace’s files (inventory_list), waiting for another task’s listing (inventory_wait) and parsing pnpm-lock.yaml (lockfile_parse); each is charged to the task that needed it. A remote fetch also times waiting for the store’s index (remote_index_wait), waiting for the store to respond (remote_response) and receiving the entry (remote_body). From a store server that sends a Server-Timing header, as qk’s does, remote_response adds it as server: the part of the wait the server spent, the rest being the network. The last adds bytes, what was received, held_bytes, how much of the files it carried the local cache held already, apart_bytes, what was received for outputs stored apart, and skipped_bytes, the outputs stored apart that were not fetched because the local cache held them. Fetching outputs stored apart is remote_blobs, with their count, and waiting for another fetch of the same entry is remote_flight_wait. Fetches made ahead of the tasks that need them are timed under the task prefetch, and a run that prefetched ends with task=run stage=remote_prefetch, counting the entries fetched ahead, those claimed by a task, and the bytes received. Runtime lines identify the command; other lines identify the task. Spans can overlap: runtime evaluation and the inventory are part of input resolution, restore phases are part of local restoration, and storing new warm files is part of saving warm state. Do not sum them as exclusive costs. Shared runtime commands are timed only when evaluated, so a task reusing a result has no separate runtime line.

The flag leaves task stdout and cache results unchanged. Timings are diagnostic stderr output, available with every output style, and are not task warnings. Compare repeated runs with the same cache state; a remote fetch, a local restore into missing outputs and a hit with outputs already present do different work.

Change selection

affected, show affected, show tasks, and show projects accept:

OptionBehavior
--base <revision>Base: NX_BASE, then defaultBase, then main
--head <revision>Head: NX_HEAD, otherwise working tree
--affected-profile <name>Opt-in refinement of selection from qk:affectedProfiles: change projections, which apply to project selection and require a committed comparison, and import reachability, which applies to project and task selection and requires the head to be the checkout
--fail-on-projection-fallback[=all|adapter]Fail on every fallback (bare flag or all), or adapter failures (adapter) with notices for conservative comparisons; requires --affected-profile and a committed head, rejects explicit/working-tree file selection
--files <paths>Replace Git comparison with named workspace-relative paths
--stdinRead changed workspace-relative paths from stdin, one per line; an empty list means no changes
--uncommittedOnly uncommitted changes
--untrackedOnly untracked files

show projects change options imply --affected. As in Nx, --head needs --base; --files, --stdin, --uncommitted and --untracked exclude --base, --head and each other, except that the paths --stdin reads are added to --files. --stdin preserves spaces in paths. For show tasks, use --affected to filter the planned tasks. affected --granularity task selects tasks by their inputs and --granularity project the targets of affected projects; without it, NX_LEGACY_AFFECTED, then nx.json affected.legacy, decide, and tasks are the default, as in Nx 24. See Tasks or projects. See Affected selection for merge-base behavior.

Selection and inspection

run-many, affected and show tasks accept -t/--targets (--target too), -p/--projects and --exclude; lists accept commas, spaces or repeated flags. run-many accepts Nx’s deprecated --all, the default; --all=false without --projects selects nothing, as in Nx. show projects accepts -p/--projects, --exclude, -t/--with-target, --type app|lib|e2e, and --sep. --sep conflicts with --json. With --affected and -t, it lists the projects owning an affected task of the targets under task selection, which --granularity decides as for affected, except that, as in Nx, -p selects projects unless given --granularity task. --affected-targets lists the projects where a task of one of the targets is affected by its inputs, narrowed by --affected-profile, whatever the granularity; it has no Nx equivalent. Quote name/tag globs so the shell does not expand them.

show runs --limit defaults to 20, and show task <id> --limit to 10. show run without an ID uses the most recent run. All support --json. show flaky [task] --limit defaults to 20 task/key groups and supports --json. show log <run> <task> replays output to its original stdout/stderr streams. See Run history for retention limits and interpretation.

Cache administration

cache path prints the location without creating it. cache prune uses --max-size, else NX_MAX_CACHE_SIZE, else maxCacheSize, else the default disk fraction. See Storage limits.

reset, or its alias clear-cache, removes the local cache and this worktree’s state: file digests, output records and warm directories. The cache is shared by the repository’s worktrees, so resetting clears it for all of them. Run history and the remote cache are kept. --only-cache and --only-workspace-data (or --onlyCache and --onlyWorkspaceData) limit it to one of the two; --only-daemon and --only-cloud are accepted without effect.

Accepted Nx options

--runner, --batch, --skip-sync, --cloud, --no-cloud, --dte, --no-dte, --agents, --tui, --no-tui, and --tui-auto-exit, and --verbose on show projects, are accepted without effect. They do not enable Nx plugins, distributed execution or Nx Cloud. As in Nx, --runner must name default or a runner in nx.json tasksRunnerOptions.

Target inspection

qk show target <project:target[:configuration]> --json shows the resolved executor, options, caching and concurrency settings, task dependencies, readyWhen with the timeout its check gets, or null where it turns one off, and qk’s own fields, such as qk:warm and qk:threads, as configured. -c <configuration> selects a configuration. A target name without a project uses the current project, as run does.

qk show target inputs <target> --json lists files and declared environment, runtime and dependency-output inputs. qk show target outputs <target> --json lists resolved output patterns, existing output paths and unresolved templates. Without --json, inputs and outputs print one entry per line. Inspection does not load dotenv files, execute runtime inputs or require a supported executor. The listed inputs include qk’s mandatory workspace and package configuration files; these can differ from Nx’s hash plan.

Both input and output commands accept --check <values...>. File and directory queries use workspace-relative paths. Inputs also accept declared environment variable names and runtime commands. The command exits with 1 if any query does not match. --json returns each query and its membership result.

Key inspection

qk show hash <project:target[:configuration]> prints the key qk run would cache the task under now, without running the task, and a summary of what the key covers: the declared inputs, file count, env and runtime values, dependencies and tooling. --json prints the key and everything it is computed from, with set env values shown as "<redacted>" and unset ones as null. Arguments after -- are forwarded as qk run forwards them, to both tasks with --against <task>. A target name without a project uses the current project.

Computing a key executes the task’s runtime inputs. Dependencies are not run: each counts as having succeeded, with its outputs as they are on disk, so a dependency that has not run yet, or whose outputs are stale, keys differently here than after it runs.

--against <task> keys another task the same way, a target name alone naming one of the current project’s targets, and lists where the two keys differ: the definition fields with both values, the files added, removed or changed, and the values and dependencies that differ. --against <run-id> compares with this task’s key in a recorded run, as qk show runs lists them, while that run’s inputs are kept. Differences read from the --against key to this one. Two different tasks always differ in id. With --json, the report holds both keys and their inputs, same, and differences in the shape qk show task reports causes.

$ qk show hash web:build --against web:check
web:build  55a02f0c…
web:check  963b6121…
keys differ in: definition, files
  field id: "web:check" -> "web:build"
  field definition.inputs[0]: "{projectRoot}/src/**/*" -> "{projectRoot}/**/*"
  field definition.inputs[1]: {"runtime":"node -v"} -> {"env":"MODE"}
  field definition.inputs[2]: {"env":"MODE"} -> {"runtime":"node -v"}
  added apps/web/README.md

The target definition is part of the key as written, so reordering inputs changes the key even when the files and values it selects do not; such a change shows as definition.inputs reordered.

Project watch

qk watch -p web --includeDependencies -- <shell command> watches the selected projects and, optionally, their project dependencies. --all watches all projects, including projects created after watch starts. --initialRun runs once before waiting for edits. -g/--includeGlobalWorkspaceFiles also reacts to files that belong to no project, such as root configuration; as in Nx, a command naming NX_PROJECT_NAME is refused with it. The kebab-case aliases also work.

Callbacks run from the invocation directory with NX_PROJECT_NAME and NX_FILE_CHANGES (workspace-relative paths joined with spaces). If the command mentions NX_PROJECT_NAME, a batch runs concurrently once per changed project; otherwise it runs once for the batch with an empty project name. Initial --all also runs once with an empty project name, matching Nx. Quote the shell command so variables expand in the callback rather than in the invoking shell.

Native file events are debounced and changes arriving during a callback are queued for the next batch. A directory event or backend rescan can include other eligible source files in the reported batch. Callback failures are reported; watch continues. Edits to sources known to be regular files reuse the current project view. Configuration and ignore-policy changes, file creation/deletion, directories, symlinks and backend rescans reload discovery and selection before the next callback. Workspaces using package presets or presets outside the workspace retain a reload on every source batch, because preset resolution has inputs outside the source set. Ctrl+C cancels callbacks and their process groups and exits with 130. Ignored source files, node_modules, Git/qk state, configured caches, explicit target outputs and warm paths do not trigger callbacks. Files outside any selected project do not trigger callbacks, unless -g is given and they belong to no project at all. Watch uses no daemon or Nx plugins.

Compatibility diagnostics

qk doctor reports unsupported executors, configured Nx plugins, sync generators and fields in qk:warm or qk:threads that this qk does not know, without executing commands, loading dotenv files or running plugins. It also reports dependentTasksOutputFiles globs that select nothing for any task of a target, which therefore key nothing: none of those tasks depends on a task declaring outputs, or the outputs on disk hold no file the glob matches. Outputs not built yet are taken to match. Targets sharing a set of such globs, as through a named input, are one finding. It also lists Nx CLI options accepted without effect. --json returns a versioned report with stable finding codes, locations and error/warning counts. Unsupported executors cause exit code 1; --strict also fails on warnings.

This checks normalized explicit target definitions. A clean report does not establish that commands can execute or that plugin-inferred targets exist.

Workspace configuration

qk reads nx.json as JSON with comments, trailing commas and "//" comment keys. Unknown metadata is retained, but that does not imply qk executes it. Use Nx compatibility to check the supported surface.

{
  "defaultBase": "main",
  "parallel": 3,
  "qk:cores": 8,
  "namedInputs": {
    "default": ["{projectRoot}/**/*"],
    "production": ["default", "!{projectRoot}/**/*.test.ts"]
  },
  "targetDefaults": {
    "build": {
      "cache": true,
      "inputs": ["production", "^production"],
      "dependsOn": ["^build"]
    }
  }
}

extends

A file whose settings nx.json starts from, resolved as Nx resolves it with Node’s require.resolve from the workspace root: a path beginning ./ or ../, or a package subpath such as nx/presets/npm.json, found in node_modules through the package’s exports, whose conditions apply in the order they are declared. A bare package name without exports resolves to its main, else index. As in Nx, each setting nx.json declares replaces the extended file’s whole, and the extended file’s own extends is not followed. nx.local.json merges over the result. A change to the extended file counts as a change to nx.json does: through what it changes, by the merged settings (see What changes the key).

namedInputs

An object mapping names to arrays of input declarations. Defaults to an empty object. Without an explicit definition, default means {projectRoot}/**/*. Projects override workspace definitions by name. A named input cannot itself declare dependencies or projects.

targetDefaults

An object of target defaults; defaults to empty. Matching keys are tried in this order: executor, target name, then matching target-name globs, longest first. A key is a glob when it holds one of *, |, {, }, (, ) or [, and it matches as minimatch matches: * does not cross a /. The first key with a matching entry wins; keys do not all accumulate. As in Nx 24, the selected defaults lie beneath the project’s files, which merge over them as described in Merging, spreads included. Beneath a default there is only the earlier matching entries of its key, so a spread in the first adds nothing.

Each value may be a target object or an array of target objects. Array entries may have filter.projects, an array of project selectors, and filter.executor, and other filter keys are ignored. filter.plugin names the plugin in nx.json plugins that wrote the target; targets from project.json and package.json have none, so an entry with filter.plugin never applies. Matching entries merge in array order, later ones winning. An entry naming an executor different from the target’s own is left out, although its key still wins.

As in Nx, a default takes the executor of the target it applies to, and a run-commands target’s command, so that the target merges over it, unless it names its own command; a default naming another command or script is replaced by the target whole. A default whose target the files replace along the way still lies beneath the result; see Nx compatibility.

defaultBase

A revision string for affected selection and remote warm state’s default branch. The fallback is main. Affected selection resolves --base, then NX_BASE, then this setting. See Affected selection.

affected

affected.legacy: true makes affected selection select projects rather than tasks, as in Nx 24. NX_LEGACY_AFFECTED and --granularity override it. See Tasks or projects.

qk:affectedProfiles

Named, opt-in refinements of affected project selection. Each profile contains a projections array of adapters with name, command, sources, outputs and optional timeoutSeconds, reachability, or both. reachability is true, or {"default": "<name>"} naming the qk:reachability config for targets that declare none. Select a profile with --affected-profile <name>. See Project change projections for the protocol, configuration example, tool setup and conservative fallback rules, and Import reachability for reachability.

qk:reachability

Named import reachability configs, each an object with anchors, cases and sources arrays of globs. A target’s qk:reachability names one, is false, or declares its own settings; without one, it takes its profile’s default. See Import reachability.

qk:environments and qk:defaultEnv

qk:environments names environments a target can run in, each an array of entries, as qk:env reads them. qk:defaultEnv is the environment of every target without its own qk:env, the same array, or false. Without it, a target with no qk:env inherits every variable, as qk’s own process holds them. [] lets no inherited variable through to any task that does not name the ones it needs. See Declared environments.

parallel

A positive integer giving the default number of concurrently running tasks. Defaults to 3; --parallel and NX_PARALLEL take precedence. This is separate from options.parallel, which runs commands within one task.

qk:cores

A positive integer giving the core budget for a run. Defaults to the cores available to qk. --cores, then QK_CORES, take precedence. Targets opt in to sharing this budget with qk:threads.

cli.packageManager

Selects the package manager for nx:run-script. Lockfile detection is used when absent; see Executors for the full order.

cli.defaultProjectName and defaultProject

Fallback project names for a target invoked without a project. The project whose root most specifically contains the current directory normally wins. In the root project, NX_DEFAULT_PROJECT can pick another. Where no project contains the directory, qk tries NX_DEFAULT_PROJECT, then cli.defaultProjectName, then defaultProject.

workspaceLayout

An object with appsDir and libsDir string prefixes used to infer package project types. Matching is by plain string prefix. Explicit project.json configuration can override the inferred type.

pluginsConfig["@nx/js"]

The settings Nx’s JavaScript support reads, which qk reads too:

  • analyzePackageJson: anything but true stops package.json dependencies making graph edges.
  • analyzeLockfile: false leaves the installed packages out of the graph, so graph --external adds none and a lockfile change under projectsAffectedByDependencyUpdates: "auto" affects every project, where Nx affects none (see Compatibility). It defaults to on, unless a lerna.json stands without an nx.json. Cache keys and externalDependencies inputs read the lockfile either way.
  • projectsAffectedByDependencyUpdates: see Affected selection.

cacheDirectory

A path relative to the workspace root, or an absolute path, overriding the default cache location. NX_CACHE_DIRECTORY takes precedence; without either, tasksRunnerOptions.default.options.cacheDirectory is read, as in Nx. qk stores its entries in qk/v1 beneath this directory so it can coexist with Nx. History and worktree state keep their default locations. A relative override gives each worktree its own cache.

Without an override, the cache lives in <git common dir>/qk/cache/v1, or .qk/cache/v1 outside Git. Use qk cache path to inspect it.

tasksRunnerOptions

Nx’s task runners. qk runs tasks itself, so it reads only what Nx’s default runner reads from here: default.options.cacheDirectory, under cacheDirectory. As in Nx, --runner must name a runner configured here, or default. A runner’s parallel option counts in neither Nx nor qk: Nx’s own parallel setting replaces it.

maxCacheSize

A byte count or size such as "2GB". Units KB, MB and GB use powers of 1024; 0 means unlimited. NX_MAX_CACHE_SIZE takes precedence. The default is a tenth of the disk holding the cache. Entries unused for a week are evicted whatever the limit. See Storage limits.

s3

An optional object configuring S3-compatible remote storage. Without it qk uses only the local cache. Put credentials in the environment or local dotenv files rather than checked-in configuration.

{
  "s3": {
    "bucket": "my-task-cache",
    "region": "eu-north-1",
    "cacheKeyPrefix": "tasks/",
    "localMode": "read",
    "ciMode": "read-write"
  }
}
FieldTypeDefault / behavior
bucketstringRequired
regionstringRequired
endpointURL stringhttps://s3.<region>.amazonaws.com
forcePathStylebooleanfalse; use true for path-style storage
cacheKeyPrefixstringEmpty; qk appends qk/v4/
accessKeyIdstringFalls back to AWS_ACCESS_KEY_ID
secretAccessKeystringFalls back to AWS_SECRET_ACCESS_KEY
localModestringread-write outside CI
ciModestringread-write in CI
uploadModestringwait; qk can detach uploads with background

Modes are read-write, read (or read-only), and no-cache. NX_POWERPACK_CACHE_MODE overrides the selected mode. uploadMode is "wait" (the default) or "background"; QK_REMOTE_UPLOAD_MODE overrides it. See Remote caching for detached uploads, logs and lifecycle behavior. AWS_SESSION_TOKEN supplies a temporary credential token. --skip-remote-cache, NX_SKIP_REMOTE_CACHE=true and NX_DISABLE_REMOTE_CACHE=true disable the remote store. encryptionKey and SSO profiles are unsupported. Unusable remote stores are reported; local caching continues. See Remote caching.

qk:storeServer

An optional object sending the remote cache to a store server instead of S3. Nx does not read it, so when it is set qk uses it and ignores s3, which can stay for Nx’s own cache plugin. The token comes from QK_STORE_SERVER_TOKEN.

{
  "qk:storeServer": { "url": "https://cache.example.com" }
}
FieldTypeDefault / behavior
urlURL stringRequired; QK_STORE_SERVER_URL overrides it
localModestringread-write outside CI
ciModestringread-write in CI
uploadModestringwait; qk can detach uploads with background

Modes and upload modes, and the variables that override them, are as for s3.

nx.local.json

A machine-specific overlay on nx.json. targetDefaults merge per entry, namedInputs merge by name, and other settings replace their checked-in values. Add the file to your workspace’s .gitignore. Nx does not read it. Every run reports the overlay, and every task’s key includes what it changes, as for nx.json.

Projects and discovery

qk finds the workspace root as Nx does: NX_WORKSPACE_ROOT_PATH when set, else the nearest ancestor with an nx.json or an nx or nx.bat wrapper script, else the topmost ancestor with Nx installed in node_modules. For a workspace without Nx, it takes the nearest ancestor with pnpm-workspace.yaml or a package.json with workspaces; a standalone project.json, or package with an nx object, also works when no parent workspace marker exists. --workspace <path> selects a root explicitly.

Workspace membership

qk discovers project.json files, including nested projects, and package manifests the package manager’s workspaces take in, as Nx’s package.json plugin does. The workspace patterns are those of the root package.json workspaces (an array, or an object’s packages), pnpm-workspace.yaml packages and lerna.json packages together; a lerna.json without packages means packages/*. A pattern names directories, or package.json files when it ends in /package.json. Patterns are minimatch globs without the dot option, so * and ** pass over directories starting with .. ! patterns exclude, and with exclusions alone every package.json not excluded is taken in. An unreadable pnpm-workspace.yaml or lerna.json warns and adds none.

A package.json beside a project.json is part of that project whether the workspaces take it in or not. The root package is a project when it has an nx object or an adjacent project.json. With NX_INFER_ALL_PACKAGE_JSONS=true, a workspace without a root package.json takes in every package.json, as in Nx.

Discovery reads .gitignore and .nxignore files with Git’s rules, in the workspace and, when the workspace is inside a Git repository, the repository’s .gitignore files above it, as Nx does. It does not follow symbolic links, and leaves out node_modules, .git, .nx/cache, .nx/workspace-data, .yarn/cache and qk’s .qk wherever they are. Other hidden directories remain discoverable. Unlike Nx, qk does not read the machine’s global Git excludes, or .gitignore files above a workspace that is not in a Git repository.

Configuration precedence

Package scripts become nx:run-script targets, except a script that an adjacent project.json runs another way: with a command, another executor, or run-script running another script. Package nx configuration is merged over these; a target there with an executor or command replaces the script’s. Adjacent project.json merges over that, then project.local.json. Workspace targetDefaults lie beneath them all. Targets merge as described in Merging. As in Nx, tags merge as an ordered union, implicitDependencies concatenate, named inputs merge by name, generators merge by generator and then option, and metadata merges by field, arrays concatenating one level down; other project fields are replaced.

A package’s metadata is what Nx reads from it, replacing any in its nx object: targetGroups listing its script targets under NPM Scripts, its description, and under js its name, version, exports and main and whether the package manager’s workspaces take it in. Each script target’s metadata holds the script and the package manager’s command for it. Neither is part of a cache key.

{
  "name": "web",
  "projectType": "application",
  "sourceRoot": "apps/web/src",
  "tags": ["scope:web"],
  "implicitDependencies": ["codegen"],
  "targets": {
    "build": {
      "command": "echo build web",
      "outputs": ["{projectRoot}/dist"]
    }
  }
}

name

A string. An explicit name wins over the package name; otherwise, as in Nx, a project is named after its directory, in lower case: libs/data/Access is access. For the workspace root qk uses the workspace directory’s name. An unnamed root package.json cannot be a project, as in Nx: name it, or name the project in its nx object. Duplicate names fail with both roots in the error.

root

The workspace-relative project directory, with forward slashes (. for the workspace root). It defaults to the configuration’s directory; as in Nx, a root given in project.json or the package’s nx object is used as written, an empty string meaning the workspace root. It must stay inside the workspace, and two projects cannot share a root. As in Nx, the project’s package dependencies, scripts and lockfile entry then come from the package.json at that root, not the one beside the configuration.

sourceRoot

An optional string retained in the normalized project configuration. Set it to the source directory to describe the project.

projectType

An optional type string. qk show projects --type accepts app, lib and e2e and applies Nx’s project typing rules. Package entry points and workspaceLayout also contribute to inferred types.

tags

An array of strings, empty by default. Select them with tag:<glob>. Packages also gain npm:private or npm:public and npm:<keyword> tags.

implicitDependencies

An array of project selectors, empty by default. What the selectors match, less the project itself, gains an implicit graph edge; a ! selector removes every edge to the projects it matches, package dependencies included. As in Nx, a selector matching no project is an error, and the graph’s node lists the expanded names, each exclusion as !name. Package dependency sections also create graph edges when the range identifies a workspace package; see Nx compatibility.

namedInputs

An object of named input arrays, overriding workspace definitions by name. See Inputs and outputs.

targets

An object mapping target names to target definitions. Inspect the merged result with qk show project web --json. Unknown executor names are preserved during inspection; execution rejects unsupported executors before commands start.

includedScripts

An array under the package’s nx object restricting which package scripts become targets. By default all scripts do. An empty array disables script target creation. Listed names initially create targets even if the script is absent, allowing explicit definitions to replace them; missing scripts fail in execution preflight, not inspection.

qk:reachability

A qk extension: the anchors and sources an affected profile with reachability uses to leave the project out when its anchors do not import what changed. See Import reachability.

project.local.json

A machine-specific overlay beside project.json or package.json. It can change options or add targets, but cannot set name or root. Add it to your workspace’s .gitignore. Nx does not read it.

{
  "targets": {
    "serve": { "options": { "port": 4300 } }
  }
}

qk reports each overlay used. What it changes is included in cache keys, as for project.json, so overridden tasks do not reuse the checked-in definition’s cache entries, and the project’s other tasks keep theirs.

Target configuration

Targets appear in a project’s targets object or workspace targetDefaults. The example uses explicit command execution and output declarations:

{
  "targets": {
    "build": {
      "executor": "nx:run-commands",
      "options": { "command": "pnpm exec tsc --build", "cwd": "{projectRoot}" },
      "configurations": { "release": { "args": "--verbose" } },
      "dependsOn": ["^build"],
      "inputs": ["default", "^default"],
      "outputs": ["{projectRoot}/dist"],
      "cache": true
    }
  }
}

Merging

A target can be defined in several places: target defaults, package.json and project.json (see Configuration precedence). As in Nx 24, each definition merges over the one beneath it field by field: a field it sets replaces the one beneath, except that options merge by option name and configurations by configuration name and then by option name. Arrays and nested option values are replaced.

A definition that runs the target differently replaces the one beneath it whole, every field included: it names another executor, or for nx:run-commands another command, or for nx:run-script another script. A definition without an executor, command or script merges over any.

The spread token "..." merges instead of replacing. In an array, such as inputs, outputs or dependsOn, the item "..." stands for the items beneath it, in its place: "inputs": ["mine", "..."]. In an object, the key "...": true stands for the fields beneath it: a field written after it wins over the one beneath, and one written before it yields to it. It works on the target itself, on options and each option’s value, on configurations, on each configuration and each of its options’ values. Beneath that, a spread is an ordinary value. With nothing beneath it, a spread adds nothing. An object with "..." cannot also have a key that is a whole number, such as "1": Nx cannot tell where it was written.

A target key that is a glob, such as "e2e-ci--*", merges onto every target defined beneath it whose name it matches, and is a target of its own when it matches none. Keys are globs and match as targetDefaults keys do (see Workspace): * does not cross a /.

executor

A string naming nx:run-commands, nx:run-script, or nx:noop. Unsupported names remain inspectable but fail execution preflight. See Executors for their options. A target with neither an executor nor a command runs nx:noop when it has dependsOn, and is left out otherwise, as in Nx.

command

A shorthand command normalized into nx:run-commands options. Prefer an explicit executor with options.commands for multiple commands. A target cannot have both command and executor.

options

An object, empty by default, of executor-specific options. Selected configuration values merge over these by key, followed by task argument overrides. Nested objects, including env, are replaced rather than recursively merged. A configuration with env: {} removes the base option’s overrides; process and dotenv values still apply.

As in Nx, the tokens {projectRoot} and {projectName} are replaced in every string of the options and configurations, nested ones included, as the workspace loads, and qk show project shows the result. {workspaceRoot} may only start a string, and is removed with the slash after it, leaving a path relative to the workspace root: "{workspaceRoot}/dist" becomes "dist". Anywhere else it is an error.

configurations

An object mapping configuration names to option objects, empty by default. Select with -c release or project:target:release. --prod selects production. Requested configuration names propagate through dependencies. A dependency without that configuration uses its own default, while its dependencies still receive the requested name. A configuration’s keys are all options: outputs in a configuration is an option named outputs, not the target’s outputs, as in Nx.

defaultConfiguration

An optional string selecting a configuration when none is requested. Without a requested configuration, each task chooses its own default; a parent’s default does not propagate to its dependencies.

dependsOn

An array of dependency declarations. Without it, a target has no declared task dependencies. Shared dependencies run once.

DeclarationMeaning
"build"Target in the same project
"^build"Target on project dependencies, looking through projects without it
"core:build"Target in an explicit project
"lint-*" or "^build-*"Matching target names
{ "target": "build", "projects": ["tag:library"] }Target on selected projects
{ "target": "build", "dependencies": true }Target on project dependencies

Target names may contain colons. As in Nx, "a:b" names target b of project a when a is a project with that target, and otherwise target a:b of the same project; "^a:b" and { "target": "a:b" } name target a:b.

Object declarations also accept params: "forward" and options: "forward"; any other value, or none, does not forward. params passes the run’s own arguments; options passes the target’s options, its configuration’s included, as the dependency’s options, overriding its own, lists and objects included. With both projects and dependencies: true, projects applies. Other fields are ignored, as in Nx. As in Nx 24, the Nx 15 spellings projects: "self" and projects: "dependencies" fail.

An object declaration naming a continuous target also accepts inputs and waitFor. inputs: false keeps the continuous task’s inputs from counting for the dependent; see Affected selection. waitFor is "started", the default, or "ready", which starts the dependent once the target’s readyWhen check passes. When several declarations name the same task, "ready" wins, and its inputs count unless every declaration sets inputs: false. Both have no effect on a dependency that is not continuous.

Other missing dependency targets are skipped. Unknown explicit projects and task cycles fail unless --nx-ignore-cycles is used. A shared task reached with different forwarded arguments or options keeps those it was first reached with, as in Nx. See Running tasks.

inputs

An array of input declarations. Without it, qk uses default and ^default. Inputs determine both cache keys and task-level affected selection.

outputs

An array of output paths or globs with fixed directory prefixes. On a cache hit these are restored and recorded logs replayed. Explicit outputs also let dependent keys reflect output content instead of the dependency’s entire key. See Inputs and outputs.

cache

A boolean; absent or false means the target does not opt in to result caching. true enables caching when supported by the task’s inputs and outputs. Continuous and readiness tasks run uncached. As in Nx, a target with both cache and continuous set to true is an error. See Local cache.

continuous

A boolean, default false. Dependents may start when this task starts, or, with waitFor: "ready", when its readyWhen check passes. Continuous tasks do not count towards --parallel; they run until exit or cancellation. A continuous task used only as a dependency is stopped successfully once its consumers finish. See Continuous tasks.

readyWhen

How a continuous target tells that it is ready, for tasks depending on it with waitFor: "ready", as in Nx 24. It is an object with exactly one of:

KeyReady when
urlAn HTTP or HTTPS GET gets a status from 200 to 403. Redirects are not followed, and a 404 at the root path is tried again at /index.html.
portA TCP connection succeeds, to 127.0.0.1 or ::1 unless host names the address.
commandThe command, run through the shell at the workspace root with the environment the target’s commands run with, exits with 0. It reads the options they run with, as JSON, from NX_TASK_OPTIONS.
logMatchesEvery one of a string or an array of strings has appeared in the output, terminal control sequences left out.
signaltrue: the task reports that it is ready, as @nx/devkit’s signalTaskReady does, through the socket qk names in NX_READY_SIGNAL_SOCKET.

timeout, 600000 (ten minutes) by default, is how many milliseconds the check may take, and interval, 250 by default, how long a url, port or command check waits between tries. Strings may use {workspaceRoot}, {projectRoot}, {projectName}, {options.name}, {env.NAME} and ?? fallbacks, read from the options, arguments and environment the task runs with. null turns off a check set by a target default. See Continuous tasks.

A signal takes no interval. The address a signalling task reports is passed on in NX_READY_URL to a task that waited for it, when it is the only server that task waited for to report one. A task that sets NX_USE_V8_SERIALIZER=true signals in Node’s own serialization, which qk cannot read.

As in Nx 24.0.1, a task resolves its own readyWhen once the servers it waits for are ready, so {env.NX_READY_URL} and a command check’s environment have the address. Only a signal check reports one: behind a server with another check, {env.NX_READY_URL} has no value. Behind a signalling server, a port read from NX_READY_URL gets the address rather than a ?? fallback, so it is not an integer, and the check is invalid.

This is unrelated to the run-commands readyWhen option, which marks the task itself as ready.

parallelism

A boolean, default true. false runs the task alone, waiting for all other tasks, including continuous ones. No other task starts while it runs. Graphs requiring it to overlap a continuous dependency are rejected.

env

A target-level object of environment variables, for every executor. This is a qk extension; Nx ignores it. Executor options.env and selected configuration env can override it. See Environment precedence.

qk:env

The environment the task inherits: an array of entries, each the name of an environment in nx.json’s qk:environments, whose entries it stands for, or an object with one of these fields:

FieldTypeWhat it does
inheritarray of variable namesPasses the variables through and keys their values
passarray of variable namesPasses the variables through without keying their values, as for credentials and proxies
setobject of string valuesSets the variables, keyed as written
binarray of command names, or of objects with name and keyPuts the commands, as found on the PATH qk inherited, on the task’s PATH, keyed by the files they resolve to, or with "key": "content" by their content
dirsarray of paths, with {projectRoot} and {workspaceRoot}Adds the directories to the task’s PATH, after the commands, keyed as written; relative to the workspace root
fromobject with command and, optionally, inputs, an array of pathsSets the variables the command prints as JSON, as nix print-dev-env --json, direnv export json or mise env --json print them; see Declared environments

A name can contain *, which matches any run of characters, as LC_*. A variable both inherited and passed fails the task, as does a command bin names that is not on PATH. An environment with bin or dirs entries builds PATH, so it cannot also inherit, pass or set it. false inherits every variable, as without the field, which overrides nx.json’s qk:defaultEnv. See Declared environments.

qk:threads

true, or an object containing:

FieldTypeDefault
envobject of string valuesEmpty; {threads} expands to the threads the task is told to start: its share of cores, times scale, rounded up
minpositive integer1
maxpositive integer, at least minRun’s core budget
scalenumber above 0, at most 81; threads the task is told to start per core it holds, rounded up

Opted-in tasks receive QK_THREADS; true sets only that variable. The share is fixed when the task starts. min and max bound the cores a task holds; scale only changes how many threads it is told to start on them. Without this key a task holds one core. The setting and allocated count do not affect cache keys. Continuous tasks do not take a core share; this setting does not allocate threads or set these environment variables for them. false is not a supported value; omit the key to disable it. Other fields are ignored, as Nx ignores keys it does not know, so a configuration can use fields a newer qk added; qk doctor reports them. See Sharing cores for allocation examples.

qk:sandbox

false runs the task unsandboxed under --sandbox, listed with the reason, for a task that applies a sandbox of its own, which cannot run inside qk’s, such as an Xcode build running Swift macros. true, the default, sandboxes it. An object sandboxes it too, and names its scratch directories:

FieldTypeDefault
scratcharray of paths, with {projectRoot} and {workspaceRoot}Empty; directories emptied before each run of the task, with or without --sandbox, which the task may read and write and which nothing keeps

A scratch directory lies in the workspace, outside .git, .qk and node_modules in any case, holds no project and neither qk’s cache nor its state, and neither is nor lies under a symbolic link; a task naming another fails, and so does a run in which two tasks share one or have one inside the other’s. qk doctor reports one, any other value, and fields it does not know. See Scratch directories and ignored reads.

qk:ultracache

An object: the task is keyed by what it read when a run with --record last recorded it, rather than by its declared file inputs, once it has been recorded. See Keying tasks by what they read.

FieldTypeDefault
mode"on", "record" or "off""on"; "record" records the task and reports what its recordings would have done, but keys it by its declared inputs; "off" keys it by its declared inputs, as without the field
ignoreReadsarray of workspace globsEmpty; files left out of recordings, whose changes cannot change the result; the sandbox’s audit leaves them out too, and enforce lets the task read them. A glob starting with ^ is matched from the root of each project the task’s project depends on, directly or not

Nx’s "warn" and "error" modes are not supported yet. Other fields are ignored, as in qk:threads, and qk doctor reports them. The field does not change the task’s key; ignoreReads changes what its recordings key.

qk:warm

An object describing reusable scratch state, or an array of them restored and saved separately. In an array, at most one entry omits group, no group is named twice and no variable is set by two entries. See Several entries. Each object has:

FieldTypeDefault
outputsbooleanfalse; restore previous outputs before execution
pathsarray of workspace paths and globsEmpty; ! excludes; {warm} is not allowed here
envobject of stringsEmpty; {warm}, {workspaceRoot}, {projectRoot} expand to absolute directories
maxSizebytes or size stringNo per-group limit
remotebooleantrue; permit remote exchange when portable is true or "path"
portabletrue, false or "path"false; true permits another worktree’s save and remote exchange; "path" permits remote exchange, restoring a save only when made in a checkout at the same path
mtimes"epoch" or "preserve""epoch"; "preserve" keeps a worktree’s own save’s modification times
keyarray of workspace paths and {"env": name} objectsEmpty; a save is restored only where the key matches
restoreKeyscountNone; how many leading key parts a save must match when none matches whole
groupstringNone; share {warm} and its saves with every target naming the group; an entry with a group cannot set outputs, paths or survive
survivearray of targets, target or project:targetEmpty; keep paths across these dependencies
save"wait" or "background""wait"; "background" saves after the task has reported
restore"whole" or "each-path""whole"; "each-path" restores each missing path from paths and leaves those on disk; see Restoring paths one by one
prefetchbooleanfalse; fetch the remote save as the run starts, while the task’s dependencies run; see Fetching remote state ahead

Other fields are ignored, as in qk:threads, and qk doctor reports them. Put it beside inputs and outputs, rather than in executor options. Environment values retain their other text unchanged; their directory tokens are absolute even when the task runs from a project subdirectory. Warm state is restored on misses and uncached runs, saved after success, and never changes a key or a hit. --skip-cache disables it. See Warm state for tool configurations, portability checks and lifecycle details.

Executors

qk executes commands through /bin/sh -c on Unix and cmd.exe /D /S /C on Windows. Task PATH starts with node_modules/.bin directories from cwd through every ancestor to the filesystem root, then the selected Node runtime’s directory, then the task’s environment PATH. Node is selected from qk’s inherited PATH in the workspace root before task environment or cwd overrides, as in Nx. Version-manager proxies are probed when a command first starts, with a two-second limit. Cache hits do not probe Node. A failed probe of the first executable Node match does not promote a later installation from the inherited PATH; executable lookup continues through the task’s PATH without adding a runtime directory. Unsupported executors fail before tasks start.

nx:run-commands

{
  "executor": "nx:run-commands",
  "options": {
    "commands": ["echo first", "echo second"],
    "parallel": false,
    "cwd": "{projectRoot}"
  }
}

command and commands

Use command for a string (or an array joined with spaces) or commands for an array of strings or command objects; command wins when both are set. An empty commands succeeds without running a process. Each object can have command, forwardAllArgs, description, prefix, prefixColor, color, and bgColor. Decorations apply when commands run in parallel.

parallel

A boolean, default true, controlling concurrency within the task. false runs commands sequentially and stops at the first failure. It is separate from the run’s --parallel task limit. With command, as in Nx, it is ignored: the command runs in parallel exactly when readyWhen is set.

cwd

A directory relative to the workspace root, which is the default, or an absolute one. Like every option, it may use {projectRoot} and {projectName}, and start with {workspaceRoot}, which resolve as the workspace loads; see options.

env, envFile, and color

env is an object of environment variables. envFile is a dotenv path relative to the workspace root, loading only variables not already set, or set but empty, as in Nx 24. color: true sets FORCE_COLOR=true. NX_LOAD_DOT_ENV_FILES=false disables all dotenv loading, including envFile. See Environment.

args and forwardAllArgs

args supplies extra arguments as shell text or an array joined with spaces. They are forwarded after other options and before the task’s own arguments. forwardAllArgs defaults to true. Argument placeholders select which values to interpolate. See Forwarding arguments.

readyWhen

A string or array of strings. The task is ready once all strings appear on stdout or stderr, as printed or with terminal control sequences left out. If every command exits first, the task fails, naming the strings that did not appear. With commands, it requires parallel: true. It keeps running while dependents need it, and is not cached. See Readiness.

Other options and task overrides

Other scalar options are forwarded as --name=value and exposed as {args.name}; lists, objects and nulls are not forwarded, and {args.name} writes a list’s items joined with commas. Task arguments override values of the same name. An argument naming an executor option sets that option, for example -- --args='--watch', --cwd=dir, --no-parallel, or --env.NAME=value. As in Nx, the tokens {projectRoot}, {projectName}, {project.<field>} and a leading {workspaceRoot} resolve in such a value for the task’s project.

usePty: false runs the target’s commands with pipes rather than a pseudo-terminal, and tty: false leaves the terminal out of raw mode while the task a run is for reads it. streamOutput and verbose are accepted without effect. See Terminals and input.

nx:run-script

options.script names a package script. As in Nx, it is the only option read: qk runs the script in the project directory, whatever cwd says, with the task’s environment, without options.env or envFile, using the detected package manager:

ManagerInvocation
npmnpm run <script> -- <args>
pnpmpnpm run <script> <args>
yarnyarn <script> <args>
bunbun run <script> -- <args>

Detection order: nx.json’s cli.packageManager, then the lockfile (bun.lockb or bun.lock, yarn.lock, pnpm-lock.yaml, package-lock.json), then root packageManager or pnpm-workspace.yaml, then the invoking manager’s npm_config_user_agent, else npm. Nx does not use the root packageManager/workspace-file fallback. Missing scripts fail in execution preflight after explicit overrides apply. The task’s arguments are each quoted for the shell; Nx joins them as they are.

nx:noop

Succeeds after its dependencies without launching a process. No executor options are needed.

Inputs and outputs

Inputs describe what changes a task’s result. Outputs describe files to save and restore. Local caching explains the complete key, including the root workspace files and manifests always included.

inputs

Without an explicit array, a task uses default and ^default. Unless overridden by a named input, default means {projectRoot}/**/*.

{
  "inputs": [
    "production",
    "^production",
    { "env": "NODE_ENV" },
    { "runtime": "node --version" }
  ],
  "outputs": ["{projectRoot}/dist"]
}

Supported declarations:

  • filesets, as strings or {"fileset": "…"}, with ! exclusions, and {"fileset": "…", "includeIgnored": true}, which reads files on disk that ignore rules leave out;
  • named inputs, also as {"input": "name"};
  • ^name and {"input": "name", "dependencies": true} (or "projects": "dependencies"), which as in Nx cover every project the project depends on, directly or not, and ^{projectRoot}/…, ^{workspaceRoot}/… or {"fileset": "…", "dependencies": true}, a fileset of each of them;
  • {"input": "name", "projects": […]}, the named input of the projects the project selectors select, and with "dependencies": true their ^name instead, as in Nx 24: every project they depend on, but not themselves;
  • env, runtime, dependentTasksOutputFiles with transitive, and externalDependencies, which adds every installation of the named packages in the pnpm lockfile, whichever importer installs them;
  • {"workingDirectory": "relative" | "absolute"}, the directory qk was run from;
  • {"json": "path", "fields": […], "excludeFields": […]}, only the selected dotted fields of a JSON or YAML file, or of each file a glob selects.

As in Nx, a string names a named input when one of that name exists, and is a fileset otherwise. A fileset, like a json path, starts from {projectRoot} or {workspaceRoot}. {projectRoot} and {projectName} are replaced anywhere in it; {workspaceRoot} only at the start, and anywhere else it is glob text, as in Nx. A fileset starting from {projectRoot}/ selects the project’s files, and one from {workspaceRoot}/ the workspace’s. Write globs with / on every platform: \ escapes the character after it, so {projectRoot}/app/\\(marketing\\)/**/* in JSON selects the app/(marketing) directory. A path with no glob pattern names that file, or that directory and everything under it, so {projectRoot}/src, {projectRoot}/src/ and {projectRoot}/src/**/* select the same files.

As in Nx, a named input cannot use dependencies or projects, an input with "always": true and a projects pattern that matches no project is an error, and so is an externalDependencies entry naming a workspace project or, with a pnpm lockfile qk can read, a package nothing installs. With a package manager other than pnpm, lockfiles are always keyed by content. The runtime input environment determines which tasks can share a runtime evaluation. dependentTasksOutputFiles selects matching files from completed dependency outputs rather than their full output fingerprints. . and .. segments in paths are resolved within the workspace. A symlinked input is keyed by its target text and the content it resolves to, including the files below a linked directory; links that resolve outside the workspace are not supported.

As in Nx, a project’s filesets select only the files the project owns, each file belonging to the project whose root most specifically contains it. A project nested in another’s root owns its files, so they reach neither the outer project’s {projectRoot}/**/* nor, for a root project at ., its default; select files outside a project’s own with a {workspaceRoot} fileset.

File exclusions apply to every inclusion in the same input scope, regardless of declaration order. Workspace filesets and each project’s filesets have separate scopes: an exclusion in ^production cannot remove a file selected explicitly by a {workspaceRoot} fileset. Generated files selected by source filesets are keyed after dependencies complete, independently of dependentTasksOutputFiles; a task’s own declared outputs stay out of its source inputs.

Extended globs (?(…), *(…), +(…), @(…), !(…), (a|b) and {,…}) are expanded into plain globs. Some expansions follow Nx 23’s approximations: +(a|b) matches one occurrence, and an omitted group in a directory segment widens that segment to *.

Bare parenthesized alternation differs for affected-project selection. qk selects projects with **/*.(svg|png) inputs when SVG or PNG files change, while Nx 23.1.0’s affected-project matcher treats that group as literal text. Nx’s native cache hasher does expand the group. Use **/*.{svg,png} for consistent hashing and affected selection in both tools.

includeIgnored

A fileset only sees files that .gitignore and .nxignore do not exclude. With "includeIgnored": true it reads the disk instead, whether Git tracks a file or not, as in Nx 24:

{
  "inputs": [
    { "fileset": "{projectRoot}/generated/**/*.json", "includeIgnored": true },
    { "fileset": "!{projectRoot}/generated/**/*.local.json", "includeIgnored": true },
    { "fileset": "{workspaceRoot}/openapi.generated.json", "includeIgnored": true }
  ]
}

Each glob starts from {projectRoot} or {workspaceRoot}; one that climbs out with .., has a . segment or names an absolute path is an error. A glob is read from its leading directory: with no pattern after it, it names that file, or that directory and everything under it, and a path that names a missing file changes the key when the file appears. A glob read from inside a project matches only that project’s files, not those of a project nested below it, and one read from outside every project matches all it names. A walk does not enter node_modules, .git, .nx/cache, .nx/workspace-data, .yarn/cache or .qk unless it starts inside one, skips the timestamp files Vite and Vitest write beside their config, and does not enter a linked directory it meets. A linked file, or a directory named exactly, is read wherever it points.

A ! negation with includeIgnored filters every includeIgnored glob resolved with it: the project’s own, or, with "dependencies": true, the globs carrying that flag, applied together to each project the project depends on, directly or not. A negation with nothing to filter is an error.

Such a fileset reading another task’s declared outputs makes that task’s changes carry to this one in affected selection, as dependentTasksOutputFiles does. Affected selection matches a changed path against the globs, so a deleted file counts. Unlike Nx, the task’s own declared outputs stay out of these inputs, as they do for every fileset.

env and runtime

{"env": "NAME"} keys the declared variable’s value. Dotenv file content is not automatically keyed. {"runtime": "command"} keys command output; commands run once per run for each environment. Task-level affected selection treats both as unchanged because it cannot recover the base revision’s environment.

externalDependencies

{"externalDependencies": ["typescript"]} adds every installation of the named packages in the pnpm lockfile, whichever importer installs them. The task’s root/project/transitive importer installations are already keyed.

dependentTasksOutputFiles

{"dependentTasksOutputFiles": "**/*.d.ts", "transitive": true} is selects matching files from declared outputs of direct task dependencies. transitive: true also includes outputs of their dependencies. As in Nx, a dependency without declared outputs offers none, and other artifacts do not change this input’s key. Multiple patterns are combined. This and filesets or json globs selecting a dependency’s outputs are the only ways a dependency task keys a task, and the only ways a change to it makes the task affected.

workingDirectory

{"workingDirectory": "relative"} or "absolute" includes the directory qk was invoked from in the key.

json

{"json": "path", "fields": ["compilerOptions"], "excludeFields": ["scripts"]} selects dotted fields of a JSON or JSONC file. A path with no glob pattern is read even when source ignore rules exclude it, which Nx does not do. A glob selects, as in Nx, among the files a fileset from the same root would: the project’s own, or the workspace’s from {workspaceRoot}/, a dependency’s declared outputs among them once it has written them. A file named .yaml or .yml is read as YAML, which Nx does not do: Nx keys such a file by its whole content, so a key qk leaves unchanged can still change Nx’s. As in Nx, a file qk cannot read is keyed by its whole content, without the field filters.

Affected task selection compares the selected fields at both revisions, so a change to fields the input leaves out does not affect the task. A change it cannot compare counts, such as an added or deleted file, or one it cannot read at either revision. Project selection, as in Nx, does not read json inputs: a changed file touches the project that contains it, if any.

outputs

Outputs resolve as in Nx 24. {projectRoot} and {projectName} name the project, {options.name} reads the target’s options, with the task’s --name=value arguments applied, and the legacy {project.<path>} reads the project’s configuration, such as {project.sourceRoot}. A placeholder can fall back, left to right, to others and finally to a literal: {options.outputPath ?? options.outDir ?? {projectRoot}/dist}. A value is written as JavaScript writes it, so false is false, 0 is 0 and an array is its items joined with ,; only a missing option, null and "" have no value. Option values have {projectRoot}, {projectName} and a leading {workspaceRoot} resolved first, as Nx does when it builds the project graph. Braces that do not start with a token are glob text, so {projectRoot}/dist/**/*.{js,map} is one output, and so is {env.NAME}: environment variables are no placeholder in outputs. As in inputs, \ escapes a glob character, so {projectRoot}/app/\\(group\\) in JSON names the app/(group) directory. The project’s root and name go in as the paths they are, so {projectRoot}/dist of a project at apps/(group) names apps/(group)/dist, which Nx does not do; an option’s value goes in as written, a pattern if it holds one.

As in Nx 24, every output starts with a token, after its ! if negated: {projectRoot}/dist or {workspaceRoot}/dist, not dist, which nx repair rewrites. An output whose {options.…} placeholder, or a {projectRoot} or {workspaceRoot} left as text, has no value is left out. {workspaceRoot} may only start an output, and an output from it must name a directory before any pattern: {workspaceRoot}/dist/**, not {workspaceRoot}/**. Building the task graph fails on an output that breaks either rule. An absolute output from an option is read relative to the workspace, and one outside the workspace is an error. ! negates an output: what it matches is neither cached nor removed when an entry is restored. A target without outputs takes options.outputPath, and a build or prepare target dist/<root>, <root>/dist, <root>/build and <root>/public. Those defaults are cached and restored, but because they are guesses they are not declared outputs: they stay source files that other tasks’ filesets can select, and dependentTasksOutputFiles selects none of them.

A negated group such as src/**/!(*.test).ts or dist/!(cache)/** expands as Nx expands it, into a glob with the group widened and one naming the group’s items that excludes, so both tools select the same files, including where Nx’s expansion is surprising: *.!(ts) keeps only files ending in ., as in Nx. Nx applies such exclusions to every pattern of a project at once; qk applies each to the pattern it comes from, which can only add inputs.

A task with any other input declaration, or an output without a fixed directory prefix, runs uncached and reports why.

Use explicit output declarations for build artifacts. A cache hit removes existing matching outputs before restoring the saved result; negated outputs are preserved. An output pattern without a fixed directory prefix disables caching, with a reported reason, and so does an output naming a project’s root or a directory that holds a project, which a restore would replace.

Root .gitignore and .nxignore rules exclude files from source input discovery, including tracked files. Negations use Git ignore semantics. Watch uses the same rules, and project affected selection also applies the root ignore files. qk still hashes mandatory workspace/project configuration independently of source filesets, including the root .gitignore and .nxignore files themselves. Task-level affected selection follows these cache inputs, including explicitly declared JSON inputs and mandatory configuration hidden by source ignore rules.

Environment variables

Environment precedence

Environment precedence, highest first: FORCE_COLOR from color: true, configuration env, options.env, target-level env (a qk extension), the variables Nx sets for every task, the inherited process environment, the task’s dotenv files in Nx’s order: in the project root and then the workspace root, .env.<target>.<configuration>, .env.<configuration>, .env.<target>, then .env.local, .local.env and .env, each also as .env.<name>.local, .<name>.local.env and .<name>.env, then envFile. color, env and envFile are run-commands options; run-script reads none of them. A target that a plugin atomized, named in its project’s metadata.targetGroups with a metadata.nonAtomizedTarget, also reads the files of the target it was split from, after its own. Each atomized target reads its own files, where Nx gives every target of the group the files of its first atomized one. The task variables are NX_TASK_TARGET_PROJECT, NX_TASK_TARGET_TARGET, NX_TASK_TARGET_CONFIGURATION, NX_WORKSPACE_ROOT, LERNA_PACKAGE_NAME, NX_TUI=false, NX_INVOCATION_ANCESTOR_PIDS, NX_TERMINAL_CAPTURE_STDERR=true for a test target, FORCE_COLOR, which is true unless already set, and NX_TASK_HASH, the task’s cache key, which every task is given, as in Nx, whether or not the run uses the cache, except under --sandbox and input analysis.

Declared environments

A target with qk:env, or with nx.json’s qk:defaultEnv when it names none, inherits only the variables its environment lets through. Its environment applies with or without --sandbox. What the precedence above calls the inherited process environment holds then only the variables the environment inherits or passes, and the ones it sets. The rest of the task’s environment is built as before: its dotenv files, envFile, its env settings and the variables Nx sets apply on top, since its configuration declares them.

PATH is either a variable like any other, which the environment passes or inherits, or built from its bin and dirs entries. A built PATH holds a directory linking each command bin names, found on the PATH qk inherited, and nothing else from that directory, then the directories dirs names. The directory of links is in the user’s cache directory, under ~/Library/Caches/qk/bin on macOS and $XDG_CACHE_HOME/qk/bin or ~/.cache/qk/bin elsewhere, named by the commands it links, so tasks linking the same commands share one. On Windows, where making a link takes a privilege a user may not hold, PATH holds the directory of each command instead. Either way, qk adds the workspace’s node_modules/.bin directories and Node’s directory to it, as for every task.

A from entry runs its command in the workspace root, with every variable qk inherited, and sets the variables it prints as JSON: either Nix’s {"variables": {NAME: {"type": …, "value": …}}}, of which the exported variables are taken, less those nix develop also leaves as they are, such as HOME and TMPDIR, or a flat {NAME: value}, in which null leaves a variable unset. What it sets takes the place of an inherited variable, and the environment’s set entries take the place of what it sets. Its PATH ends a PATH built from bin and dirs entries, or is the task’s PATH without them; the environment cannot also inherit or pass PATH then. The command runs once per run for every task sharing it. With inputs, files relative to the workspace root, such as flake.nix and flake.lock, its output is kept in the user’s cache directory, readable by its owner alone, until they change, and the task’s key holds the command and their content rather than the output, which can hold paths that differ from machine to machine. Without them, the key holds a digest of each value it printed, which changes with the value without keeping it, as it can be a credential. A command that fails, or prints something else, fails the task.

The task’s key holds its environment’s entries, with named environments replaced by theirs, so a change to an environment changes the key of every task running in it, the values of the variables it inherits, and the file each command bin names resolves to, whatever links lead to it. A command found somewhere else changes the key; the rest of the inherited PATH, and where the links are, do not. A command written as {"name": "clang", "key": "content"} is keyed by its content instead: the same command installed elsewhere keys alike, and one replaced in place, as some system toolchains update, changes the key. Its digest is kept in the user’s cache directory until the file’s size or modification time changes.

A version manager’s shim, as mise and asdf install, resolves to the version manager itself, whichever version it starts, so neither its path nor its content changes with that version. Name such a tool’s directory with dirs, or set the version manager’s own environment with a from entry, such as mise env --json, rather than naming its shim in bin. The values of the variables it passes are not keyed. Without a declared environment, keys are as before.

A process reads its environment from its own memory, so the sandbox’s audit cannot see which variables a task reads: a variable a task needs and its environment leaves out shows as the task failing. When a task with a declared environment fails, qk names the inherited variables its environment left out that its dotenv files and settings do not set again, by name only, as values can be secrets.

Dotenv files

Dotenv files are read as Nx reads them, with npm’s dotenv and dotenv-expand. A task’s files are read together: the first to set a variable wins, a variable the environment already holds keeps its value unless it is empty, and then every value is expanded once, across all the files: $NAME, ${NAME}, ${NAME:-default}, ${NAME-default}, ${NAME:+alternate} and ${NAME+alternate} read the environment, then the values the files set before. A reference preceded by \ is left as $, and a value that refers back to itself expands to its default. Values in single quotes expand too, as dotenv-expand does not know about quotes. Lines are read as dotenv reads them: export is allowed, KEY: value works, quoted values may span lines, \n and \r are line breaks inside double quotes, an unquoted value ends at #, and a line dotenv cannot read is skipped rather than failing the run. envFile is read the same way, and so fills a variable set but empty, as in Nx 24.

As Nx does at startup, qk loads the workspace’s root .local.env, .env.local and .env, in that order of precedence, into its own environment before it reads its command line, so the run controls below can be set there; remote cache credentials arrive the same way. For each task, a variable whose value is one of those files’ is then removed again, so the task’s own files decide it, as in Nx. NX_LOAD_DOT_ENV_FILES=false turns every dotenv file off, envFile included, as in Nx; qk then removes nothing, where Nx still removes a shell variable whose value a root file repeats. For envFile, the task’s environment decides, so a task’s dotenv file can turn it off.

A task does not see NX_SET_CLI, NX_ORIGINAL_FORCE_COLOR, NX_TERMINAL_OUTPUT_PATH, NX_STREAM_OUTPUT or NX_PREFIX_OUTPUT from an enclosing run, as in Nx. It sees QK_TASK_INVOCATIONS, the tasks it runs inside, which a run it starts reads to refuse running one of them again. dotenv-expand’s use of JavaScript’s String.replace, which reads $& and similar patterns in a value it substitutes, is not reproduced.

Run controls

VariableBehavior
NX_DEFAULT_PROJECTFallback project for shorthand commands
NX_WORKSPACE_ROOT_PATHWorkspace root, unless --workspace names one
NX_INFER_ALL_PACKAGE_JSONS=trueEvery package.json is a project, in a workspace without a root package.json
NX_BASE, NX_HEADAffected comparison revisions; CLI overrides them
NX_LEGACY_AFFECTEDtrue makes affected selection select projects, false tasks; overrides nx.json affected.legacy, and --granularity overrides it
NX_PARALLELTask concurrency, overridden by --parallel
QK_CORESCore budget, overridden by --cores
QK_COLD=trueRun each task from scratch and save what it gives, as --cold does; ignored by a run that skips the cache
QK_RECORD=trueRecord tasks with qk:ultracache that execute, as --record does
QK_RECORD=auditRecord them and audit what they did beyond their declarations, as --record --sandbox does
QK_SANDBOX_OVERLAY0 holds Linux enforce by Landlock alone, rather than running each task over an overlay that keeps what it writes outside its outputs from the workspace
QK_RECORDERHow Linux records tasks: auto (the default) uses eBPF where it loads and strace otherwise; ebpf or strace uses only that one
NX_BAIL=trueStop starting tasks after a failure, unless --nx-bail=false
NX_IGNORE_CYCLES=trueBreak dependency cycles with a warning
NX_DEFAULT_OUTPUT_STYLEDefault output style, overridden by --output-style; an unknown name is ignored, as in Nx
NX_TASKS_RUNNER_DYNAMIC_OUTPUT=falseNo live panel; static instead
NX_STREAM_OUTPUTtrue streams every task’s output rather than holding it; false streams none but continuous tasks
NX_PREFIX_OUTPUT=trueUnder the static styles, streamed output carries the project prefix
NX_PROCESS_KILL_GRACE_PERIODMilliseconds a cancelled or stopped command has to exit after SIGTERM before it is killed; 5000 by default
NX_LOAD_DOT_ENV_FILES=falseLoad no dotenv file
NX_RUN_REPORTJSON report path, overridden by --report
NX_VERBOSE_LOGGINGTask verbosity; tasks see true or false, and --verbose or --verbose=false sets it; true also turns the live panel off
NX_SKIP_LOG_GROUPING=trueDisable static output groups in GitHub Actions
NO_COLOR, FORCE_COLOROutput color controls; FORCE_COLOR=0 or false keeps qk’s own output plain, and tasks receive it as set
NX_NATIVE_COMMAND_RUNNER=falseRun nx:run-commands targets with pipes rather than a pseudo-terminal
CICI output and remote cache mode selection; as Nx’s isCI, any value but false (0 included), or a CI provider’s variable such as GITHUB_ACTIONS=true, GITLAB_CI, BUILDKITE=true or JENKINS_URL

Cache controls

VariableBehavior
NX_SKIP_NX_CACHE=true, NX_DISABLE_NX_CACHE=trueBypass cache reads, writes and warm state
NX_SKIP_REMOTE_CACHE=true, NX_DISABLE_REMOTE_CACHE=trueLocal cache only
NX_CACHE_DIRECTORYOverride nx.json’s cacheDirectory
NX_MAX_CACHE_SIZEOverride nx.json’s maxCacheSize
NX_POWERPACK_CACHE_MODEOverride the remote store’s localMode/ciMode
QK_REMOTE_UPLOAD_MODEOverride the remote store’s uploadMode: wait or background
QK_REMOTE_BLOB_THRESHOLDThe compressed size from which an output is stored apart, such as 256KB, or off; 1 MiB by default
QK_REMOTE_PREFETCH=offDo not fetch entries ahead of the tasks that need them
QK_REMOTE_EXPECTATIONSWhat a run does with the task expectations the remote store holds: read-write (the default, as the store’s mode allows), read or off
QK_STORE_SERVER_TOKENThe store server’s token
QK_STORE_SERVER_URLOverride qk:storeServer.url
QK_PROFILE_CACHE=1Enable cache phase timings, as --profile does
AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKENRemote credentials
GITHUB_HEAD_REF, GITHUB_REF_NAMECI branch for remote warm state

See Workspace configuration for defaults and Remote caching for failure behavior.

Variables supplied to tasks

NX_TASK_TARGET_PROJECT, NX_TASK_TARGET_TARGET, NX_TASK_TARGET_CONFIGURATION, NX_WORKSPACE_ROOT, LERNA_PACKAGE_NAME, NX_TUI=false, and FORCE_COLOR describe the running task. NX_TASK_HASH holds its cache key. NX_VERBOSE_LOGGING is true or false, NX_INVOCATION_ANCESTOR_PIDS lists the qk and Nx processes it runs inside, outermost first, and a test target sees NX_TERMINAL_CAPTURE_STDERR=true, as in Nx. Targets using qk:threads additionally receive QK_THREADS and their configured thread variables. Targets using qk:warm receive their expanded warm environment when warm state is enabled. A task an affected profile with reachability narrowed to some of its cases receives QK_AFFECTED_CASES, the absolute path of a file naming those cases one per line, each relative to the workspace root; see Import reachability.

Nx compatibility

qk reads a bounded subset of Nx 24 configuration. It runs without invoking Nx or loading Nx plugins. The implementation, fixtures and parity tests establish supported behavior; preserving unknown metadata during inspection does not mean qk executes it.

Supported executors

nx:run-commands, nx:run-script and nx:noop are executable. Other executor names remain inspectable but fail execution preflight.

Project dependencies

Package dependencies create workspace graph edges when their ranges are workspace:…, *, a file: path to the package, or a semver range the package’s version satisfies (including prereleases by npm’s rules). Ranges such as catalog:, npm aliases, or ranges outside the workspace version do not create an edge. Names are looked up by dependency key, so "ui": "workspace:@scope/ui@*" does not link to @scope/ui. When a name occurs in multiple sections, precedence is dependencies, devDependencies, peerDependencies, then optionalDependencies. When two packages share a name, the name means the one whose package.json path sorts last, as in Nx. pluginsConfig["@nx/js"].analyzePackageJson turns these edges off. implicitDependencies adds selected project edges.

Deliberate differences

  • A pnpm lockfile change under projectsAffectedByDependencyUpdates: "auto" follows installed sets, avoiding projects whose installations are unchanged and including transitive installation changes Nx can miss. Task selection judges such a change by the same installed sets.
  • projectsAffectedByDependencyUpdates as a list of selectors touches its projects only when a lockfile changed; Nx’s project selection touches them on every change, which reads as a bug, since its task selection checks.
  • nx.json counts through what it changes. Nx hashes it whole into every task, so any change to it, even a comment, misses every task’s cache and affects every project and task. qk keys each task by its resolved configuration, which targetDefaults and namedInputs reach it through, and by the remaining settings that can change a task’s work. A change affects every project and task only where it changes those settings; one to targetDefaults or namedInputs affects the projects whose configuration it changes and the tasks whose key it changes; and one to comments, formatting or settings such as defaultBase affects nothing. A tool that reads nx.json declares it as an input. See What changes the key.
  • A change to the file nx.json extends counts as a change to nx.json does; Nx does not look at it.
  • Where no input selects a project’s project.json or project.local.json, they count through what they change. Nx hashes the whole configuration of every project whose inputs a task reads, so editing one target misses and affects every task of the project and of its dependents reading it through ^ inputs. qk keys each task by its resolved configuration and what remains of its own project’s, such as its root and output paths, so the edit misses and affects the tasks of that target. An input selecting the file, as {projectRoot}/**/* does, keys it as in Nx; a workspace opts in by leaving the files out of default. See What changes the key.
  • Task selection adds to what Nx selects every task whose qk key changed, by the parts of the key Nx does not hash (see below). Nx selects some tasks whose qk key is unchanged, such as for a changed tracked output or a lockfile change under "all"; qk selects them too, and they are cache hits.
  • Cache keys leave every planned task’s declared outputs out of source filesets, unless a task reads them from a dependency. Nx hashes a tracked output like any file, so a task regenerating one changes its own key.
  • The root tsconfig is keyed without the version of the installed typescript package Nx hashes beside it; qk keys what the task’s importers install instead.
  • The pnpm-workspace.yaml qk adds to every key leaves out its resolution keys, which reach tasks through the lockfile. A task whose inputs select the file keys it whole, as in Nx.
  • Cache keys always include the task’s project’s package.json, what its project.json decides for the task, and each dependency project’s package.json without its version, scripts and other metadata, even when the task’s inputs leave them out. Nx keys these files only through declared inputs. qk keeps them so that a task’s scripts, and where a dependency’s imports resolve, cannot change without a cache miss. See What changes the key.
  • A tracked upstream can adjust the affected base to avoid counting changes already landed upstream.
  • Deleting a project’s manifests affects the projects that depended on it, read from its manifests at the base revision, where Nx affects every project. See How changes reach projects.
  • qk affected takes --granularity task|project, which comes before NX_LEGACY_AFFECTED and nx.json affected.legacy. Nx has no such option. See Tasks or projects.
  • Nx’s Ultracache keys tasks by recordings Nx Cloud makes, which qk cannot read. qk’s qk:ultracache records tasks itself, with --record on Linux, and keys them by the files they read, the directories they listed and the paths they looked up, rather than by globs. Nx’s target ultracache field is read and merged, but has no effect. Inputs marked "always": true count for a task keyed by its recordings, and read as without the flag otherwise. See Keying tasks by what they read.
  • A target’s readyWhen is checked only while a task in the run waits for it, and its verdict is not shared with other qk processes. The local-ip hosts resolve to the machine’s outward address of their family, close to but not exactly the dev servers’ choice.
  • exec --exclude-task-dependencies runs the command in the selected projects; Nx’s leaves every project out and runs nothing.
  • From a package script, exec runs the script’s target with the run options it was given, and passes the arguments beyond the script’s own to the task. Nx runs nx run for it without the options, and reads those arguments as its own options.
  • When a command that runs tasks is given both options it does not know and arguments after --, qk forwards both, the unknown options first. Nx forwards only what follows -- and drops the others.
  • qk run <name> --target <target> runs the target on project <name>; Nx ignores the positional and uses the current directory’s project.
  • Under --nx-bail, a run that failed and left tasks unstarted exits with 1. Nx counts it as interrupted and exits with 130.
  • Task headers name qk rather than Nx: > qk run <task>, ✔ qk run <task> and ✖ qk run <task> failed where Nx prints nx run. Anything that reads logs by Nx’s headers has to match qk’s.
  • qk’s default output styles are its own: static in CI, the live panel on a terminal, and otherwise quiet for several tasks and raw output for one. Nx defaults to static-failures-only where it does not use its terminal UI, and to summary when driven by an AI agent.
  • --output-style=summary writes the output of failed tasks only, to the worktree’s state directory; Nx writes every task’s output to its cache directory.
  • project.local.json, nx.local.json, qk:threads, qk:warm, qk:affectedProfiles, qk:reachability, qk:env, qk:environments, qk:defaultEnv and a target-level env are qk extensions. Nx does not apply them: under Nx, a task inherits every variable.
  • A target default stays beneath a target the project’s files replace along the way, for instance when includedScripts makes a script target that project.json gives a command. Nx stamps the default with the executor the files end with, so the script target in between replaces it and the default is lost, unlike when the script is left out.
  • Two run-commands targets whose command is an array merge when the arrays are equal. Nx compares arrays by identity, so they never do.
  • run-script quotes each of the task’s arguments for the shell. Nx joins them as they are, so an argument with a space becomes several.
  • Options a dependency forwards with options: "forward" are taken as they are. Nx interpolates them again, which only normalizes as a path the text of a value holding braces, turning http://x/{args.y} into http:/x/{args.y}.
  • Tokens in a task’s arguments resolve with / separators on every platform; Nx joins them with \ on Windows.
  • Input negated-group exclusions apply to the pattern they came from in qk; Nx applies them across a project’s patterns. qk can include extra inputs.
  • A json input naming one file reads it even when source ignore rules exclude it; Nx reads only the files a fileset could select, so qk can key a file Nx leaves out.
  • An includeIgnored fileset leaves out the task’s own declared outputs, as every qk fileset does, so a task that writes into what it reads can still be cached. Nx keys them.
  • In filesets, json inputs, includeIgnored filesets and outputs, {projectRoot} and {projectName} go in with their glob characters escaped, so a project at apps/(marketing) reads and caches that directory. So do the project’s values in an output’s {project.…} and the root in the outputs a target without outputs takes. Nx inserts them as they are, which turns such a root into a pattern that misses the project’s files.
  • pnpm-workspace.yaml and lerna.json name workspace packages in a workspace without a root package.json too, and package.json dependencies make edges there; Nx then reads no workspace patterns and analyses no package.json.
  • With analyzeLockfile: false, a lockfile change under projectsAffectedByDependencyUpdates: "auto" affects every project even when no package changed, where Nx affects none; cache keys and externalDependencies inputs still read the lockfile.
  • Project discovery ignores the machine’s global Git excludes, and the .gitignore files above a workspace that is not in a Git repository; Nx reads both, so what they hide depends on the machine.
  • An unnamed project at the workspace root is named after the workspace directory; Nx names it .. Two configurations giving the same root are an error, where Nx merges them into one project, and a root outside the workspace is refused.
  • Project selectors read tag:* and directory:* as globs, where Nx takes them for every project, tagged or not.
  • An atomized target, one naming a metadata.nonAtomizedTarget, reads its own .env.<target> files; Nx reads those of the first atomized target in its group for every target of the group.
  • With NX_LOAD_DOT_ENV_FILES=false, a task keeps a shell variable whose value a root .env file repeats. Nx removes it, unloading files it never loaded.
  • A targetDefaults filter.projects given as a string is one pattern; Nx spreads it into one pattern per character.
  • Globs in project selectors, workspace package patterns, targetDefaults keys, project target keys and dependsOn targets do not support minimatch’s extended globs such as @(a|b): their characters match themselves.
  • For affected-project selection, bare parenthesized extension groups such as **/*.(svg|png) select projects in qk that Nx 23.1.0 does not. See Inputs and outputs for compatible syntax.

See Affected selection, Inputs and outputs and Running tasks for the detailed rules.

Limits

This is a subset of the design’s compatibility surface. There is no Nx parity claim yet. In particular:

  • The graph includes workspace projects only, as nx graph --file does, and is written as JSON only: qk has no HTML graph viewer, so graph --file <name>.html fails. --external adds a node per installation in the pnpm lockfile, npm:<name>@<version> with peers and patch in the version, with edges from projects to what they install directly and between installations.
  • Nx plugins and inferred targets are outside the design’s scope.
  • qk has its own live output panel, which takes no input, and does not implement Nx release commands. Nx adds an nx-release-publish target to each public package when @nx/js is installed; qk does not, as its executor is a release executor qk cannot run.
  • An externalDependencies entry naming a package nothing installs is an error only with a pnpm lockfile qk can read; with any other lockfile qk cannot tell, and keys the lockfile whole.
  • Pseudo-terminals are Unix only; on Windows commands see pipes, as they do in Nx unless NX_WINDOWS_PTY_SUPPORT is set. A pseudo-terminal passes \n through, where Nx’s turns it into \r\n.

The compatibility baseline is documented in Nx’s project configuration and workspace configuration references. The bounded subset above and fixture tests define qk’s current behavior; new Nx features are not automatically supported.

Architecture

qk separates workspace discovery from execution. Inspecting projects and exporting graphs reads configuration without running executor commands.

From a request to a run

  1. qk-config discovers the workspace and normalizes layered definitions.
  2. qk-graph builds project dependency edges and selects projects.
  3. qk-affected narrows selection when the request specifies changes.
  4. qk-taskgraph expands target dependencies into a task graph, resolves configurations, and checks cycles and continuous-task relationships.
  5. qk-executor prepares commands and child environments before execution.
  6. qk-runner schedules ready tasks with bounded concurrency and core shares, propagates failures and handles cancellation.
  7. qk-cache computes keys, restores results and warm state, and saves successful outputs. qk-lockfile supplies pnpm installation fingerprints.
  8. qk-history records runs and key changes. qk-cli presents results and optional JSON reports.

inputs analyze attaches a per-task recorder at command launch. The runner collects macOS Seatbelt reports or Linux traces, from qk-trace’s eBPF recorder or strace, and classifies observations using the cache’s input resolver. qk-input-analysis owns the report model and observation unions. Analysis never substitutes observations into cache keys.

--dry-run ends after planning. It does not load dotenv, validate executor support or execute runtime inputs, so a valid dry run does not guarantee a successful execution preflight.

Project graph and task graph

The project graph describes relationships between workspace projects, including package and implicit dependencies. Project cycles are allowed; reverse traversal terminates when a project has already been visited.

The task graph describes a particular request after dependsOn expansion. It may run one shared task needed by several projects. Task cycles normally fail; --nx-ignore-cycles drops the edge closing the cycle with a warning.

qk graph --file - exports project relationships; qk run web:build --graph exports the project and task graph without running commands.

Storage and worktrees

Within qk-cache, the internal record module owns result and warm-state record formats, identity checks, blob references and atomic publication. Remote packs, detached uploads and eviction use its shared record interpretation. Result and warm-state restoration retain their own selection rules and verify blob contents and checkout-specific paths where the files are used.

By default, linked Git worktrees share a result cache and history database under the Git common directory. File digests, restore records and active scratch state live under each worktree’s own Git directory. Branch names and checkout locations are not part of result keys.

See Local cache for key construction, Warm state for scratch state, and Run history for recorded causes.

Declared task inputs

The internal qk-cache::inputs module owns the resolved input model, declaration inspection and matching changes to file, linked-path and installation inputs. Affected selection supplies revision contents and propagates changes through the task graph; input analysis uses the same declaration interpretation to classify observations. Hashing evaluates content and runtime values for cache keys.

These uses preserve distinct rules: affected selection reacts to whole JSON input files, while keys include only the selected fields. Affected selection includes changes beneath links to the workspace root; hashing rejects those links. Revision matching does not evaluate environment or runtime inputs.

Design proposals

The implementation plan records design decisions and development milestones. Use the guides and reference for current behavior. Development describes crate responsibilities, parity tests, releases and benchmarks.

qk: design and implementation plan

qk (short for quick) is a standalone task runner written in Rust. It reads Nx-compatible workspace configuration without requiring an Nx process. Its design targets reproducible execution, precise change selection, and efficient caching across machines and Git worktrees.

This document records design intent and development milestones. The guides and configuration reference describe implemented behavior; a proposal here is not a claim that the corresponding feature is available.

Goals

  • Speed: keep workspace inspection and cached runs inexpensive, and measure cold and warm paths using reproducible synthetic benchmarks.
  • Affected precision: attribute file and package installation changes to the projects and tasks that consume them.
  • Caching: maintain a content-addressed local store with optional S3-compatible remote storage, output verification and bounded disk usage.
  • Observability: expose run results, timings and cache-key explanations through documented CLI output and versioned JSON reports.
  • Compatibility: support a clearly bounded subset of Nx configuration, with fixture coverage and explicit documentation of differences.

Scope

The configuration model covers nx.json, project.json, package nx objects and scripts, pnpm workspace membership and pnpm v9 lockfiles. Task definitions include named inputs, outputs, configurations, dependency expansion, caching and continuous execution.

Supported executor families are shell commands, package scripts and no-op aggregation. Commands use the platform shell and receive an explicit working directory, environment and forwarded arguments.

Nx plugins, inferred targets, generators, Nx Cloud and release automation are outside the initial scope. Unknown metadata may be retained for inspection without implying execution support. Any scope expansion needs its own design, fixtures and compatibility documentation.

Architecture

A Cargo workspace produces one qk binary. Libraries separate concerns:

ConcernResponsibility
ConfigurationDiscover projects, parse files and normalize layered definitions
Project graphResolve package and implicit dependencies and project selectors
LockfileResolve importer installations and package fingerprints
Affected selectionAttribute revision changes to projects or declared task inputs
Task graphExpand dependencies, resolve configurations and validate execution relationships
ExecutorPrepare commands, environments, process groups and output capture
RunnerSchedule tasks, manage concurrency and propagate failure or cancellation
CacheCompute keys, verify and restore outputs, manage local and remote storage
HistoryRecord runs, task results, key changes and critical paths
CLIMap command syntax to the library interfaces and render results

Workspace inspection must not execute task commands. Planning and execution preflight are distinct stages: a valid task graph does not establish that all executors, tools or runtime inputs can run successfully.

Design decisions

Explicit dependency graphs

Project edges come from package manifests and implicitDependencies. Task edges come from dependsOn expansion. Shared tasks execute once; missing targets, cycles and incompatible continuous-task relationships have explicit, testable behavior. Source import analysis is a separate possible extension, rather than an assumed graph input.

Inputs connect caching and affected selection

Resolved file inputs, environment declarations, runtime values, external packages and the dependency outputs a task reads determine its cache key. Task-level affected selection reuses the input model against a change set. Environment and runtime inputs require conservative treatment because a base revision’s process environment cannot be reconstructed from Git.

Lockfile changes are attributed by what each importer installs, including transitive dependencies, peer resolutions, patches and integrity data. Resolution-only pnpm workspace changes reach tasks through the lockfile; workspace membership and other configuration changes are handled separately.

Versioned, verified cache entries

Cache keys and storage layouts are versioned. Keys include the tooling and platform information needed to avoid incompatible reuse. Paths and serialized definitions are normalized deterministically.

A dependency counts for a task only through the declared outputs the task reads, as in Nx, so a dependency that reproduces them leaves the task cached even when its own inputs change. Reads a task does not declare are not keyed.

Per-file blobs allow deduplication across entries. Manifests identify output paths, content and file metadata. Restores verify content, remove stale declared outputs and preserve exclusions. Restored files must not share mutable storage with cached blobs.

Default result storage is shared by linked Git worktrees; file digests and restore bookkeeping remain specific to each worktree. Cache location overrides and eviction limits are configurable.

Optional remote storage

An S3-compatible store uses its own namespace and verified manifests. Reads fetch missing data; uploads publish output data before the manifest. Read-only operation, missing credentials and network failures have defined fallback behavior. Remote failures should be reported without turning a successful task into a failed run.

Warm state is separate from results

Incremental tools may reuse scratch state through a target’s qk:warm configuration. Warm state is not a valid task result and never establishes a cache hit. The tool must validate its own state before reuse.

Restore warm state before execution, save it after success, prevent conflicting scratch paths, and include it in storage accounting. Record its provenance and cost so users can assess its value.

Public run records

Persist run and task records with a versioned schema. JSON reports expose status, timing, cache results, key-change reasons and the critical path. Consumers should use this documented interface rather than private implementation details.

Development milestones

  1. Fixtures and baselines: create self-contained workspaces and capture project graphs, task graphs, affected sets, execution results and timings from a pinned Nx release. Record accepted differences with reasons.
  2. Read side: implement discovery, normalization, project selection, lockfile parsing and graph export. Validate against the fixtures.
  3. Affected selection: cover file ownership, dependency propagation, workspace configuration changes and installation-aware lockfile changes.
  4. Execution: implement dependency ordering, configurations, command preparation, bounded scheduling, failure propagation and cancellation. Validate continuous tasks and readiness using controlled fixture commands.
  5. Local caching: verify deterministic keys, output restoration, log replay, corruption handling and concurrent access across worktrees.
  6. Remote storage and observability: validate S3 modes, eviction, run history, key-change explanations and JSON reports.
  7. Distribution and documentation: publish platform binaries, prepare package-manager installation, and maintain searchable guides and references.
  8. Refinement: measure task-level affected selection, incremental warm state and concurrency controls using repeatable workloads. Expand support only when requirements and tests justify it.

Each milestone has observable acceptance criteria and runs in this project’s CI. Compatibility checks remain useful after a feature ships.

Test strategy

  • Unit tests: configuration merging, selection, dependency expansion, input resolution, lockfile fingerprints, interpolation and size accounting.
  • Synthetic fixtures: nested projects, package scripts, named inputs, configurations, continuous tasks, output exclusions and package changes.
  • Nx parity: compare both tools on identical fixtures using pinned dependencies. Differences require a documented explanation.
  • Execution and cache behavior: exit codes, skipped dependents, process cancellation, environment precedence, output replay, restore verification, concurrent writers and remote failure modes.
  • Cross-platform CI: Linux, macOS and Windows, including shell quoting, path handling and executable-file behavior.
  • Performance: record cold inspection, unchanged affected selection and fully cached runs on generated workspaces; investigate measured regressions.

Risks and open questions

  • Shell syntax and argument quoting differ across platforms; document the supported forms and use environment variables where forwarding is limited.
  • Native outputs require platform-aware cache keys; a shared store must not imply cross-platform artifact compatibility.
  • Cached file metadata can become stale; content verification and conservative fallback behavior must protect correctness.
  • Greater affected precision can change task selection; explain differences and keep regression cases demonstrating why the result is correct.
  • Warm state can be stale or incomplete; tools must validate it independently of result caching.
  • Keep bootstrapping independent of qk’s own task graph: Cargo builds the runner, and installation must work before workspace tasks can execute.

Bounded project watching

Project watching uses native filesystem events and the explicit project graph. It selects projects and optional dependency closures, refreshes discovery when files change, debounces event bursts, and queues edits while callbacks execute. Callbacks use the invocation directory and Nx’s project/file-change environment variables. Cancellation uses the executor’s process-group cleanup. It does not run plugins, sync generators or a daemon. Declared outputs, warm paths and runner state are excluded to prevent feedback loops. Tests cover dependency selection, new project discovery, queued changes and cancellation.

Warm state for native builds

Status: implemented. Warm state and the qk:warm reference describe the behavior; this section records why it takes this shape.

Native build systems keep two kinds of state. Some is bound to the checkout’s absolute path and trusts file timestamps, as Gradle’s execution history, CMake’s build directories and Ninja do. Some is content-addressed and path-independent, as ccache is when its base directory is the workspace root. The first kind makes rebuilds fast inside the worktree that produced it and is worth little elsewhere, where CMake refuses a cache created in another directory. The second kind carries across worktrees and machines. The portability and timestamp controls distinguish these two kinds of state.

A React Native Android debug build, whose prebuild step recreates the native project on every run, measured once on one machine:

SituationBuild
Same worktree, build state deleted by the prebuild35–40 s
Same worktree, build state moved aside and back7–10 s
Same worktree, build state restored with Unix-epoch timestamps24 s
New worktree, cold182 s
New worktree, Gradle state copied from another worktree167 s
New worktree, CMake state copied from another worktreeFails
New worktree, ccache filled by another worktree98 s

Keeping state across a destructive dependency

"android": { "qk:warm": { "paths": ["…"], "survive": ["prebuild-android"] } }

survive names dependency targets that delete the warm paths. qk moves the paths aside before such a dependency runs and back after it succeeds, so the state keeps its files and timestamps without being stored or copied. If the dependency fails, the paths return unchanged. A path the dependency itself recreates is replaced by the kept one, so survive suits generated trees that hold build state, not a dependency’s own outputs.

Timestamps

mtimes: "preserve" restores files with the modification times they were saved with, instead of the Unix epoch. Epoch times make tsc --build check sources against restored state; they also make Ninja rebuild every restored object and Gradle rehash every restored file. Preserved times are only meaningful against the worktree that saved them, so they apply to a worktree’s own save and fall back to the epoch for any other.

Preferring the worktree’s own save

A restore chooses the worktree’s own most recent save before any other worktree’s. Before this, the most recent save of the task won, whichever worktree made it.

Groups that do not relocate

portable: false, the default, restores a group only from the worktree that saved it. State that records absolute paths, such as CMake build directories, then never reaches a checkout it would break or merely occupy. Portable groups can restore from other worktrees and the remote store.

Named groups shared between targets

"qk:warm": {
  "group": "ccache",
  "portable": true,
  "env": { "CCACHE_DIR": "{warm}", "CCACHE_BASEDIR": "{workspaceRoot}" }
}

A named group is one piece of warm state that several targets use, as one ccache serves every native build. Its targets share the live directory, which the tool itself keeps consistent, so only restoring and saving it are serialized, and each save holds everything the directory held. A group has no outputs or paths, which belong to one task. Combined with remote warm state, a new worktree or machine starts from the default branch’s ccache.

Choosing a save by key

"qk:warm": {
  "key": ["{workspaceRoot}/pnpm-lock.yaml", { "env": "ANDROID_NDK_VERSION" }],
  "restoreKeys": 1
}

A group keeps several saves, each labeled with a hash of the declared key inputs. A restore takes the save with an exact key, else one matching the first restoreKeys entries, else none. Among equal candidates, the save made at the commit nearest the checkout’s HEAD in Git history wins over the newest, so a worktree cut from main starts from main’s state. The key only chooses a save; it never makes warm state a result.

Excluding and globbing paths

A path starting with ! omits files no later build reads, such as packaged artifacts that are rebuilt on every run, as it does for outputs. Glob patterns in paths name directories whose location depends on package versions, as pnpm places a package’s native build under a versioned directory. Excluded files stay out of task inputs, like the rest of a warm path.

Saving in the background

save: "background" saves after the task has reported success, so the next task starts without waiting. Saves and restores of a group are serialized, and the run waits for background saves before it finishes its remote uploads, which a save may add to.

Suggesting warm paths

qk warm suggest <task> runs the task in the sandbox and lists what it wrote outside its declared outputs as candidate warm paths, each under its topmost directory that holds no source file. Native builds write to places that are hard to guess, such as build directories that a build script moves to the workspace root. It needs macOS, where the sandbox can report rather than refuse.

Reporting warm state’s effect

Run records hold where warm state came from, what was restored, which groups were on disk already and how long saving took. qk show task compares the task’s duration from warm state with its duration without, which shows whether its warm state is worth its size.

Import reachability

Status: implemented. Import reachability describes the behavior; this section records why it takes this shape.

A project is affected when a project it depends on changes, whether or not its code uses what changed, and a task is affected when any of its inputs changes. Both are safe, and both are coarse where the work is expensive: an app’s native builds when a shared package changes in a corner only another app imports, or a visual regression suite whose every case runs when one component changes. As code moves into shared packages, more changes pay for work they cannot affect.

fallout answers the missing question: whether any file reachable by imports from an anchor, such as an app’s entry point or a test case, is one of the changed files. qk can ask it in two places, with one vocabulary.

Anchors, cases and sources

  • Anchors are files whose imports a task depends on through, such as an app’s entry points and build configuration, or a test suite’s harness. If any anchor reaches a change, the whole task runs.
  • Cases are files a task runs separately, such as the case files of a visual regression suite. Each is asked about on its own, and a task can run only the cases a change reaches.
  • Sources are the changed files that matter only through imports, so that fallout’s answer is the whole answer for them. Any other change that reaches the task, such as build configuration, a lockfile install or a test harness outside its anchors, runs it, because no import carries it.

A workspace names its configs once in nx.json, and each target takes part through a name, settings of its own, false, or the profile’s default:

{
  "qk:reachability": {
    "app": {
      "anchors": ["{projectRoot}/index.tsx", "{projectRoot}/bundler.config.js"],
      "sources": ["{projectRoot}/src/**/*.ts", "{workspaceRoot}/packages/*/src/**/*.ts"]
    },
    "unit": {
      "anchors": ["{projectRoot}/test/setup.ts"],
      "cases": ["{projectRoot}/src/**/*.test.ts"],
      "sources": ["{projectRoot}/src/**/*.ts", "{projectRoot}/test/**/*.ts", "{workspaceRoot}/packages/*/src/**/*.ts"]
    }
  },
  "qk:affectedProfiles": { "reach": { "reachability": { "default": "app" } } }
}
{
  "name": "app",
  "targets": {
    "build": {},
    "test": { "qk:reachability": "unit" },
    "lint": { "qk:reachability": false }
  }
}

Paths take the usual {projectRoot} and {workspaceRoot} tokens, expanded for each task’s project, so one config serves every project laid out alike, and a target’s value can come from targetDefaults. A task that takes part in no config is never narrowed. A package.json is never a source: it decides what imports resolve to.

The settings belong to tasks because what a project’s tasks read differs. An app’s build reads what its entry points import, while its unit tests read a harness and helpers the app never imports. Settings on the project could only describe one of them, and leaving the project out would skip the other.

Reachability is opt-in through an affected profile, as change projections are, and changes no cache key. "reachability": true enables it without a default.

Project selection

Under the profile, project selection decides each project ordinary selection affects by its tasks: every target of it, with the tasks they depend on, are selected by their inputs and decided as below. The project is left out when the profile left out every task of it a change affects. One with a task that still runs is kept, and so is one none of whose affected tasks the profile decides, so reachability is the only way a project is left out. Each dependent is decided by its own tasks, since its inputs say what reaches it.

This is where a workspace decides which app builds a change runs, while checks keep ordinary selection and the default branch builds everything.

Task selection and cases

Under the profile, a task with settings that ordinary task selection affects is decided by its changed inputs:

  1. Any changed input its sources do not match runs the whole task, and says which file, unless it is a dependency’s package.json whose fields its dependents read are unchanged. So does an affected task whose outputs it reads.
  2. Otherwise, if an anchor reaches a change, or the search lost an edge, the whole task runs, naming the chain or the gap.
  3. Otherwise, the cases a change reaches are selected, each with the changed file it reaches. With none, the task is not affected.

qk show tasks --affected --json lists the selected cases and why, and qk passes them to the task in QK_AFFECTED_CASES, the path of a file listing them, one per line, which is absent when the whole task runs. A task that does not read it runs every case, which is always safe. A suite’s own rules, such as running the cases that show translated copy when a translation catalogue changes, stay in the suite, which can add cases to qk’s list but should not drop them.

The task’s ordinary inputs are what make this sound: a change to the suite’s harness, its workflow or the files a native build is compiled from reaches the task only if they are inputs, and as inputs that are not sources they run every case. Declaring them is the workspace’s half of the contract.

Explaining it

qk show affected and --json list each project the profile left out with what it decided for each affected task, and qk show tasks --affected each task and case it did not select, with the anchors searched. For each project, task or case it kept, they give the reason: the chain of imports that reached a change, the gap, or the change imports cannot carry. A workspace can record the profile’s answer beside ordinary selection without acting on it, and compare what it would have skipped with what later broke on the default branch, before letting it skip anything.

Requirements and limits

  • The head revision must be the checkout, since fallout reads files from disk. With --head naming anything else, the profile keeps everything and says why.
  • Workspace packages must be linked into node_modules. Without them, imports between workspace packages resolve to nothing, which counts as a gap, so nothing is left out: the profile is safe and saves nothing.
  • Only static imports are seen. Code a bundler adds through configuration, native code and requests built at run time are not, which is why changes outside sources keep everything.
  • fallout’s bundler settings, such as which package.json fields an app’s bundler reads, come from the app’s fallout.toml.
  • fallout is a library dependency of qk-affected, at a released version.

Not decided yet

  • Whether symbol granularity is worth its cost over file granularity.
  • Whether cases should be passed to tasks in another form as well, such as an argument.
  • Discovering anchors from a bundler’s configuration rather than declaring them.

Development

cargo fmt --all -- --check
cargo clippy --workspace --all-targets --locked -- -D warnings
cargo test --workspace --locked
cargo build --release --locked

The Cargo workspace contains twelve crates:

CrateResponsibility
qk-configWorkspace discovery, JSONC/YAML parsing, typed project configuration and normalization
qk-graphWorkspace edges, project selection, reverse reachability and graph export
qk-taskgraphDependency expansion, configuration selection and task DAG validation
qk-executorCommand preparation, environment, argument interpolation, process groups and output capture
qk-runnerBounded task scheduling, dependency failure propagation and cancellation
qk-lockfilepnpm v9 lockfile parsing and what each importer installs
qk-cacheInput hashing, cache entry storage, output restoration and log replay
qk-affectedGit change analysis and project/task affected selection
qk-historySQLite run history, task records and cache-key explanations
qk-input-analysisFile-access report schema, conservative suggestions and compatible observation unions
qk-traceThe eBPF recorder: its kernel program, privileged helper and strace-format traces
qk-cliArgument parsing and output; produces the qk binary

Fixture and CLI tests use self-contained workspaces.

Parity with Nx

tools/parity/parity.mjs measures qk against Nx. By default it uses the synthetic workspace in tools/parity/fixture and the goldens committed in tools/parity/goldens, captured with the Nx release pinned in tools/parity/nx. It needs Node, and pnpm for the cases that run package scripts:

node tools/parity/parity.mjs compare --qk target/release/qk

Each run builds a scratch git repository from the fixture. The cases in tools/parity/cases.json compare task graphs, show projects --affected for a change applied on a branch (the files in tools/parity/changes/<case>, plus any deletions the case lists), with the affected tasks of affectedTargets and the projects show projects --affected -t lists for them, by default and with nx.json affected.legacy, and runs, which execute tasks without cache and compare whether the run failed and which tasks ran. The comparison requires byte-identical show projects --json, a graph --file equal after normalisation, and --graph task graphs with the same tasks, with the same target, root, outputs and cache, continuous and parallelism flags, and the same dependencies and continuous dependencies. The differences it normalises away are listed at the top of the script. An affected or run case that differs fails unless it records the exact difference it accepts and why.

After changing the fixture or the pinned Nx, recapture and commit the goldens:

(cd tools/parity/nx && pnpm install)
node tools/parity/parity.mjs capture

capture --check captures into a scratch directory and fails when the committed goldens differ. CI runs both compare and capture --check.

The harness can also point at any workspace with its own Nx installation and git history, reading cases from <goldens>/parity.json, with affected cases given as base and head revisions:

node tools/parity/parity.mjs capture --workspace <dir> --goldens <dir>
node tools/parity/parity.mjs compare --workspace <dir> --goldens <dir> --qk target/release/qk

For an external workspace, capture also records Nx’s median wall times, and compare prints them beside qk’s.

Releases

Releases are cut with cargo-release (cargo install cargo-release --locked), configured under [workspace.metadata.release] in the root Cargo.toml. From an up-to-date main with a clean working tree:

cargo release <patch|minor|major>             # dry run: shows the new version
cargo release <patch|minor|major> --execute

This bumps the version shared by every crate, commits it as chore: Release, creates the single tag v<version> and pushes the commit and the tag. The tag then triggers the release workflow below, which is the only thing that publishes: the GitHub release, the npm packages and the crates.io crates. Never run cargo publish by hand, and do not push the tag before the version bump is on it, because the workflow rejects a tag that does not match the workspace version.

Pushing a tag v<version> that matches the workspace version runs .github/workflows/release.yml: it builds qk for Linux x64 and arm64, macOS arm64 and Windows x64 and attaches the binaries to a GitHub release. When the repository variable NPM_PUBLISH is true, the workflow publishes npm packages generated by cargo-npm 0.1.4 using trusted publishing (OIDC). Each package must trust the GitHub repository Pajn/qk and workflow release.yml, with permission to publish directly and no environment constraint. The npm job grants id-token: write; no npm token secret is needed. The main package is @runqk/cli, exposing the qk command. Its platform dependencies are:

  • @runqk/cli-linux-x64
  • @runqk/cli-linux-arm64
  • @runqk/cli-darwin-arm64
  • @runqk/cli-win32-x64

The names and targets are configured in crates/qk-cli/Cargo.toml under package.metadata.npm. Platform packages publish before the main package. The workflow checks that all four are present before publishing. With NPM_PUBLISH unset, GitHub binary releases still run and npm publishing is skipped.

The workflow also publishes every crate to crates.io through trusted publishing, with tools/release/publish-crates.sh. It publishes only the crates whose version crates.io does not hold yet, in dependency order. crates.io can be slow to list a crate it has just accepted, which fails the publishing of the crates depending on it, so the script tries again, publishing what is still missing. If publishing still stops partway, run the workflow by hand with the release’s tag (gh workflow run release.yml -f tag=v<version>): that run publishes the release’s missing crates and nothing else.

To inspect packages locally after compiling the configured release targets:

cargo install cargo-npm --version 0.1.4 --locked
cargo npm generate -p qk-cli

npm bootstrap and trusted publishing

The five npm packages start at version 0.0.0 under the bootstrap tag. These placeholders contain licensing and metadata but no native executable. They allow configuring trust before the first functional binary release. Their generator is separate from cargo-npm’s release packaging:

node tools/npm/bootstrap.mjs --repository Pajn/qk --out /tmp/qk-bootstrap

For initial setup, log in to npm locally with an account that owns the packages and has two-factor authentication enabled. Publish the four platform packages from that directory, then @runqk/cli, using npm publish <directory> --access public --tag bootstrap. Configure and verify trust for each package:

npm trust github <package> --repository Pajn/qk --file release.yml --allow-publish --yes
npm trust list <package>

These commands require npm 11.15.0 or newer and may request browser verification. If the default user configuration is read-only, select a writable login configuration with NPM_CONFIG_USERCONFIG=/path/to/.npmrc. After verifying all five trust entries, set the GitHub repository variable NPM_PUBLISH to true. Push the release workflow before creating a matching version tag. See npm’s trusted publishing documentation.

crates.io releases

The release workflow publishes all ten workspace crates to crates.io through OIDC, with qk-cli published after its internal dependencies. Install the CLI with cargo install qk-cli --locked. Internal crate dependencies use exact workspace versions so a release consistently uses the matching source. CI verifies the packaged distribution with cargo publish --workspace --locked --dry-run, including compiling the extracted crates without workspace paths.

CI and release builds also run installation smoke tests on all four release platforms, including Linux arm64. They pack the host packages generated by cargo-npm and install their tarballs offline into a fresh npm prefix. The installed command shim runs from an independent workspace whose paths contain spaces. The same checks run against an executable extracted from a binary archive: version, project discovery, strict doctor, task execution, cache restoration after deleting outputs, and invalidation after changing inputs. No packages are published by these tests. To run them locally:

cargo npm generate -p qk-cli --target <host-triple>
node tools/npm/smoke.mjs --packages npm --binary target/<host-triple>/release/qk

Use qk.exe on Windows. The release build must already exist for the host triple.

Trusted publishing must be configured for every workspace crate, since the short-lived token can only publish crates that trust this workflow. Each crate must trust GitHub owner Pajn, repository qk, and workflow release.yml, with no environment constraint. Configure these under each crate’s Settings → Trusted Publishing on crates.io. See crates.io’s setup guide.

crates.io requires an initial local publication before trust can be configured. qk-cli already has a metadata-only 0.0.0 reservation, whose isolated source is retained in tools/crates-io/qk-cli. The nine dependency crates also need an initial publication and trust configuration before the first workflow release. Generate their metadata-only 0.0.0 reservations with python3 tools/crates-io/bootstrap.py --out /tmp/qk-cargo-bootstrap, then publish each generated manifest locally with cargo publish --manifest-path <manifest>. These placeholders have no public API; functional releases use the workspace source and version. Subsequent releases need no long-lived Cargo token in GitHub secrets. The workflow verifies the packages before requesting its short-lived token.

Benchmarks

tools/bench/bench.mjs generates a workspace of 300 projects, 3,600 files and a 3,000-package pnpm lockfile, populates the cache, and measures with hyperfine: show projects, graph, show projects --affected with no changes, and a fully cached run-many, once with every output in place and once with every output removed first, so all 9,300 are restored. Both cached runs also run with .nxignore enabled to exercise filesystem walking and ignored generated files. A separate restoration matrix uses the same graph with both repeated and distinct blob contents, absent and stale complete output directories, and partial restoration with an excluded sentinel. Every measured restoration is followed by an untimed check of all output contents, stale-file removal, exclusion preservation and build counters, so cache misses cannot pass as restoration measurements. Preparation and verification are outside timing. Watch samples measure each source edit to the callback’s timestamp, with Git ignore rules and with .nxignore, including native event delivery, debounce, workspace reload and Node startup. Two initial samples are discarded; results are observations, with no timing thresholds in CI. It needs hyperfine, git and Node:

node tools/bench/bench.mjs --qk target/release/qk [--projects 300] [--runs 10] [--json out.json] [--markdown out.md]

CI runs it on every push, writing the table to the job summary and the numbers to an artifact.

GitHub Actions is configured for Linux, macOS and Windows. The lockfile is checked in for reproducible dependency resolution. On Windows, C dependencies are compiled with clang-cl (CC_x86_64_pc_windows_msvc=clang-cl), in CI and for releases alike: tree-sitter-scss, which fallout parses stylesheets with, passes its C compiler a GCC-style warning flag that cl.exe rejects.

See Maintaining the documentation for book builds and Pages setup.

The parity harness also captures isolated behavioral cases: direct/transitive selective dependency artifacts, broad output globs, .nxignore negations, stdin lists (including spaces and empty input), and watch callbacks queued while a command runs. Capture uses the pinned Nx daemon only for the watch oracle; qk remains daemon-free. Callback records live outside task inputs. Distinct edit timestamps avoid coarse metadata-cache timing artifacts. The Rust watch integration test runs on all three CI platforms; Windows uses a private console so cancellation cannot signal parallel tests.

Maintaining the documentation

The book’s Markdown sources live in docs/src. docs/src/SUMMARY.md defines the sidebar, and docs/book.toml configures the theme and built-in search. Add new pages to the summary so they are built and indexed.

Build and preview

Install the same mdBook version as CI, then run these commands from the repository root:

cargo install mdbook --version 0.5.4 --locked
mdbook build docs
mdbook serve docs --open

mdbook serve watches the source files and rebuilds as you edit. Generated HTML goes into docs/book/, which Git ignores. Search runs in the browser using the index generated during the build; no external search service is needed. Press / or s to open search.

Publish on GitHub Pages

Once the repository is hosted on GitHub:

  1. In Settings → Pages → Build and deployment, set Source to GitHub Actions.
  2. Push the documentation workflow to the repository’s default branch.
  3. Open the Documentation workflow run. Its deployment links to the site.

The workflow builds documentation on pull requests and pushes that touch its sources. Only the default branch deploys; Run workflow can also deploy from that branch. The github-pages environment records deployments. The build and deployment jobs use GitHub’s Pages artifact workflow. Generated files are uploaded as an artifact rather than committed to a branch.

The workflow obtains the site’s base path from GitHub Pages, so it also works with a renamed repository or a custom domain. site-url in docs/book.toml is the fallback for local builds. CI also supplies a link to the GitHub repository without hardcoding an owner in the source.

Writing reference pages

Document implemented behavior, with types, defaults, precedence and a small example. Give configuration keys their own headings so search results can link straight to the option. Explain when an accepted Nx setting has no effect in qk. Keep examples aligned with the fixtures and implementation. Use relative Markdown links between pages, and label design proposals clearly. The design plan is excluded from search so proposals do not compete with the reference for implemented behavior.

Before committing, run mdbook build docs and check changed pages and search in the preview. Pull requests build the book but do not publish it.