# Build extensions

> Declare your own build tasks in JavaScript or TypeScript

Elide embeds a compiler, a Maven resolver, a container assembler and a test runner. It does not embed `protoc`, `shellcheck`, or whatever your project needs next. A **build extension** is how you teach your build about one — as a JavaScript or TypeScript module Elide evaluates while it configures the build.

An extension declares tasks. Each one becomes a real task in the graph: you can name it as a build target, depend on it from another task, put it in a group, and see it in `elide build --inspect`.

## Declaring an extension

List it in your manifest, relative to the project root:

```pkl
amends "elide:project.pkl"

name = "demo"

extensions {
  "tools/protoc.ts"
}
```

Extensions are declared, never discovered, so running one is always something your project asked for. A path that does not exist fails the build rather than quietly registering nothing.

A path is resolved against your project root and has to stay inside your source tree — your project root, or the workspace root when your project is a member of one, so members can share a file above their own roots (`../tools/protoc.ts`). An absolute path outside the tree, a path that climbs out of it, and a symlink that points out of it are all refused, each reported like any other bad declaration.

A declaration names one of two things, and Elide decides which from the file extension alone: an ES module — `.ts`, `.mts`, `.js`, `.mjs`, `.jsx`, `.tsx` — or a `.elx` package archive (see [Packaging an extension](#packaging-an-extension)). `.cjs` is not accepted: the contract is a default export, which a CommonJS script cannot declare, and a `.js` extension is always read as a module whatever the nearest `package.json` says. Anything else fails the build rather than being guessed at.

An entry does not have to be a path at all: it may name a package to download from a URL, npm or Maven. See [Fetching an extension](#fetching-an-extension).

## Writing one

```ts
export default ({ task }: ElideBuild) => {
  task(
    "protoc",
    {
      description: "Generate protobuf sources",
      group: "compile",
      inputs: ["proto/**/*.proto"],
      outputs: ["src/main/generated"],
    },
    (ctx) => {
      const result = ctx.exec("protoc", [
        `--kotlin_out=${ctx.layout.projectDir}/src/main/generated`,
        "proto/api.proto",
      ]);
      if (!result.ok) ctx.fail(result.stderr);
    },
  );
};
```

Then:

```bash
elide build --inspect   # protoc  Generate protobuf sources
elide build protoc      # run just that task
elide build compile     # run it with everything else in its group
```

Types ship in [`@elide-dev/types`](https://www.npmjs.com/package/@elide-dev/types) as `ElideBuild`, `ElideBuildTaskContext` and `ElideBuildTaskOptions`.

Your module's default export runs while the build is being configured, and may be `async`. Each task's action runs when the task does, and may also be `async`; the build waits for either.

## Packaging an extension

An extension that outgrows a single file ships as an archive: a zip with a manifest named `extension.json` at its root, named `.elx`. The name is not decoration — the declaration's file extension is what tells Elide it has a package, and `.elx` is the only one that does, so `tools/protoc.elx` loads and the same bytes named `tools/protoc.zip` do not.

```
protoc-tasks/
├── extension.json
├── tasks.ts
└── lib/
    └── flags.ts
```

```json
{
  "name": "protoc-tasks",
  "version": "1.0.0",
  "description": "Generates sources with protoc",
  "license": "Apache-2.0",
  "repository": "https://github.com/example/protoc-tasks",
  "homepage": "https://example.dev/protoc-tasks",
  "authors": ["Example Inc."],
  "type": "source",
  "entrypoint": "tasks.ts"
}
```

The archive's manifest requires `name` and `type`. `type` is `source` — the one kind there is — and a `source` package must also name an `entrypoint`, a path relative to the archive root; one that points outside the archive is rejected. Everything else is metadata Elide reports back to you, and keys it does not know are ignored, so a package may carry keys of its own.

### Building the package

Declare the package as an artifact and `elide build` produces it:

```pkl
amends "elide:project.pkl"

import "elide:Extensions.pkl" as Extensions

name = "protoc-tasks"
version = "1.0.0"
description = "Generates sources with protoc"

artifacts {
  ["tasks"] = new Extensions.ExtensionArchive {
    entrypoint = "tasks.ts"
    srcs { "lib/**/*.ts" }
    license = "Apache-2.0"
    repository = "https://github.com/example/protoc-tasks"
    authors { "Example Inc." }
  }
}
```

That registers a task named `package-extension-tasks`, run by a plain `elide build`, which writes `.dev/artifacts/tasks.elx`. The `extension.json` above is written for you: `name`, `version` and `description` fall back to the project's own, and `type` is always `source`.

`entrypoint` is the module whose default export declares your tasks. `srcs` are the modules it imports, as paths or glob patterns relative to the project root; the entrypoint itself is always packaged. Files keep the paths they have in your project, so relative imports resolve inside the archive exactly as they do in your checkout. Every packaged file must be inside the project once its links are resolved: a symlink pointing out of the project fails the build rather than shipping what it points at.

You can of course still build the zip yourself, from the directory holding the manifest, so that `extension.json` lands at the root:

```bash
cd protoc-tasks && zip -r ../tools/protoc.elx .
```

### A source package

`"type": "source"` means the archive carries your source, and the entrypoint's file extension decides how it is loaded — exactly as it does for a file you declare directly. A `.ts`, `.tsx`, `.mts`, `.js`, `.jsx` or `.mjs` entrypoint is evaluated as a guest module, and because Elide unpacks the archive before evaluating it, relative imports inside the package (`import { flags } from "./lib/flags.ts"`) resolve.

Nothing is bundled or compiled on the way in: a package ships the sources you wrote, and you write the same extension whether it travels as one file or as an archive — the same default export, the same `task(...)`, the same `ctx`.

### Declaring a package

Like a local extension — a path, with settings if it takes them — or as something to fetch:

```pkl
extensions {
  "tools/shellcheck.elx"
  new {
    from = "tools/protoc.elx"
    config { greeting = "hei" }
  }
}
```

Elide unpacks the archive under your build directory, in `.dev/extensions/packages/<archive>-<hash>/contents/`, and unpacks it again whenever the archive changes. An entry that would be written outside that directory is rejected rather than unpacked.

### What `--inspect` reports

`elide build --inspect` prints an `Extensions:` section covering everything your manifest declared. For each one it shows the declaration you wrote, the package's name and version, its description, what kind of extension it is — `JavaScript/TypeScript module` or `packaged extension` — its license, its repository, and the names of the tasks it declared.

```
Extensions:
  tools/protoc.elx   protoc-tasks 1.0.0
    Generates sources with protoc
    packaged extension · Apache-2.0 · https://github.com/example/protoc-tasks · Example Inc.
    tasks: protoc-generate, protoc-clean
```

Distributing the archive is up to you: attach it to a release, serve it from a URL, publish it as an npm package with whatever you already use. However it travels, a project consumes it the same way — see [Fetching an extension](#fetching-an-extension).

## Fetching an extension

An extension does not have to live in your project. A declaration that opens with one of three reserved schemes names something to download:

```pkl
extensions {
  "url:https://downloads.example.com/protoc-tasks-1.4.0.elx"
  "npm:@acme/protoc-tasks@1.4.0"
  "maven:com.acme:protoc-tasks:elx:1.4.0"
  new {
    from = "npm:@acme/codegen@2.0.1"
    config { proto = "src/proto" }
  }
}
```

Only `url:`, `npm:` and `maven:` are reserved. Everything else is a path, so `C:\tools\ext.ts` still means what it says, and a scheme Elide adds later cannot change how a manifest that already builds is read.

Past the download nothing is different. A fetched `.elx` loads as a package and a fetched `.ts` as a guest module — what it resolved to decides, not how it arrived. A `url:` must be an absolute `https` URL; cleartext `http` is refused except to a loopback host, which is there so you can test against something local. Redirects are followed — up to five hops — and every hop is held to that same rule, so a redirect cannot carry a loopback fetch out to another machine in cleartext.

### Versions are pinned

A remote version names exactly one release. `npm:tasks` and `maven:com.acme:tasks` are rejected by the manifest schema for naming none; `npm:tasks@^1.2`, `npm:tasks@latest` and `maven:com.acme:tasks:1.2.3-SNAPSHOT` are rejected for naming more than one.

That is the cache's doing. A fetched extension is stored under what you declared, so a moving version would either cost a registry round trip on every build — making `--offline` impossible — or silently freeze at whatever your first build happened to see.

### Where it is cached

Fetched artifacts land in `.dev/dependencies/extensions/remote/`, beside your resolved Maven repository and shared by a whole workspace the same way. Because the declaration is pinned, what is cached is never stale: only the first build reaches the network, and `elide build --offline` works from then on. A declaration that has not been fetched and cannot be fetched says so:

```
Build extension 'npm:@acme/protoc-tasks@1.4.0' has not been downloaded, and --offline forbids fetching it
Run the build once without --offline to populate the cache.
```

Whatever digest the source publishes is checked: npm's `dist.integrity` or `dist.shasum`, and Maven's `.sha1` sidecar. A repository that serves an artifact but no digest is fine; one that serves both and disagrees with itself fails the build.

### Pinning the bytes

A version pin says which release runs; an `integrity` pin says which bytes. Spell it as Subresource Integrity — `sha256-<base64>` or `sha512-<base64>` — beside the source it belongs to:

```pkl
extensions {
  new {
    from = "url:https://downloads.example.com/protoc-tasks-1.4.0.elx"
    integrity = "sha512-Ln7jRoUMYMK7TBZ0bNyPmrHDCgAsQ3bIt5nZeUuzpuGqBbw1zYWTPdA2tRbClC2CoRP3UExpHnCw+5kZLgP0lQ=="
  }
}
```

A `url:` is trusted on TLS and its host alone, so a pin is what makes it reproducible. It is checked before the download is cached and again on every cache hit, and an npm package runs from a tree extracted out of those same verified bytes, so neither the server nor the workspace's shared cache can change what runs behind your back. A cached file that no longer matches the pin is downloaded again and replaced; under `--offline` the build fails instead, naming the cached file. A pin stacks with whatever the registry publishes rather than replacing it: an npm or Maven digest is still checked.

Only a fetched source takes one. Pinning a path under your project is rejected — its bytes are already whatever your tree holds.

A download is bounded in size and in time: a response over 64 MiB fails, and so does a host that answers and then stalls, rather than hanging your configuration.

### npm packages

The tarball is cached once for the workspace and extracted into your project's build directory, under `.dev/extensions/npm/<name>/<version>/contents/`, and loaded from its entrypoint: the one `extension.json` names if the package ships one, otherwise `package.json`'s `main`, otherwise `index.js`. A package carrying an `extension.json` is a package in the full sense, read exactly as a `.elx` is — one that is invalid, or names a `type` this Elide does not know, fails the build rather than falling back to `main`.

Resolution walks three steps, in order: `dependencies.npm.registry` when your manifest sets one, then `$ELIDE_PKGST_NPM_ENDPOINT` — the same variable Elide's own npm resolver reads — and failing both, Elide's `npm.pkg.st` proxy, or the public registry at `registry.npmjs.org` when you pass `--use-pkgst=false`.

```pkl
dependencies {
  npm {
    registry = "https://npm.internal.example.com"
  }
}
```

### Maven artifacts

The coordinate is fetched from the repositories you already declare — `dependencies.maven.repositories`, plus Elide's proxy and Maven Central unless `enableDefaultRepositories` is off — using the credentials those repositories carry. Credentials go only to the repository's own scheme, host and port; a redirect anywhere else is followed without them. The usual `[:packaging[:classifier]]` sits between artifact and version, and a coordinate that names no packaging takes `elx`: `maven:com.acme:tasks:1.4.0` fetches `tasks-1.4.0.elx`, the same as `maven:com.acme:tasks:elx:1.4.0`. A packaging Elide cannot load (`maven:com.acme:tasks:zip:1.4.0`) is rejected as any unloadable declaration is.

Repositories are tried in order. One that answers with an error, a response that is too large or stalls, or a redirect that is refused is passed over for the next; a `.sha1` it publishes that disagrees with the artifact fails the build.

One artifact is fetched, and no dependency graph is resolved, exactly as for an archive you declare by path. An extension that needs libraries ships them inside its package.

## Configuring an extension

An extension that takes settings is declared as a block rather than a bare path, and carries them with it. Elide does not interpret them:

```pkl
extensions {
  "tools/shellcheck.ts"
  new {
    from = "tools/protoc.ts"
    config {
      version = "25.1"
      plugins = new Listing { "grpc" }
    }
  }
}
```

That block reaches that extension — and only that extension — as `config`, while it declares tasks and while they run:

```ts
export default ({ task, config }: ElideBuild) => {
  task("protoc", { description: `protoc ${config.version}` }, (ctx) => {
    const { version, plugins } = ctx.config;
  });
};
```

An extension you declared as a bare path reads an empty `config`, so checking a setting is `undefined` is all a default takes.

An extension that wants its settings typed and documented ships a Pkl module of its own, which your project copies in, imports and fills in. A manifest imports local files, the Pkl standard library and Elide's own schema — never a `package://` or other network URI — so the module lives in your project:

```pkl
import "tools/Protoc.pkl"

extensions {
  new {
    from = "tools/protoc.ts"
    config = new Protoc.Settings { version = "25.1" }
  }
}
```

Entries are distinct by path however you write them, so the same extension cannot be declared — or configured — twice.

Write a list inside a block as a `Listing` (or `List("grpc")`). Inline elements — `plugins { "grpc" }` — would reach your extension keyed by position rather than as a list, so the manifest rejects them and tells you the spelling that works.

Settings are data. A value may be a string, a boolean, a number, a `Duration`, a `DataSize` (which reaches your extension as a byte count) or a `Regex`, or a block, `Listing`, `List`, `Set`, `Map` or `Pair` of those. A `Bytes`, a class, a type alias or a function is a manifest error naming where it sits, such as `config.flags.blob`.

## Task options

| Option        | Meaning                                                                                              |
| ------------- | ---------------------------------------------------------------------------------------------------- |
| `description` | Shown by `elide build --inspect`.                                                                      |
| `dependsOn`   | Task names that must complete first; a single name may be given without an array.                      |
| `before`      | Tasks this one must precede: a task name, or `deps`/`compile`/`test` for every task in that group.     |
| `group`       | `deps`, `compile` or `test` to join a built-in group; any other name mints one of your own.            |
| `default`     | Whether a bare `elide build` runs this task. Defaults to `true`.                                       |
| `inputs`      | Project-relative paths or globs whose contents decide whether the task re-runs.                        |
| `outputs`     | Project-relative paths or globs the task writes.                                                       |
| `cache`       | Whether the build cache may archive this task's outputs. Defaults to `true`.                            |
| `options`     | Command-line flags this task accepts (see below).                                                      |

`inputs` and `outputs` are one contract: declare both and Elide skips the task when nothing changed (see [Build Tasks](https://elide.help/docs/toolchain/build-tasks)); declare one or neither and it runs every build. Your extension counts as an input too — the file itself and every module it imports by relative path — so editing the entrypoint or one of its helpers re-runs the tasks it declares.

A task that declared both is also cached like Elide's own: its outputs are archived when it succeeds and restored on any later build whose inputs fingerprint the same, including in a fresh checkout. Set `cache: false` to keep the up-to-date check and skip the archive — the right call when your outputs depend on something your `inputs` do not name.

What a declaration stamps as is hashed from the mode and contents of every file it names, and your action reads the same files and the same stamps through `ctx.inputs` and `ctx.outputs` — see [The files a declaration names](#the-files-a-declaration-names).

## Flags your task accepts

A task that reads a flag should say so. Declared flags are printed by `elide build --inspect` and by `elide build <task> --help`, and checked before anything runs — so a misspelling is reported rather than silently dropped:

```ts
task(
  "publish",
  {
    options: [
      { name: "channel", description: "Channel to publish to", arity: "required" },
      { name: "tag", description: "Extra tag to push", arity: "required", repeatable: true },
      { name: "sign", description: "Sign the release", negatable: true, default: "true" },
    ],
  },
  (ctx) => {
    ctx.option("channel"); // first value, or the declared default
    ctx.values("tag"); // every value, in command-line order
    ctx.flag("sign"); // `--no-sign` reads false; absent reads the default
  },
);
```

| Field         | Meaning                                                                                                |
| ------------- | ------------------------------------------------------------------------------------------------------ |
| `name`        | The flag, without dashes and in kebab-case: `"dry-run"` is `--dry-run`.                                  |
| `description` | Shown by `--inspect` and `--help`.                                                                       |
| `arity`       | `"none"` is a bare flag, `"required"` needs `--flag=value`, `"optional"` takes either. Defaults to `"none"`. |
| `repeatable`  | Whether the flag may be passed more than once. Defaults to `false`.                                      |
| `negatable`   | Whether `--no-<name>` is accepted, reading as `"false"`. Defaults to `false`.                             |
| `default`     | What a read falls back to when the flag was not passed.                                                  |
| `keyed`       | Whether what the flag was passed decides what the task produces. Defaults to `true`.                     |

`--inspect` and `--help` print the declaration as it has to be typed, so the three above read `--channel=<value>`, `--tag=<value> (repeatable)` and `--sign (or --no-sign) [default: true]`.

A flag nothing declares fails the build, with the closest declared name when there is one; so does a value given to a bare flag, a required flag left bare, and a repeat of a flag that takes one value. Elide's own flags — `--verbose`, `--project`, `--offline` and the rest — are always accepted.

`ctx.option`, `ctx.flag` and `ctx.values` read through the declaration, which is what makes `--no-sign` and a default readable at all: neither is anything a command line says on its own. A name your task did not declare still reads from the command line as parsed.

A declared flag is part of what decides whether your task re-runs: the values it resolved to are recorded alongside your `inputs`, so a build under `--channel=stable` is never reported up to date — nor handed what `--channel=canary` cached — for one that ran under the other. Set `keyed: false` on a flag that changes how loudly the task talks rather than what it writes, or every build that spells it differently misses the cache for nothing. A flag your action reads without declaring decides nothing at all.

## Running before another task

`dependsOn` names what has to run first. `before` names what has to wait — including tasks you did not declare, which is what a codegen task needs:

```ts
task(
  "generate-sources",
  { before: "compile", default: false, outputs: ["src/main/generated"] },
  (ctx) => { ... },
);
```

`compile`, `deps` and `test` stand for every task in that group, which is what "before compilation" has to mean: the real keys are per-language and per-source-set (`compile-kotlin-main`, `compile-java-test`). Any other name is a single task's, and a name nothing registered fails the build instead of quietly running nothing. In a workspace, an extension a member declares selects only that member's tasks, while one the root manifest declares selects the group across every member.

An edge like this also pulls your task into any build that runs the task it precedes — `elide build compile` above runs `generate-sources` first, even though it opted out of `default`.

## Incremental builds

A running task is told what the build found stale, and can keep values for its next run:

```ts
task("codegen", { inputs: ["proto", "schema"], outputs: ["gen"] }, (ctx) => {
  for (const input of ctx.dirty.inputs) ctx.log(`changed: ${input}`);
  if (ctx.dirty.outputs.length) ctx.log("an output is missing or was edited");
  if (ctx.dirty.extension) ctx.log("this extension or one of its imports changed");

  ctx.log(`last built version ${ctx.state.get("version") ?? "(never)"}`);
  ctx.state.set("version", ctx.manifest.version);
});
```

`ctx.dirty` names only the declarations that changed; on a first run, everything you declared is listed. `ctx.state` holds strings, numbers, booleans, `null`, arrays and plain objects, keyed by name, and `get` answers `null` for a key nothing stored. A function, a symbol and `undefined` have no stored form, and a value that contains itself is refused rather than followed. A run that fails discards the task's state entirely, including what earlier runs recorded.

What a task remembers is a memo, not an input: it never decides whether the task re-runs, and it never moves the task's cache key.

## The files a declaration names

`ctx.inputs` and `ctx.outputs` are the declarations themselves, resolved and stamped — the same walk and the same digest the build ran to decide whether your task was up to date:

```ts
task("codegen", { inputs: ["proto"], outputs: ["gen"] }, (ctx) => {
  for (const file of ctx.inputs.files()) {
    ctx.log(`${file.path} ${file.size}b ${file.stamp}`);
  }

  const before = ctx.state.get("proto") ?? {};
  const after = Object.fromEntries(ctx.inputs.files().map((f) => [f.path, f.stamp]));
  for (const path of Object.keys(after)) {
    if (after[path] !== before[path]) ctx.exec("protoc", ["--kotlin_out=gen", path]);
  }
  ctx.state.set("proto", after);

  ctx.log(`produced ${ctx.outputs.stamp()}`);
});
```

`files()` lists every regular file the declarations name, project-relative and `/`-separated, sorted and without duplicates. Each entry carries `path`, `absolute`, `size`, `modified` and `stamp` — a SHA-256 over that file's mode and contents. `stamp()` is the digest over the whole set.

Both take patterns of their own, which need not be ones you declared: `ctx.inputs.files("proto/api/**/*.proto")` narrows a listing, and `ctx.inputs.stamp("proto")` is exactly the fingerprint the build recorded for that declaration. Both resolve when you call them, so reading `ctx.outputs` after writing your outputs describes what you just produced.

Stamps are content-addressed: an edit that leaves a file's size and modification time alone still changes them, and still re-runs your task. `ctx.dirty` names a declaration as a whole; a per-file stamp kept in `ctx.state`, as above, is how a task tells which of its files moved.

## Declarations the build has to compute

Some of what decides whether a task is current cannot be written in the declaration: a generated tree, a classpath another task resolved, the version of the tool you are about to run. Give the task a `prepare` hook and declare it there:

```ts
task(
  "package",
  {
    outputs: ["dist"],
    prepare: (ctx) => {
      ctx.inputs.add("gen/**/*.kt");
      ctx.inputs.value("classpath", ctx.state.of("resolve")?.get("classpath"));
      ctx.outputs.add(`dist/${ctx.manifest.version}.tgz`);
    },
  },
  (ctx) => {
    for (const changed of ctx.dirty.values) ctx.log(`${changed} changed`);
  },
);
```

What a hook adds is a declaration like any other: a pattern is stamped, named by `ctx.dirty.inputs` and hashed into the task's cache key exactly as a declared one is. `ctx.inputs.value(name, value)` declares an input that is not a file — anything the build can compute but not stat — and a change to it re-runs the task and keys its archive differently; `ctx.dirty.values` names which one changed. A task whose declaration is completed this way is tracked, cached and skipped as up to date like any other.

The hook runs on every build your task takes part in, including the ones where it turns out to have nothing to do, so keep it to deriving declarations: it has no `exec`, no `tools` and no `dirty` — that is the answer it is helping to compute — and `ctx.state` is read-only there. Returning `false` fails the task before it runs.

Your action can add **outputs** it only learns by writing them, with the same `ctx.outputs.add(...)`; they are stamped and archived with the rest when it returns. It cannot add inputs — by then the build has already decided whether to run it, so a late `ctx.inputs.add` is an error rather than a declaration that quietly does nothing.

## Passing a value to another task

Your extension's module is evaluated once per build and shared by every task it declares, so a module-level variable one task assigns is often still there for the next — but nothing guarantees it, and a fresh evaluation starts from nothing. Treat module scope as a cache, never as a channel. What a task holds in `ctx.state` is the supported way to pass a value on:

```ts
task("stage", (ctx) => {
  ctx.state.set("staged", `${ctx.layout.buildDir}/staged`);
});

task("publish", { dependsOn: "stage" }, (ctx) => {
  const stage = ctx.state.of("stage");
  if (stage === null) return ctx.skip("nothing has been staged");
  ctx.exec("rsync", [String(stage.get("staged")), "example:/srv"]);
});
```

`ctx.state.of(name)` answers a view of what that task is up to — `get`, `has` and `keys` — and `null` when it published nothing at all. A task the build skipped as up to date, or restored from the cache, answers with what it kept the last time it ran: being up to date is a statement about what it produced, and what it published is part of that. `null` means there was nothing to read — the name is not a task an extension declared, it was not in this build, it failed, or it has not run yet — while a view with no keys is a task that kept nothing. Depend on the task you read; without an edge, "has not run yet" is a race.

A value crosses as the structure it was, not as text, and the same rules as `ctx.state` apply to what can cross. Reading your own name answers what you hold right now.

## What your task is given

The object passed to your action carries the same things the build itself reads:

- `project` — name, version, description, root directory, and the workspace it belongs to.
- `manifest` — your resolved `elide.pkl`, as ordinary JavaScript data. This is exactly what `elide project export --format=json` writes, so `manifest.dependencies.maven.packages.map(...)` works as you would expect.
- `config` — the settings your manifest declared for this extension (see above), empty when it declared none.
- `layout` — `projectDir`, `buildDir`, `artifacts`, `staging` and friends, as absolute paths.
- `option(name)` / `flag(name)` / `values(name)` — the flags this invocation was run with, read through your declaration (see above); `options` is the raw parse, for anything you did not declare.
- `task` — your own declaration, as the build recorded it, including the options it declares.
- `dirty` / `state` — what changed since your task last ran, what it kept, and what other tasks in this build recorded (see above).

## Reporting

| In your action           | What the build records      |
| ------------------------ | --------------------------- |
| return, or return `true` | success                     |
| `ctx.fail(reason)`       | failure, with that reason   |
| return `false`           | failure, with no reason     |
| `ctx.skip(reason)`       | skipped, with that reason   |
| throw, or reject         | failure, with its message   |
| `ctx.exit(code, reason)` | failure exiting with `code` |

`ctx.log`, `ctx.warn` and `ctx.error` write lines attributed to your task. They are build messages, so they go to standard error along with every other build message; standard output carries only what a program you ran writes.

### Exiting with a specific status

`ctx.exit(code, …message)` fails the task and asks the build to exit with `code`. `0` succeeds instead, which is what makes this the whole pass/fail decision for a task that wraps a program:

```ts
ctx.exit(ctx.exec("shellcheck", ["scripts/build.sh"]).exitCode);
```

Reach for it when a status carries meaning a boolean would destroy. A correctness ratchet that exits 2 because the analyzer crashed and 1 because it found a new violation is only useful if the caller can still tell those apart; so is a diagnostic that exits 2 for "not supported on this host" and 1 for "found something".

The code reaches the process only when your task is the one that stopped the build — another task failing first wins. A code outside `0`-`255` wraps the way a shell reports one, and never wraps to zero.

## Running external tools

```ts
const result = ctx.exec("shellcheck", ["scripts/build.sh"], {
  cwd: "tools",            // relative paths resolve against the project root
  env: { CI: "1" },        // added on top of the inherited environment
  quiet: false,            // set true to keep the program's output out of the build log
  timeout: 30_000,         // milliseconds; the child is killed and the task fails
});

result.ok;        // exited zero
result.exitCode;  // 0, 3, …
result.stdout;    // captured up to 1 MiB, even when not quiet; the rest is streamed only
result.stderr;    // under the same cap
```

`exec` blocks until the program exits and streams its output to the build display as it arrives, attributed to your task. No shell is involved: arguments reach the program exactly as you wrote them, so nothing in a filename or a flag can be reinterpreted as shell syntax. A bare command name is looked up on the `PATH` the child will run with — the one your `env` sets, if it sets one — and a name containing a separator resolves against `cwd`. Cancelling the build kills the program and anything it started. `timeout` bounds the program you named; once it exits, output still arriving is read for one more second and then dropped, so a background process it left holding its output does not hold up your task — that process is neither waited for nor killed.

Everything guest JavaScript already has is available too — `node:fs`, `node:child_process`, `fetch` — so reach for `exec` when you want the output attributed to your task, and for `node:child_process` when you need something it does not cover. One rule comes with that: a child you give the build's own terminal (`stdio: "inherit"`) writes where the progress display is drawing, so hand it the terminal with `ctx.foreground`. A child whose output you pipe and print yourself needs nothing — your `console` output is routed through the display like any other.

### Giving a program the terminal

```ts
const code = await ctx.foreground(
  () =>
    new Promise((resolve) => {
      const child = spawn(bin, args, { stdio: "inherit" });
      child.on("close", resolve);
    }),
);
```

The progress display stops drawing for exactly as long as the function runs, and comes back however it ends — so the program's output and the display's rows never erase each other. An `async` function keeps the terminal until its promise settles, and `await ctx.foreground(...)` answers what the function answered and rethrows what it threw.

Wrap the hand-off, not the whole task: everything your task does before it spawns — resolving paths, reading files, calling `ctx.tools` — should still be showing progress. Reach for it only when something writes to the build's own descriptors, which is a child with inherited stdio or anything interactive like a REPL or a debugger. Output written with `ctx.log`, `ctx.exec`, `ctx.tools` or your task's own `console` is already linearized against the display.

## Using the built-in toolchain

Elide already carries a Kotlin compiler, `javac`, `jar`, Native Image, ktfmt and google-java-format. A task that needs one does not have to start another Elide to reach it:

```ts
// Before: a new process per compile, diagnostics as text on a stream.
const compiled = ctx.exec("elide", ["kotlinc", "-d", ".dev/classes", "-cp", cp, "src/main/kotlin"]);
if (!compiled.ok) return ctx.fail(compiled.stderr);
ctx.exec("elide", ["jar", "--create", "--file", ".dev/lib/app.jar", "-C", ".dev/classes", "."]);

// After: the same tools, in-process, with structured results.
const result = ctx.tools.kotlinc({
  sources: ["src/main/kotlin"],                 // files, directories or globs, project-relative
  out: ".dev/classes",                          // a directory, or a path ending in `.jar`
  classpath: ctx.tools.classpath(),             // the resolved compile classpath, in order
  friendPaths: [".dev/classes-core"],           // whose `internal` declarations this compile may see
  args: ["-Xjvm-default=all"],                  // any further kotlinc flags, verbatim
});
if (!result.ok) {
  const first = result.diagnostics.find((d) => d.severity === "error");
  return ctx.fail(`${first?.file}:${first?.line}: ${first?.message}`);
}

ctx.tools.jar({
  out: ".dev/lib/app.jar",
  entries: [{ from: ".dev/classes" }, { from: "LICENSE", into: "META-INF" }],
  mainClass: "com.acme.MainKt",
  manifest: { "Implementation-Version": ctx.project.version ?? "dev" },
});
```

Every call blocks until the tool returns, and answers `{ ok, exitCode, tool, diagnostics }`. Compiler and formatter diagnostics are shown in the build output exactly as a built-in compile shows them, and come back in `diagnostics` as `{ severity, message, file, line, column }`; `jar` and `nativeImage` produce text, which streams to the build log attributed to your task (pass `quiet: true` to keep it out) and leave `diagnostics` empty. Paths are project-relative unless absolute. You may write outputs anywhere, but only outputs under the project directory can be cached and restored, so keep them there when the task declares them.

- `kotlinc({ sources, out, classpath?, friendPaths?, args?, incremental?, plugins? })` — the embedded Kotlin compiler. Compiler plugins your manifest declares or enables (`kotlin.features`, `kotlin.plugins`, `mavenDependencies().kotlinPlugins`) are applied as they are for the built-in compile, and the runtimes those features provide (Metro's, for one) join the classpath with them — a plugin without its runtime fails the compiler rather than doing nothing. `plugins: false` runs the bare compiler, which is what annotation-only sources want. No `-jvm-target` is added unless you pass one in `args`.
- `javac({ sources, out, classpath?, processorPath?, args? })` — the embedded Java compiler; a `processorPath` turns annotation processing on.
- `jar({ out, entries, exclude?, date?, manifest?, mainClass?, quiet? })` — each entry is a directory (its contents), a file, or another archive whose entries are taken (`archive: true`), landing at the archive root or under `into`. `Manifest-Version` and `Created-By` are always written; `mainClass` becomes `Main-Class`.
- `nativeImage({ args, cwd?, quiet? })` — the embedded Native Image driver; relative paths in `args` anchor on `cwd`, which defaults to the project root. The builder runs as a child JVM pinned to Elide's own JDK.
- `format({ tool, files, check?, args? })` — the embedded formatters: `tool` is `"ktfmt"` for Kotlin or `"javaformat"` for google-java-format. `files` are files, directories or globs, narrowed to what the formatter accepts (`.kt`/`.kts` or `.java`); matching nothing succeeds and does nothing. Files are rewritten in place, or with `check: true` only reported, which fails the call when any file would change. Either way `changed` lists the absolute paths of the files that were not formatted, and each arrives as a diagnostic: a warning under `check`, an info for a rewrite, an error for a file that could not be read or parsed. `args` passes further flags to the formatter, such as `["--google-style"]`.
- `classpath({ sourceSet?, usage? })` — the resolved dependency classpath as absolute paths in order. `usage` is `"compile"` (default; what the compile tasks see), `"runtime"`, `"test"` (the test set's runtime classpath), `"processor"` or `"modules"`. It answers what this build has resolved, so a task reading it should `dependsOn: "maven-dependencies"`.

`ctx.tools.version` is the version of these option and result shapes — `1` today. It moves only on an incompatible change; a new optional option or result member does not move it. An extension that must run against more than one Elide branches on it.

The compilers run in-process only through the embedded toolchain. A manifest that selects a custom or managed `kotlin.toolchain`, or the external `jvm.java.compiler.mode`, gets an error from `ctx.tools.kotlinc` / `ctx.tools.javac` naming the task and the tool; drop the selection, or run that toolchain as a program with `ctx.exec`. The same is true of a distribution built without the JVM toolchain.

### Repacking an archive

`jar` reads an existing archive as a source, so stripping a dependency down and stamping it reproducibly is one in-process call rather than an `unzip` / `rm -rf` / `jar` pipeline:

```ts
ctx.tools.jar({
  out: ".dev/lib/compiler-slim.jar",
  entries: [{ from: "libs/compiler.jar", archive: true }, { from: ".dev/patched" }],
  exclude: ["com/intellij/**", "META-INF/*.SF"],
  date: "2024-01-01T00:00:00Z",
});
```

`archive: true` is never inferred from the file extension: without it, a `.jar` named by `from` is added as a single file. The source archive's own `META-INF/MANIFEST.MF` is dropped — the result carries the manifest `manifest` and `mainClass` describe, never the source's — and `into` re-roots everything else. A member whose path, after `into`, would land outside the archive (`../x`, or an absolute `/x`) fails the task rather than being written, and so does an `into` that climbs out.

`exclude` globs match the path an entry would take *inside the archive*, after `into` has re-rooted it. They are paths, not package names, so a package tree is `"kotlin/**"` and a file kind is `"META-INF/*.SF"`. A glob on the options applies to every entry whatever its source; a glob on an entry applies to that entry alone, on top of them.

`date` stamps every entry, the manifest included, at that instant, and the tool writes a root's files in sorted order — so a repack of unchanged input is byte-identical, which is what an AOT or distribution stamp needs. Without it entry times are not reproducible: an entry passed through untouched keeps its files' modification times, while an archive member, an entry with `into`, and an entry an exclude or a collision touched carry the time of the call.

Entries are packaged in the order you list them, and two of them may claim the same archive path: the last one wins, and the build warns naming the path and the source it was taken from. Nothing is merged — a `Jar` artifact declared in the manifest unions the `META-INF/services` files of its roots, `ctx.tools.jar` does not, so a task that needs that union writes the merged file itself and lists it last.
